Full Code of harlan-zw/request-indexing for AI

main 219014292a49 cached
90 files
388.7 KB
125.1k tokens
88 symbols
1 requests
Download .txt
Showing preview only (414K chars total). Download the full file or copy to clipboard to get everything.
Repository: harlan-zw/request-indexing
Branch: main
Commit: 219014292a49
Files: 90
Total size: 388.7 KB

Directory structure:
gitextract_zzq12ri9/

├── .editorconfig
├── .eslintignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.yml
│   │   └── feature_request.yml
│   └── workflows/
│       └── release.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── app/
│   └── router.options.ts
├── app.d.ts
├── app.vue
├── components/
│   ├── Footer.vue
│   ├── GithubStar.vue
│   ├── GoogleSvg.vue
│   ├── Gradient.vue
│   ├── GraphClicks.vue
│   ├── Header.vue
│   ├── Icon/
│   │   ├── IconClicks.vue
│   │   └── IconImpressions.vue
│   ├── InspectionResult.vue
│   ├── MetricGuage.vue
│   ├── OgImage/
│   │   └── Home.vue
│   ├── PositionMetric.vue
│   ├── SiteCard.vue
│   ├── Table/
│   │   ├── TableData.vue
│   │   ├── TableKeywords.vue
│   │   ├── TableNonIndexedUrls.vue
│   │   └── TablePages.vue
│   └── TrendPercentage.vue
├── composables/
│   ├── auth.ts
│   ├── fetch.ts
│   ├── formatting.ts
│   └── loader.ts
├── data/
│   └── home.ts
├── error.vue
├── eslint.config.js
├── layouts/
│   ├── account.vue
│   ├── auth.vue
│   └── default.vue
├── middleware/
│   └── auth.global.ts
├── nuxt.config.ts
├── package.json
├── pages/
│   ├── account/
│   │   ├── index.vue
│   │   └── upgrade.vue
│   ├── admin/
│   │   └── index.vue
│   ├── dashboard/
│   │   ├── index.vue
│   │   └── site/
│   │       └── [slug].vue
│   ├── get-started.vue
│   ├── index.vue
│   ├── privacy.vue
│   └── terms.vue
├── robots.txt
├── server/
│   ├── api/
│   │   ├── admin/
│   │   │   └── usage.get.ts
│   │   ├── github/
│   │   │   └── repo.get.ts
│   │   ├── indexing/
│   │   │   ├── [url].post.ts
│   │   │   └── auth.delete.ts
│   │   ├── sites/
│   │   │   ├── [siteUrl]/
│   │   │   │   ├── [url].get.ts
│   │   │   │   └── crawl.post.ts
│   │   │   ├── [siteUrl].get.ts
│   │   │   └── list.get.ts
│   │   └── user/
│   │       ├── me.delete.ts
│   │       └── me.post.ts
│   ├── composables/
│   │   └── auth.ts
│   ├── email/
│   │   └── welcome.ts
│   ├── middleware/
│   │   └── auth.ts
│   ├── routes/
│   │   └── auth/
│   │       ├── google-indexing.get.ts
│   │       └── google.get.ts
│   ├── tsconfig.json
│   └── utils/
│       ├── api/
│       │   └── googleSearchConsole.ts
│       ├── auth/
│       │   └── googleAuthEventHandler.ts
│       ├── crawler/
│       │   ├── crawl.ts
│       │   └── robotsTxt.ts
│       ├── crypto.ts
│       ├── date.ts
│       ├── formatting.ts
│       ├── oauthPool.ts
│       ├── quota.ts
│       ├── session.ts
│       ├── sharedCache.ts
│       └── storage.ts
├── tailwind.config.ts
├── tsconfig.json
├── types/
│   ├── auth.ts
│   ├── data.ts
│   ├── index.ts
│   ├── nitro.d.ts
│   └── util.ts
└── vitest.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
# editorconfig.org
root = true

[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false


================================================
FILE: .eslintignore
================================================
dist
node_modules
test/fixtures
playground/*

saas
.unlighthouse


================================================
FILE: .github/FUNDING.yml
================================================
github: [harlan-zw]


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: 🐞 Bug report
description: Report an issue
labels: [pending triage]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for taking the time to fill out this bug report!
  - type: textarea
    id: bug-description
    attributes:
      label: Describe the bug
      description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!
      placeholder: Bug description
    validations:
      required: true


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: 🚀 New feature proposal
description: Propose a new feature
labels: [enhancement]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for your interest in the project and taking the time to fill out this feature report!
  - type: textarea
    id: feature-description
    attributes:
      label: Clear and concise description of the problem
      description: 'As a developer using VueUse I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!'
    validations:
      required: true


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

permissions:
  contents: write

on:
  push:
    tags:
      - 'v*'
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Install pnpm
        uses: pnpm/action-setup@v2

      - name: Set node
        uses: actions/setup-node@v3
        with:
          node-version: 18.x

      - run: npx changelogithub
        env:
          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}


================================================
FILE: .gitignore
================================================
# Nuxt dev/build outputs
.output
.data
../.nuxt
.nuxt
.nitro
.cache
dist

.saas

# Node dependencies
node_modules

# Logs
logs
*.log

# Misc
.DS_Store
.fleet
.idea

# Local env files
.env
.env.*
!.env.example

.tokens.js
.db


================================================
FILE: .npmrc
================================================
shamefully-hoist=true
ignore-workspace-root-check=true


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 Harlan Wilton

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<h1 align='center'>Request Indexing</h1>

<p align="center">
Get your pages indexed on Google within 48 hours. (on average)
</p>

<p align="center">
<table>
<tbody>
<td align="center">
<img width="800" height="0" /><br>
<i></i> <a href="https://requestindexing.com/">requestindexing.com 🥳</a></b> <br>
<sup> Please report any issues 🐛</sup><br>
<sub>Made possible by my <a href="https://github.com/sponsors/harlan-zw">Sponsor Program 💖</a><br> Follow me <a href="https://twitter.com/harlan_zw">@harlan_zw</a> 🐦 • Join <a href="https://discord.gg/275MBUBvgP">Discord</a> for help</sub><br>
<img width="800" height="0" />
</td>
</tbody>
</table>
</p>

> [!NOTE]
> These docs are a work in progress. Please check back soon for updates.

## Features

- ⚡ Request indexing on new sites and pages, have them appear on Google in 48 hours.
- 📊 Dashboard to see the search performance of all your Google Search Console sites.
- 🗓️ Keep all your site data. Google Search Console data deletes site data longer than 16 months, start keeping it. (soon)

## Background

Building a SaaS is quick and easy with [Nuxt](https://nuxt.com).

This project is an effort to prove that and was a success. I shipped the first version in 64 hours total.

Built With:

- [Nuxt](https://nuxt.com)
- [Nuxt UI Pro](https://ui.nuxt.com/pro?aff=5zj9e)
- [Nuxt SEO](https://nuxtseo.com)
- [Google APIs](https://developers.google.com/apis-explorer)

Credits to [google-indexing-script](https://github.com/goenning/google-indexing-script) is the inspiration for this project.
Learn more about how it works by reading the [Google Indexing Script](https://seogets.com/blog/google-indexing-script).

## Run Locally

1. Git clone the project:

```bash
git clone git@github.com:harlan-zw/request-indexing.git
```

2. Install deps:

```bash
pnpm i
```

3. Configure the keys:

You will need to create a Google OAuth Client ID and Secret. You can do this by visiting the [Google Developer Console](https://console.developers.google.com/).

The following scopes are required:
- `userinfo.email`
- `webmasters.readonly`
- `indexing`

You will need to add the redirect URL to your OAuth client. This will be `http://localhost:3000/auth/google` and `http://localhost:3000/auth/google-indexing`.

```bash
NUXT_OAUTH_GOOGLE_CLIENT_ID=<clientId>
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=<clientSecret>
```

You should also set a unique 32 character string for the security keys:

```bash
NUXT_KEY=<mustbe32chars>
NUXT_SESSION_PASSWORD=<secret>
```

4. Start the server:

```bash
pnpm dev
```

5. Building your site:

To build and deploy site you will need to purchase a [Nuxt UI Pro](https://ui.nuxt.com/pro?aff=5zj9e) license.

Once you have your license update your `.env` file with your license key:

```bash
NUXT_UI_PRO_LICENSE_KEY=<license>
```

Then run the build command:

```bash
pnpm build
```

That's it!

## Sponsors

<p align="center">
  <a href="https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg">
    <img src='https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg'/>
  </a>
</p>

## License

MIT License © 2022-PRESENT [Harlan Wilton](https://github.com/harlan-zw)


================================================
FILE: app/router.options.ts
================================================
import type { RouterConfig } from '@nuxt/schema'

function findHashPosition(hash): { el: any, behavior: ScrollBehavior, top: number } {
  const el = document.querySelector(hash)
  // vue-router does not incorporate scroll-margin-top on its own.
  if (el) {
    const top = Number.parseFloat(getComputedStyle(el).scrollMarginTop)

    return {
      el: hash,
      behavior: 'smooth',
      top,
    }
  }
}

// https://router.vuejs.org/api/#routeroptions
export default <RouterConfig>{
  scrollBehavior(to, from, savedPosition) {
    const nuxtApp = useNuxtApp()

    // If history back
    if (savedPosition) {
      // Handle Suspense resolution
      return new Promise((resolve) => {
        nuxtApp.hooks.hookOnce('page:finish', () => {
          setTimeout(() => resolve(savedPosition), 50)
        })
      })
    }

    // Scroll to heading on click
    if (to.hash) {
      return new Promise((resolve) => {
        if (to.path === from.path) {
          setTimeout(() => resolve(findHashPosition(to.hash)), 50)
        }
        else {
          nuxtApp.hooks.hookOnce('page:finish', () => {
            setTimeout(() => resolve(findHashPosition(to.hash)), 50)
          })
        }
      })
    }

    // Scroll to top of window
    return { top: 0 }
  },
}


================================================
FILE: app.d.ts
================================================
module '#auth-utils' {
  export interface UserSession {
    user?: {
      userId: string
      picture: string
      indexingTokenId?: string
    }
  }
}

export {}


================================================
FILE: app.vue
================================================
<script setup>
const colorMode = useColorMode()

const color = computed(() => colorMode.value === 'dark' ? '#111827' : 'white')

useHead({
  meta: [
    { key: 'theme-color', name: 'theme-color', content: color },
  ],
})

const entry = useHead({
  link: [
    {
      rel: 'icon',
      type: 'image/svg+xml',
      href: `/icons/icon-${colorMode.value === 'system' ? 'dark' : colorMode.value}.svg`,
    },
  ],
})
// switch logos on colorMode
watch(colorMode, () => {
  entry.patch({
    link: [
      {
        rel: 'icon',
        type: 'image/svg+xml',
        href: `/icons/icon-${colorMode.value}.svg`,
      },
    ],
  })
})

useSeoMeta({
  titleTemplate: '%s %separator Request Indexing',
  ogSiteName: 'Request Indexing',
  ogTitle: 'Get your pages indexed within 48 hours.',
  twitterTitle: 'Get your pages indexed within 48 hours.',
})

defineOgImageComponent('Home')
</script>

<template>
  <div>
    <NuxtLoadingIndicator />

    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>

    <UNotifications />
  </div>
</template>

<style>
pre {
  --scrollbar-thumb: #3b5178;
}

.dark pre {
  --scrollbar-thumb: #acbad2;
}

* {
  --scrollbar-track: initial;
  --scrollbar-thumb: initial;
  scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
  scrollbar-width: thin;
  --scrollbar-thumb: #acbad2;
}

::-webkit-scrollbar-track {
  background-color: var(--scrollbar-track)
}

::-webkit-scrollbar-thumb {
  background-color: var(--scrollbar-thumb);
  border-radius: .25rem
}

::-webkit-scrollbar {
  width: 8px;
  height: 8px
}

.dark * {
  --scrollbar-thumb: #3b5178;
}

.page-enter-active,
.page-leave-active {
  transition: all 0.2s;
}
.page-enter-from,
.page-leave-to {
  opacity: 0;
  transform: translateY(1rem);
  filter: blur(0.2rem);
}
</style>


================================================
FILE: components/Footer.vue
================================================
<script setup lang="ts">
const toast = useToast()
const links = [
  {
    label: 'Request Indexing',
    children: [
      {
        label: 'Get Started',
        to: '/get-started',
      },
    ],
  },
  {
    label: 'Resources',
    children: [
      {
        label: 'Docs',
        to: 'https://github.com/harlan-zw/request-indexing',
        target: '_blank',
      },
      {
        label: 'Privacy',
        to: '/privacy',
      },
      {
        label: 'Terms',
        to: '/terms',
      },
    ],
  },
  {
    label: 'Project',
    children: [
      {
        label: 'Report a bug',
        to: 'https://github.com/harlan-zw/request-indexing/issues/new?assignees=&labels=pending+triage&projects=&template=bug_report.yml',
        target: '_blank',
      },
      {
        label: 'Roadmap',
        to: 'https://github.com/harlan-zw/request-indexing/issues?q=is%3Aopen+label%3Aenhancement+sort%3Aupdated-desc',
        target: '_blank',
      },
      {
        label: 'Changelog',
        to: 'https://github.com/harlan-zw/request-indexing/releases',
        target: '_blank',
      },
    ],
  },
]

interface JSConfettiApi {
  addConfetti: (options?: { emojis: string[] }) => void
}
declare global {
  interface Window {
    JSConfetti: { new (): JSConfettiApi }
  }
}
function toaster() {
  const $script = useScript<JSConfettiApi>({
    key: 'confetti',
    src: 'https://cdn.jsdelivr.net/npm/js-confetti@latest/dist/js-confetti.browser.js',
  }, {
    skipEarlyConnections: true,
    use() {
      return new window.JSConfetti()
    },
  })
  $script.addConfetti({ emojis: ['🍞'] })
  toast.add({
    title: 'So you like easters eggs? 🥚',
    description: 'How about some bread? 🍞',
  })
}
</script>

<template>
  <UFooter>
    <template #top>
      <UFooterColumns :links="links">
        <template #right>
          <UCard class=" p-5">
            <div>
              <div class="mb-2">
                Hey <Icon name="noto:waving-hand" /> My name is <a href="https://harlanzw.com" target="_blank" class="underline">Harlan</a> <img alt="Harlan Wilton" loading="lazy" src="https://avatars.githubusercontent.com/u/5326365?v=4" class="inline rounded-full w-5 h-5">, I'm the creator of Request Indexing.
              </div>
              <div>
                Do you like this tool? Need a hand? Get in <a href="https://twitter.com/harlan_zw" class="underline">touch</a> with me.
              </div>
            </div>
          </UCard>
        </template>
      </UFooterColumns>
    </template>

    <template #left>
      <p class="text-gray-500 dark:text-gray-400 text-sm mr-5">
        Copyright © {{ new Date().getFullYear() }}. All rights reserved.
      </p>
      <p class="text-gray-500 dark:text-gray-400 text-sm">
        Credits for the idea to <a href="https://seogets.com/" target="_blank" class="underline">SEO Gets</a>.
      </p>
    </template>

    <template #right>
      <UButton color="gray" title="Bread" variant="link" target="_blank" class="group" @click="toaster">
        <div class="hidden group-hover:block h-0">
          <Icon name="noto:bread" class="text-xl  " />
        </div>
      </UButton>

      <UColorModeButton size="sm" />

      <UButton color="gray" title="Twitter" variant="link" to="https://twitter.com/harlan_zw" target="_blank">
        <UIcon name="i-simple-icons-twitter" class="text-xl" />
      </UButton>
      <UButton color="gray" title="GitHub" aria-label="GitHub" variant="link" to="https://github.com/harlan-zw/request-indexing" target="_blank">
        <UIcon name="i-simple-icons-github" class="text-xl" />
      </UButton>
      <UButton color="gray" title="Discord" aria-label="Discord" variant="link" to="https://discord.gg/275MBUBvgP" target="_blank">
        <UIcon name="i-simple-icons-discord" class="text-xl" />
      </UButton>
    </template>
  </UFooter>
</template>


================================================
FILE: components/GithubStar.vue
================================================
<script lang="ts" setup>
import { withBase, withoutBase } from 'ufo'
import { useFetch } from '#imports'

const props = defineProps<{
  repo: string
  raw?: boolean
  to?: string
}>()

const repo = computed(() => {
  // support users providing full github url
  return withoutBase(props.repo, 'https://github.com/')
})
const link = computed(() => {
  return props.to || withBase(repo.value, 'https://github.com/')
})
// pull the stars from the server
const { data } = await useFetch('/api/github/repo', {
  query: {
    repo: repo.value,
  },
  watch: [
    repo,
  ],
  key: `github-stars-${repo.value}`,
}).catch(() => {
  return {
    data: ref({ stars: 0 }),
  }
})

const stars = computed(() => {
  if (props.raw)
    return data.value
  return new Intl.NumberFormat('en', { notation: 'compact' }).format(data.value?.stars || 0)
})
</script>

<template>
  <NuxtLink :to="link" target="_blank" :aria-label="`Star ${repo} on GitHub`">
    <slot :stars="stars">
      <div>{{ stars }}</div>
    </slot>
  </NuxtLink>
</template>


================================================
FILE: components/GoogleSvg.vue
================================================
<template>
  <svg width="800" height="800" viewBox="-0.5 0 48 48" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M9.827 24c0-1.524.253-2.986.705-4.356l-7.909-6.04A23.456 23.456 0 0 0 .213 24c0 3.737.868 7.26 2.407 10.388l7.905-6.05A13.885 13.885 0 0 1 9.827 24" fill="#FBBC05" /><path d="M23.714 10.133c3.311 0 6.302 1.174 8.652 3.094L39.202 6.4C35.036 2.773 29.695.533 23.714.533a23.43 23.43 0 0 0-21.09 13.071l7.908 6.04a13.849 13.849 0 0 1 13.182-9.51" fill="#EB4335" /><path d="M23.714 37.867a13.849 13.849 0 0 1-13.182-9.51l-7.909 6.038a23.43 23.43 0 0 0 21.09 13.072c5.732 0 11.205-2.036 15.312-5.849l-7.507-5.804c-2.118 1.335-4.786 2.053-7.804 2.053" fill="#34A853" /><path d="M46.145 24c0-1.387-.213-2.88-.534-4.267H23.714V28.8h12.604c-.63 3.091-2.346 5.468-4.8 7.014l7.507 5.804c4.314-4.004 7.12-9.969 7.12-17.618" fill="#4285F4" /></g></svg>
</template>


================================================
FILE: components/Gradient.vue
================================================
<script lang="ts" setup>
const colorMode = useColorMode()
const dark = computed(() => colorMode.value === 'dark')
</script>

<template>
  <div class="gradient" :class="{ dark }">
    <div class="overlay" :class="{ dark }">
      <slot />
    </div>
  </div>
</template>

<style lang="postcss" scoped>
.gradient {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(50% 50% at 50% 50%, rgb(var(--color-primary-500) / 0.25) 0, #FFF 100%);
}

.dark {
  .gradient {
    background: radial-gradient(50% 50% at 50% 50%, rgb(var(--color-primary-400) / 0.1) 0, rgb(var(--color-gray-950)) 100%);
  }
}

.overlay {
  background-size: 100px 100px;
  background-image:
    linear-gradient(to right, rgb(var(--color-gray-200)) 0.5px, transparent 0.5px),
    linear-gradient(to bottom, rgb(var(--color-gray-200)) 0.5px, transparent 0.5px);
}
.dark {
  .overlay {
    background-image:
      linear-gradient(to right, rgb(var(--color-gray-900)) 0.5px, transparent 0.5px),
      linear-gradient(to bottom, rgb(var(--color-gray-900)) 0.5px, transparent 0.5px);
  }
}
</style>


================================================
FILE: components/GraphClicks.vue
================================================
<script lang="ts" setup>
import { createChart } from 'lightweight-charts'

const props = defineProps<{
  value: { time: string, value: number }[]
  value2: { time: string, value: number }[]
  height?: number | string
}>()

const colorMode = useColorMode()

const chart = ref(null)
const container = ref(null)

const tooltipData = ref({
  clicks: 0,
  impressions: 0,
  time: '',
})

const darkTheme = {
  chart: {
    layout: {
      background: {
        type: 'solid',
        color: 'transparent',
      },
      lineColor: '#2B2B43',
      textColor: '#D9D9D9',
    },
    watermark: {
      color: 'rgba(0, 0, 0, 0)',
    },
    crosshair: {
      color: '#758696',
    },
    grid: {
      vertLines: {
        visible: false,
      },
      horzLines: {
        visible: false,
      },
    },
  },
  series: {
    topColor: 'rgba(32, 226, 47, 0.56)',
    bottomColor: 'rgba(32, 226, 47, 0.04)',
    lineColor: 'rgba(32, 226, 47, 1)',
  },
  series2: {
    topColor: 'rgba(156, 39, 176, 0.4)',
    bottomColor: 'rgba(156, 39, 176, 0.04)',
    lineColor: 'rgba(156, 39, 176, 0.5)',
  },
}

const lightTheme = {
  chart: {
    layout: {
      background: {
        type: 'solid',
        color: 'transparent',
      },
      lineColor: '#2B2B43',
      textColor: '#191919',
    },
    watermark: {
      color: 'rgba(0, 0, 0, 0)',
    },
    grid: {
      vertLines: {
        visible: false,
      },
      horzLines: {
        visible: false,
      },
    },
  },
  series: {
    topColor: 'rgba(33, 150, 243, 0.9)',
    bottomColor: 'rgba(33, 150, 243, 0.04)',
    lineColor: 'rgba(33, 150, 243, 0.5)',
  },
  // this is the impressions from google search console, we want to use a similar purple
  series2: {
    topColor: 'rgba(156, 39, 176, 0.3)',
    bottomColor: 'rgba(156, 39, 176, 0.04)',
    lineColor: 'rgba(156, 39, 176, 0.4)',
  },
}

const themesData = {
  Dark: darkTheme,
  Light: lightTheme,
}

onMounted(() => {
  const _chart = createChart(chart.value!, {
    height: Number(props.height) || 100,
    autoSize: true,
    rightPriceScale: {
      visible: false,
    },
    timeScale: {
      visible: false,
    },
    crosshair: {
      horzLine: {
        visible: false,
      },
      vertLine: {
        visible: false,
      },
    },
  })
  _chart.timeScale().fitContent()

  const areaSeries = _chart.addAreaSeries({
    topColor: 'rgba(33, 150, 243, 0.56)',
    bottomColor: 'rgba(33, 150, 243, 0.04)',
    lineColor: 'rgba(33, 150, 243, 1)',
    lineWidth: 2,
    priceLineVisible: false,
    lastValueVisible: false,
    priceFormat: {
      type: 'volume',
    },
    lineType: 2,
  })
  areaSeries.setData(props.value)

  const areaSeries2 = _chart.addAreaSeries({
    topColor: 'rgba(33, 150, 243, 0.56)',
    bottomColor: 'rgba(33, 150, 243, 0.04)',
    lineColor: 'rgba(33, 150, 243, 1)',
    lineWidth: 2,
    priceLineVisible: false,
    lastValueVisible: false,
    priceFormat: {
      type: 'volume',
    },
    lineType: 2,
  })
  areaSeries2.setData(props.value2)

  _chart.subscribeCrosshairMove((param) => {
    const _container = container.value!
    if (
      param.point === undefined
      || !param.time
      || param.point.x < 0
      || param.point.x > _container.clientWidth
      || param.point.y < 0
      || param.point.y > _container.clientHeight
    ) {
      tooltipData.value = {
        clicks: 0,
        impressions: 0,
        time: '',
      }
    }
    else {
      // time will be in the same format that we supplied to setData.
      // thus it will be YYYY-MM-DD
      const dateStr = param.time
      // toolTip.style.display = 'block'
      const _clicks = param.seriesData.get(areaSeries)!
      const clicks = _clicks.value !== undefined ? _clicks.value : _clicks.time
      const _impressions = param.seriesData.get(areaSeries2)
      const impressions = _impressions.value !== undefined ? _impressions.value : _impressions.time

      tooltipData.value = {
        clicks,
        impressions,
        time: dateStr,
      }
    }
  })

  function syncToTheme(theme) {
    _chart.applyOptions(themesData[theme].chart)
    areaSeries.applyOptions(themesData[theme].series)
    areaSeries2.applyOptions(themesData[theme].series2)
  }

  syncToTheme(colorMode.value === 'dark' ? 'Dark' : 'Light')
  watch(colorMode, (newVal) => {
    syncToTheme(newVal.value === 'dark' ? 'Dark' : 'Light')
  })
})
</script>

<template>
  <div ref="container" class="w-full h-full">
    <div ref="chart" />
    <div class="tooltip">
      <div v-if="tooltipData.time" class="dark:text-gray-200 text-gray-600 text-xs">
        <div class="dark:text-gray-400 text-gray-500 text-xs ">
          {{ tooltipData.time }}
        </div>
        <div>Clicks {{ useHumanFriendlyNumber(tooltipData.clicks) }}</div>
        <div>Impressions {{ useHumanFriendlyNumber(tooltipData.impressions) }}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.tooltip {
  text-align: right;
  position: absolute;
  padding: 4px;
  z-index: 20;
  top: 4px;
  right: 8px;
  pointer-events: none;
}
</style>


================================================
FILE: components/Header.vue
================================================
<script setup lang="ts">
import { createLogoutHandler } from '~/composables/auth'
import type { User } from '~/types'

const { loggedIn, user, session } = useUserSession()

const logout = createLogoutHandler()
const router = useRouter()

const links = computed(
  () => loggedIn.value
    ? [{
        label: 'Dashboard',
        to: '/dashboard',
      }]
    : [],
)

const authDropdownItems = computed(() => {
  return [
    [
      { label: 'Account', slot: 'account', to: '/account', icon: 'i-heroicons-user-circle' },
    ],
    user.value.access === 'pro'
      ? false
      : [
          // upgrade to pro item
          { label: 'Upgrade', slot: 'pro', to: '/account/upgrade', icon: 'i-heroicons-star' },
        ],
    [
      {
        label: 'Logout',
        click: () => logout(),
        icon: 'i-heroicons-arrow-left-end-on-rectangle',
      },
    ],
  ].filter(Boolean)
})

async function updateAnalyticsPeriod(newPeriod: User['analyticsPeriod']) {
  session.value = await $fetch('/api/user/me', {
    method: 'POST',
    body: JSON.stringify({ analyticsPeriod: newPeriod }),
  })
  const currentRoute = router.currentRoute.value
  const sites = await fetchSites()
  // refresh all sites
  if (currentRoute.path === '/dashboard') {
    for (const site of sites.data.value!) {
      const res = await fetchSite(site)
      await res.forceRefresh()
    }
  }
  else if (currentRoute.name === 'dashboard-site-slug') {
    const res = await fetchSite(sites.data.value!.find(site => site.siteUrl === currentRoute.params.slug)!)
    await res.forceRefresh()
  }
}

const periodItems = [
  [{ slot: 'info', disabled: true }],
  // days
  [
    { label: '7 days', value: '7d', click: () => updateAnalyticsPeriod('7d') },
    { label: '28 days', value: '28d', click: () => updateAnalyticsPeriod('28d') },
  ],
  // months
  [
    { label: '3 months', value: '3mo', click: () => updateAnalyticsPeriod('3mo') },
    { label: '6 months', value: '6mo', click: () => updateAnalyticsPeriod('6mo') },
    { label: '12 months', value: '12mo', click: () => updateAnalyticsPeriod('12mo') },
    { label: '16 months', value: '16mo', click: () => updateAnalyticsPeriod('16mo') },
  ],
]

const isOnDashboard = computed(() => router.currentRoute.value.path.startsWith('/dashboard'))
</script>

<template>
  <UHeader :links="links">
    <template #logo>
      <div class="flex items-center gap-2 group">
        <svg height="25" width="25" class="text-green-500 group-hover:text-green-300 transition" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 267 262" xml:space="preserve">
          <!-- magnifying glass  -->
          <path class="text-gray-900 dark:text-gray-200" fill="currentColor" d="M263.158 210.588a356.834 356.834 0 0 1-4.262 20.272c-6.823 10.235-16.074 16.193-27.508 15.709-9.868-.419-18.375-5.864-25.2-13.736-7.18-7.119-14.246-13.954-21.458-21.087-4.04-3.958-7.935-7.62-11.912-11.405-.083-.122-.328-.288-.451-.52-.814-.79-1.505-1.35-2.333-2.224-5.983-5.888-11.828-11.46-16.3-15.723-9.814 1.528-18.278 2.847-27.163 4.085-3.796-.411-7.172-.742-10.549-1.072-19.389-5.02-33.04-16.713-40.374-35.368-10.582-26.915.226-58.306 24.396-71.556 25.178-13.803 57.44-7.278 74.137 15.112 6.41 8.596 10.107 18.333 10.876 29.054.275 3.834 1.534 6.756 4.436 9.489 21.078 19.857 42.005 39.875 62.945 59.878 5.396 5.154 9.691 10.99 10.72 19.092m-69.483-19.776c.804.663 1.608 1.325 2.566 2.512.287.213.573.427 1.057 1.278 2.907 2.725 5.815 5.45 9.127 8.72 2.455 2.256 4.911 4.512 7.592 7.414 3.66 3.52 7.319 7.042 11.37 11.21 3.465 3.398 7.165 5.256 11.897 2.24 4.23-2.697 5.523-6.184 4.113-12.584-2.101-2.542-3.977-5.32-6.341-7.588-13.16-12.62-26.433-25.122-39.679-37.653-4.967-4.698-9.964-9.365-15.173-14.258-2.053 3.451-3.647 6.132-5.838 9.14l-5.036 5.914c6.051 5.716 11.883 11.225 17.91 17.228.29.192.579.384 1.088 1.179 1.682 1.562 3.364 3.124 5.347 5.248m-43.732-90.58c-10.045-7.61-21.03-9.549-33.713-5.474-1.433.688-2.866 1.375-4.906 2.127-.823.554-1.645 1.107-3.11 1.95-1.039.934-2.077 1.87-3.61 2.887-.303.377-.606.755-1.512 1.472-5.304 5.515-8.8 11.892-9.804 20.37-1.858 24.173 14.898 41.888 38.296 40.312 2.839-.19 5.615-1.305 9.083-1.922.778-.395 1.557-.79 2.999-1.304 4.836-4.424 9.673-8.849 15.047-13.656.408-.997.816-1.995 1.76-3.519 3.31-7.65 3.88-15.5 1.815-24.308-.937-2.443-1.875-4.887-2.895-7.816 0 0-.342-.355-.329-.988-.84-1.146-1.681-2.293-2.834-4.067-1.981-1.82-3.963-3.64-6.287-6.063z" />
          <path fill="currentColor" d="M142.168 224.923c-.526 1.84-1.433 3.664-1.505 5.52-.188 4.822.056 9.658.009 14.487-.057 5.809-2.836 8.954-8.622 9.652a36.509 36.509 0 0 1-6.98.147c-5.381-.391-8.263-3.162-8.426-8.61-.247-8.319-.117-16.65-.165-24.975-.025-4.483 1.265-8.377 5.64-10.267 4.862-2.1 9.787-1.754 14.345 1.032 1.224.748 2.085 2.088 3.38 3.442.266.285.322.316.323.626.354 2.784.706 5.26 1.107 7.933.332.47.613.742.894 1.013z" />
          <path fill="currentColor" d="M229.032 137.723c-2.992-.023-5.493.017-7.988-.077-7.696-.289-11.938-4.695-11.957-12.346-.018-7.269 4.268-11.49 11.917-11.63 7.66-.14 15.322-.278 22.983-.27 7.137.006 10.745 4.34 10.699 12.578-.046 8.296-3.206 11.818-10.67 11.827-4.83.006-9.66-.05-14.984-.082z" />
          <path fill="currentColor" d="M25.01 137.827c-4.32-.054-8.17.15-11.964-.22-6.226-.61-8.5-4.17-8.358-12.357.126-7.24 3.074-11.277 8.997-11.502 8.31-.314 16.64-.423 24.953-.257 6.21.125 10.54 5.497 10.444 12.268-.101 7.245-4.43 11.996-11.095 12.082-4.16.054-8.32-.005-12.976-.014z" />
          <path fill="currentColor" d="M116.648 11.1c3.823-6.647 16.339-8.611 21.333-3.45 1.458 1.506 2.47 4.046 2.56 6.16.316 7.3.195 14.62.127 21.933-.068 7.247-3.83 11.077-10.982 11.382-8.093.345-12.946-3.286-13.235-10.728-.32-8.287.032-16.599.197-25.298z" />
          <path fill="currentColor" d="M72.732 206.65c-4.6 4.605-8.826 9.08-13.316 13.273-4.275 3.993-7.4 4.17-11.957.613-2.451-1.914-4.745-4.227-6.512-6.775-3.156-4.553-2.753-8.061 1.071-12.05 4.254-4.435 8.672-8.716 13.059-13.022 6.077-5.966 11.537-6.346 16.865-1.235 6.481 6.217 6.842 12.726.79 19.196z" />
          <path fill="currentColor" d="M196.987 72.077c-5.68.823-10.224-.669-13.739-4.82-4.215-4.979-4.175-10.527.33-15.182 4.389-4.536 8.932-8.93 13.54-13.243 4.206-3.936 8.573-4.372 12.893-.621 2.793 2.425 5.056 5.692 6.809 8.98.834 1.564.758 4.848-.333 6-6.143 6.484-12.72 12.557-19.5 18.886z" />
          <path fill="currentColor" d="M77.148 59.041c-.723 5.547-3.386 9.498-7.936 11.834-4.095 2.103-8.397 2.318-12.106-1.054-5.422-4.927-10.722-9.99-16.12-14.944-2.673-2.454-3.005-5.423-.94-8.037 2.857-3.616 5.937-7.273 9.634-9.915 1.78-1.271 6.2-1.4 7.693-.084 6.712 5.92 12.833 12.514 19.07 18.96.584.605.481 1.875.705 3.24z" />
        </svg>
        <div class="text-black-800 text-mono" style="letter-spacing: -1px;">
          Request Indexing
        </div>
      </div>
    </template>

    <template #right>
      <div v-if="!loggedIn" class="flex gap-3 items-center gap-2">
        <UButton to="/get-started" external color="gray" variant="link" class="hidden md:block">
          <span>Login</span>
        </UButton>
        <UButton to="/get-started" external color="green" variant="outline">
          <span>Get Started</span>
        </UButton>
      </div>
      <template v-else>
        <div class="items-center gap-2 hidden md:flex">
          <UDropdown v-if="isOnDashboard" mode="click" :items="periodItems" class="mr-5">
            <template #info>
              <p class="text-xs">
                Change the period used to display your site data.
              </p>
            </template>
            <template #item="{ item }">
              <template v-if="item.value === user.analyticsPeriod">
                <span class="truncate font-bold">{{ item.label }}</span>
                <UIcon v name="i-heroicons-check-circle" class="flex-shrink-0 h-5 w-5 text-gray-400 dark:text-gray-500" />
              </template>
              <template v-else>
                <span class="truncate">{{ item.label }}</span>
              </template>
            </template>
            <UButton
              icon="i-heroicons-calendar"
              color="gray"
              size="xs"
            >
              <template v-if="user.analyticsPeriod.endsWith('d')">
                {{ user.analyticsPeriod.replace('d', '') }} days
              </template>
              <template v-else>
                {{ user.analyticsPeriod.replace('mo', '') }} months
              </template>
            </UButton>
          </UDropdown>
        </div>
        <UDropdown mode="click" :items="authDropdownItems" class="flex items-center">
          <template #account="{ item }">
            <div class="flex flex-col w-full">
              <div class="flex items-center gap-2">
                <UIcon :name="item.icon" class="flex-shrink-0 h-4 w-4 text-gray-400 dark:text-gray-500" />
                <span class="truncate">{{ item.label }}</span>
              </div>
              <div class="text-gray-400 text-xs">
                {{ user.email }}
              </div>
            </div>
          </template>
          <template #pro="{ item }">
            <UIcon :name="item.icon" class="flex-shrink-0 h-4 w-4 text-gray-400 dark:text-gray-500" />
            <span class="truncate">{{ item.label }}</span>
            <UBadge label="0 left" color="purple" variant="subtle" class="ml-0.5" />
          </template>
          <UAvatar :src="user.picture" />
          <div class="ml-2 flex items-center">
            <UBadge v-if="user.access === 'pro'" label="Pro" color="purple" variant="subtle" class="ml-0.5" />
          </div>
          <UButton
            icon="i-heroicons-chevron-down"
            color="gray"
            size="xs"
            variant="ghost"
          />
        </UDropdown>
      </template>
    </template>

    <template #panel>
      <UNavigationTree
        v-if="loggedIn"
        :links="[links[0], ...authDropdownItems.flat()]" default-open
      />
      <UButton v-else to="/get-started" external color="green" variant="outline">
        <span>Get Started</span>
      </UButton>
    </template>
  </UHeader>
</template>


================================================
FILE: components/Icon/IconClicks.vue
================================================
<template>
  <svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24" class="w-4 h-4 text-blue-500"><path fill="currentColor" d="m11.5 11l6.38 5.37l-.88.18l-.64.12c-.63.13-.99.83-.71 1.4l.27.58l1.36 2.94l-1.42.66l-1.36-2.93l-.26-.58a.985.985 0 0 0-1.52-.36l-.51.4l-.71.57zm-.74-2.31a.76.76 0 0 0-.76.76V20.9c0 .42.34.76.76.76c.19 0 .35-.06.48-.16l1.91-1.55l1.66 3.62c.13.27.4.43.69.43c.11 0 .22 0 .33-.08l2.76-1.28c.38-.18.56-.64.36-1.01L17.28 18l2.41-.45a.88.88 0 0 0 .43-.26c.27-.32.23-.79-.12-1.08l-8.74-7.35l-.01.01a.756.756 0 0 0-.49-.18M15 10V8h5v2zm-1.17-5.24l2.83-2.83l1.41 1.41l-2.83 2.83zM10 0h2v5h-2zM3.93 14.66l2.83-2.83l1.41 1.41l-2.83 2.83zm0-11.32l1.41-1.41l2.83 2.83l-1.41 1.41zM7 10H2V8h5z" /></svg>
</template>


================================================
FILE: components/Icon/IconImpressions.vue
================================================
<template>
  <svg xmlns="http://www.w3.org/2000/svg" width="0" height="20" viewBox="0 0 32 32" class="w-4 h-4 text-purple-500"><path fill="currentColor" d="M30.94 15.66A16.69 16.69 0 0 0 16 5A16.69 16.69 0 0 0 1.06 15.66a1 1 0 0 0 0 .68A16.69 16.69 0 0 0 16 27a16.69 16.69 0 0 0 14.94-10.66a1 1 0 0 0 0-.68ZM16 25c-5.3 0-10.9-3.93-12.93-9C5.1 10.93 10.7 7 16 7s10.9 3.93 12.93 9C26.9 21.07 21.3 25 16 25Z" /><path fill="currentColor" d="M16 10a6 6 0 1 0 6 6a6 6 0 0 0-6-6Zm0 10a4 4 0 1 1 4-4a4 4 0 0 1-4 4Z" /></svg>
</template>


================================================
FILE: components/InspectionResult.vue
================================================
<script setup lang="ts">
import { useTimeAgo } from '~/composables/formatting'
import type { SitePage } from '~/types'

const props = defineProps<{
  value: Required<SitePage>
}>()

const root = computed(() => {
  return props.value
})

const value = computed(() => {
  return props.value?.inspectionResult
})
</script>

<template>
  <div v-if="value?.indexStatusResult" class="flex items-center gap-2">
    <UPopover mode="hover">
      <template v-if="value.indexStatusResult.verdict === 'PASS'">
        <UButton :to="value.inspectionResultLink" icon="i-heroicons-check-circle" color="green" variant="link">
          <UChip color="green" />
        </UButton>
      </template>
      <template v-else-if="value.indexStatusResult.verdict === 'NEUTRAL'">
        <UButton :to="value.inspectionResultLink" icon="i-heroicons-clock" color="gray" variant="link">
          <UChip color="gray" />
        </UButton>
      </template>
      <template v-else-if="value.indexStatusResult.verdict === 'FAIL'">
        <UButton :to="value.inspectionResultLink" icon="i-heroicons-x-circle" color="red" variant="link">
          <UChip color="red" />
        </UButton>
      </template>
      <template #panel>
        <div class="p-4">
          <div>
            <div class="text-gray-800 dark:text-gray-100 font-semibold mb-2">
              {{ value.indexStatusResult.coverageState }}
            </div>
            <div class="flex gap-3 justify-between mb-1">
              <div class="text-gray-700 dark:text-gray-200">
                Verdict
              </div>
              <div>{{ value.indexStatusResult.verdict }}</div>
            </div>
            <div class="flex gap-3 justify-between mb-1">
              <div class="text-gray-700 dark:text-gray-200">
                Robots.txt
              </div>
              <div>{{ value.indexStatusResult.robotsTxtState }}</div>
            </div>
            <div class="flex gap-3 justify-between mb-1">
              <div class="text-gray-700 dark:text-gray-200">
                Indexing
              </div>
              <div>{{ value.indexStatusResult.indexingState }}</div>
            </div>
            <div class="flex gap-3 justify-between mb-1">
              <div class="text-gray-700 dark:text-gray-200">
                Last Crawled
              </div>
              <div>{{ useTimeAgo(value.indexStatusResult.lastCrawlTime, true) }}</div>
            </div>
          </div>
          <slot />
        </div>
      </template>
    </UPopover>
    <div>
      <div v-if="root.lastInspected" class="text-xs mb-1">
        Inspected {{ useTimeAgo(root.lastInspected, true) }}
      </div>
      <UPopover v-if="value.indexStatusResult.verdict === 'NEUTRAL' && !root.urlNotificationMetadata?.latestUpdate" mode="hover">
        <div class="flex items-center gap-1">
          <UIcon name="i-heroicons-question-mark-circle" size="w-5 h-5" />
          <div class="text-xs">
            Why am I seeing this?
          </div>
        </div>
        <template #panel>
          <div class="p-4 text-sm space-y-2">
            <div>Google knows about your URL but has not indexed it yet.<br>Click the Request Indexing button to move it along.</div>
          </div>
        </template>
      </UPopover>
      <UPopover v-else-if="value.indexStatusResult.verdict === 'NEUTRAL'" mode="hover">
        <div class="flex items-center gap-1">
          <UIcon name="i-heroicons-question-mark-circle" size="w-5 h-5" />
          <div class="text-xs">
            Why am I seeing this?
          </div>
        </div>
        <template #panel>
          <div class="p-4 text-sm space-y-2">
            <div>You have submitted a Request Index request.<br>We're waiting for Google to process it.</div>
          </div>
        </template>
      </UPopover>
      <UPopover v-if="value.indexStatusResult.verdict === 'PASS'" mode="hover">
        <div class="flex items-center gap-1">
          <UIcon name="i-heroicons-question-mark-circle" size="w-5 h-5" />
          <div class="text-xs">
            Why am I seeing this?
          </div>
        </div>
        <template #panel>
          <div class="p-4 text-sm space-y-2">
            <div>Google has reported that this URL has been indexed. Congrats! <br>But it doesn't mean people can find it just yet.</div>
            <div>We'll track this URL here until it has appeared on a<br> search page at least once. Use the "Hide Actioned" filter to hide this.</div>
          </div>
        </template>
      </UPopover>
    </div>
  </div>
</template>


================================================
FILE: components/MetricGuage.vue
================================================
<script lang="ts" setup>
const props = defineProps<{
  score?: number
}>()

const { score } = toRefs(props)

const arc = ref(null)

const guageModifiers = computed(() => {
  let result = 'fail'
  if (score.value >= 0.9)
    result = 'pass'
  else if (score.value >= 0.5)
    result = 'average'

  return [
    `guage__wrapper--${result}`,
  ]
})

const guageArcStyle = computed(() => {
  // r = 56
  const r = 56
  // stroke-width = 8
  const n = 2 * Math.PI * r
  const rotationOffset = 0.25 * 8 / n

  let o = score.value * n - r / 2
  if (score.value === 1)
    o = n

  return {
    opacity: score.value === 0 ? '0' : 1,
    transform: `rotate(${360 * rotationOffset - 90}deg)`,
    strokeDasharray: `${Math.max(o, 0)}, ${n}`,
  }
})
</script>

<template>
  <div class="guage__wrapper guage__wrapper--huge" :class="guageModifiers">
    <div
      class="guage__svg-wrapper relative"
    >
      <svg class="guage" viewBox="0 0 120 120">
        <circle
          class="guage-base"
          r="56"
          cx="60"
          cy="60"
          stroke-width="8"
        />
        <circle
          v-if="score !== null"
          ref="arc"
          class="guage-arc"
          r="56"
          cx="60"
          cy="60"
          stroke-width="8"
          :style="guageArcStyle"
        />
      </svg>
      <div
        class="text-sm mt-[1px] font-bold left-[50%] top-[50%] transform -translate-y-[50%] -translate-x-[50%] absolute text-mono font-mono"
      >
        <slot />
      </div>
    </div>
  </div>
</template>

<style scoped>
* {
  --color-amber-50: #fff8e1;
  --color-blue-200: #90caf9;
  --color-blue-900: #0d47a1;
  --color-blue-A700: #2962ff;
  --color-cyan-500: #00bcd4;
  --color-gray-100: #f5f5f5;
  --color-gray-300: #cfcfcf;
  --color-gray-200: #e0e0e0;
  --color-gray-400: #bdbdbd;
  --color-gray-50: #fafafa;
  --color-gray-500: #9e9e9e;
  --color-gray-600: #757575;
  --color-gray-700: #616161;
  --color-gray-800: #424242;
  --color-gray-900: #212121;
  --color-gray: #000000;
  --color-green-700: #018642;
  --color-green: #0cce6b;
  --color-lime-400: #d3e156;
  --color-orange-50: #fff3e0;
  --color-orange-700: #d04900;
  --color-orange: #ffa400;
  --color-red-700: #eb0f00;
  --color-red: #ff4e42;
  --color-teal-600: #00897b;
  --color-white: #ffffff;
  --color-average-secondary: var(--color-orange-700);
  --color-average: var(--color-orange);
  --color-fail-secondary: var(--color-red-700);
  --color-fail: var(--color-red);
  --color-hover: var(--color-gray-50);
  --color-informative: var(--color-blue-900);
  --color-pass-secondary: var(--color-green-700);
  --color-pass: var(--color-green);
  --color-not-applicable: var(--color-gray-600);
}
.guage__wrapper--pass {
  @apply text-green-500 fill-current stroke-current;
}
.guage__wrapper--average {
  @apply  text-yellow-500 fill-current stroke-current;
}

.guage__wrapper--fail {
  @apply text-red-500 fill-current stroke-current;
}

.guage__wrapper--not-applicable {
  color: var(--color-not-applicable);
  fill: var(--color-not-applicable);
  stroke: var(--color-not-applicable);
}
.guage__wrapper--huge {
  --gauge-circle-size: 50px;
}
.guage__wrapper {
  position: relative;
  display: flex;
  align-items: center;
  flex-direction: column;
  text-decoration: none;
  padding: var(--score-container-padding);
  --transition-length: 1s;
  contain: content;
  will-change: opacity;
}
.guage__svg-wrapper {
  position: relative;
  height: var(--gauge-circle-size);
}
.guage {
  stroke-linecap: round;
  width: var(--gauge-circle-size);
  height: var(--gauge-circle-size);
}
.guage-base {
  opacity: 0.1;
}
.guage-arc {
  fill: none;
  transform-origin: 50% 50%;
  animation: load-gauge var(--transition-length) ease forwards;
  animation-delay: 250ms;
}
</style>


================================================
FILE: components/OgImage/Home.vue
================================================
<script setup lang="ts">
import { computed } from 'vue'

// convert to typescript props
const props = withDefaults(defineProps<{
  colorMode?: 'dark' | 'light'
  title?: string
  description?: string
  icon?: string | boolean
  version?: string
  siteName?: string
  siteLogo?: string
  theme?: string
}>(), {
  colorMode: 'light',
  theme: '#00dc82',
  title: 'title',
})

const HexRegex = /^#([0-9a-f]{3}){1,2}$/i

const themeHex = computed(() => {
  // regex test if valid hex
  if (HexRegex.test(props.theme))
    return props.theme

  // if it's hex without the hash, just add the hash
  if (HexRegex.test(`#${props.theme}`))
    return `#${props.theme}`

  // if it's rgb or rgba, we convert it to hex
  if (props.theme.startsWith('rgb')) {
    const rgb = props.theme
      .replace('rgb(', '')
      .replace('rgba(', '')
      .replace(')', '')
      .split(',')
      .map(v => Number.parseInt(v.trim(), 10))
    const hex = rgb
      .map((v) => {
        const hex = v.toString(16)
        return hex.length === 1 ? `0${hex}` : hex
      })
      .join('')
    return `#${hex}`
  }
  return '#FFFFFF'
})

const themeRgb = computed(() => {
  // we want to convert it so it's just `<red>, <green>, <blue>` (255, 255, 255)
  return themeHex.value
    .replace('#', '')
    .match(/.{1,2}/g)
    ?.map(v => Number.parseInt(v, 16))
    .join(', ')
})
</script>

<template>
  <div
    class="w-full h-full flex flex-col justify-center items-center relative p-[60px]"
    :class="[
      colorMode === 'light' ? ['bg-white', 'text-gray-900'] : ['bg-gray-900', 'text-gray-50'],
    ]"
  >
    <div
      class="flex absolute bottom-[-200%] right-[-50%]" :style="{
        width: '200%',
        height: '250%',
        backgroundImage: `radial-gradient(circle, rgba(${themeRgb}, 0.5) 0%,  ${colorMode === 'dark' ? 'rgba(5, 5, 5,0.3)' : 'rgba(255, 255, 255, 0.7)'} 50%, ${props.colorMode === 'dark' ? 'rgba(5, 5, 5,0)' : 'rgba(255, 255, 255, 0)'} 70%)`,
      }"
    />
    <div class="flex flex-row justify-center items-center text-left w-full mb-12">
      <svg height="100" width="100" class="text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 267 262" xml:space="preserve">
        <path class="text-gray-900 dark:text-gray-200" fill="currentColor" d="M263.158 210.588a356.834 356.834 0 0 1-4.262 20.272c-6.823 10.235-16.074 16.193-27.508 15.709-9.868-.419-18.375-5.864-25.2-13.736-7.18-7.119-14.246-13.954-21.458-21.087-4.04-3.958-7.935-7.62-11.912-11.405-.083-.122-.328-.288-.451-.52-.814-.79-1.505-1.35-2.333-2.224-5.983-5.888-11.828-11.46-16.3-15.723-9.814 1.528-18.278 2.847-27.163 4.085-3.796-.411-7.172-.742-10.549-1.072-19.389-5.02-33.04-16.713-40.374-35.368-10.582-26.915.226-58.306 24.396-71.556 25.178-13.803 57.44-7.278 74.137 15.112 6.41 8.596 10.107 18.333 10.876 29.054.275 3.834 1.534 6.756 4.436 9.489 21.078 19.857 42.005 39.875 62.945 59.878 5.396 5.154 9.691 10.99 10.72 19.092m-69.483-19.776c.804.663 1.608 1.325 2.566 2.512.287.213.573.427 1.057 1.278 2.907 2.725 5.815 5.45 9.127 8.72 2.455 2.256 4.911 4.512 7.592 7.414 3.66 3.52 7.319 7.042 11.37 11.21 3.465 3.398 7.165 5.256 11.897 2.24 4.23-2.697 5.523-6.184 4.113-12.584-2.101-2.542-3.977-5.32-6.341-7.588-13.16-12.62-26.433-25.122-39.679-37.653-4.967-4.698-9.964-9.365-15.173-14.258-2.053 3.451-3.647 6.132-5.838 9.14l-5.036 5.914c6.051 5.716 11.883 11.225 17.91 17.228.29.192.579.384 1.088 1.179 1.682 1.562 3.364 3.124 5.347 5.248m-43.732-90.58c-10.045-7.61-21.03-9.549-33.713-5.474-1.433.688-2.866 1.375-4.906 2.127-.823.554-1.645 1.107-3.11 1.95-1.039.934-2.077 1.87-3.61 2.887-.303.377-.606.755-1.512 1.472-5.304 5.515-8.8 11.892-9.804 20.37-1.858 24.173 14.898 41.888 38.296 40.312 2.839-.19 5.615-1.305 9.083-1.922.778-.395 1.557-.79 2.999-1.304 4.836-4.424 9.673-8.849 15.047-13.656.408-.997.816-1.995 1.76-3.519 3.31-7.65 3.88-15.5 1.815-24.308-.937-2.443-1.875-4.887-2.895-7.816 0 0-.342-.355-.329-.988-.84-1.146-1.681-2.293-2.834-4.067-1.981-1.82-3.963-3.64-6.287-6.063z" />
        <path fill="currentColor" d="M142.168 224.923c-.526 1.84-1.433 3.664-1.505 5.52-.188 4.822.056 9.658.009 14.487-.057 5.809-2.836 8.954-8.622 9.652a36.509 36.509 0 0 1-6.98.147c-5.381-.391-8.263-3.162-8.426-8.61-.247-8.319-.117-16.65-.165-24.975-.025-4.483 1.265-8.377 5.64-10.267 4.862-2.1 9.787-1.754 14.345 1.032 1.224.748 2.085 2.088 3.38 3.442.266.285.322.316.323.626.354 2.784.706 5.26 1.107 7.933.332.47.613.742.894 1.013z" />
        <path fill="currentColor" d="M229.032 137.723c-2.992-.023-5.493.017-7.988-.077-7.696-.289-11.938-4.695-11.957-12.346-.018-7.269 4.268-11.49 11.917-11.63 7.66-.14 15.322-.278 22.983-.27 7.137.006 10.745 4.34 10.699 12.578-.046 8.296-3.206 11.818-10.67 11.827-4.83.006-9.66-.05-14.984-.082z" />
        <path fill="currentColor" d="M25.01 137.827c-4.32-.054-8.17.15-11.964-.22-6.226-.61-8.5-4.17-8.358-12.357.126-7.24 3.074-11.277 8.997-11.502 8.31-.314 16.64-.423 24.953-.257 6.21.125 10.54 5.497 10.444 12.268-.101 7.245-4.43 11.996-11.095 12.082-4.16.054-8.32-.005-12.976-.014z" />
        <path fill="currentColor" d="M116.648 11.1c3.823-6.647 16.339-8.611 21.333-3.45 1.458 1.506 2.47 4.046 2.56 6.16.316 7.3.195 14.62.127 21.933-.068 7.247-3.83 11.077-10.982 11.382-8.093.345-12.946-3.286-13.235-10.728-.32-8.287.032-16.599.197-25.298z" />
        <path fill="currentColor" d="M72.732 206.65c-4.6 4.605-8.826 9.08-13.316 13.273-4.275 3.993-7.4 4.17-11.957.613-2.451-1.914-4.745-4.227-6.512-6.775-3.156-4.553-2.753-8.061 1.071-12.05 4.254-4.435 8.672-8.716 13.059-13.022 6.077-5.966 11.537-6.346 16.865-1.235 6.481 6.217 6.842 12.726.79 19.196z" />
        <path fill="currentColor" d="M196.987 72.077c-5.68.823-10.224-.669-13.739-4.82-4.215-4.979-4.175-10.527.33-15.182 4.389-4.536 8.932-8.93 13.54-13.243 4.206-3.936 8.573-4.372 12.893-.621 2.793 2.425 5.056 5.692 6.809 8.98.834 1.564.758 4.848-.333 6-6.143 6.484-12.72 12.557-19.5 18.886z" />
        <path fill="currentColor" d="M77.148 59.041c-.723 5.547-3.386 9.498-7.936 11.834-4.095 2.103-8.397 2.318-12.106-1.054-5.422-4.927-10.722-9.99-16.12-14.944-2.673-2.454-3.005-5.423-.94-8.037 2.857-3.616 5.937-7.273 9.634-9.915 1.78-1.271 6.2-1.4 7.693-.084 6.712 5.92 12.833 12.514 19.07 18.96.584.605.481 1.875.705 3.24z" />
      </svg>
      <div class="text-black-800 text-5xl text-mono font-bold ml-10" style="letter-spacing: -2px;">
        Request Indexing
      </div>
    </div>
    <div class="flex flex-row w-full justify-around items-center relative">
      <div class="w-1/2">
        <div class="text-4xl" style="line-height: 1.6">
          <p>A free, open-source tool to get your pages indexed on Google within 48 hours.</p>
        </div>
      </div>
      <div>
        <img src="/card.png" style="width: 400px; height: 298px;" class="rounded-xl shadow-xl border-1 border-gray-50">
      </div>
    </div>
  </div>
</template>


================================================
FILE: components/PositionMetric.vue
================================================
<script lang="ts" setup>
const props = defineProps<{ value: number }>()
// we're showing the Position metric from Google Search Console, we want to
// alter the colour depending on the position of the keyword
const color = computed(() => {
  if (props.value < 10)
    return 'green'
  if (props.value < 20)
    return 'amber'
  if (props.value < 30)
    return 'orange'
  return 'red'
})
// we should show the value without decimals
const formattedValue = computed(() => Math.round(props.value))

// it should be shown in a badge with minimal padding
</script>

<template>
  <UTooltip :text="`Average position of ${useHumanFriendlyNumber(value)}.`">
    <UBadge :color="color" variant="soft" class="px-2 py-1">
      {{ formattedValue }}
    </UBadge>
  </UTooltip>
</template>


================================================
FILE: components/SiteCard.vue
================================================
<script lang="ts" setup>
import type { Ref } from 'vue'
import { toRef } from 'vue'
import { useFriendlySiteUrl } from '~/composables/formatting'
import type { GoogleSearchConsoleSite, SiteExpanded } from '~/types'
import { fetchSite } from '~/composables/fetch'

const props = defineProps<{
  site: GoogleSearchConsoleSite
  mockData?: SiteExpanded
}>()

const { site } = toRefs(props)

const { session, user } = useUserSession()

const { data: _data, pending: _pending, forceRefresh, error } = await fetchSite(site.value, !!props.mockData)

const isForceRefreshing = ref(false)
const pending = computed(() => {
  if (props.mockData)
    return false
  return toValue(_pending) || toValue(isForceRefreshing)
})
const data = toRef(props.mockData || _data) as Ref<SiteExpanded | null>

const siteUrlFriendly = computed(() => {
  return useFriendlySiteUrl(site.value.siteUrl)
})
const link = computed(() => `/dashboard/site/${encodeURIComponent(site.value.siteUrl)}`)

function refresh() {
  callFnSyncToggleRef(forceRefresh, isForceRefreshing)
}

async function hide() {
  const sites = new Set([...(session.value.user.hiddenSites || []), props.site.siteUrl])
  // save it upstream
  session.value = await $fetch('/api/user/me', {
    method: 'POST',
    body: JSON.stringify({ hiddenSites: [...sites] }),
  })
}
</script>

<template>
  <UCard class="min-h-[270px] flex flex-col" :ui="{ body: { base: 'flex-grow flex items-center', padding: ' px-0 py-0 sm:p-0' } }">
    <template #header>
      <div class="flex justify-between">
        <NuxtLink :to="link" class="flex items-center gap-2">
          <img :src="`https://www.google.com/s2/favicons?domain=${site.siteUrl.replace('sc-domain:', 'https://')}`" alt="favicon" class="w-4 h-4">
          <h3 class="font-bold">
            {{ siteUrlFriendly }}
          </h3>
        </NuxtLink>
        <UDropdown :class="mockData ? 'pointer-events-none' : ''" :items="[[{ label: 'View', icon: 'i-heroicons-eye', click: () => link && navigateTo(link) }, { label: 'Reload', icon: 'i-heroicons-arrow-path', click: refresh }, { label: 'Hide', icon: 'i-heroicons-trash', click: hide }]]" :popper="{ offsetDistance: 0, placement: 'right-start' }">
          <UButton color="white" label="" variant="ghost" trailing-icon="i-heroicons-chevron-down" />
        </UDropdown>
      </div>
    </template>
    <div class="flex-grow w-full h-full">
      <div v-if="pending" class="w-full h-full flex items-center justify-center">
        <UIcon name="i-heroicons-arrow-path" class="animate-spin w-12 h-12" />
      </div>
      <div v-else-if="error" class="w-full h-full flex items-center justify-center">
        <div>
          <div class="mb-3">
            <div class="text-lg font-semibold">
              <UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4" /> Failed to refresh
            </div>
            <div class="opacity-80 text-sm">
              {{ error.statusMessage }}
            </div>
          </div>
          <UButton size="xs" color="gray" @click="refresh">
            Refresh
          </UButton>
        </div>
      </div>
      <div v-else-if="data" class="relative w-full h-full">
        <div class="flex px-5 pt-2 items-start justify-start gap-4">
          <div class="flex items-center justify-center gap-2">
            <div class="flex items-center flex-col justify-center">
              <div class="text-2xl font-semibold">
                <template v-if="data.nonIndexedPercent && data.nonIndexedPercent !== -1">
                  {{ Math.round(data.nonIndexedPercent * 100) }}<span class="text-xl">%</span>
                </template>
                <template v-else>
                  ?
                </template>
              </div>
              <div class="opacity-80 text-sm">
                Indexed
              </div>
            </div>
          </div>
          <div class="flex flex-col items-center justify-center gap-1">
            <div class="flex items-center justify-center gap-2">
              <TrendPercentage :value="data.analytics.period.totalClicks" :prev-value="data.analytics.prevPeriod.totalClicks" />
              <UTooltip :text="`Clicks last ${user?.analyticsPeriod || '28d'}`" class="flex items-end gap-2">
                <span>{{ useHumanFriendlyNumber(data!.analytics.period.totalClicks) }}</span>
                <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="w-5 h-5 text-blue-300"><path fill="currentColor" d="m11.5 11l6.38 5.37l-.88.18l-.64.12c-.63.13-.99.83-.71 1.4l.27.58l1.36 2.94l-1.42.66l-1.36-2.93l-.26-.58a.985.985 0 0 0-1.52-.36l-.51.4l-.71.57zm-.74-2.31a.76.76 0 0 0-.76.76V20.9c0 .42.34.76.76.76c.19 0 .35-.06.48-.16l1.91-1.55l1.66 3.62c.13.27.4.43.69.43c.11 0 .22 0 .33-.08l2.76-1.28c.38-.18.56-.64.36-1.01L17.28 18l2.41-.45a.88.88 0 0 0 .43-.26c.27-.32.23-.79-.12-1.08l-8.74-7.35l-.01.01a.756.756 0 0 0-.49-.18M15 10V8h5v2zm-1.17-5.24l2.83-2.83l1.41 1.41l-2.83 2.83zM10 0h2v5h-2zM3.93 14.66l2.83-2.83l1.41 1.41l-2.83 2.83zm0-11.32l1.41-1.41l2.83 2.83l-1.41 1.41zM7 10H2V8h5z" /></svg>
              </UTooltip>
            </div>
            <div class="flex items-center justify-center gap-2">
              <TrendPercentage :value="data.analytics.period.totalImpressions" :prev-value="data.analytics.prevPeriod.totalImpressions" />
              <UTooltip :text="`Impressions last ${user?.analyticsPeriod || '28d'}`" class="flex items-end gap-2">
                <span>{{ useHumanFriendlyNumber(data!.analytics.period.totalImpressions) }}</span>
                <svg xmlns="http://www.w3.org/2000/svg" width="0" height="24" viewBox="0 0 32 32" class="w-5 h-5 text-purple-300"><path fill="currentColor" d="M30.94 15.66A16.69 16.69 0 0 0 16 5A16.69 16.69 0 0 0 1.06 15.66a1 1 0 0 0 0 .68A16.69 16.69 0 0 0 16 27a16.69 16.69 0 0 0 14.94-10.66a1 1 0 0 0 0-.68ZM16 25c-5.3 0-10.9-3.93-12.93-9C5.1 10.93 10.7 7 16 7s10.9 3.93 12.93 9C26.9 21.07 21.3 25 16 25Z" /><path fill="currentColor" d="M16 10a6 6 0 1 0 6 6a6 6 0 0 0-6-6Zm0 10a4 4 0 1 1 4-4a4 4 0 0 1-4 4Z" /></svg>
              </UTooltip>
            </div>
          </div>
        </div>
        <div class="h-[100px] max-w-full overflow-hidden">
          <GraphClicks v-if="!pending && data.graph" :value="data.graph.map(g => ({ time: g.time, value: g.clicks }))" :value2="data.graph.map(g => ({ time: g.time, value: g.impressions }))" />
        </div>
      </div>
    </div>
    <template #footer>
      <div class="flex items-center justify-between">
        <div class="flex gap-2 items-center ">
          <UButton :class="mockData ? 'pointer-events-none' : ''" :disabled="pending" variant="ghost" color="gray" :to="link">
            View
          </UButton>
        </div>

        <div class="flex items-center gap-2">
          <div class="opacity-60 text-xs">
            <template v-if="site.siteUrl.includes('sc-domain:')">
              Domain Property
            </template>
            <template v-else>
              Site Property
            </template>
          </div>
          <UTooltip v-if="!mockData && site.permissionLevel !== 'siteOwner'" :text="`'${site.permissionLevel}' is unable to submit URLs for indexing`">
            <UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5 text-yellow-500" />
          </UTooltip>
          <UTooltip v-else text="You can submit URLs for indexing for this site.">
            <UIcon name="i-heroicons-check-circle" class="w-5 h-5 text-green-500" />
          </UTooltip>
        </div>
      </div>
    </template>
  </UCard>
</template>


================================================
FILE: components/Table/TableData.vue
================================================
<script lang="ts" setup generic="T extends Record<string, any>">
import { useUrlSearchParams } from '@vueuse/core'
import { get } from '#ui/utils'

const props = defineProps<{
  value: T[]
  columns: any[]
  filters?: { key: string, label: string, filter: (rows: T[]) => T[] }[]
  expandable?: boolean
  pageCount?: number
}>()

const emit = defineEmits<{
  'update:expanded': [id: number]
  'update:rows': [rows: T[]]
}>()

const params = useUrlSearchParams('history', {
  removeNullishValues: true,
  removeFalsyValues: false,
})

const sort = ref()
const q = ref(params.q || '')
const page = ref(params.page || 1)
const filter = ref(params.filter || 'default')
const rows = computed<T>(() => props.value || [])
const expandedRow = ref(null)

function toggleExpandedRow(index: number) {
  if (expandedRow.value === index)
    expandedRow.value = null
  else
    expandedRow.value = index
}

watch([q, filter, page, sort], () => {
  expandedRow.value = null
})

// order: sort, query, paginate

function defaultSort(a, b, direction) {
  if (a === b)
    return 0

  if (direction === 'asc')
    return a < b ? -1 : 1
  else
    return a > b ? -1 : 1
}

const sortedRows = computed(() => {
  if (!sort.value)
    return rows.value
  const { column, direction } = sort.value
  return rows.value.slice().sort((a, b) => {
    const aValue = get(a, column)
    const bValue = get(b, column)
    return defaultSort(aValue, bValue, direction)
  })
})

const filters = computed(() => {
  return [
    {
      key: 'default',
      label: 'Show all',
      description: 'Show all results',
      filter: (rows: T[]) => {
        return rows
      },
    },
    ...props.filters || [],
  ].filter(Boolean)
})

const queriedRows = computed<T[]>(() => {
  const queried = q.value
    ? sortedRows.value.filter((row) => {
      return Object.values(row).some((value) => {
        return String(value).toLowerCase().includes(q.value.toLowerCase())
      })
    })
    : sortedRows.value
  const applyFilter = filters.value.find(f => f.key === filter.value)
  if (applyFilter)
    return applyFilter.filter(queried)
  return queried
})

function toggleFilter(_filter: string) {
  if (filter.value === _filter)
    filter.value = null
  else
    filter.value = _filter
}
const pageCount = props.pageCount || 8
const paginatedRows = computed<T[]>(() => {
  return queriedRows.value.slice((page.value - 1) * pageCount, (page.value) * pageCount)
})

function updateSort(_sort: any) {
  if (_sort.column === null) {
    sort.value = null
    return
  }
  sort.value = {
    column: _sort.column,
    direction: _sort.direction,
  }
}

const columns = computed(() => {
  return [props.expandable ? { key: 'expand' } : null, ...props.columns].filter(Boolean).map(c => ({
    ...c,
    slotName: `${c.key}-data`,
  }))
})

watch(expandedRow, () => {
  emit('update:expanded', expandedRow.value ? paginatedRows.value[expandedRow.value] : null)
})

watch(paginatedRows, () => {
  emit('update:rows', paginatedRows.value)
})
</script>

<template>
  <div>
    <div class="flex justify-between">
      <div class="flex items-center gap-5 mb-5">
        <div class="flex w-[300px] dark:border-gray-700">
          <UInput
            v-model="q"
            class="w-full"
            placeholder="Search..."
            icon="i-heroicons-magnifying-glass"
            autocomplete="off"
            :ui="{ icon: { trailing: { pointer: '' } } }"
          >
            <template #trailing>
              <UButton
                v-show="q !== ''"
                color="gray"
                variant="link"
                icon="i-heroicons-x-mark"
                :padded="false"
                @click="q = ''"
              />
            </template>
          </UInput>
        </div>
      </div>
      <div>
        <div class="flex items-center gap-3 mb-3">
          <UBadge v-for="_filter in filters.filter(f => !f.special)" :key="_filter.key" class="cursor-pointer" :ui="{ rounded: 'rounded-full' }" :color="filter === _filter.key ? 'green' : 'gray'" :variant="filter === _filter.key ? 'subtle' : 'soft'" @click="toggleFilter(_filter.key)">
            <UTooltip :text="_filter.description || ''" class="flex gap-1 items-center">
              {{ _filter.label }} <span v-if="_filter.key === filter"> - {{ queriedRows.length }}</span>
            </UTooltip>
          </UBadge>
        </div>
        <div>
          <UBadge v-for="_filter in filters.filter(f => f.special)" :key="_filter.key" class="cursor-pointer" :ui="{ rounded: 'rounded-full' }" :color="filter === _filter.key ? 'green' : 'gray'" :variant="filter === _filter.key ? 'subtle' : 'soft'" @click="toggleFilter(_filter.key)">
            <UTooltip :text="_filter.description || ''" :ui="{ width: 'max-w-lg' }" class="flex gap-1 items-center">
              <UIcon name="i-heroicons-sparkles" class="w-4 h-4" />
              {{ _filter.label }} <span v-if="_filter.key === filter"> - {{ queriedRows.length }}</span>
            </UTooltip>
          </UBadge>
        </div>
      </div>
    </div>
    <UDivider />
    <UTable :loading="!value" :rows="paginatedRows" :columns="columns" @update:sort="updateSort">
      <template #expand-data="{ index }">
        <UButton :icon="expandedRow === index ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" color="gray" size="xs" variant="ghost" @click="toggleExpandedRow(index)" />
      </template>
      <!-- we need to re-implement the slots i think  -->
      <template v-for="column in columns.filter(c => c.key !== 'expand')" #[column.slotName]="data">
        <slot :name="column.slotName" v-bind="data" :rows="paginatedRows" :expanded="expandedRow === data.index" />
      </template>
    </UTable>
    <div v-if="queriedRows.length > pageCount" class="flex items-center justify-between mt-7 px-3 py-5 border-t border-gray-200 dark:border-gray-700">
      <UPagination v-model="page" :page-count="pageCount" :total="queriedRows.length" />
      <div class="text-base dark:text-gray-300 text-gray-600 mb-2">
        {{ queriedRows.length }} total
      </div>
    </div>
  </div>
</template>


================================================
FILE: components/Table/TableKeywords.vue
================================================
<script setup lang="ts">
import Fuse from 'fuse.js'
import type { GoogleSearchConsoleSite } from '~/types/data'

const props = withDefaults(
  defineProps<{ mock?: boolean, value?: GscDataRow[], site: GoogleSearchConsoleSite, pending?: boolean, pageCount?: number }>(),
  {
    pageCount: 8,
  },
)

const { user } = useUserSession()

const columns = computed(() => {
  return [{
    key: 'keyword',
    label: 'Keyword',
    sortable: true,
  }, {
    key: 'position',
    label: 'Position',
    sortable: true,
  }, user.value?.analyticsPeriod === 'all'
    ? null
    : {
        key: 'positionPercent',
        label: '%',
        sortable: true,
      }, {
    key: 'ctr',
    label: 'CTR',
    sortable: true,
  }, user.value?.analyticsPeriod === 'all'
    ? null
    : {
        key: 'ctrPercent',
        label: '%',
        sortable: true,
      }].filter(Boolean)
})

function highestRowClickCount(rows) {
  return rows.reduce((acc, row) => acc + row.clicks, 0)
}
//
// const selected = ref([])
// function select(row) {
//   const index = selected.value.findIndex(item => item.url === row.url)
//   if (index === -1)
//     selected.value.push(row)
//   else
//     selected.value.splice(index, 1)
// }

const filters = computed(() => {
  return [
    {
      key: 'new',
      label: 'New',
      description: 'Keywords that are new verse the previous period.',
      filter: (rows: T[]) => {
        return rows.filter(row => !row.prevPosition)
      },
    },
    {
      key: 'improving',
      description: 'Keywords that are improving verse the previous period.',
      label: 'Improving',
      filter: (rows: T[]) => {
        return rows.filter(row => row.position > row.prevPosition)
      },
    },
    {
      key: 'declining',
      description: 'Keywords that are declining verse the previous period.',
      label: 'Declining',
      filter: (rows: T[]) => {
        return rows.filter(row => row.position < row.prevPosition)
      },
    },
    {
      key: 'first-page',
      label: 'First Page',
      description: 'Keywords that are on the first page of Google search',
      special: true,
      filter: (rows: GscDataRow[]) => rows.filter(row => row.impressions > 5 && row.position <= 12 && row.position >= 0),
    },
    {
      key: 'content-gap',
      label: 'Content Gap',
      description: 'Keywords that are not on the first page but have high impressions',
      special: true,
      filter: (rows: GscDataRow[]) => {
        // compute avg. impressions and avg ctr
        const avgImpressions = rows.filter(row => row.impressions >= 0).reduce((acc, row) => acc + (row.impressions || 0), 0) / rows.length
        const avgCtr = rows.filter(row => row.ctr >= 0).reduce((acc, row) => acc + (row.ctr || 0), 0) / rows.length
        const thresholdImpressions = Math.max(avgImpressions * 0.5, 50)
        const thresholdCTR = avgCtr * 0.5
        return rows.filter(row => row.impressions > thresholdImpressions && row.ctr < thresholdCTR)
          .sort((a, b) => b.impressions - a.impressions)
      },
    },
    {
      key: 'indirect',
      label: 'Indirect',
      description: 'Keywords with impressions that don\'t include the site name',
      special: true,
      filter: (rows: GscDataRow[]) => {
        // only with 1 clicks
        const trafficRows = rows.filter(row => row.impressions >= 10)
        // use fuse.js, add all data, search for the siteurl and see what' not returned
        const fuse = new Fuse(trafficRows, {
          keys: ['keyword'],
          includeScore: true,
          threshold: 0.5,
        })
        const siteName = props.site.siteUrl.replace('www.', '').replace('https://', '').replace('sc-domain:', '').split('.')[0]
        const results = fuse.search(siteName)
        // do a diff of results on rows, we only want the ones that are not returned
        return trafficRows.filter(row => !results.find(result => result.item.keyword === row.keyword))
      },
    },
  ]
})
</script>

<template>
  <div>
    <TableData :value="value" :columns="columns" :filters="filters" :page-count="pageCount">
      <template #keyword-data="{ row, rows }">
        <div class="flex items-center">
          <div class="relative group w-[225px] truncate text-ellipsis">
            <div class="flex items-center">
              <UButton class="max-w-[185px] block" variant="link" size="xs" :class="mock ? ['pointer-events-none'] : []" color="gray" @click="q = row.keyword">
                <div class="text-black dark:text-white max-w-[185px] truncate text-ellipsis">
                  {{ row.keyword }}
                </div>
              </UButton>
              <UBadge v-if="!row.prevPosition" size="xs" variant="subtle">
                New
              </UBadge>
              <UBadge v-else-if="row.lost" size="xs" color="red" variant="subtle">
                Lost
              </UBadge>
            </div>
            <UProgress :value="Math.round((row.clicks / highestRowClickCount(rows)) * 100)" color="blue" size="xs" class="ml-2 opacity-75 group-hover:opacity-100 transition" />
          </div>
        </div>
      </template>

      <template #position-data="{ row }">
        <div class="text-center">
          <UDivider v-if="row.lostKeyword" />
          <template v-else>
            <div>
              <PositionMetric :value="row.position" />
            </div>
            <UTooltip :text="`${row.impressions} impressions`">
              <div class="text-xs flex items-center gap-1">
                {{ useHumanFriendlyNumber(row.impressions) }}
                <IconImpressions />
              </div>
            </UTooltip>
          </template>
        </div>
      </template>
      <template #prevPosition-data="{ row }">
        <div class="text-center">
          <div>
            {{ useHumanFriendlyNumber(row.prevPosition, 1) }}
          </div>
          <UTooltip :text="`${row.prevImpressions} impressions`">
            <div class="text-xs">
              {{ useHumanFriendlyNumber(row.prevImpressions) }} impressions
            </div>
          </UTooltip>
        </div>
      </template>
      <template #positionPercent-data="{ row }">
        <UDivider v-if="!row.prevPosition" />
        <TrendPercentage v-else :value="row.position" :prev-value="row.prevPosition" symbol="%" />
      </template>
      <template #ctr-data="{ row }">
        <div class="text-center">
          <div>{{ useHumanFriendlyNumber(row.ctr * 100, 1) }}%</div>
          <UTooltip :text="`${row.clicks} impressions`">
            <div class="text-xs flex items-center gap-1">
              {{ useHumanFriendlyNumber(row.clicks) }}
              <IconClicks />
            </div>
          </UTooltip>
        </div>
      </template>
      <template #prevCtr-data="{ row }">
        <div class="text-center">
          <div>
            {{ useHumanFriendlyNumber(row.prevCtr * 100, 1) }}%
          </div>
          <UTooltip :text="`${row.prevClicks} clicks`">
            <div class="text-xs">
              {{ useHumanFriendlyNumber(row.prevClicks) }} clicks
            </div>
          </UTooltip>
        </div>
      </template>
      <template #ctrPercent-data="{ row }">
        <TrendPercentage v-if="row.prevCtr" :value="row.ctr * 100" :prev-value="row.prevCtr * 100" symbol="%" />
        <UDivider v-else />
      </template>
      <template #actions-data>
        <UDropdown :items="[]">
          <UButton variant="link" icon="i-heroicons-ellipsis-vertical" color="gray" />
        </UDropdown>
      </template>
    </TableData>
  </div>
</template>


================================================
FILE: components/Table/TableNonIndexedUrls.vue
================================================
<script setup lang="ts">
import { joinURL, withBase, withHttps } from 'ufo'
import { defu } from 'defu'
import { useTimeAgo } from '~/composables/formatting'
import { createLogoutHandler, createSessionReloader, useAuthenticatedUser } from '~/composables/auth'
import type { GoogleSearchConsoleSite, SitePage } from '~/types'

const props = defineProps<{ mock?: boolean, value: SitePage[], site: GoogleSearchConsoleSite }>()

const logout = createLogoutHandler()
const user = useAuthenticatedUser()
const reloadSession = createSessionReloader()

const toast = useToast()
const params = useUrlSearchParams('history')
const autoRequestIndex = ref(false)

const q = ref(params.q || '')
const page = ref(params.page || 1)

watch(q, (q) => {
  params.q = q
})
watch(page, (page) => {
  params.page = page
})

const columns = [{
  key: 'url',
  label: 'URL',
  sortable: true,
}, {
  key: 'inspection',
  label: 'URL Inspection',
  icon: 'i-heroicons-magnifying-glass',
}, {
  key: 'requestIndexing',
  label: 'Request Indexing',
}, props.mock
  ? false
  : {
      key: 'actions',
    }].filter(Boolean)

const siteUrlFriendly = useFriendlySiteUrl(props.site.siteUrl)
const inspectionsLoading = ref([])
const submitIndexingLoading = ref([])
const updatedUrls = ref([])
async function inspectUrl(row: SitePage) {
  if (props.mock)
    return

  const siteUrl = withHttps(siteUrlFriendly)
  inspectionsLoading.value = [...inspectionsLoading.value, row.url]
  await $fetch<SitePage>(`/api/sites/${encodeURIComponent(props.site.siteUrl)}/${encodeURIComponent(withBase(row.url, siteUrl))}`, {
    timeout: 90000, // 90 seconds
  })
    .finally(() => {
      inspectionsLoading.value = inspectionsLoading.value.filter(url => url !== row.url)
    }).then((data) => {
      toast.add({
        color: 'green',
        title: `Inspected URL Successfully`,
        description: `Received a verdict of ${data.inspectionResult?.indexStatusResult.verdict}.`,
      })
      pushUpdatedUrls(data, row)
    })
    .catch(async (err) => {
      if (err.status === 401) {
        // make sure we have context
        await logout(true)
        toast.add({
          id: 'unauthorized-error',
          title: 'Oops, looks like session has expired.',
          description: 'Please login again to continue.',
          color: 'red',
        })
      }
      else if (err.status === 429) {
        toast.add({
          color: 'red',
          title: 'Rate Limited',
          description: err.statusText,
        })
      }
      else {
        toast.add({
          color: 'red',
          title: 'Failed to inspect the URL.',
          description: err.statusText || err.message,
        })
      }
    })
}

function getUpdatedRow(row: SitePage) {
  return updatedUrls.value.find(result => result.url === row.url) || row
}

function getUrlNotificationLatestUpdate(row: SitePage) {
  return updatedUrls.value.find(result => result.url === row.url)?.urlNotificationMetadata?.latestUpdate || row.urlNotificationMetadata?.latestUpdate
}

function pushUpdatedUrls(data: SitePage, row: SitePage) {
  let updatedUrl
  updatedUrls.value = updatedUrls.value.map((result) => {
    if (result.url === row.url) {
      updatedUrl = true
      return { ...defu(data, result, row), url: row.url }
    }
    return result
  })
  if (!updatedUrl)
    updatedUrls.value = [...updatedUrls.value, { ...defu(data, row), url: row.url }]
}
const { pause: pauseAutoRequestIndex, resume: resumeAutoRequestIndex } = useTimeoutPoll(pollForRequestIndex, 2000, { immediate: false })

async function submitForIndexing(row: SitePage) {
  if (props.mock)
    return

  const siteUrl = withHttps(siteUrlFriendly)
  submitIndexingLoading.value = [...submitIndexingLoading.value, row.url]
  const { url: data, status } = await $fetch<{ status: 'already-submitted' | 'submitted', url: SitePage }>(`/api/indexing/${encodeURIComponent(withBase(row.url, siteUrl))}`, {
    method: 'POST',
    timeout: 90000, // 90 seconds
    query: { siteUrl },
    onResponseError({ response }) {
      // handle 429
      if (response.status === 429) {
        toast.add({
          color: 'red',
          title: 'Rate Limited',
          description: response.statusText,
        })
      }
      else {
        toast.add({
          color: 'red',
          title: 'Failed to submit the URL for indexing.',
          description: response.statusText,
        })
      }
    },
  }).catch((e) => {
    pauseAutoRequestIndex()
    submitIndexingLoading.value = submitIndexingLoading.value.filter(url => url !== row.url)
    throw e
  })
  if (status === 'already-submitted') {
    const hoursAgo = useDayjs()(data.urlNotificationMetadata?.latestUpdate?.notifyTime).fromNow()
    toast.add({
      color: 'blue',
      title: 'Already Submitted',
      description: `This URL was submitted for indexing ${hoursAgo}.`,
    })
  }
  else {
    toast.add({
      color: 'green',
      title: 'Submitted',
      description: 'Your URL has been submitted for indexing.',
    })
    await reloadSession()
  }
  pushUpdatedUrls(data, row)
  submitIndexingLoading.value = submitIndexingLoading.value.filter(url => url !== row.url)
}

function hasOneHourPassed(date?: string | number) {
  if (!date)
    return true
  return useDayjs()().diff(useDayjs()(date), 'hour') >= 1
}

function hasOneDayPassed(date?: string | number) {
  if (!date)
    return true
  return useDayjs()().diff(useDayjs()(date), 'day') >= 1
}

function openUrl(url: string, target?: string) {
  window.open(url, target)
}

const visibleRows = ref([])

const { pause, resume } = useTimeoutPoll(pollForInspectUrl, 2000, { immediate: false })

async function pollForInspectUrl() {
  const row = [...visibleRows.value]
    .map(row => getUpdatedRow(row))
    .filter((row) => {
      if (row.inspectionResult?.indexStatusResult?.verdict && row.inspectionResult?.indexStatusResult?.verdict !== 'NEUTRAL')
        return false

      if (row.lastInspected) {
        // if url has been submitted for indexing, we will trigger it after one day
        if (row.urlNotificationMetadata?.latestUpdate?.type === 'URL_UPDATED')
          return hasOneDayPassed(row.lastInspected)
        return false
      }

      return true
    })
    .shift()
  if (row)
    await inspectUrl(row)
  else
    pause()
}

watch(visibleRows, () => {
  resume()
}, {
  immediate: true,
})

async function pollForRequestIndex() {
  const row = [...visibleRows.value]
    .map(row => getUpdatedRow(row))
    .filter(row => row.inspectionResult?.indexStatusResult?.verdict === 'NEUTRAL' && getUrlNotificationLatestUpdate(row)?.type !== 'URL_UPDATED')
    .shift()
  if (row)
    await submitForIndexing(row)
  else
    pauseAutoRequestIndex()
}

watch([visibleRows, autoRequestIndex], () => {
  if (autoRequestIndex.value && user.value.indexingOAuthIdNext)
    resumeAutoRequestIndex()
}, {
  immediate: true,
})

const filters = [
  {
    key: 'new',
    label: 'Hide Actioned',
    description: 'Hide pages indexed or requested indexing pages.',
    filter: (rows: any) => {
      return rows.filter((row) => {
        row = getUpdatedRow(row)
        if (row.inspectionResult?.indexStatusResult?.verdict && row.inspectionResult?.indexStatusResult?.verdict !== 'NEUTRAL')
          return false

        // if url has been submitted for indexing, we will trigger it after one day
        if (row.urlNotificationMetadata?.latestUpdate?.type === 'URL_UPDATED')
          return hasOneDayPassed(row.lastInspected)

        return true
      })
    },
  },
]
</script>

<template>
  <div v-if="!mock" class="flex justify-between mb-3">
    <div v-if="value?.length">
      <div>
        <UTooltip :ui="{ width: 'max-w-md' }" text="Non-Indexed pages are initially guessed from your page impressions.">
          <div><strong>{{ value.filter(row => getUpdatedRow(row).inspectionResult?.indexStatusResult?.verdict === 'NEUTRAL').length }}</strong> Confirmed non-indexed pages.</div>
        </UTooltip>
      </div>
      <div>
        <UTooltip :ui="{ width: 'max-w-md' }" text="Total pages that you've 'Requested Indexing' on">
          <div><strong>{{ value.filter(row => !!getUpdatedRow(row)?.urlNotificationMetadata?.latestUpdate).length }}</strong> Indexing requests.</div>
        </UTooltip>
      </div>
    </div>
    <div v-if="site.permissionLevel === 'siteOwner'">
      <UTooltip text="Automatically trigger the Request Indexing button.">
        <label class="flex items-center gap-2 text-sm font-semibold">
          Auto Request Indexing
          <UToggle
            v-model="autoRequestIndex"
            on-icon="i-heroicons-check-20-solid"
            off-icon="i-heroicons-x-mark-20-solid"
          />
        </label>
      </UTooltip>
    </div>
  </div>
  <TableData :columns="columns" :value="value" :filters="mock ? [] : filters" @update:rows="rows => visibleRows = rows">
    <template #url-data="{ row }">
      <div style="max-width: 400px;" class="flex flex-col">
        <UButton :title="row.url" variant="link" size="xs" :class="mock ? ['pointer-events-none'] : []" :to="joinURL(`https://${siteUrlFriendly}`, row.url)" target="_blank" color="gray" class="w-full">
          <div class="max-w-[300px] truncate text-ellipsis">
            {{ row.url }}
          </div>
        </UButton>
        <UTooltip v-if="getUpdatedRow(row)?.inspectionResult?.inspectionResultLink" mode="hover" text="View Inspection Result">
          <UButton size="xs" target="_blank" :to="mock ? undefined : getUpdatedRow(row)?.inspectionResult?.inspectionResultLink" icon="i-heroicons-document-magnifying-glass" color="gray" variant="link">
            View Inspection Report
          </UButton>
        </UTooltip>
      </div>
    </template>
    <template #inspection-data="{ row }">
      <div>
        <InspectionResult :value="getUpdatedRow(row)">
          <template v-if="getUrlNotificationLatestUpdate(row)?.type === 'URL_UPDATED' || getUpdatedRow(row)?.inspectionResult?.indexStatusResult?.verdict === 'NEUTRAL'">
            <UDivider class="my-3" />
            <div class="flex items-center justify-between">
              <div class="text-gray-600">
                <span class="text-sm">Inspected</span><br>
                {{ $dayjs(getUpdatedRow(row)?.lastInspected).fromNow() }}
              </div>
              <div v-if="getUpdatedRow(row)?.lastInspected && !hasOneHourPassed(row?.lastInspected)">
                <UButton :disabled="mock" color="gray" size="xs" class="mt-2" icon="i-heroicons-arrow-path" :loading="inspectionsLoading.includes(row.url)" @click="inspectUrl(row)">
                  Inspect Again
                </UButton>
              </div>
              <div v-else>
                <UButton :disabled="mock" color="gray" size="xs" class="mt-2" icon="i-heroicons-arrow-path" :loading="inspectionsLoading.includes(row.url)" @click="inspectUrl(row)">
                  Inspect Again
                </UButton>
              </div>
            </div>
          </template>
        </InspectionResult>
        <div v-if="!getUpdatedRow(row)?.lastInspected">
          <UButton size="xs" color="gray" :loading="inspectionsLoading.includes(row.url)" @click="inspectUrl(row)">
            Inspect
          </UButton>
        </div>
        <div v-else-if="hasOneHourPassed(getUpdatedRow(row)?.lastInspected) && getUpdatedRow(row)?.inspectionResult?.indexStatusResult?.verdict === 'NEUTRAL'">
          <UButton size="xs" color="gray" :loading="inspectionsLoading.includes(row.url)" @click="inspectUrl(row)">
            Inspect Again
          </UButton>
        </div>
      </div>
    </template>

    <template #requestIndexing-header>
      <div class="flex justify-end">
        <div class="flex items-center gap-2">
          Request Indexing
        </div>
      </div>
    </template>
    <template #requestIndexing-data="{ row }">
      <div class="flex justify-end">
        <div v-if="getUrlNotificationLatestUpdate(row)?.type !== 'URL_UPDATED' && getUpdatedRow(row)?.inspectionResult?.indexStatusResult.verdict === 'NEUTRAL'" class="flex items-center gap-2">
          <UButton :disabled="!mock && (!user?.indexingOAuthIdNext || site.permissionLevel !== 'siteOwner')" size="xs" :loading="submitIndexingLoading.includes(row.url)" icon="i-heroicons-arrow-up-circle" variant="outline" @click="submitForIndexing(row)">
            Request Indexing
          </UButton>
        </div>
        <div v-else-if="getUrlNotificationLatestUpdate(row)?.type === 'URL_UPDATED'" class="flex items-center text-right">
          <div><span class="text-xs">Submitted</span><br>{{ useTimeAgo(getUrlNotificationLatestUpdate(row).notifyTime) }}</div>
        </div>
        <div v-else>
          <div class="w-5 h-[2px] dark:bg-gray-800 bg-gray-200" />
        </div>
      </div>
    </template>
    <template #actions-data="{ row }">
      <UDropdown :items="[[{ label: 'Open URL', click: () => openUrl(row.url, '_blank'), icon: 'i-heroicons-arrow-up-right' }]]">
        <UButton variant="link" icon="i-heroicons-ellipsis-vertical" color="gray" />
      </UDropdown>
    </template>
  </TableData>
</template>


================================================
FILE: components/Table/TablePages.vue
================================================
<script setup lang="ts">
import { withLeadingSlash, withoutLeadingSlash } from 'ufo'
import type { GoogleSearchConsoleSite, GscDataRow } from '~/types/data'

withDefaults(
  defineProps<{ mock?: boolean, value?: GscDataRow[], site: GoogleSearchConsoleSite, pending?: boolean, pageCount?: number }>(),
  {
    pageCount: 8,
  },
)

const { user } = useUserSession()

const columns = computed(() => [
  {
    key: 'url',
    label: 'URL',
    sortable: true,
  },
  {
    key: 'clicks',
    label: 'Clicks',
    sortable: true,
  },
  (user.value?.analyticsPeriod === 'all')
    ? null
    : {
        key: 'clicksPercent',
        label: '%',
        sortable: true,
      },
  {
    key: 'impressions',
    label: 'Impressions',
    sortable: true,
  },
  (user.value?.analyticsPeriod === 'all')
    ? null
    : {
        key: 'impressionsPercent',
        label: '%',
        sortable: true,
      },
].filter(Boolean))

const filters = [
  {
    key: 'new',
    label: 'New',
    filter: (rows: T[]) => {
      return rows.filter(row => !row.prevImpressions)
    },
  },
  {
    key: 'improving',
    label: 'Improving',
    filter: (rows: T[]) => {
      return rows.filter(row => row.clicks > row.prevClicks)
    },
  },
  {
    key: 'declining',
    label: 'Declining',
    filter: (rows: T[]) => {
      return rows.filter(row => row.clicks < row.prevClicks)
    },
  },
  {
    key: 'top-level',
    special: true,
    label: 'Top Level',
    filter: (_rows: GscDataRow[]) => {
      const topLevelPaths = _rows.map(row => row.url.split('/').slice(1, 2)?.[0] || false)
      const uniqueTopLevelPaths = Array.from(new Set(topLevelPaths)).filter(Boolean)
      return uniqueTopLevelPaths.map((topLevelPath) => {
        const rows = _rows.filter(row => withoutLeadingSlash(row.url).startsWith(topLevelPath))
        const clicks = rows.reduce((acc, row) => acc + row.clicks, 0)
        const prevClicks = rows.reduce((acc, row) => acc + row.prevClicks, 0)
        const impressions = rows.reduce((acc, row) => acc + row.impressions, 0)
        const prevImpressions = rows.reduce((acc, row) => acc + row.prevImpressions, 0)
        // compute the avg keyword position
        const avgKeywordPosition = rows.reduce((acc, row) => acc + (row.keywordPosition || 0), 0) / rows.length
        return {
          url: withLeadingSlash(topLevelPath),
          keyword: rows[0].keyword,
          keywordPosition: avgKeywordPosition,
          clicks,
          prevClicks,
          impressions,
          prevImpressions,
        }
      })
    },
  },
]
// const siteUrlFriendly = useFriendlySiteUrl(props.siteUrl)

function highestRowClickCount(rows) {
  // do a sum of all clicks
  return rows.reduce((acc, row) => acc + row.clicks, 0)
}
//
// const selected = ref([])
// function select(row) {
//   const index = selected.value.findIndex(item => item.url === row.url)
//   if (index === -1)
//     selected.value.push(row)
//   else
//     selected.value.splice(index, 1)
// }

function openUrl(url: string, target?: string) {
  window.open(url, target)
}
</script>

<template>
  <div>
    <TableData :value="value" :columns="columns" :filters="filters" :page-count="pageCount">
      <template #url-data="{ row, rows }">
        <div class="flex items-center">
          <div class="relative group w-[260px] max-w-full">
            <div class="flex items-center">
              <UButton :title="`Open ${row.url}`" class="max-w-[260px]" variant="link" size="xs" :class="mock ? ['pointer-events-none'] : []" target="_blank" color="gray" @click="q = row.url">
                <div class="max-w-[220px] truncate text-ellipsis">
                  {{ row.url }}
                </div>
              </UButton>
              <UBadge v-if="!row.prevImpressions" size="xs" variant="subtle">
                New
              </UBadge>
              <UBadge v-else-if="row.lost" size="xs" color="red" variant="subtle">
                Lost
              </UBadge>
            </div>
            <UTooltip :text="`${Math.round((row.clicks / highestRowClickCount(rows)) * 100)}% of clicks`" class="w-full block">
              <UProgress :value="Math.round((row.clicks / highestRowClickCount(rows)) * 100)" color="blue" size="xs" class="ml-2 opacity-75 group-hover:opacity-100 transition py-1" />
            </UTooltip>
          </div>
        </div>
      </template>
      <template #keywordPosition-data="{ row }">
        <div class="flex items-center">
          <UButton :title="row.keyword" variant="link" size="xs" :class="mock ? ['pointer-events-none'] : []" :to="`/dashboard/site/${site.domain}/keywords?q=${encodeURIComponent(row.keyword)}`" color="gray">
            <div class="max-w-[150px] truncate text-ellipsis">
              <PositionMetric :value="row.keywordPosition" />
              {{ row.keyword }}
            </div>
          </UButton>
        </div>
      </template>
      <template #clicks-data="{ row }">
        <div class="text-center">
          <UDivider v-if="row.lostPage" />
          <UTooltip v-else :text="row.clicks" class="flex items-center justify-center gap-1">
            <IconClicks />
            {{ useHumanFriendlyNumber(row.clicks) }}
          </UTooltip>
        </div>
      </template>
      <template #clicksPercent-data="{ row }">
        <UDivider v-if="!row.prevImpressions" />
        <TrendPercentage v-else :value="row.clicks" :prev-value="row.prevClicks" />
      </template>
      <template #impressions-data="{ row }">
        <UTooltip :text="row.impressions" class="flex items-center justify-center gap-1">
          <IconImpressions />
          {{ useHumanFriendlyNumber(row.impressions) }}
        </UTooltip>
      </template>
      <template #impressionsPercent-data="{ row }">
        <UDivider v-if="!row.prevImpressions" />
        <TrendPercentage v-else :value="row.impressions" :prev-value="row.prevImpressions" />
      </template>
      <template #actions-data="{ row }">
        <UDropdown :items="[[{ label: 'Open URL', click: () => openUrl(row.url, '_blank') }], [{ label: 'URL Inspections', icon: 'i-heroicons-document-magnifying-glass', disabled: true }, (row.inspectionResult?.inspectionResultLink ? { label: 'View Inspection Result' } : undefined), { label: 'Inspect Index Status' }].filter(Boolean)]">
          <UButton variant="link" icon="i-heroicons-ellipsis-vertical" color="gray" />
        </UDropdown>
      </template>
    </TableData>
  </div>
</template>


================================================
FILE: components/TrendPercentage.vue
================================================
<script lang="ts" setup>
const props = defineProps<{
  prevValue?: string | number | null
  value: string | number
  symbol?: string
  negative?: boolean
}>()

const percentage = computed(() => {
  const prev = Number(props.prevValue)
  const current = Number(props.value)
  if (prev === 0)
    return 1
  return (current - prev) / ((prev + current) / 2)
})
</script>

<template>
  <UTooltip v-if="prevValue" :text="`${useHumanFriendlyNumber(Number(prevValue))}${symbol || ''} previous period`">
    <div v-if="percentage > 0 && !negative" class="text-sm items-center flex gap-1 text-green-500">
      <UIcon name="i-heroicons-arrow-trending-up" class="w-4 h-4 opacity-70" />
      <div>{{ useHumanFriendlyNumber(Math.round(percentage * 100)) }}%</div>
    </div>
    <div v-else class="text-sm  items-center flex gap-1 text-red-500">
      <UIcon name="i-heroicons-arrow-trending-down" class="w-4 h-4 opacity-70" />
      <div>{{ useHumanFriendlyNumber(Math.round(percentage * 100)) }}%</div>
    </div>
  </UTooltip>
</template>


================================================
FILE: composables/auth.ts
================================================
import type { ComputedRef } from 'vue'
import type { User } from '~/types'

export function useAuthenticatedUser() {
  const { loggedIn, user } = useUserSession()
  if (!loggedIn) {
    throw createError({
      statusCode: 401,
      message: 'Unauthorized',
    })
  }
  return user as ComputedRef<User>
}

export function createSessionReloader() {
  const { session } = useUserSession()
  return async () => {
    session.value = await $fetch('/api/_auth/session')
  }
}

// work around nuxt-auth-utils async context bug
export function createLogoutHandler() {
  const { session } = useUserSession()
  const toast = useToast()

  const nextTickFn = nextTick
  return async (force?: boolean) => {
    if (!force) {
      toast.add({ id: 'logout', title: 'See you next time!', description: 'You have logged out of the site.', color: 'green' })
      await navigateTo('/')
    }
    else {
      await navigateTo('/get-started')
    }
    await nextTickFn(() => {
      // can't access clear API here
      $fetch('/api/_auth/session', { method: 'DELETE' })
        .then(() => {
          session.value = {}
        })
        .catch(() => {})
    })
  }
}


================================================
FILE: composables/fetch.ts
================================================
import { createLogoutHandler } from '~/composables/auth'
import type { GoogleSearchConsoleSite } from '~/types'
import { useAsyncData } from '#imports'

export async function fetchSite(site: GoogleSearchConsoleSite, isMock?: boolean) {
  const toast = useToast()
  const logout = createLogoutHandler()
  const force = ref()
  const fetchFn = useRequestFetch()
  const res = await useAsyncData(
    `sites:${site.siteUrl}`,
    async () => await fetchFn(`/api/sites/${encodeURIComponent(site.siteUrl)}`, {
      query: { force: force.value },
      onResponseError: async ({ response }) => {
        if (response.status === 401) {
          // make sure we have context
          await logout(true)
          toast.add({
            id: 'unauthorized-error',
            title: 'Oops, looks like session has expired.',
            description: 'Please login again to continue.',
            color: 'red',
          })
        }
        else {
          toast.add({
            id: `sites:${site.siteUrl}:error`,
            title: `Failed to fetch ${site.siteUrl}`,
            description: response.statusText,
            color: 'red',
          })
        }
      },
    }),
    {
      server: false,
      deep: false,
      immediate: !isMock,
    },
  )
  return {
    ...res,
    async forceRefresh() {
      return callFnSyncToggleRef(res.refresh, force)
    },
  }
}

export async function fetchSites() {
  const force = ref()
  const toast = useToast()
  const logout = createLogoutHandler()
  const fetchFn = useRequestFetch()
  const res = await useAsyncData(
    `sites`,
    async () => await fetchFn('/api/sites/list', {
      query: { force: force.value },
      async onResponseError(res) {
        if ([401, 400].includes(res.response.status)) {
          // make sure we have context
          await logout(true)
          toast.add({
            id: 'unauthorized-error',
            title: 'Oops, looks like session has expired.',
            description: 'Please login again to continue.',
            color: 'red',
          })
        }
        else { toast.add({ id: 'unauthorized-error', title: 'Error fetching sites', description: res.error?.message, color: 'red' }) }
      },
    }),
    {
      server: true,
      deep: false,
    },
  )
  return {
    ...res,
    forceRefresh() {
      force.value = true
      res.refresh().then(() => {
        force.value = false
      })
    },
  }
}


================================================
FILE: composables/formatting.ts
================================================
import type { Ref } from '@vue/reactivity'
import type { type ComputedRef, MaybeRef } from 'vue'
import { withoutTrailingSlash } from 'ufo'

function useHumanFriendlyNumber(number: Ref<number>): ComputedRef<string>
function useHumanFriendlyNumber(number: number): string
export function useHumanFriendlyNumber(number: MaybeRef<number>) {
  const format = (number: number) => new Intl.NumberFormat('en', { notation: 'compact' }).format(number)
  if (isRef(number)) {
    return computed(() => {
      return format(number.value)
    })
  }
  // use intl to format the number, should have `k` or `m` suffix if needed
  return format(number)
}

export function useFriendlySiteUrl(url: string): string
export function useFriendlySiteUrl(url: MaybeRef<string>) {
  const format = (s: string) => withoutTrailingSlash(s.replace('https://', '').replace('sc-domain:', ''))
  if (isRef(url)) {
    return computed(() => {
      return format(url.value)
    })
  }
  // use intl to format the number, should have `k` or `m` suffix if needed
  return format(url)
}

export function useTimeAgo(date: string, absAgo?: boolean): string
export function useTimeAgo(date: MaybeRef<string>, absAgo?: boolean): string {
  const format = (_d: string) => {
    const d = useDayjs()(_d)
    const hourDiff = useDayjs()().diff(d, 'hour')
    if (hourDiff < 1 || absAgo)
      return d.fromNow()
    return `${hourDiff} hours ago`
  }
  if (isRef(date)) {
    return computed(() => {
      return format(date.value)
    })
  }
  return format(date)
}

export function useTimeHoursAgo(date: string): string
export function useTimeHoursAgo(date: MaybeRef<string>) {
  const format = (_d: string) => {
    const d = useDayjs()(_d)
    return useDayjs()().diff(d, 'hour')
  }
  if (isRef(date)) {
    return computed(() => {
      return format(date.value)
    })
  }
  return format(date)
}


================================================
FILE: composables/loader.ts
================================================
import type { Ref } from 'vue'

export async function callFnSyncToggleRef<T extends (() => (Promise<any> | any))>(
  fn: T,
  toggleRef: Ref<boolean>,
  thresholdMs: number = 550,
): Promise<ReturnType<T>> {
  toggleRef.value = true
  return callFnDelayedResolve(fn, thresholdMs).finally(() => {
    toggleRef.value = false
  })
}

export async function callFnDelayedResolve<T extends (() => (Promise<any> | any))>(
  fn: T,
  thresholdMs: number = 550,
): Promise<ReturnType<T>> {
  const res = await Promise.all([
    fn(),
    new Promise(resolve => setTimeout(resolve, thresholdMs)),
  ])
  return Array.isArray(res) ? res[0] : res
}


================================================
FILE: data/home.ts
================================================
export const NuxtSeoPages = [
  {
    url: '/',
    clicks: 654,
    prevClicks: 665,
    clicksPercent: -1.6679302501895377,
    impressions: 2414,
    impressionsPercent: -2.5761602944183193,
    prevImpressions: 2477,
  },
  {
    url: '/sitemap/getting-started/installation',
    clicks: 206,
    prevClicks: 242,
    clicksPercent: -16.071428571428573,
    impressions: 2807,
    impressionsPercent: 5.9427732942039615,
    prevImpressions: 2645,
  },
  {
    url: '/robots/getting-started/installation',
    clicks: 62,
    prevClicks: 58,
    clicksPercent: 6.666666666666667,
    impressions: 2216,
    impressionsPercent: 56.11095059231436,
    prevImpressions: 1245,
  },
  {
    url: '/og-image/guides/cache',
    clicks: 46,
    prevClicks: 49,
    clicksPercent: -6.315789473684211,
    impressions: 550,
    impressionsPercent: -6.848112379280071,
    prevImpressions: 589,
  },
  {
    url: '/experiments/guides/open-graph-images',
    clicks: 40,
    prevClicks: 22,
    clicksPercent: 58.06451612903226,
    impressions: 2332,
    impressionsPercent: 18.14780168381665,
    prevImpressions: 1944,
  },
  {
    url: '/sitemap/guides/prerendering',
    clicks: 39,
    prevClicks: 37,
    clicksPercent: 5.263157894736842,
    impressions: 821,
    impressionsPercent: 19.812583668005352,
    prevImpressions: 673,
  },
  {
    url: '/og-image/api/define-og-image',
    clicks: 32,
    prevClicks: 10,
    clicksPercent: 104.76190476190477,
    impressions: 207,
    impressionsPercent: 118.46153846153847,
    prevImpressions: 53,
  },
  {
    url: '/site-config/getting-started/installation',
    clicks: 27,
    prevClicks: 28,
    clicksPercent: -3.6363636363636362,
    impressions: 349,
    impressionsPercent: -124.98656636217088,
    prevImpressions: 1512,
  },
  {
    url: '/og-image/getting-started/installation',
    clicks: 26,
    prevClicks: 33,
    clicksPercent: -23.728813559322035,
    impressions: 1182,
    impressionsPercent: 21.867667761614264,
    prevImpressions: 949,
  },
  {
    url: '/sitemap/guides/dynamic-urls',
    clicks: 24,
    prevClicks: 17,
    clicksPercent: 34.146341463414636,
    impressions: 757,
    impressionsPercent: 24.296296296296298,
    prevImpressions: 593,
  },
  {
    url: '/nuxt-seo/guides/redirect-canonical',
    clicks: 22,
    prevClicks: 2,
    clicksPercent: 166.66666666666669,
    impressions: 339,
    impressionsPercent: 94.14316702819957,
    prevImpressions: 122,
  },
  {
    url: '/robots/guides/disable-indexing',
    clicks: 21,
    prevClicks: 3,
    clicksPercent: 150,
    impressions: 132,
    impressionsPercent: 123.92638036809815,
    prevImpressions: 31,
  },
  {
    url: '/sitemap/releases/v4',
    clicks: 20,
    prevClicks: 16,
    clicksPercent: 22.22222222222222,
    impressions: 405,
    impressionsPercent: 8.494208494208493,
    prevImpressions: 372,
  },
  {
    url: '/nuxt-seo/guides/title-templates',
    clicks: 19,
    prevClicks: 12,
    clicksPercent: 45.16129032258064,
    impressions: 641,
    impressionsPercent: -2.1604938271604937,
    prevImpressions: 655,
  },
  {
    url: '/schema-org/getting-started/installation',
    clicks: 19,
    prevClicks: 18,
    clicksPercent: 5.405405405405405,
    impressions: 654,
    impressionsPercent: 10.120481927710843,
    prevImpressions: 591,
  },
  {
    url: '/sitemap/api/config',
    clicks: 19,
    prevClicks: 10,
    clicksPercent: 62.06896551724138,
    impressions: 263,
    impressionsPercent: 41.284403669724774,
    prevImpressions: 173,
  },
  {
    url: '/robots/guides/robots-txt',
    clicks: 13,
    prevClicks: 6,
    clicksPercent: 73.68421052631578,
    impressions: 386,
    impressionsPercent: 129.21108742004265,
    prevImpressions: 83,
  },
  {
    url: '/robots/guides/route-rules',
    clicks: 13,
    prevClicks: 10,
    clicksPercent: 26.08695652173913,
    impressions: 350,
    impressionsPercent: -1.9801980198019802,
    prevImpressions: 357,
  },
  {
    url: '/nuxt-seo/api/breadcrumbs',
    clicks: 12,
    prevClicks: 4,
    clicksPercent: 100,
    impressions: 42,
    impressionsPercent: 50.74626865671642,
    prevImpressions: 25,
  },
  {
    url: '/site-config/getting-started/how-it-works',
    clicks: 12,
    prevClicks: 6,
    clicksPercent: 66.66666666666666,
    impressions: 1102,
    impressionsPercent: 6.077606358111267,
    prevImpressions: 1037,
  },
  {
    url: '/site-config/guides/setting-site-config',
    clicks: 12,
    prevClicks: 6,
    clicksPercent: 66.66666666666666,
    impressions: 80,
    impressionsPercent: -7.228915662650602,
    prevImpressions: 86,
  },
  {
    url: '/sitemap/guides/best-practices',
    clicks: 12,
    prevClicks: 2,
    clicksPercent: 142.85714285714286,
    impressions: 701,
    impressionsPercent: -20.601407549584135,
    prevImpressions: 862,
  },
  {
    url: '/sitemap/guides/multi-sitemaps',
    clicks: 12,
    prevClicks: 6,
    clicksPercent: 66.66666666666666,
    impressions: 324,
    impressionsPercent: -9.131075110456553,
    prevImpressions: 355,
  },
  {
    url: '/sitemap/integrations/i18n',
    clicks: 12,
    prevClicks: 4,
    clicksPercent: 100,
    impressions: 635,
    impressionsPercent: 0.949367088607595,
    prevImpressions: 629,
  },
  {
    url: '/robots/api/config',
    clicks: 11,
    prevClicks: 8,
    clicksPercent: 31.57894736842105,
    impressions: 371,
    impressionsPercent: 6.397774687065369,
    prevImpressions: 348,
  },
  {
    url: '/og-image/api/define-og-image-component',
    clicks: 10,
    prevClicks: 6,
    clicksPercent: 50,
    impressions: 45,
    impressionsPercent: 43.24324324324324,
    prevImpressions: 29,
  },
  {
    url: '/link-checker/getting-started/installation',
    clicks: 9,
    prevClicks: 4,
    clicksPercent: 76.92307692307693,
    impressions: 419,
    impressionsPercent: 24.966442953020135,
    prevImpressions: 326,
  },
  {
    url: '/nuxt-seo/guides/configuring-modules',
    clicks: 9,
    prevClicks: 5,
    clicksPercent: 57.14285714285714,
    impressions: 107,
    impressionsPercent: -7.207207207207207,
    prevImpressions: 115,
  },
  {
    url: '/og-image/guides/custom-fonts',
    clicks: 9,
    prevClicks: 6,
    clicksPercent: 40,
    impressions: 401,
    impressionsPercent: 71.40439932318104,
    prevImpressions: 190,
  },
  {
    url: '/nuxt-seo/migration-guide/nuxt-seo-kit',
    clicks: 8,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 343,
    impressionsPercent: 158.22454308093995,
    prevImpressions: 40,
  },
  {
    url: '/sitemap/guides/cache',
    clicks: 8,
    prevClicks: 4,
    clicksPercent: 66.66666666666666,
    impressions: 226,
    impressionsPercent: 41.06666666666667,
    prevImpressions: 149,
  },
  {
    url: '/sitemap/releases/v3',
    clicks: 8,
    prevClicks: 4,
    clicksPercent: 66.66666666666666,
    impressions: 181,
    impressionsPercent: 16.766467065868262,
    prevImpressions: 153,
  },
  {
    url: '/og-image/guides/satori',
    clicks: 7,
    prevClicks: 1,
    clicksPercent: 150,
    impressions: 54,
    impressionsPercent: 154.0983606557377,
    prevImpressions: 7,
  },
  {
    url: '/og-image/releases/v3',
    clicks: 7,
    prevClicks: 7,
    clicksPercent: 0,
    impressions: 273,
    impressionsPercent: -21.859706362153343,
    prevImpressions: 340,
  },
  {
    url: '/experiments/getting-started/installation',
    clicks: 6,
    prevClicks: 2,
    clicksPercent: 100,
    impressions: 1862,
    impressionsPercent: -5.8900182434193376,
    prevImpressions: 1975,
  },
  {
    url: '/robots/guides/disable-page-indexing',
    clicks: 6,
    prevClicks: 2,
    clicksPercent: 100,
    impressions: 108,
    impressionsPercent: 132.3076923076923,
    prevImpressions: 22,
  },
  {
    url: '/nuxt-seo/guides/fallback-title',
    clicks: 5,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 253,
    impressionsPercent: 80.33240997229917,
    prevImpressions: 108,
  },
  {
    url: '/nuxt-seo/guides/trailing-slashes',
    clicks: 5,
    prevClicks: 2,
    clicksPercent: 85.71428571428571,
    impressions: 18,
    impressionsPercent: -36.36363636363637,
    prevImpressions: 26,
  },
  {
    url: '/site-config/api/site-link',
    clicks: 5,
    prevClicks: 2,
    clicksPercent: 85.71428571428571,
    impressions: 30,
    impressionsPercent: 124.32432432432432,
    prevImpressions: 7,
  },
  {
    url: '/site-config/api/use-site-config',
    clicks: 5,
    prevClicks: 3,
    clicksPercent: 50,
    impressions: 27,
    impressionsPercent: 34.78260869565217,
    prevImpressions: 19,
  },
  {
    url: '/experiments/guides/nuxt-config-seo-meta',
    clicks: 4,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 343,
    impressionsPercent: 187.57062146892656,
    prevImpressions: 11,
  },
  {
    url: '/nuxt-seo/api/config',
    clicks: 4,
    prevClicks: 1,
    clicksPercent: 120,
    impressions: 20,
    impressionsPercent: 66.66666666666666,
    prevImpressions: 10,
  },
  {
    url: '/nuxt-seo/getting-started/installation',
    clicks: 4,
    prevClicks: 6,
    clicksPercent: -40,
    impressions: 1788,
    impressionsPercent: -5.281786005989654,
    prevImpressions: 1885,
  },
  {
    url: '/schema-org/guides/nodes',
    clicks: 4,
    prevClicks: 3,
    clicksPercent: 28.57142857142857,
    impressions: 84,
    impressionsPercent: 30.136986301369863,
    prevImpressions: 62,
  },
  {
    url: '/site-config/integrations/i18n',
    clicks: 4,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 505,
    impressionsPercent: 175.4646840148699,
    prevImpressions: 33,
  },
  {
    url: '/sitemap/guides/route-rules',
    clicks: 4,
    prevClicks: 5,
    clicksPercent: -22.22222222222222,
    impressions: 287,
    impressionsPercent: -13.333333333333334,
    prevImpressions: 328,
  },
  {
    url: '/ui',
    clicks: 4,
    prevClicks: 6,
    clicksPercent: -40,
    impressions: 47,
    impressionsPercent: -88.09523809523809,
    prevImpressions: 121,
  },
  {
    url: '/link-checker/releases/v2',
    clicks: 3,
    prevClicks: 6,
    clicksPercent: -66.66666666666666,
    impressions: 177,
    impressionsPercent: 42.465753424657535,
    prevImpressions: 115,
  },
  {
    url: '/robots/getting-started/how-it-works',
    clicks: 3,
    prevClicks: 4,
    clicksPercent: -28.57142857142857,
    impressions: 283,
    impressionsPercent: 161.66134185303514,
    prevImpressions: 30,
  },
  {
    url: '/schema-org/guides/default-schema-org',
    clicks: 3,
    prevClicks: 2,
    clicksPercent: 40,
    impressions: 23,
    impressionsPercent: 78.78787878787878,
    prevImpressions: 10,
  },
  {
    url: '/site-config/api/config',
    clicks: 3,
    prevClicks: 3,
    clicksPercent: 0,
    impressions: 8,
    impressionsPercent: -47.61904761904761,
    prevImpressions: 13,
  },
  {
    url: '/site-config/api/nuxt-hooks',
    clicks: 3,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 117,
    impressionsPercent: 174.4,
    prevImpressions: 8,
  },
  {
    url: '/site-config/guides/runtime-site-config',
    clicks: 3,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 110,
    impressionsPercent: 10.526315789473683,
    prevImpressions: 99,
  },
  {
    url: '/sitemap/api/schema',
    clicks: 3,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 104,
    impressionsPercent: 188.78504672897196,
    prevImpressions: 3,
  },
  {
    url: '/sitemap/guides/debugging',
    clicks: 3,
    prevClicks: 2,
    clicksPercent: 40,
    impressions: 72,
    impressionsPercent: -5.405405405405405,
    prevImpressions: 76,
  },
  {
    url: '/sitemap/guides/images-videos',
    clicks: 3,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 35,
    impressionsPercent: 50,
    prevImpressions: 21,
  },
  {
    url: '/sitemap/integrations/content',
    clicks: 3,
    prevClicks: 1,
    clicksPercent: 100,
    impressions: 205,
    impressionsPercent: 131.9838056680162,
    prevImpressions: 42,
  },
  {
    url: '/sitemap/releases/v5',
    clicks: 3,
    prevClicks: 1,
    clicksPercent: 100,
    impressions: 67,
    impressionsPercent: 12.698412698412698,
    prevImpressions: 59,
  },
  {
    url: '/link-checker/guides/live-inspections',
    clicks: 2,
    prevClicks: 4,
    clicksPercent: -66.66666666666666,
    impressions: 43,
    impressionsPercent: -6.741573033707865,
    prevImpressions: 46,
  },
  {
    url: '/og-image/api/components',
    clicks: 2,
    prevClicks: 1,
    clicksPercent: 66.66666666666666,
    impressions: 25,
    impressionsPercent: 170.37037037037038,
    prevImpressions: 2,
  },
  {
    url: '/og-image/api/define-og-image-screenshot',
    clicks: 2,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/getting-started/getting-familar-with-nuxt-og-image',
    clicks: 2,
    prevClicks: 1,
    clicksPercent: 66.66666666666666,
    impressions: 95,
    impressionsPercent: 130.43478260869566,
    prevImpressions: 20,
  },
  {
    url: '/og-image/guides/chromium',
    clicks: 2,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 24,
    impressionsPercent: 82.35294117647058,
    prevImpressions: 10,
  },
  {
    url: '/robots/guides/nuxt-config',
    clicks: 2,
    prevClicks: 2,
    clicksPercent: 0,
    impressions: 101,
    impressionsPercent: -12.093023255813954,
    prevImpressions: 114,
  },
  {
    url: '/sitemap/getting-started/data-sources',
    clicks: 2,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 8,
    impressionsPercent: 0,
    prevImpressions: 8,
  },
  {
    url: '/sitemap/guides/customising-ui',
    clicks: 2,
    prevClicks: 1,
    clicksPercent: 66.66666666666666,
    impressions: 8,
    impressionsPercent: 0,
    prevImpressions: 8,
  },
  {
    url: '/sitemap/guides/filtering-urls',
    clicks: 2,
    prevClicks: 1,
    clicksPercent: 66.66666666666666,
    impressions: 9,
    impressionsPercent: -20,
    prevImpressions: 11,
  },
  {
    url: '/sitemap/guides/submitting-sitemap',
    clicks: 2,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 17,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/sitemap/nitro-api/nitro-hooks',
    clicks: 2,
    prevClicks: 2,
    clicksPercent: 0,
    impressions: 44,
    impressionsPercent: -6.593406593406594,
    prevImpressions: 47,
  },
  {
    url: '/link-checker/api/config',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 3,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/link-checker/guides/exclude-links',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/getting-started',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/getting-started/what-is-nuxt-seo',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 99,
    impressionsPercent: -7.766990291262135,
    prevImpressions: 107,
  },
  {
    url: '/nuxt-seo/guides/default-meta',
    clicks: 1,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 35,
    impressionsPercent: 69.23076923076923,
    prevImpressions: 17,
  },
  {
    url: '/nuxt-seo/guides/disabling-modules',
    clicks: 1,
    prevClicks: 2,
    clicksPercent: -66.66666666666666,
    impressions: 4,
    impressionsPercent: -85.71428571428571,
    prevImpressions: 10,
  },
  {
    url: '/og-image/api/config',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 20,
    impressionsPercent: 75.86206896551724,
    prevImpressions: 9,
  },
  {
    url: '/og-image/guides/emojis',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 11,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/guides/icons-and-images',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/integrations/content',
    clicks: 1,
    prevClicks: 2,
    clicksPercent: -66.66666666666666,
    impressions: 54,
    impressionsPercent: 22.68041237113402,
    prevImpressions: 43,
  },
  {
    url: '/og-image/nitro-api/nitro-hooks',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 53,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/api/nuxt-hooks',
    clicks: 1,
    prevClicks: 5,
    clicksPercent: -133.33333333333331,
    impressions: 124,
    impressionsPercent: 65.24064171122996,
    prevImpressions: 63,
  },
  {
    url: '/robots/getting-started/features',
    clicks: 1,
    prevClicks: 3,
    clicksPercent: -100,
    impressions: 26,
    impressionsPercent: -105.45454545454544,
    prevImpressions: 84,
  },
  {
    url: '/robots/integrations/i18n',
    clicks: 1,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 703,
    impressionsPercent: 175.43391188251002,
    prevImpressions: 46,
  },
  {
    url: '/robots/nitro-api/get-site-indexable',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/releases/v4',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 67,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/schema-org/api/nuxt-hooks',
    clicks: 1,
    prevClicks: 2,
    clicksPercent: -66.66666666666666,
    impressions: 42,
    impressionsPercent: 111.11111111111111,
    prevImpressions: 12,
  },
  {
    url: '/site-config/getting-started/background',
    clicks: 1,
    prevClicks: 2,
    clicksPercent: -66.66666666666666,
    impressions: 611,
    impressionsPercent: 27.137546468401485,
    prevImpressions: 465,
  },
  {
    url: '/site-config/guides/debugging',
    clicks: 1,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 15,
    impressionsPercent: -46.15384615384615,
    prevImpressions: 24,
  },
  {
    url: '/experiments/api/config',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/experiments/getting-started/features',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 9,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/experiments/guides/app-icons',
    clicks: 0,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: -85.71428571428571,
    prevImpressions: 5,
  },
  {
    url: '/experiments/guides/open-graph-images',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 56,
    impressionsPercent: 83.54430379746836,
    prevImpressions: 23,
  },
  {
    url: '/experiments/guides/open-graph-images',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 56,
    impressionsPercent: 83.54430379746836,
    prevImpressions: 23,
  },
  {
    url: '/experiments/guides/open-graph-images',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 56,
    impressionsPercent: 83.54430379746836,
    prevImpressions: 23,
  },
  {
    url: '/experiments/guides/route-rules',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: -40,
    prevImpressions: 3,
  },
  {
    url: '/experiments/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 63,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/learn',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: -150,
    prevImpressions: 7,
  },
  {
    url: '/link-checker/guides/build-scans',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: 66.66666666666666,
    prevImpressions: 1,
  },
  {
    url: '/link-checker/guides/skip-inspections',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/link-checker/integrations/sitemap',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 7,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/getting-started/faq',
    clicks: 0,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 12,
    impressionsPercent: -147.25274725274727,
    prevImpressions: 79,
  },
  {
    url: '/nuxt-seo/guides/going-live',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 17,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/guides/i18n',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 17,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/guides/using-the-modules',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 12,
    impressionsPercent: 142.85714285714286,
    prevImpressions: 2,
  },
  {
    url: '/nuxt-seo/migration-guide/nuxt-seo-kit',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/migration-guide/nuxt-seo-kit',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/migration-guide/nuxt-seo-kit',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/nuxt-seo/migration-guide/nuxt-seo-kit',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/api/define-og-image',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 36,
    impressionsPercent: 160,
    prevImpressions: 4,
  },
  {
    url: '/og-image/api/define-og-image',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 36,
    impressionsPercent: 160,
    prevImpressions: 4,
  },
  {
    url: '/og-image/api/define-og-image',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 36,
    impressionsPercent: 160,
    prevImpressions: 4,
  },
  {
    url: '/og-image/api/nuxt-hooks',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 6,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/api/nuxt-seo-template',
    clicks: 0,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: -66.66666666666666,
    prevImpressions: 4,
  },
  {
    url: '/og-image/getting-started/examples',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: -66.66666666666666,
    prevImpressions: 4,
  },
  {
    url: '/og-image/getting-started/stackblitz',
    clicks: 0,
    prevClicks: 2,
    clicksPercent: 0,
    impressions: 20,
    impressionsPercent: -36.734693877551024,
    prevImpressions: 29,
  },
  {
    url: '/og-image/guides/compatibility',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/guides/jpegs',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/guides/styling',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/integrations/color-mode',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 59,
    impressionsPercent: 147.05882352941177,
    prevImpressions: 9,
  },
  {
    url: '/og-image/migration-guide/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 2,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/og-image/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: -160,
    prevImpressions: 9,
  },
  {
    url: '/og-image/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: -160,
    prevImpressions: 9,
  },
  {
    url: '/og-image/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: -160,
    prevImpressions: 9,
  },
  {
    url: '/robots/api/config',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 4,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/api/config',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 4,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/getting-started/stackblitz',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: -18.181818181818183,
    prevImpressions: 6,
  },
  {
    url: '/robots/integrations',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 4,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/integrations/content',
    clicks: 0,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 118,
    impressionsPercent: -9.67741935483871,
    prevImpressions: 130,
  },
  {
    url: '/robots/nitro-api/nitro-hooks',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 9,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/releases',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/robots/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 3,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/schema-org/api/config',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 68,
    impressionsPercent: 21.138211382113823,
    prevImpressions: 55,
  },
  {
    url: '/schema-org/getting-started',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 1,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/schema-org/getting-started/stackblitz',
    clicks: 0,
    prevClicks: 1,
    clicksPercent: 0,
    impressions: 9,
    impressionsPercent: 127.27272727272727,
    prevImpressions: 2,
  },
  {
    url: '/schema-org/guides/debugging',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 11,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/schema-org/guides/full-documentation',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 133.33333333333331,
    prevImpressions: 1,
  },
  {
    url: '/schema-org/guides/quick-setup',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 47,
    impressionsPercent: 32.098765432098766,
    prevImpressions: 34,
  },
  {
    url: '/site-config/api/create-site-path-resolver',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 5,
    impressionsPercent: 0,
    prevImpressions: 0,
  },
  {
    url: '/sitemap/getting-started/stackblitz',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 4,
    impressionsPercent: 120,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/guides/multi-sitemaps',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 6,
    impressionsPercent: 142.85714285714286,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/guides/multi-sitemaps',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 6,
    impressionsPercent: 142.85714285714286,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/guides/multi-sitemaps',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 6,
    impressionsPercent: 142.85714285714286,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 3,
    impressionsPercent: 100,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/releases/v3',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 3,
    impressionsPercent: 100,
    prevImpressions: 1,
  },
  {
    url: '/sitemap/releases/v4',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 47,
    impressionsPercent: 23.809523809523807,
    prevImpressions: 37,
  },
  {
    url: '/sitemap/releases/v4',
    clicks: 0,
    prevClicks: 0,
    clicksPercent: 0,
    impressions: 47,
    impressionsPercent: 23.809523809523807,
    prevImpressions: 37,
  },
]

export const NuxtSeoKeywords = [
  {
    keyword: 'nuxt seo',
    position: 2.369410861638841,
    positionPercent: 2.9748164400652812,
    prevPosition: 2.2999582811848143,
    ctr: 0.07944732297063903,
    ctrPercent: 2.895430519328157,
    prevCtr: 0.07717980809345015,
    clicks: 414,
    impressions: 5211,
  },
  {
    keyword: 'nuxtseo',
    position: 2.087403598971722,
    positionPercent: 2.2153622662066197,
    prevPosition: 2.041666666666667,
    ctr: 0.14395886889460155,
    ctrPercent: 15.9566158584213,
    prevCtr: 0.12268518518518519,
    clicks: 56,
    impressions: 389,
  },
  {
    keyword: 'nuxt-simple-sitemap',
    position: 2.8443579766536966,
    positionPercent: -4.286851327435932,
    prevPosition: 2.968962172647915,
    ctr: 0.03501945525291829,
    ctrPercent: -87.2642225799593,
    prevCtr: 0.0892337536372454,
    clicks: 45,
    impressions: 1285,
  },
  {
    keyword: 'nuxt simple sitemap',
    position: 2.896030245746692,
    positionPercent: -2.823958737766958,
    prevPosition: 2.978984238178634,
    ctr: 0.06427221172022685,
    ctrPercent: -39.91424271784616,
    prevCtr: 0.09632224168126094,
    clicks: 34,
    impressions: 529,
  },
  {
    keyword: 'nuxt seo kit',
    position: 2.7309941520467835,
    positionPercent: -9.916090188189237,
    prevPosition: 3.015929203539823,
    ctr: 0.08771929824561403,
    ctrPercent: 31.699815460323965,
    prevCtr: 0.06371681415929203,
    clicks: 30,
    impressions: 342,
  },
  {
    keyword: 'nuxt sitemap',
    position: 8.979700854700855,
    positionPercent: 2.8856875209472865,
    prevPosition: 8.724260355029585,
    ctr: 0.029914529914529916,
    ctrPercent: 39.15900131406045,
    prevCtr: 0.020118343195266272,
    clicks: 28,
    impressions: 936,
  },
  {
    keyword: 'nuxt-simple-robots',
    position: 3.494071146245059,
    positionPercent: 1.986636855913256,
    prevPosition: 3.425339366515837,
    ctr: 0.10276679841897234,
    ctrPercent: 3.1824611032531926,
    prevCtr: 0.09954751131221719,
    clicks: 26,
    impressions: 253,
  },
  {
    keyword: 'definesitemapeventhandler',
    position: 1.9903846153846154,
    positionPercent: -1.0014019627478599,
    prevPosition: 2.010416666666667,
    ctr: 0.20192307692307693,
    ctrPercent: 2.0040080160320706,
    prevCtr: 0.19791666666666666,
    clicks: 21,
    impressions: 104,
  },
  {
    keyword: 'nuxt seo module',
    position: 1.6939890710382515,
    positionPercent: 0.16617210682493275,
    prevPosition: 1.6911764705882353,
    ctr: 0.11475409836065574,
    ctrPercent: 18.22349570200574,
    prevCtr: 0.09558823529411764,
    clicks: 21,
    impressions: 183,
  },
  {
    keyword: 'nuxt-seo',
    position: 2.1271676300578033,
    positionPercent: -1.6917755871342168,
    prevPosition: 2.1634615384615383,
    ctr: 0.12138728323699421,
    ctrPercent: -29.541463414634155,
    prevCtr: 0.16346153846153846,
    clicks: 21,
    impressions: 173,
  },
  {
    keyword: 'nuxt seo sitemap',
    position: 2.166666666666667,
    positionPercent: -15.647921760391178,
    prevPosition: 2.5344827586206895,
    ctr: 0.16666666666666666,
    ctrPercent: -21.538461538461544,
    prevCtr: 0.20689655172413793,
    clicks: 20,
    impressions: 120,
  },
  {
    keyword: 'defineogimage',
    position: 1.2972972972972974,
    positionPercent: -46.41428704306576,
    prevPosition: 2.0813953488372094,
    ctr: 0.12162162162162163,
    ctrPercent: -21.658986175115196,
    prevCtr: 0.1511627906976744,
    clicks: 18,
    impressions: 148,
  },
  {
    keyword: '@nuxtseo/module',
    position: 1.353448275862069,
    positionPercent: -23.17009093971035,
    prevPosition: 1.7081339712918662,
    ctr: 0.10344827586206896,
    ctrPercent: 12.903225806451607,
    prevCtr: 0.09090909090909091,
    clicks: 12,
    impressions: 116,
  },
  {
    keyword: 'nuxt simple robots',
    position: 3.4324324324324325,
    positionPercent: 8.887606406002021,
    prevPosition: 3.1403508771929824,
    ctr: 0.16216216216216217,
    ctrPercent: 20.823244552058124,
    prevCtr: 0.13157894736842105,
    clicks: 12,
    impressions: 74,
  },
  {
    keyword: 'nuxt image cache',
    position: 4.5,
    positionPercent: 40,
    prevPosition: 3,
    ctr: 0.22727272727272727,
    ctrPercent: -0.56980056980057,
    prevCtr: 0.22857142857142856,
    clicks: 10,
    impressions: 44,
  },
  {
    keyword: '@nuxtjs/sitemap',
    position: 7.691029900332226,
    positionPercent: -15.370164931425029,
    prevPosition: 8.971563981042653,
    ctr: 0.026578073089700997,
    ctrPercent: 139.46706887883357,
    prevCtr: 0.004739336492890996,
    clicks: 8,
    impressions: 301,
  },
  {
    keyword: 'nuxt site config',
    position: 5.916666666666667,
    positionPercent: -5.200191418089011,
    prevPosition: 6.232558139534884,
    ctr: 0.14583333333333334,
    ctrPercent: 4.4142614601018755,
    prevCtr: 0.13953488372093023,
    clicks: 7,
    impressions: 48,
  },
  {
    keyword: 'nuxt 3 seo',
    position: 9.937007874015748,
    positionPercent: 46.33621386143734,
    prevPosition: 6.198675496688741,
    ctr: 0.047244094488188976,
    ctrPercent: -42.64003473729917,
    prevCtr: 0.0728476821192053,
    clicks: 6,
    impressions: 127,
  },
  {
    keyword: 'nuxt-seo-kit',
    position: 2.7515151515151515,
    positionPercent: -5.361482570819672,
    prevPosition: 2.9031007751937983,
    ctr: 0.030303030303030304,
    ctrPercent: -56.666666666666664,
    prevCtr: 0.05426356589147287,
    clicks: 5,
    impressions: 165,
  },
  {
    keyword: 'nuxt_public_site_url',
    position: 5.16,
    positionPercent: 8.115375213884134,
    prevPosition: 4.757575757575758,
    ctr: 0.1,
    ctrPercent: 49.056603773584904,
    prevCtr: 0.06060606060606061,
    clicks: 5,
    impressions: 50,
  },
  {
    keyword: 'nuxtjs sitemap',
    position: 7.231343283582089,
    positionPercent: -23.622118196194688,
    prevPosition: 9.168316831683168,
    ctr: 0.03731343283582089,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 5,
    impressions: 134,
  },
  {
    keyword: '@nuxtjs/seo',
    position: 6.404761904761905,
    positionPercent: -67.18571971135586,
    prevPosition: 12.884615384615385,
    ctr: 0.09523809523809523,
    ctrPercent: 84.93150684931507,
    prevCtr: 0.038461538461538464,
    clicks: 4,
    impressions: 42,
  },
  {
    keyword: 'nuxt robots',
    position: 6.512195121951219,
    positionPercent: 8.747926350541194,
    prevPosition: 5.966386554621849,
    ctr: 0.032520325203252036,
    ctrPercent: 25.325443786982255,
    prevCtr: 0.025210084033613446,
    clicks: 4,
    impressions: 123,
  },
  {
    keyword: 'nuxtjs/sitemap',
    position: 7.146067415730337,
    positionPercent: -11.2759643916914,
    prevPosition: 8,
    ctr: 0.0449438202247191,
    ctrPercent: 99.15014164305948,
    prevCtr: 0.015151515151515152,
    clicks: 4,
    impressions: 89,
  },
  {
    keyword: 'seo nuxt',
    position: 3.633663366336634,
    positionPercent: 40.62553919031262,
    prevPosition: 2.4066985645933014,
    ctr: 0.039603960396039604,
    ctrPercent: -18.851570964247017,
    prevCtr: 0.04784688995215311,
    clicks: 4,
    impressions: 101,
  },
  {
    keyword: 'seo nuxt 3',
    position: 7.666666666666667,
    positionPercent: 15.006150061500618,
    prevPosition: 6.5964912280701755,
    ctr: 0.12121212121212122,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 4,
    impressions: 33,
  },
  {
    keyword: 'nuxt og image',
    position: 6.7615384615384615,
    positionPercent: -3.6255090403055106,
    prevPosition: 7.011204481792717,
    ctr: 0.007692307692307693,
    ctrPercent: -74.40633245382585,
    prevCtr: 0.01680672268907563,
    clicks: 3,
    impressions: 390,
  },
  {
    keyword: 'nuxt prerender routes',
    position: 5.65625,
    positionPercent: 64.42658875091307,
    prevPosition: 2.9,
    ctr: 0.09375,
    ctrPercent: -95.95375722543352,
    prevCtr: 0.26666666666666666,
    clicks: 3,
    impressions: 32,
  },
  {
    keyword: 'nuxt schema org',
    position: 4.87719298245614,
    positionPercent: -1.2289129706178068,
    prevPosition: 4.9375,
    ctr: 0.05263157894736842,
    ctrPercent: 23.255813953488374,
    prevCtr: 0.041666666666666664,
    clicks: 3,
    impressions: 57,
  },
  {
    keyword: 'nuxt titletemplate',
    position: 8.147058823529413,
    positionPercent: -12.253634174618739,
    prevPosition: 9.210526315789474,
    ctr: 0.08823529411764706,
    ctrPercent: 108.10810810810811,
    prevCtr: 0.02631578947368421,
    clicks: 3,
    impressions: 34,
  },
  {
    keyword: 'nuxt v4',
    position: 5.735849056603773,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0.05660377358490566,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 3,
    impressions: 53,
  },
  {
    keyword: 'nuxt3 seo',
    position: 13.166666666666666,
    positionPercent: 43.07692307692307,
    prevPosition: 8.5,
    ctr: 0.5,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 3,
    impressions: 6,
  },
  {
    keyword: 'useseometa',
    position: 8.297777777777778,
    positionPercent: -67.9955402061295,
    prevPosition: 16.846153846153847,
    ctr: 0.013333333333333334,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 3,
    impressions: 225,
  },
  {
    keyword: 'nuxt hooks',
    position: 13.938461538461539,
    positionPercent: -59.12688557514767,
    prevPosition: 25.63888888888889,
    ctr: 0.015384615384615385,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 2,
    impressions: 130,
  },
  {
    keyword: 'nuxt robots.txt',
    position: 10.084615384615384,
    positionPercent: -20.275550071972035,
    prevPosition: 12.36,
    ctr: 0.015384615384615385,
    ctrPercent: 14.285714285714285,
    prevCtr: 0.013333333333333334,
    clicks: 2,
    impressions: 130,
  },
  {
    keyword: 'nuxt routerules',
    position: 10.96938775510204,
    positionPercent: -0.5565862708719879,
    prevPosition: 11.03061224489796,
    ctr: 0.02040816326530612,
    ctrPercent: 66.66666666666666,
    prevCtr: 0.01020408163265306,
    clicks: 2,
    impressions: 98,
  },
  {
    keyword: 'nuxt-site-config',
    position: 5.477611940298507,
    positionPercent: 5.474795544980093,
    prevPosition: 5.185714285714286,
    ctr: 0.029850746268656716,
    ctrPercent: -17.88617886178861,
    prevCtr: 0.03571428571428571,
    clicks: 2,
    impressions: 67,
  },
  {
    keyword: 'nuxtseo/module',
    position: 1,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0.16666666666666666,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 2,
    impressions: 12,
  },
  {
    keyword: 'sitemap nuxt',
    position: 7.5058823529411764,
    positionPercent: -22.541087649974063,
    prevPosition: 9.412698412698413,
    ctr: 0.023529411764705882,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 2,
    impressions: 85,
  },
  {
    keyword: 'defineogimagecomponent',
    position: 1.4,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0.2,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 5,
  },
  {
    keyword: 'google fonts nuxt',
    position: 26,
    positionPercent: -6.511627906976744,
    prevPosition: 27.75,
    ctr: 0.3333333333333333,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 3,
  },
  {
    keyword: 'i18n nuxt',
    position: 24.033898305084747,
    positionPercent: -17.237805780769662,
    prevPosition: 28.56756756756757,
    ctr: 0.00847457627118644,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 118,
  },
  {
    keyword: 'nitro prerender',
    position: 10.108695652173912,
    positionPercent: 20.2039121446115,
    prevPosition: 8.253731343283583,
    ctr: 0.010869565217391304,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 92,
  },
  {
    keyword: 'nitro route rules',
    position: 10.484848484848484,
    positionPercent: 4.022322838791196,
    prevPosition: 10.071428571428571,
    ctr: 0.030303030303030304,
    ctrPercent: -16.39344262295081,
    prevCtr: 0.03571428571428571,
    clicks: 1,
    impressions: 33,
  },
  {
    keyword: 'nuxt canonical',
    position: 12.181818181818182,
    positionPercent: -14.675374567806374,
    prevPosition: 14.11111111111111,
    ctr: 0.030303030303030304,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 33,
  },
  {
    keyword: 'nuxt canonical url',
    position: 8.26,
    positionPercent: -23.544670786602573,
    prevPosition: 10.464285714285714,
    ctr: 0.02,
    ctrPercent: -56.4102564102564,
    prevCtr: 0.03571428571428571,
    clicks: 1,
    impressions: 50,
  },
  {
    keyword: 'nuxt config hooks',
    position: 9.947368421052632,
    positionPercent: 12.807881773399018,
    prevPosition: 8.75,
    ctr: 0.05263157894736842,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 19,
  },
  {
    keyword: 'nuxt config routerules',
    position: 11.9,
    positionPercent: 32.76283618581906,
    prevPosition: 8.55,
    ctr: 0.05,
    ctrPercent: 0,
    prevCtr: 0.05,
    clicks: 1,
    impressions: 20,
  },
  {
    keyword: 'nuxt content i18n',
    position: 24.627906976744185,
    positionPercent: -8.753386094438842,
    prevPosition: 26.88235294117647,
    ctr: 0.011627906976744186,
    ctrPercent: -23.376623376623375,
    prevCtr: 0.014705882352941176,
    clicks: 1,
    impressions: 86,
  },
  {
    keyword: 'nuxt js seo',
    position: 12.421052631578947,
    positionPercent: 60.429877295561226,
    prevPosition: 6.656716417910448,
    ctr: 0.02631578947368421,
    ctrPercent: -95.71984435797664,
    prevCtr: 0.07462686567164178,
    clicks: 1,
    impressions: 38,
  },
  {
    keyword: 'nuxt js sitemap',
    position: 8,
    positionPercent: -6.0606060606060606,
    prevPosition: 8.5,
    ctr: 0.02040816326530612,
    ctrPercent: -84.05797101449278,
    prevCtr: 0.05,
    clicks: 1,
    impressions: 49,
  },
  {
    keyword: 'nuxt publicruntimeconfig',
    position: 20.125,
    positionPercent: 15.673141326188883,
    prevPosition: 17.2,
    ctr: 0.125,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 8,
  },
  {
    keyword: 'nuxt schema',
    position: 8.765625,
    positionPercent: 6.935233258801679,
    prevPosition: 8.178082191780822,
    ctr: 0.015625,
    ctrPercent: 13.138686131386867,
    prevCtr: 0.0136986301369863,
    clicks: 1,
    impressions: 64,
  },
  {
    keyword: 'nuxt title template',
    position: 7.409090909090909,
    positionPercent: 4.69849518241854,
    prevPosition: 7.068965517241379,
    ctr: 0.045454545454545456,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 22,
  },
  {
    keyword: 'nuxt-og-image',
    position: 6.4021739130434785,
    positionPercent: -9.060324413221634,
    prevPosition: 7.009756097560976,
    ctr: 0.0036231884057971015,
    ctrPercent: -120.61955469506292,
    prevCtr: 0.014634146341463415,
    clicks: 1,
    impressions: 276,
  },
  {
    keyword: 'nuxt-schema-org',
    position: 5.565656565656566,
    positionPercent: 15.088139738854355,
    prevPosition: 4.784810126582279,
    ctr: 0.010101010101010102,
    ctrPercent: -115.95744680851064,
    prevCtr: 0.0379746835443038,
    clicks: 1,
    impressions: 99,
  },
  {
    keyword: 'nuxt.config.ts',
    position: 40,
    positionPercent: 19.17808219178082,
    prevPosition: 33,
    ctr: 0.5,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 2,
  },
  {
    keyword: 'nuxt_site_env',
    position: 1.8333333333333335,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0.16666666666666666,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 6,
  },
  {
    keyword: 'nuxtjs seo',
    position: 8.971428571428572,
    positionPercent: -10.408873137756784,
    prevPosition: 9.956521739130435,
    ctr: 0.02857142857142857,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 35,
  },
  {
    keyword: 'nuxtjs/seo',
    position: 8.333333333333332,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0.3333333333333333,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 3,
  },
  {
    keyword: 'opengraph image url',
    position: 17,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 1,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 1,
    impressions: 1,
  },
  {
    keyword: '"seo"',
    position: 92,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: '<meta name="robots" content="max-image-preview:large">',
    position: 87,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: '<meta property="og:image" content=',
    position: 58,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: '<meta property="og:image" content="',
    position: 89,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: '@nuxt/content',
    position: 25.5,
    positionPercent: -9.345794392523365,
    prevPosition: 28,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: '@nuxt/devtools',
    position: 75,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: '@nuxt/i18n',
    position: 20,
    positionPercent: 5.128205128205128,
    prevPosition: 19,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: '@nuxt/schema',
    position: 8.25531914893617,
    positionPercent: 19.11733946873039,
    prevPosition: 6.814814814814815,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 47,
  },
  {
    keyword: '@nuxtjs/color-mode',
    position: 60,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: '@nuxtjs/i18n',
    position: 24.225806451612904,
    positionPercent: -41.72813487881981,
    prevPosition: 37,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 31,
  },
  {
    keyword: '@nuxtjs/robots',
    position: 6.3125,
    positionPercent: -35.10204081632653,
    prevPosition: 9,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 16,
  },
  {
    keyword: '@nuxtjs/sitmeap',
    position: 7,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: '@unhead/vue',
    position: 47,
    positionPercent: 16.091954022988507,
    prevPosition: 40,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'adsbot login',
    position: 21.583333333333332,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 12,
  },
  {
    keyword: 'app config nuxt',
    position: 21,
    positionPercent: -47.27272727272727,
    prevPosition: 34,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'article:published_time',
    position: 94,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'best sitemap',
    position: 150,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'best sitemaps',
    position: 101,
    positionPercent: 5.912334352701323,
    prevPosition: 95.2,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'cache engine',
    position: 78,
    positionPercent: -1.2738853503184715,
    prevPosition: 79,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'cache images',
    position: 73,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'cache photography',
    position: 50,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'cache seo',
    position: 88.8,
    positionPercent: -5.26315789473684,
    prevPosition: 93.6,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'canonical redirect',
    position: 70.4,
    positionPercent: -5.297308796936482,
    prevPosition: 74.23076923076923,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 20,
  },
  {
    keyword: 'cf_pages_url',
    position: 17.333333333333332,
    positionPercent: -0.9569377990430691,
    prevPosition: 17.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'cfg simple',
    position: 65,
    positionPercent: -16.901408450704224,
    prevPosition: 77,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'check open graph image',
    position: 88,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'chromium binary',
    position: 46.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'chromium rendering',
    position: 50,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'clear cache nuxt',
    position: 42.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'cloudflare sitemap',
    position: 64,
    positionPercent: 72.3404255319149,
    prevPosition: 30,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'colormode nuxt',
    position: 26.2,
    positionPercent: -76.99530516431923,
    prevPosition: 59,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'com sitemap',
    position: 97.5,
    positionPercent: 13.698630136986301,
    prevPosition: 85,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'config simple',
    position: 79,
    positionPercent: 47.84313725490196,
    prevPosition: 48.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'config site',
    position: 10,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'create og image',
    position: 20.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'custom robot txt',
    position: 114,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'debug nuxt',
    position: 21,
    positionPercent: 4.878048780487805,
    prevPosition: 20,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'defineeventhandler nuxt',
    position: 32,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'definei18nroute',
    position: 6.833333333333333,
    positionPercent: -1.1776931070315295,
    prevPosition: 6.914285714285715,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 66,
  },
  {
    keyword: 'definenitroplugin',
    position: 13.696428571428571,
    positionPercent: 5.482805607519809,
    prevPosition: 12.96551724137931,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 56,
  },
  {
    keyword: 'definenitroplugin is not defined',
    position: 14.5,
    positionPercent: 41.66666666666667,
    prevPosition: 9.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'definenuxtmodule',
    position: 28,
    positionPercent: 11.320754716981133,
    prevPosition: 25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'definepagemeta',
    position: 51.25,
    positionPercent: 40.00000000000001,
    prevPosition: 34.166666666666664,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'definepagemeta title',
    position: 10.375,
    positionPercent: 3.6809815950920246,
    prevPosition: 10,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'disable nuxt link',
    position: 38,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'discord image cache',
    position: 66,
    positionPercent: 35.714285714285715,
    prevPosition: 46,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'dynamic endpoint',
    position: 49,
    positionPercent: -5.9405940594059405,
    prevPosition: 52,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'dynamic url',
    position: 13.1,
    positionPercent: 27.418943068675006,
    prevPosition: 9.941176470588236,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 20,
  },
  {
    keyword: 'dynamic url seo',
    position: 101,
    positionPercent: 80.55555555555556,
    prevPosition: 43,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'dynamic urls',
    position: 56.4,
    positionPercent: 193.03135888501743,
    prevPosition: 1,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'dynamic urls seo',
    position: 67.66666666666667,
    positionPercent: -18.79044856058915,
    prevPosition: 81.7,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'easy robots',
    position: 60,
    positionPercent: -23.52941176470588,
    prevPosition: 76,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'endpoint urls',
    position: 1,
    positionPercent: -163.63636363636365,
    prevPosition: 10,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'experiments seo',
    position: 83.375,
    positionPercent: 3.3020066040132017,
    prevPosition: 80.66666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'generate open graph image',
    position: 59,
    positionPercent: -34.96503496503497,
    prevPosition: 84,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'get og image from url',
    position: 56,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'get site map',
    position: 94,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'github seo',
    position: 63.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'google font api',
    position: 82,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'google font inter',
    position: 79.2,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'google fonts inter',
    position: 80.86363636363636,
    positionPercent: 5.763855421686748,
    prevPosition: 76.33333333333333,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 22,
  },
  {
    keyword: 'google sitemap best practices',
    position: 80,
    positionPercent: 5.178791615289759,
    prevPosition: 75.96153846153847,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 13,
  },
  {
    keyword: 'google sitemap lastmod',
    position: 82.6,
    positionPercent: -5.951448707909175,
    prevPosition: 87.66666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'google-indexing-script',
    position: 39,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'googlefonts inter',
    position: 95,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'graph image png',
    position: 29,
    positionPercent: -65.11627906976744,
    prevPosition: 57,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'graph images',
    position: 87.25,
    positionPercent: 1.0079193664506838,
    prevPosition: 86.375,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'graph photo',
    position: 91,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'graph pics',
    position: 92.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'how many urls in sitemap',
    position: 53,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'how to disable indexing',
    position: 1,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'how to split large sitemap',
    position: 36.25,
    positionPercent: 4.946996466431095,
    prevPosition: 34.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping',
    position: 13,
    positionPercent: 8,
    prevPosition: 12,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'hyperlink checker',
    position: 68,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'i18 nuxt',
    position: 21.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'i18n alternatives',
    position: 56,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'i18n module',
    position: 55,
    positionPercent: 4.911092294665531,
    prevPosition: 52.36363636363637,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 10,
  },
  {
    keyword: 'i18n nuxt config',
    position: 17.25,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'i18n nuxt example',
    position: 58,
    positionPercent: 26.34146341463415,
    prevPosition: 44.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'i18n nuxt js',
    position: 33.38095238095238,
    positionPercent: -20.083413538658977,
    prevPosition: 40.833333333333336,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 21,
  },
  {
    keyword: 'i18n nuxt3',
    position: 28,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'i18n nuxtjs',
    position: 26.88235294117647,
    positionPercent: -0.436681222707422,
    prevPosition: 27,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 17,
  },
  {
    keyword: 'i18n seo',
    position: 38.333333333333336,
    positionPercent: -12.539184952978047,
    prevPosition: 43.46153846153846,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'image cache server',
    position: 34,
    positionPercent: 10.30927835051546,
    prevPosition: 30.666666666666668,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'image caching',
    position: 93,
    positionPercent: 7.82122905027933,
    prevPosition: 86,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'image open graph',
    position: 80.54838709677419,
    positionPercent: -9.695798053776727,
    prevPosition: 88.7560975609756,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 31,
  },
  {
    keyword: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
    position: 42.333333333333336,
    positionPercent: 34.10138248847927,
    prevPosition: 30,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'install nuxt',
    position: 59,
    positionPercent: 15.244299674267095,
    prevPosition: 50.642857142857146,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 27,
  },
  {
    keyword: 'inter google font',
    position: 81,
    positionPercent: -9.411764705882353,
    prevPosition: 89,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'inter google fonts',
    position: 83.57142857142857,
    positionPercent: -11.745776347546261,
    prevPosition: 94,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'kv cache explained',
    position: 69,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'kv caching',
    position: 71,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'lastmod',
    position: 76.875,
    positionPercent: 4.585956991802301,
    prevPosition: 73.42857142857143,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'lastmod sitemap format',
    position: 89,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'link checker',
    position: 109,
    positionPercent: -6.222222222222222,
    prevPosition: 116,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'link checker seo',
    position: 136.66666666666666,
    positionPercent: 41.06739032112166,
    prevPosition: 90.1025641025641,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 87,
  },
  {
    keyword: 'linkchecker',
    position: 128,
    positionPercent: 18.803418803418804,
    prevPosition: 106,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'linkcheker',
    position: 73,
    positionPercent: -11.612903225806452,
    prevPosition: 82,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'max-image-preview:large, max-snippet:-1, max-video-preview:-1',
    position: 46,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'max-snippet:-1',
    position: 51,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'max-video-preview:-1',
    position: 27,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'mehrere sitemaps',
    position: 49.3125,
    positionPercent: 10.821643286573146,
    prevPosition: 44.25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 16,
  },
  {
    keyword: 'meta image dimensions',
    position: 78,
    positionPercent: 1.2903225806451613,
    prevPosition: 77,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'meta name image',
    position: 71.5,
    positionPercent: 2.1201413427561837,
    prevPosition: 70,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'meta og image',
    position: 86.3125,
    positionPercent: -7.637840975043534,
    prevPosition: 93.16666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 16,
  },
  {
    keyword: 'meta og:image',
    position: 89.5,
    positionPercent: 16.314199395770395,
    prevPosition: 76,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'meta property og image',
    position: 85,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'meta property og image content',
    position: 87.4,
    positionPercent: 10.729355033152508,
    prevPosition: 78.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 10,
  },
  {
    keyword: 'meta property="og:image',
    position: 93,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'meta redirect seo',
    position: 98,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'meta tags for images',
    position: 93,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'multiple sitemap',
    position: 26.666666666666668,
    positionPercent: -20.476858345021032,
    prevPosition: 32.75,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'multiple sitemaps',
    position: 34.4054054054054,
    positionPercent: -1.33173802216096,
    prevPosition: 34.86666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 37,
  },
  {
    keyword: 'next js metadata open graph',
    position: 71.5,
    positionPercent: 17.490494296577946,
    prevPosition: 60,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'next open graph',
    position: 86,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'next.js og image',
    position: 89,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nextjs og image',
    position: 92,
    positionPercent: 9.885931558935368,
    prevPosition: 83.33333333333333,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nitro cache',
    position: 25.666666666666668,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nitro cloudflare',
    position: 41,
    positionPercent: -19.78021978021978,
    prevPosition: 50,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nitro cloudflare pages',
    position: 68,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nitro hooks',
    position: 9.333333333333334,
    positionPercent: 15.384615384615389,
    prevPosition: 8,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nitro middleware',
    position: 22,
    positionPercent: -7.299270072992706,
    prevPosition: 23.666666666666668,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nitro nuxt',
    position: 84,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nitro prerender routes',
    position: 7.666666666666667,
    positionPercent: 16.069221260815826,
    prevPosition: 6.526315789473684,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0.05263157894736842,
    clicks: 0,
    impressions: 21,
  },
  {
    keyword: 'nitro routerules',
    position: 10.708333333333334,
    positionPercent: -9.597575349385407,
    prevPosition: 11.787878787878787,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 48,
  },
  {
    keyword: 'nitro routes',
    position: 27,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nitro.prerender.routes',
    position: 5.857142857142857,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'npm satori',
    position: 61,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nu html checker',
    position: 91,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxi generate',
    position: 25.25,
    positionPercent: -14.9618320610687,
    prevPosition: 29.333333333333332,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'nuxot',
    position: 4,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt',
    position: 67.63636363636364,
    positionPercent: -10.251142361234097,
    prevPosition: 74.94444444444444,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 11,
  },
  {
    keyword: 'nuxt 18n',
    position: 21.333333333333332,
    positionPercent: -11.764705882352947,
    prevPosition: 24,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt 2 cache',
    position: 18,
    positionPercent: -5.405405405405405,
    prevPosition: 19,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 2 config',
    position: 34,
    positionPercent: 102.22222222222221,
    prevPosition: 11,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 2 redirect',
    position: 48,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt 2 robots.txt',
    position: 15.666666666666666,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt 2 runtime config',
    position: 20,
    positionPercent: 43.65482233502537,
    prevPosition: 12.833333333333334,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 2 seo',
    position: 8.357142857142858,
    positionPercent: -1.302009623549387,
    prevPosition: 8.466666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'nuxt 2 sitemap',
    position: 17.2,
    positionPercent: -51.88374596340151,
    prevPosition: 29.25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt 3 app config',
    position: 39,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 config',
    position: 41,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 env',
    position: 38,
    positionPercent: 11.11111111111111,
    prevPosition: 34,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt 3 env production',
    position: 28,
    positionPercent: -22.22222222222222,
    prevPosition: 35,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 env variables',
    position: 48,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 environment variables',
    position: 38.2,
    positionPercent: 12.475168851807714,
    prevPosition: 33.714285714285715,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt 3 font',
    position: 39,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 og image',
    position: 10,
    positionPercent: -26.08695652173913,
    prevPosition: 13,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 process.env',
    position: 31,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 production environment variables',
    position: 32.5,
    positionPercent: -1.5267175572519083,
    prevPosition: 33,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt 3 robots',
    position: 9.833333333333334,
    positionPercent: -1.6806722689075566,
    prevPosition: 10,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt 3 robots.txt',
    position: 26.357142857142858,
    positionPercent: 61.238938053097336,
    prevPosition: 14,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'nuxt 3 routerules',
    position: 40,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt 3 runtime config',
    position: 18,
    positionPercent: -5.405405405405405,
    prevPosition: 19,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt 3 runtimeconfig',
    position: 18,
    positionPercent: -20,
    prevPosition: 22,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt 3 sitemap',
    position: 95.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt 4.0',
    position: 6.428571428571429,
    positionPercent: -4.878048780487801,
    prevPosition: 6.75,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 7,
  },
  {
    keyword: 'nuxt access environment variables',
    position: 49,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt add font',
    position: 25,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt add fonts',
    position: 25,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt alternatives',
    position: 68,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt api party',
    position: 45,
    positionPercent: 33.00970873786408,
    prevPosition: 32.25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt api url',
    position: 39,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt app config',
    position: 36.2,
    positionPercent: -13.88174807197943,
    prevPosition: 41.6,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt app head',
    position: 43,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt app hooks',
    position: 55.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt appconfig',
    position: 54.5,
    positionPercent: 84.9673202614379,
    prevPosition: 22,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt assets images',
    position: 73,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt breadcrumb',
    position: 14,
    positionPercent: 24,
    prevPosition: 11,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt build environment variables',
    position: 44,
    positionPercent: -16.666666666666664,
    prevPosition: 52,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt build hooks',
    position: 21.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt cache',
    position: 25.884615384615383,
    positionPercent: 7.555898226676941,
    prevPosition: 24,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 26,
  },
  {
    keyword: 'nuxt cache data',
    position: 31,
    positionPercent: 10.16949152542373,
    prevPosition: 28,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt caching',
    position: 26.4,
    positionPercent: 28.57142857142856,
    prevPosition: 19.8,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 10,
  },
  {
    keyword: 'nuxt carousel',
    position: 57.111111111111114,
    positionPercent: -3.1688286822076486,
    prevPosition: 58.95,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 9,
  },
  {
    keyword: 'nuxt change page title',
    position: 17,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt change title',
    position: 8.75,
    positionPercent: 8.264462809917356,
    prevPosition: 8.055555555555555,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 16,
  },
  {
    keyword: 'nuxt chart',
    position: 44,
    positionPercent: -18.556701030927837,
    prevPosition: 53,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt clean cache',
    position: 20,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt cloudfare',
    position: 79.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt color mode',
    position: 30.25,
    positionPercent: -69.1891891891892,
    prevPosition: 62.25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 16,
  },
  {
    keyword: 'nuxt color-mode',
    position: 48,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt colormode',
    position: 23.666666666666668,
    positionPercent: -32.94117647058823,
    prevPosition: 33,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt config',
    position: 25.071428571428573,
    positionPercent: -32.49821045096635,
    prevPosition: 34.8,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'nuxt config components',
    position: 39,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt config file',
    position: 45,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt config meta',
    position: 10.833333333333334,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt content',
    position: 45.375,
    positionPercent: 16.37296875369587,
    prevPosition: 38.507936507936506,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 136,
  },
  {
    keyword: 'nuxt content api',
    position: 70,
    positionPercent: 45.614035087719294,
    prevPosition: 44,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt content blog',
    position: 67.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt content cache',
    position: 24.6,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt content document driven',
    position: 16,
    positionPercent: -34.48275862068966,
    prevPosition: 22.666666666666668,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0.16666666666666666,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt content image',
    position: 67.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt content images',
    position: 80,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt content module',
    position: 51.5,
    positionPercent: 25.95978062157222,
    prevPosition: 39.666666666666664,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt content pagination',
    position: 92,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt content prerender',
    position: 27.25,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'nuxt content search',
    position: 40,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt content sitemap',
    position: 16.583333333333336,
    positionPercent: -0.5012531328320518,
    prevPosition: 16.666666666666664,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 12,
  },
  {
    keyword: 'nuxt content table of contents',
    position: 59,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt context',
    position: 33,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt csr',
    position: 40.5,
    positionPercent: 72.26890756302521,
    prevPosition: 19,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'nuxt custom font',
    position: 12,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt custom fonts',
    position: 10.428571428571429,
    positionPercent: -80.57259713701431,
    prevPosition: 24.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 7,
  },
  {
    keyword: 'nuxt debug',
    position: 21.166666666666668,
    positionPercent: -10.750399148483236,
    prevPosition: 23.571428571428573,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt debugger',
    position: 30.166666666666668,
    positionPercent: 21.406727828746185,
    prevPosition: 24.333333333333332,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt debugging',
    position: 18.5,
    positionPercent: 20.8955223880597,
    prevPosition: 15,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt define props',
    position: 55.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt defineeventhandler',
    position: 40,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt definenitroplugin',
    position: 20.25,
    positionPercent: 26.573426573426573,
    prevPosition: 15.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'nuxt definenuxtconfig',
    position: 46,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt dev tools',
    position: 65.11111111111111,
    positionPercent: -1.3559322033898256,
    prevPosition: 66,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 9,
  },
  {
    keyword: 'nuxt devtools',
    position: 81,
    positionPercent: 25.39130434782609,
    prevPosition: 62.75,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'nuxt disable cache',
    position: 9.05,
    positionPercent: -17.632241813602015,
    prevPosition: 10.8,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 20,
  },
  {
    keyword: 'nuxt disable nitro',
    position: 29.6,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt download',
    position: 75,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt dynamic route',
    position: 87,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt dynamic routes',
    position: 53,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt env',
    position: 43,
    positionPercent: -18.309859154929576,
    prevPosition: 51.666666666666664,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt env config',
    position: 39.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt env variables',
    position: 54,
    positionPercent: -5.988023952095812,
    prevPosition: 57.333333333333336,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt env vars',
    position: 56.5,
    positionPercent: -17.00404858299595,
    prevPosition: 67,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt environment variables',
    position: 51.142857142857146,
    positionPercent: -7.569988801791704,
    prevPosition: 55.166666666666664,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 7,
  },
  {
    keyword: 'nuxt experimental',
    position: 9.294117647058824,
    positionPercent: -5.976258698321732,
    prevPosition: 9.866666666666667,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 17,
  },
  {
    keyword: 'nuxt fetch',
    position: 74,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt font',
    position: 23.285714285714285,
    positionPercent: -11.014492753623193,
    prevPosition: 26,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 7,
  },
  {
    keyword: 'nuxt font loader',
    position: 15.5,
    positionPercent: -30.136986301369863,
    prevPosition: 21,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt fonts',
    position: 19.857142857142858,
    positionPercent: -1.959038290293853,
    prevPosition: 20.25,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 14,
  },
  {
    keyword: 'nuxt gallery',
    position: 53,
    positionPercent: 3.8461538461538463,
    prevPosition: 51,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt generate dynamic routes',
    position: 57,
    positionPercent: 28.000000000000004,
    prevPosition: 43,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt generate sitemap',
    position: 25.5,
    positionPercent: 6.756756756756762,
    prevPosition: 23.833333333333332,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt get page title',
    position: 10.875,
    positionPercent: -25.125628140703515,
    prevPosition: 14,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 8,
  },
  {
    keyword: 'nuxt get started',
    position: 84,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt google font',
    position: 23,
    positionPercent: 6.741573033707865,
    prevPosition: 21.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 6,
  },
  {
    keyword: 'nuxt google fonts',
    position: 21.5,
    positionPercent: -22.273249138920782,
    prevPosition: 26.88888888888889,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 26,
  },
  {
    keyword: 'nuxt head title',
    position: 29,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt hook',
    position: 27,
    positionPercent: -12.173913043478262,
    prevPosition: 30.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0.25,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt i18',
    position: 23.6,
    positionPercent: 24.22802850356295,
    prevPosition: 18.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt i18n',
    position: 22.370967741935484,
    positionPercent: -5.511519660350168,
    prevPosition: 23.63888888888889,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 434,
  },
  {
    keyword: 'nuxt i18n change language',
    position: 70,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt i18n config',
    position: 22.4,
    positionPercent: -35.29411764705883,
    prevPosition: 32,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt i18n current locale',
    position: 38.44444444444444,
    positionPercent: -17.894736842105267,
    prevPosition: 46,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 9,
  },
  {
    keyword: 'nuxt i18n example',
    position: 44.02197802197802,
    positionPercent: -19.99146240086274,
    prevPosition: 53.8,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 91,
  },
  {
    keyword: 'nuxt i18n get current language',
    position: 59,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt i18n get current locale',
    position: 30.75,
    positionPercent: -13.871374527112238,
    prevPosition: 35.333333333333336,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 48,
  },
  {
    keyword: 'nuxt i18n locale',
    position: 52.083333333333336,
    positionPercent: 13.492741246797612,
    prevPosition: 45.5,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 12,
  },
  {
    keyword: 'nuxt i18n middleware',
    position: 60,
    positionPercent: 10.526315789473683,
    prevPosition: 54,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt i18n module',
    position: 26.6,
    positionPercent: 26.382978723404264,
    prevPosition: 20.4,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 5,
  },
  {
    keyword: 'nuxt i18n seo',
    position: 10.258064516129032,
    positionPercent: -91.06353229048327,
    prevPosition: 27.408163265306122,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 62,
  },
  {
    keyword: 'nuxt i18n set locale',
    position: 40.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt i18n strategy',
    position: 29.25,
    positionPercent: 22.503961965134714,
    prevPosition: 23.333333333333332,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 4,
  },
  {
    keyword: 'nuxt iamge',
    position: 31,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt image',
    position: 44.5,
    positionPercent: -35.85570924871169,
    prevPosition: 63.94117647058823,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 22,
  },
  {
    keyword: 'nuxt image background image',
    position: 57,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt image config',
    position: 15,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt image gallery',
    position: 77.5,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt image install',
    position: 7,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 2,
  },
  {
    keyword: 'nuxt image module',
    position: 20.666666666666668,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 3,
  },
  {
    keyword: 'nuxt image npm',
    position: 9,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 1,
  },
  {
    keyword: 'nuxt image sharp',
    position: 12.285714285714286,
    positionPercent: 0,
    prevPosition: 0,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 7,
  },
  {
    keyword: 'nuxt images',
    position: 60.31818181818182,
    positionPercent: -9.100266633595831,
    prevPosition: 66.06896551724138,
    ctr: 0,
    ctrPercent: 0,
    prevCtr: 0,
    clicks: 0,
    impressions: 44,
  },
  {
    keyword: 'nuxt img',
    position: 43.8888
Download .txt
gitextract_zzq12ri9/

├── .editorconfig
├── .eslintignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.yml
│   │   └── feature_request.yml
│   └── workflows/
│       └── release.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── app/
│   └── router.options.ts
├── app.d.ts
├── app.vue
├── components/
│   ├── Footer.vue
│   ├── GithubStar.vue
│   ├── GoogleSvg.vue
│   ├── Gradient.vue
│   ├── GraphClicks.vue
│   ├── Header.vue
│   ├── Icon/
│   │   ├── IconClicks.vue
│   │   └── IconImpressions.vue
│   ├── InspectionResult.vue
│   ├── MetricGuage.vue
│   ├── OgImage/
│   │   └── Home.vue
│   ├── PositionMetric.vue
│   ├── SiteCard.vue
│   ├── Table/
│   │   ├── TableData.vue
│   │   ├── TableKeywords.vue
│   │   ├── TableNonIndexedUrls.vue
│   │   └── TablePages.vue
│   └── TrendPercentage.vue
├── composables/
│   ├── auth.ts
│   ├── fetch.ts
│   ├── formatting.ts
│   └── loader.ts
├── data/
│   └── home.ts
├── error.vue
├── eslint.config.js
├── layouts/
│   ├── account.vue
│   ├── auth.vue
│   └── default.vue
├── middleware/
│   └── auth.global.ts
├── nuxt.config.ts
├── package.json
├── pages/
│   ├── account/
│   │   ├── index.vue
│   │   └── upgrade.vue
│   ├── admin/
│   │   └── index.vue
│   ├── dashboard/
│   │   ├── index.vue
│   │   └── site/
│   │       └── [slug].vue
│   ├── get-started.vue
│   ├── index.vue
│   ├── privacy.vue
│   └── terms.vue
├── robots.txt
├── server/
│   ├── api/
│   │   ├── admin/
│   │   │   └── usage.get.ts
│   │   ├── github/
│   │   │   └── repo.get.ts
│   │   ├── indexing/
│   │   │   ├── [url].post.ts
│   │   │   └── auth.delete.ts
│   │   ├── sites/
│   │   │   ├── [siteUrl]/
│   │   │   │   ├── [url].get.ts
│   │   │   │   └── crawl.post.ts
│   │   │   ├── [siteUrl].get.ts
│   │   │   └── list.get.ts
│   │   └── user/
│   │       ├── me.delete.ts
│   │       └── me.post.ts
│   ├── composables/
│   │   └── auth.ts
│   ├── email/
│   │   └── welcome.ts
│   ├── middleware/
│   │   └── auth.ts
│   ├── routes/
│   │   └── auth/
│   │       ├── google-indexing.get.ts
│   │       └── google.get.ts
│   ├── tsconfig.json
│   └── utils/
│       ├── api/
│       │   └── googleSearchConsole.ts
│       ├── auth/
│       │   └── googleAuthEventHandler.ts
│       ├── crawler/
│       │   ├── crawl.ts
│       │   └── robotsTxt.ts
│       ├── crypto.ts
│       ├── date.ts
│       ├── formatting.ts
│       ├── oauthPool.ts
│       ├── quota.ts
│       ├── session.ts
│       ├── sharedCache.ts
│       └── storage.ts
├── tailwind.config.ts
├── tsconfig.json
├── types/
│   ├── auth.ts
│   ├── data.ts
│   ├── index.ts
│   ├── nitro.d.ts
│   └── util.ts
└── vitest.config.ts
Download .txt
SYMBOL INDEX (88 symbols across 27 files)

FILE: app.d.ts
  type UserSession (line 2) | interface UserSession {

FILE: app/router.options.ts
  function findHashPosition (line 3) | function findHashPosition(hash): { el: any, behavior: ScrollBehavior, to...
  method scrollBehavior (line 19) | scrollBehavior(to, from, savedPosition) {

FILE: composables/auth.ts
  function useAuthenticatedUser (line 4) | function useAuthenticatedUser() {
  function createSessionReloader (line 15) | function createSessionReloader() {
  function createLogoutHandler (line 23) | function createLogoutHandler() {

FILE: composables/fetch.ts
  function fetchSite (line 5) | async function fetchSite(site: GoogleSearchConsoleSite, isMock?: boolean) {
  function fetchSites (line 49) | async function fetchSites() {

FILE: composables/formatting.ts
  function useHumanFriendlyNumber (line 7) | function useHumanFriendlyNumber(number: MaybeRef<number>) {
  function useFriendlySiteUrl (line 19) | function useFriendlySiteUrl(url: MaybeRef<string>) {
  function useTimeAgo (line 31) | function useTimeAgo(date: MaybeRef<string>, absAgo?: boolean): string {
  function useTimeHoursAgo (line 48) | function useTimeHoursAgo(date: MaybeRef<string>) {

FILE: composables/loader.ts
  function callFnSyncToggleRef (line 3) | async function callFnSyncToggleRef<T extends (() => (Promise<any> | any))>(
  function callFnDelayedResolve (line 14) | async function callFnDelayedResolve<T extends (() => (Promise<any> | any...

FILE: server/api/github/repo.get.ts
  type GitHubRepo (line 4) | interface GitHubRepo {

FILE: server/api/sites/[siteUrl].get.ts
  method shouldInvalidateCache (line 16) | shouldInvalidateCache(_, force?: boolean) {
  method getKey (line 19) | getKey({ user }: NitroAuthData, siteUrl: string) {

FILE: server/composables/auth.ts
  function getHashSecure (line 6) | function getHashSecure(input: any) {
  function getAuthenticatedData (line 12) | async function getAuthenticatedData(event: H3Event): Promise<H3Error | (...

FILE: server/email/welcome.ts
  function sendWelcomeEmail (line 15) | function sendWelcomeEmail(event: H3Event, email: string) {

FILE: server/routes/auth/google.get.ts
  method onSuccess (line 21) | async onSuccess(event, { user: _user, tokens }) {
  method onError (line 56) | onError(event, error) {

FILE: server/utils/api/googleSearchConsole.ts
  function formatDate (line 12) | function formatDate(date: Date = new Date()) {
  function createGoogleOAuthClient (line 16) | function createGoogleOAuthClient(credentials: Credentials, token?: { cli...
  function fetchGoogleSearchConsoleSites (line 27) | async function fetchGoogleSearchConsoleSites(credentials: Credentials): ...
  function recursiveQuery (line 35) | async function recursiveQuery(api: searchconsole_v1.Searchconsole, query...
  function fetchGoogleSearchConsoleAnalytics (line 52) | async function fetchGoogleSearchConsoleAnalytics(credentials: Credential...

FILE: server/utils/auth/googleAuthEventHandler.ts
  function googleAuthEventHandler (line 17) | function googleAuthEventHandler({

FILE: server/utils/crawler/crawl.ts
  type CrawlOptions (line 7) | interface CrawlOptions {
  function crawlSite (line 12) | async function crawlSite(options: CrawlOptions) {
  function processPage (line 55) | async function processPage(options: { robots: ParsedRobotsTxt, url: stri...
  function fetchRobots (line 106) | async function fetchRobots(options: { cacheKey: string, siteUrl: string ...
  function fetchSitemapUrls (line 124) | async function fetchSitemapUrls(options: { cacheKey: string, siteUrl: st...

FILE: server/utils/crawler/robotsTxt.ts
  type ParsedRobotsTxt (line 1) | interface ParsedRobotsTxt {
  type Arrayable (line 7) | type Arrayable<T> = T | T[]
  type RobotsGroupInput (line 9) | type RobotsGroupInput = GoogleInput | YandexInput
  type GoogleInput (line 11) | interface GoogleInput {
  type YandexInput (line 18) | interface YandexInput extends GoogleInput {
  type RobotsGroupResolved (line 22) | interface RobotsGroupResolved {
  function parseRobotsTxt (line 45) | function parseRobotsTxt(s: string): ParsedRobotsTxt {
  function asArray (line 117) | function asArray(v: any) {
  function indexableFromGroup (line 121) | function indexableFromGroup(groups: RobotsGroupInput[], path: string) {
  function generateRobotsTxt (line 140) | function generateRobotsTxt({ groups, sitemaps }: { groups: RobotsGroupRe...

FILE: server/utils/crypto.ts
  function encryptToken (line 4) | function encryptToken(token: string, secretKey: string): string {
  function decryptToken (line 21) | function decryptToken(encryptedToken: string, secretKey: string): string {

FILE: server/utils/date.ts
  function datePST (line 2) | function datePST() {

FILE: server/utils/formatting.ts
  function normalizeSiteUrl (line 3) | function normalizeSiteUrl(siteUrl: string) {
  function percentDifference (line 7) | function percentDifference(a?: number, b?: number) {

FILE: server/utils/oauthPool.ts
  function createOAuthPool (line 11) | function createOAuthPool() {

FILE: server/utils/quota.ts
  function getUserQuotaUsage (line 4) | async function getUserQuotaUsage(userId: string, key: keyof UserQuota) {
  function incrementUserQuota (line 9) | async function incrementUserQuota(userId: string, key: keyof UserQuota) {
  function getUserQuota (line 16) | async function getUserQuota(userId: string) {

FILE: server/utils/session.ts
  function mergeUserSessionData (line 7) | async function mergeUserSessionData(event: H3Event, data: Partial<UserSe...
  function _useSession (line 15) | function _useSession(event: H3Event) {

FILE: server/utils/sharedCache.ts
  method validate (line 12) | validate(item) {
  method shouldInvalidateCache (line 15) | shouldInvalidateCache(_, force?: boolean) {
  method transform (line 18) | transform(entry) {

FILE: server/utils/storage.ts
  type UserAppStorage (line 9) | interface UserAppStorage {}
  function normalizeSiteUrlForKey (line 11) | function normalizeSiteUrlForKey(siteUrl: string) {
  function userAppStorage (line 21) | function userAppStorage<T extends StorageValue = UserAppStorage>(userId:...
  function userSiteAppStorage (line 28) | function userSiteAppStorage<T extends StorageValue = UserAppStorage>(use...
  function updateUser (line 40) | async function updateUser(userId: string, value: Partial<User>) {
  function getUser (line 46) | function getUser(userId: string) {
  function getUserSite (line 50) | async function getUserSite(userId: string, siteUrl: string) {
  function updateUserSite (line 73) | async function updateUserSite(userId: string, siteUrl: string, payload: ...
  function clearUserStorage (line 78) | async function clearUserStorage(userId: string) {
  function updateUserToken (line 84) | async function updateUserToken(userId: string, key: 'indexing' | 'login'...
  function getUserToken (line 90) | async function getUserToken(userId: string, key: 'indexing' | 'login') {
  function deleteUserToken (line 97) | function deleteUserToken(userId: string, key: 'indexing' | 'login') {
  function incrementMetric (line 101) | async function incrementMetric(key: string) {
  function getMetric (line 108) | async function getMetric(key: string) {

FILE: types/auth.ts
  type NitroAuthData (line 4) | interface NitroAuthData {
  type UserOAuthToken (line 9) | type UserOAuthToken = RequiredNonNullable<Credentials>
  type TokenPayload (line 11) | interface TokenPayload {
  type OAuthPoolPayload (line 17) | interface OAuthPoolPayload {
  type OAuthPoolToken (line 22) | interface OAuthPoolToken {
  type UserQuota (line 29) | interface UserQuota {
  type User (line 33) | interface User {
  type UserSession (line 47) | interface UserSession {

FILE: types/data.ts
  type IndexedUrl (line 5) | interface IndexedUrl extends RequiredNonNullable<searchconsole_v1.Schema...
  type SitePage (line 7) | interface SitePage {
  type UserSite (line 16) | interface UserSite {
  type GoogleSearchConsoleSite (line 24) | interface GoogleSearchConsoleSite {
  type SiteAnalytics (line 29) | interface SiteAnalytics {
  type SiteExpanded (line 69) | interface SiteExpanded extends SiteAnalytics, GoogleSearchConsoleSite {

FILE: types/nitro.d.ts
  type H3EventContext (line 4) | interface H3EventContext {

FILE: types/util.ts
  type NonNullable (line 1) | type NonNullable<T> = Exclude<T, null | undefined>
  type RequiredNonNullable (line 3) | type RequiredNonNullable<T> = Required<Exclude<T, null | undefined>>
Condensed preview — 90 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (421K chars).
[
  {
    "path": ".editorconfig",
    "chars": 207,
    "preview": "# editorconfig.org\nroot = true\n\n[*]\nindent_size = 2\nindent_style = space\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_"
  },
  {
    "path": ".eslintignore",
    "chars": 65,
    "preview": "dist\nnode_modules\ntest/fixtures\nplayground/*\n\nsaas\n.unlighthouse\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 20,
    "preview": "github: [harlan-zw]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "chars": 507,
    "preview": "name: 🐞 Bug report\ndescription: Report an issue\nlabels: [pending triage]\nbody:\n  - type: markdown\n    attributes:\n      "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "chars": 572,
    "preview": "name: 🚀 New feature proposal\ndescription: Propose a new feature\nlabels: [enhancement]\nbody:\n  - type: markdown\n    attri"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 472,
    "preview": "name: Release\n\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - 'v*'\njobs:\n  release:\n    runs-on: ubuntu-l"
  },
  {
    "path": ".gitignore",
    "chars": 225,
    "preview": "# Nuxt dev/build outputs\n.output\n.data\n../.nuxt\n.nuxt\n.nitro\n.cache\ndist\n\n.saas\n\n# Node dependencies\nnode_modules\n\n# Log"
  },
  {
    "path": ".npmrc",
    "chars": 55,
    "preview": "shamefully-hoist=true\nignore-workspace-root-check=true\n"
  },
  {
    "path": "LICENSE",
    "chars": 1070,
    "preview": "MIT License\n\nCopyright (c) 2024 Harlan Wilton\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
  },
  {
    "path": "README.md",
    "chars": 3156,
    "preview": "<h1 align='center'>Request Indexing</h1>\n\n<p align=\"center\">\nGet your pages indexed on Google within 48 hours. (on avera"
  },
  {
    "path": "app/router.options.ts",
    "chars": 1271,
    "preview": "import type { RouterConfig } from '@nuxt/schema'\n\nfunction findHashPosition(hash): { el: any, behavior: ScrollBehavior, "
  },
  {
    "path": "app.d.ts",
    "chars": 166,
    "preview": "module '#auth-utils' {\n  export interface UserSession {\n    user?: {\n      userId: string\n      picture: string\n      in"
  },
  {
    "path": "app.vue",
    "chars": 1772,
    "preview": "<script setup>\nconst colorMode = useColorMode()\n\nconst color = computed(() => colorMode.value === 'dark' ? '#111827' : '"
  },
  {
    "path": "components/Footer.vue",
    "chars": 3863,
    "preview": "<script setup lang=\"ts\">\nconst toast = useToast()\nconst links = [\n  {\n    label: 'Request Indexing',\n    children: [\n   "
  },
  {
    "path": "components/GithubStar.vue",
    "chars": 1031,
    "preview": "<script lang=\"ts\" setup>\nimport { withBase, withoutBase } from 'ufo'\nimport { useFetch } from '#imports'\n\nconst props = "
  },
  {
    "path": "components/GoogleSvg.vue",
    "chars": 896,
    "preview": "<template>\n  <svg width=\"800\" height=\"800\" viewBox=\"-0.5 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"none\" fill"
  },
  {
    "path": "components/Gradient.vue",
    "chars": 1095,
    "preview": "<script lang=\"ts\" setup>\nconst colorMode = useColorMode()\nconst dark = computed(() => colorMode.value === 'dark')\n</scri"
  },
  {
    "path": "components/GraphClicks.vue",
    "chars": 5060,
    "preview": "<script lang=\"ts\" setup>\nimport { createChart } from 'lightweight-charts'\n\nconst props = defineProps<{\n  value: { time: "
  },
  {
    "path": "components/Header.vue",
    "chars": 10097,
    "preview": "<script setup lang=\"ts\">\nimport { createLogoutHandler } from '~/composables/auth'\nimport type { User } from '~/types'\n\nc"
  },
  {
    "path": "components/Icon/IconClicks.vue",
    "chars": 755,
    "preview": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"25\" height=\"25\" viewBox=\"0 0 24 24\" class=\"w-4 h-4 text-blue"
  },
  {
    "path": "components/Icon/IconImpressions.vue",
    "chars": 529,
    "preview": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"20\" viewBox=\"0 0 32 32\" class=\"w-4 h-4 text-purpl"
  },
  {
    "path": "components/InspectionResult.vue",
    "chars": 4559,
    "preview": "<script setup lang=\"ts\">\nimport { useTimeAgo } from '~/composables/formatting'\nimport type { SitePage } from '~/types'\n\n"
  },
  {
    "path": "components/MetricGuage.vue",
    "chars": 3762,
    "preview": "<script lang=\"ts\" setup>\nconst props = defineProps<{\n  score?: number\n}>()\n\nconst { score } = toRefs(props)\n\nconst arc ="
  },
  {
    "path": "components/OgImage/Home.vue",
    "chars": 6837,
    "preview": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n// convert to typescript props\nconst props = withDefaults(defin"
  },
  {
    "path": "components/PositionMetric.vue",
    "chars": 778,
    "preview": "<script lang=\"ts\" setup>\nconst props = defineProps<{ value: number }>()\n// we're showing the Position metric from Google"
  },
  {
    "path": "components/SiteCard.vue",
    "chars": 7562,
    "preview": "<script lang=\"ts\" setup>\nimport type { Ref } from 'vue'\nimport { toRef } from 'vue'\nimport { useFriendlySiteUrl } from '"
  },
  {
    "path": "components/Table/TableData.vue",
    "chars": 6102,
    "preview": "<script lang=\"ts\" setup generic=\"T extends Record<string, any>\">\nimport { useUrlSearchParams } from '@vueuse/core'\nimpor"
  },
  {
    "path": "components/Table/TableKeywords.vue",
    "chars": 7566,
    "preview": "<script setup lang=\"ts\">\nimport Fuse from 'fuse.js'\nimport type { GoogleSearchConsoleSite } from '~/types/data'\n\nconst p"
  },
  {
    "path": "components/Table/TableNonIndexedUrls.vue",
    "chars": 13111,
    "preview": "<script setup lang=\"ts\">\nimport { joinURL, withBase, withHttps } from 'ufo'\nimport { defu } from 'defu'\nimport { useTime"
  },
  {
    "path": "components/Table/TablePages.vue",
    "chars": 6454,
    "preview": "<script setup lang=\"ts\">\nimport { withLeadingSlash, withoutLeadingSlash } from 'ufo'\nimport type { GoogleSearchConsoleSi"
  },
  {
    "path": "components/TrendPercentage.vue",
    "chars": 1031,
    "preview": "<script lang=\"ts\" setup>\nconst props = defineProps<{\n  prevValue?: string | number | null\n  value: string | number\n  sym"
  },
  {
    "path": "composables/auth.ts",
    "chars": 1158,
    "preview": "import type { ComputedRef } from 'vue'\nimport type { User } from '~/types'\n\nexport function useAuthenticatedUser() {\n  c"
  },
  {
    "path": "composables/fetch.ts",
    "chars": 2421,
    "preview": "import { createLogoutHandler } from '~/composables/auth'\nimport type { GoogleSearchConsoleSite } from '~/types'\nimport {"
  },
  {
    "path": "composables/formatting.ts",
    "chars": 1863,
    "preview": "import type { Ref } from '@vue/reactivity'\nimport type { type ComputedRef, MaybeRef } from 'vue'\nimport { withoutTrailin"
  },
  {
    "path": "composables/loader.ts",
    "chars": 638,
    "preview": "import type { Ref } from 'vue'\n\nexport async function callFnSyncToggleRef<T extends (() => (Promise<any> | any))>(\n  fn:"
  },
  {
    "path": "data/home.ts",
    "chars": 167871,
    "preview": "export const NuxtSeoPages = [\n  {\n    url: '/',\n    clicks: 654,\n    prevClicks: 665,\n    clicksPercent: -1.667930250189"
  },
  {
    "path": "error.vue",
    "chars": 561,
    "preview": "<script setup lang=\"ts\">\nimport type { NuxtError } from '#app'\n\ndefineProps({\n  error: {\n    type: Object as PropType<Nu"
  },
  {
    "path": "eslint.config.js",
    "chars": 200,
    "preview": "import antfu from '@antfu/eslint-config'\n\nexport default antfu({\n  rules: {\n    'unicorn/prefer-node-protocol': 'off',\n "
  },
  {
    "path": "layouts/account.vue",
    "chars": 1609,
    "preview": "<script setup lang=\"ts\">\nimport DefaultLayout from './default.vue'\n\nconst user = useAuthenticatedUser()\n\nconst links = ["
  },
  {
    "path": "layouts/auth.vue",
    "chars": 337,
    "preview": "<script setup lang=\"ts\">\nuseHead({\n  bodyAttrs: {\n    class: 'dark:bg-gray-950',\n  },\n})\n</script>\n\n<template>\n  <div cl"
  },
  {
    "path": "layouts/default.vue",
    "chars": 112,
    "preview": "<template>\n  <div>\n    <Header />\n\n    <UMain>\n      <slot />\n    </UMain>\n\n    <Footer />\n  </div>\n</template>\n"
  },
  {
    "path": "middleware/auth.global.ts",
    "chars": 555,
    "preview": "const AuthenticatedRoutePrefixes = ['/dashboard', '/account']\nconst AdminRoutePrefixes = ['/admin']\n\nexport default defi"
  },
  {
    "path": "nuxt.config.ts",
    "chars": 3092,
    "preview": "import { env } from 'std-env'\nimport { hash } from 'ohash'\nimport type { OAuthPoolToken } from '~/types'\n\nlet tokens: Pa"
  },
  {
    "path": "package.json",
    "chars": 1325,
    "preview": "{\n  \"name\": \"requestindexing.com\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"packageManager\": \"pnp"
  },
  {
    "path": "pages/account/index.vue",
    "chars": 4779,
    "preview": "<script setup lang=\"ts\">\nimport { withHttps } from 'ufo'\nimport { useFriendlySiteUrl } from '~/composables/formatting'\n\n"
  },
  {
    "path": "pages/account/upgrade.vue",
    "chars": 2585,
    "preview": "<script setup lang=\"ts\">\ndefinePageMeta({\n  layout: 'account',\n})\n\nconst user = useAuthenticatedUser()\n</script>\n\n<templ"
  },
  {
    "path": "pages/admin/index.vue",
    "chars": 1146,
    "preview": "<script setup lang=\"ts\">\nconst links = [{\n  label: 'Users',\n  to: '/admin/users',\n  icon: 'i-heroicons-user-circle',\n}]\n"
  },
  {
    "path": "pages/dashboard/index.vue",
    "chars": 5263,
    "preview": "<script lang=\"ts\" setup>\nimport { fetchSites } from '~/composables/fetch'\n\nconst { user } = useUserSession()\n\nconst { da"
  },
  {
    "path": "pages/dashboard/site/[slug].vue",
    "chars": 14549,
    "preview": "<script lang=\"ts\" setup>\nimport { useAuthenticatedUser } from '~/composables/auth'\nimport type { GoogleSearchConsoleSite"
  },
  {
    "path": "pages/get-started.vue",
    "chars": 3126,
    "preview": "<script setup lang=\"ts\">\ndefinePageMeta({\n  layout: 'auth',\n})\n\nuseSeoMeta({\n  title: 'Login',\n})\n\nconst keyLogin = useR"
  },
  {
    "path": "pages/index.vue",
    "chars": 31993,
    "preview": "<script lang=\"ts\" setup>\nimport { NuxtSeoKeywords, NuxtSeoPages } from '~/data/home'\n\nconst { loggedIn, user } = useUser"
  },
  {
    "path": "pages/privacy.vue",
    "chars": 2009,
    "preview": "<template>\n  <UContainer>\n    <UPage>\n      <UPageHeader title=\"Privacy Policy\" description=\"We value your privacy and w"
  },
  {
    "path": "pages/terms.vue",
    "chars": 2123,
    "preview": "<script setup lang=\"ts\">\n</script>\n\n<template>\n  <UContainer>\n    <UPage>\n      <UPageHeader title=\"Terms of Service\" de"
  },
  {
    "path": "robots.txt",
    "chars": 71,
    "preview": "User-agent: *\nDisallow: /account\nDisallow: /admin\nDisallow: /dashboard\n"
  },
  {
    "path": "server/api/admin/usage.get.ts",
    "chars": 232,
    "preview": "import { getMetric } from '~/server/utils/storage'\n\nexport default defineEventHandler(async () => {\n  const pool = creat"
  },
  {
    "path": "server/api/github/repo.get.ts",
    "chars": 789,
    "preview": "import { getQuery } from 'h3'\nimport { $fetch } from 'ofetch'\n\nexport interface GitHubRepo {\n  id: number\n  name: string"
  },
  {
    "path": "server/api/indexing/[url].post.ts",
    "chars": 3227,
    "preview": "import { indexing } from '@googleapis/indexing'\nimport type { GaxiosError } from 'googleapis-common'\nimport { OAuth2Clie"
  },
  {
    "path": "server/api/indexing/auth.delete.ts",
    "chars": 1222,
    "preview": "import { OAuth2Client } from 'googleapis-common'\nimport { setUserSession } from '#imports'\nimport { deleteUserToken, get"
  },
  {
    "path": "server/api/sites/[siteUrl]/[url].get.ts",
    "chars": 2085,
    "preview": "import { searchconsole } from '@googleapis/searchconsole'\nimport type { GaxiosError } from 'googleapis-common'\nimport { "
  },
  {
    "path": "server/api/sites/[siteUrl]/crawl.post.ts",
    "chars": 2107,
    "preview": "export default defineEventHandler(async () => {\n  // TODO re-implement\n  // const { tokens, user } = event.context.authe"
  },
  {
    "path": "server/api/sites/[siteUrl].get.ts",
    "chars": 3264,
    "preview": "import { parseURL, withoutTrailingSlash } from 'ufo'\nimport { getUserSite, normalizeSiteUrlForKey } from '~/server/utils"
  },
  {
    "path": "server/api/sites/list.get.ts",
    "chars": 151,
    "preview": "export default defineEventHandler((event) => {\n  return fetchSitesCached(event.context.authenticatedData, String(getQuer"
  },
  {
    "path": "server/api/user/me.delete.ts",
    "chars": 828,
    "preview": "import { OAuth2Client } from 'googleapis-common'\nimport { clearUserStorage } from '~/server/utils/storage'\n\nexport defau"
  },
  {
    "path": "server/api/user/me.post.ts",
    "chars": 547,
    "preview": "import type { User } from '~/types'\nimport { updateUser, userMerger } from '~/server/utils/storage'\nimport { mergeUserSe"
  },
  {
    "path": "server/composables/auth.ts",
    "chars": 992,
    "preview": "import type { H3Error, H3Event } from 'h3'\nimport { hash } from 'ohash'\nimport type { TokenPayload, UserSession } from '"
  },
  {
    "path": "server/email/welcome.ts",
    "chars": 1007,
    "preview": "import type { H3Event } from 'h3'\nimport { ServerClient } from 'postmark'\n\nexport const WelcomeEmail = `Thanks for tryin"
  },
  {
    "path": "server/middleware/auth.ts",
    "chars": 1000,
    "preview": "import { isError } from 'h3'\nimport { getAuthenticatedData } from '~/server/composables/auth'\n\n// TODO move to route rul"
  },
  {
    "path": "server/routes/auth/google-indexing.get.ts",
    "chars": 3872,
    "preview": "import {\n  createError,\n  defineEventHandler,\n  getQuery,\n  getRequestURL,\n  sendRedirect,\n} from 'h3'\nimport { parsePat"
  },
  {
    "path": "server/routes/auth/google.get.ts",
    "chars": 2184,
    "preview": "import { defu } from 'defu'\nimport { getUser, getUserToken, incrementMetric, updateUserToken } from '~/server/utils/stor"
  },
  {
    "path": "server/tsconfig.json",
    "chars": 49,
    "preview": "{\n  \"extends\": \"../.nuxt/tsconfig.server.json\"\n}\n"
  },
  {
    "path": "server/utils/api/googleSearchConsole.ts",
    "chars": 6879,
    "preview": "import { parseURL } from 'ufo'\nimport type { Credentials } from 'google-auth-library'\nimport { OAuth2Client } from 'goog"
  },
  {
    "path": "server/utils/auth/googleAuthEventHandler.ts",
    "chars": 2531,
    "preview": "import {\n  createError,\n  eventHandler,\n  getQuery,\n  getRequestURL,\n  sendRedirect,\n} from 'h3'\nimport { parsePath, wit"
  },
  {
    "path": "server/utils/crawler/crawl.ts",
    "chars": 5704,
    "preview": "import { $fetch } from 'ofetch'\nimport { withBase } from 'ufo'\nimport Sitemapper from 'sitemapper'\nimport type { ParsedR"
  },
  {
    "path": "server/utils/crawler/robotsTxt.ts",
    "chars": 4910,
    "preview": "export interface ParsedRobotsTxt {\n  groups: RobotsGroupResolved[]\n  sitemaps: string[]\n  test: (path: string) => boolea"
  },
  {
    "path": "server/utils/crypto.ts",
    "chars": 1278,
    "preview": "import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'\n\n// Function to encrypt a token\nexport function e"
  },
  {
    "path": "server/utils/date.ts",
    "chars": 247,
    "preview": "// pacific timezone is 7 hours behind UTC, it has 3 letter abbreviation PST\nexport function datePST() {\n  const d = new "
  },
  {
    "path": "server/utils/formatting.ts",
    "chars": 313,
    "preview": "import { withHttps } from 'ufo'\n\nexport function normalizeSiteUrl(siteUrl: string) {\n  return siteUrl.startsWith('sc-dom"
  },
  {
    "path": "server/utils/oauthPool.ts",
    "chars": 2561,
    "preview": "import type { Storage } from 'unstorage'\nimport { prefixStorage } from 'unstorage'\nimport type { OAuthPoolPayload, OAuth"
  },
  {
    "path": "server/utils/quota.ts",
    "chars": 685,
    "preview": "import { datePST } from '~/server/utils/date'\nimport type { UserQuota } from '~/types'\n\nexport async function getUserQuo"
  },
  {
    "path": "server/utils/session.ts",
    "chars": 746,
    "preview": "import type { H3Event, SessionConfig } from 'h3'\nimport type { defuFn } from 'defu'\nimport { defu } from 'defu'\nimport {"
  },
  {
    "path": "server/utils/sharedCache.ts",
    "chars": 842,
    "preview": "import type { GoogleSearchConsoleSite, NitroAuthData } from '~/types'\nimport { fetchGoogleSearchConsoleSites } from '~/s"
  },
  {
    "path": "server/utils/storage.ts",
    "chars": 4065,
    "preview": "import type { Storage, StorageValue } from 'unstorage'\nimport { prefixStorage } from 'unstorage'\nimport { createDefu, de"
  },
  {
    "path": "tailwind.config.ts",
    "chars": 277,
    "preview": "import type { Config } from 'tailwindcss'\nimport defaultTheme from 'tailwindcss/defaultTheme'\n\nexport default <Partial<C"
  },
  {
    "path": "tsconfig.json",
    "chars": 94,
    "preview": "{\n  // https://nuxt.com/docs/guide/concepts/typescript\n  \"extends\": \"./.nuxt/tsconfig.json\"\n}\n"
  },
  {
    "path": "types/auth.ts",
    "chars": 1085,
    "preview": "import type { Credentials } from 'google-auth-library'\nimport type { RequiredNonNullable } from '~/types/util'\n\nexport i"
  },
  {
    "path": "types/data.ts",
    "chars": 1696,
    "preview": "import type { searchconsole_v1 } from '@googleapis/searchconsole/v1'\nimport type { indexing_v3 } from '@googleapis/index"
  },
  {
    "path": "types/index.ts",
    "chars": 46,
    "preview": "export * from './auth'\nexport * from './data'\n"
  },
  {
    "path": "types/nitro.d.ts",
    "chars": 163,
    "preview": "import type { NitroAuthData } from '~/types/auth'\n\ndeclare module 'h3' {\n  export interface H3EventContext {\n    authent"
  },
  {
    "path": "types/util.ts",
    "chars": 135,
    "preview": "export type NonNullable<T> = Exclude<T, null | undefined>\n\nexport type RequiredNonNullable<T> = Required<Exclude<T, null"
  },
  {
    "path": "vitest.config.ts",
    "chars": 210,
    "preview": "import { defineVitestConfig } from '@nuxt/test-utils/config'\n\nexport default defineVitestConfig({\n  // any custom Vitest"
  }
]

About this extraction

This page contains the full source code of the harlan-zw/request-indexing GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 90 files (388.7 KB), approximately 125.1k tokens, and a symbol index with 88 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!