6 Commits

Author SHA1 Message Date
Jakub Zych
9cc7f91318 chore: pin vue-seo-layer v0.2.0 2026-11-05 01:21:53 +01:00
Jakub Zych
5e5950ac50 chore: version 0.5.0 and install pin
- package.json version 0.5.0, README install line #v0.5.0 (L4 CR-01)
- Upgrading from 0.4: the HTML sitemap pages, the catch-all rule and
  seo.htmlSitemap.overCatchAll (L4 WR-04); an app without pages needs no
  htmlSitemap: false any more
- the link:../vue-seo-layer dependency stays until the release swap
2026-11-04 22:54:59 +01:00
Jakub Zych
7d952fa6de test: FaqBlock heading levels, labelling and FAQPage node, rendered on the server
The SFC is compiled with vue/compiler-sfc and rendered with
vue/server-renderer, the Nuxt composables stubbed: headingLevel 0 renders a
<p> that still labels the section, the FAQPage node has plain-text answers,
and a missing or empty FAQ renders the fallback slot.
2026-11-04 19:48:28 +01:00
Jakub Zych
f7ec4fe56f feat: FAQPage node and widget headings
- FaqBlock prop schema (default true) adds a FAQPage node (@id
  <page URL>#faq, one Question with its acceptedAnswer as plain text per
  item) to the page's one JSON-LD graph through vue-seo-layer's
  useSchemaNode (faqSchema, faqAnswerText)
- headingLevel 0 renders the title as <p class="faq-block__title"> with
  the same id, still labelling the section, for sidebar and footer
  widgets
- smoke: count= and repeatable options; expectations for the widget
  title, one FAQPage node and the answers in the HTML inside details
2026-11-04 14:02:05 +01:00
Jakub Zych
cb321e9437 feat!: extend the SEO layer and drop the copied purge route
- extend vue-seo-layer (link dependency during development); every page
  takes its head from the SEO plugin through it
- remove the copied shared purge route, library, plugin and their test:
  POST /api/__site__/cache/purge comes from vue-seo-layer with the same
  contract and environment variables
- the page-less playground sets seo.htmlSitemap false; the smoke points
  NUXT_SEO_API_BASE at the fixture API (fails open)

BREAKING CHANGE: server/api/__site__/cache/purge.post.ts,
server/lib/siteCachePurge.ts and server/plugins/site-cache-purge.ts are
no longer in this layer, and the runtime keys cachePurge and siteCache
are declared by vue-seo-layer. An app without pages sets
seo.htmlSitemap false.
2026-11-04 14:00:04 +01:00
Jakub Zych
1f23340a2f fix: keep the site cache purge token out of the page payload (NUXT_CACHE_PURGE_TOKEN)
nuxt-site-config copies every NUXT_SITE_* variable into the public site
config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as
cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied).

- The purge settings move to runtimeConfig cachePurge.token
  (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
  (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix.
- The old siteCache.* names keep working for one release: a non-empty
  new token wins, either allowProxied flag turns it on, and one
  deprecation warning per process names the variables (never a value).
- The shared Nitro plugin server/plugins/site-cache-purge.ts strips
  cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on
  site-config:init, so a site on the old names leaks nothing either; it
  does nothing without nuxt-site-config.
- The shared lib, route and plugin and tests/sitePurge.test.ts are
  byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer,
  with three sha256 pins.
- README: new names, the deprecation, the stripping and the advice to
  rotate a token ever set under the old name.
2026-10-20 20:49:34 +02:00
15 changed files with 455 additions and 545 deletions

View File

@@ -8,5 +8,8 @@
</template> </template>
</FaqBlock> </FaqBlock>
<FaqBlock slug="empty" /> <FaqBlock slug="empty" />
<aside class="playground-widget">
<FaqBlock slug="acme" :heading-level="0" :show-description="false" :schema="false" />
</aside>
</main> </main>
</template> </template>

View File

@@ -3,4 +3,8 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
extends: ['..'], extends: ['..'],
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
// An app without pages: vue-seo-layer's HTML sitemap pages would be its
// only routes and turn the router on, so every other path would answer
// 404. A site with pages keeps them.
seo: { htmlSitemap: false },
}) })

View File

@@ -3,22 +3,26 @@
A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `<details>` items. A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `<details>` items.
```bash ```bash
pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.2.0 pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.5.0
``` ```
## Overview ## Overview
The layer is consumed with `extends: ['vue-faq-layer']`. It reads one route of the FAQ plugin's anonymous API, `GET /_faq/api/v1/faqs/{slug}`, during SSR and renders it with `<FaqBlock slug="…">`. Editors manage FAQs in the SummerCMS admin; the site needs no FAQ data of its own. The layer is consumed with `extends: ['vue-faq-layer']`. It reads one route of the FAQ plugin's anonymous API, `GET /_faq/api/v1/faqs/{slug}`, during SSR and renders it with `<FaqBlock slug="…">`. Editors manage FAQs in the SummerCMS admin; the site needs no FAQ data of its own.
The layer extends [vue-seo-layer](https://git.golem15.com/golem15/vue-seo-layer), which sets every page's head from the SummerCMS SEO plugin and serves the shared site cache purge route; an app that extends vue-faq-layer gets it with no extra entry in `extends`.
The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--faq-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app. There is deliberately no component named `FaqSection`, so an app's own `FaqSection.vue` can wrap `FaqBlock`. The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--faq-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app. There is deliberately no component named `FaqSection`, so an app's own `FaqSection.vue` can wrap `FaqBlock`.
## Features ## Features
- **`FaqBlock`**: a `<section>` labelled by its heading (the FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq:<slug>:<locale>`). - **`FaqBlock`**: a `<section>` labelled by its heading (the FAQ `name`), the description and a `FaqList`. Fetched during SSR through `useAsyncData` (key `faq:<slug>:<locale>`).
- **`FaqList`** and **`FaqItem`**: one native `<details>` per question in API order, the question in `<summary>` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, so the browser keeps one open at a time. - **`FaqList`** and **`FaqItem`**: one native `<details>` per question in API order, the question in `<summary>` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, so the browser keeps one open at a time.
- **Structured data**: each `FaqBlock` adds a `FAQPage` node (`@id` `<page URL>#faq`, one `Question` with its `acceptedAnswer` as plain text per item) to the page's one JSON-LD `@graph` through vue-seo-layer's `useSchemaNode`, so the page keeps a single JSON-LD script. Blocks on the same page share the `@id`, so the first block's questions are the node; set `:schema="false"` on the others. `faqSchema(faq, pageUrl)` and `faqAnswerText(html)` in `app/utils/faqSchema.ts` build it.
- **Widget titles**: `headingLevel: 0` renders the title as `<p class="faq-block__title">` (same id, still labelling the section) for sidebar and footer widgets, which are styled text rather than content headings; content blocks keep `h2` to `h4`.
- **HTML**: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with `v-html` and adds no client sanitizer. Questions are plain text. - **HTML**: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with `v-html` and adds no client sanitizer. Questions are plain text.
- **Empty states**: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the `#fallback` slot. There is never an empty heading. - **Empty states**: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the `#fallback` slot. There is never an empty heading.
- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge). - **Site cache purge**: vue-seo-layer's shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge).
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server. - **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
## Usage ## Usage
@@ -43,10 +47,11 @@ export default defineNuxtConfig({
|---|---|---| |---|---|---|
| `slug` | required | The FAQ slug. | | `slug` | required | The FAQ slug. |
| `locale` | the API's default locale | Sent as `?locale=`. | | `locale` | the API's default locale | Sent as `?locale=`. |
| `headingLevel` | `2` | Heading element, `2` to `4`. | | `headingLevel` | `2` | Heading element, `2` to `4`; `0` renders a styled `<p class="faq-block__title">` for widgets. |
| `showHeading` | `true` | `false` hides the heading (the section is then labelled with `aria-label`), for an app that shows its own title. | | `showHeading` | `true` | `false` hides the heading (the section is then labelled with `aria-label`), for an app that shows its own title. |
| `showDescription` | `true` | `false` hides the description. | | `showDescription` | `true` | `false` hides the description. |
| `exclusive` | `false` | One item open at a time. | | `exclusive` | `false` | One item open at a time. |
| `schema` | `true` | Add the `FAQPage` node to the page's JSON-LD graph; `false` on every block but one when a page shows several FAQs. |
`FaqList` takes `items` (`{ question, answer }[]`), `exclusive` and `openFirst`; `FaqItem` takes `question`, `answer`, `name` and `open`. `FaqList` takes `items` (`{ question, answer }[]`), `exclusive` and `openFirst`; `FaqItem` takes `question`, `answer`, `name` and `open`.
@@ -59,27 +64,26 @@ export default defineNuxtConfig({
| `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) | | `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) |
| `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) | | `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) |
| `runtimeConfig.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
The purge settings (`runtimeConfig.cachePurge.token`, `NUXT_CACHE_PURGE_TOKEN`; `cachePurge.allowProxied`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`; the deprecated `siteCache.*` names) and the SEO options (`seo.*`, `NUXT_SEO_API_BASE` and the rest) are vue-seo-layer's; see its README.
The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary. The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary.
## Site cache purge ## Site cache purge
A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 } }`) shows an admin edit only when the cache window ends. The layer ships the shared purge route that the SummerCMS backend calls after an admin save commits: The purge route `POST /api/__site__/cache/purge` that the SummerCMS backend calls after an admin save commits lives in vue-seo-layer: one copy for every layer, which also empties the SEO layer's resolve cache. Its contract is unchanged (`Authorization: Bearer <NUXT_CACHE_PURGE_TOKEN>`, body `{"kind":"<kind>","slug":"<slug>"}`, answer `{"purged": <n>}`, 404 without a token of at least 32 characters, 401 for a wrong token, proxied requests refused unless `NUXT_CACHE_PURGE_ALLOW_PROXIED=true`). Set the token and the backend's `site_cache.purge_url` as vue-seo-layer's README describes.
``` ### Upgrading from 0.4
POST /api/__site__/cache/purge
Authorization: Bearer <NUXT_SITE_CACHE_PURGE_TOKEN>
{"kind":"faq","slug":"shipping"}
→ {"purged": <n>}
```
- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug. - The layer extends vue-seo-layer. Nothing is needed in the app's `extends`, but every page now resolves through the SEO plugin (`GET /_seo/api/v1/resolve`) and takes its head from the answer; set `NUXT_SEO_API_BASE` when the backend is not on `http://127.0.0.1:8096`, or `seo: { cms: false }` for a site without the SEO plugin.
- Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404. - `server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` and `server/plugins/site-cache-purge.ts` are removed from this layer: the same route, library and plugin come from vue-seo-layer, with the same environment variables.
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time). - **HTML sitemap pages.** The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default, set in `seo.htmlSitemap.paths`). An app that renders its pages through a root catch-all (`pages/[...slug].vue`, as with the pages plugin) gets none, so a CMS page at such a path is not shadowed, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps its page; `seo: { htmlSitemap: false }` turns them off. An app without pages of its own (only `app.vue`) gets none and needs no setting.
- Behind a TLS-terminating proxy, for a backend that reaches the site at an https `site_cache.purge_url` on another host, the proxy adds those headers. Set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.allowProxied`, default `false`) and the route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret. Unset, proxied requests stay refused.
- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache. ### Upgrading from 0.3
- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.4.x (a non-empty new token wins) and are removed in the next minor release.
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so in a site with nuxt-site-config a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.4 the layer strips both keys from the site config even under the old names.
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
## API ## API
@@ -129,6 +133,6 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d
## Testing ## Testing
```bash ```bash
pnpm test # node --test: the client and the site cache purge pnpm test # node --test: the client and the FAQPage node
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
``` ```

View File

@@ -3,7 +3,16 @@
// heading (the FAQ name), the server-sanitized description and the items. // heading (the FAQ name), the server-sanitized description and the items.
// A missing FAQ (404), a failed request or a FAQ without items renders // A missing FAQ (404), a failed request or a FAQ without items renders
// nothing, or the #fallback slot; never an empty heading. // nothing, or the #fallback slot; never an empty heading.
//
// headingLevel 0 renders the title as <p class="faq-block__title"> (same id,
// still the section's aria-labelledby) for sidebar and footer widgets, which
// are styled text, not content headings (D-52, UI-SPEC L6). With schema (the
// default) the block adds a FAQPage node to the page's one JSON-LD graph
// (vue-seo-layer's useSchemaNode, D-43); set schema false on every block but
// one when a page shows several FAQs. The answers are in the HTML inside
// <details>, so crawlers read them without opening an item.
import { computed, useId } from 'vue' import { computed, useId } from 'vue'
import { faqSchema } from '../../utils/faqSchema.ts'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -11,14 +20,16 @@ const props = withDefaults(
slug: string slug: string
/** ?locale= for the API; the API's default locale when unset. */ /** ?locale= for the API; the API's default locale when unset. */
locale?: string locale?: string
/** Level of the heading element, 2 to 4. */ /** Level of the heading element, 2 to 4; 0 renders a styled <p> (widgets). */
headingLevel?: 2 | 3 | 4 headingLevel?: 0 | 2 | 3 | 4
showHeading?: boolean showHeading?: boolean
showDescription?: boolean showDescription?: boolean
/** Only one item open at a time (native exclusive details). */ /** Only one item open at a time (native exclusive details). */
exclusive?: boolean exclusive?: boolean
/** Add the FAQPage node to the page's JSON-LD graph. */
schema?: boolean
}>(), }>(),
{ locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false }, { locale: undefined, headingLevel: 2, showHeading: true, showDescription: true, exclusive: false, schema: true },
) )
defineSlots<{ fallback?(): unknown }>() defineSlots<{ fallback?(): unknown }>()
@@ -29,9 +40,20 @@ const { data: faq, error } = await useFaq(
) )
const items = computed(() => (error.value ? [] : (faq.value?.content ?? []))) const items = computed(() => (error.value ? [] : (faq.value?.content ?? [])))
const headingTag = computed(() => `h${Math.min(4, Math.max(2, Math.trunc(Number(props.headingLevel)) || 2))}`) const headingTag = computed(() => {
const level = Math.trunc(Number(props.headingLevel))
return level === 0 ? 'p' : `h${Math.min(4, Math.max(2, level || 2))}`
})
const description = computed(() => (props.showDescription ? (faq.value?.description ?? '').trim() : '')) const description = computed(() => (props.showDescription ? (faq.value?.description ?? '').trim() : ''))
const headingId = `${useId()}-title` const headingId = `${useId()}-title`
const answer = useSeoAnswer()
const request = useRequestURL()
useSchemaNode(() => {
if (!props.schema || error.value || items.value.length === 0) return null
const url = answer.value?.action === 'ok' && /^https?:\/\//i.test(answer.value.url) ? answer.value.url : `${request.origin}${request.pathname}`
return faqSchema(faq.value, url)
})
</script> </script>
<template> <template>

53
app/utils/faqSchema.ts Normal file
View File

@@ -0,0 +1,53 @@
// The schema.org FAQPage node of a FAQ (D-43): one Question per item with its
// acceptedAnswer as plain text (the answer's HTML without markup), @id
// <page URL>#faq. FaqBlock adds it to the page's one JSON-LD @graph through
// vue-seo-layer's useSchemaNode (prop schema, default true), so a page keeps
// one JSON-LD script; a second block on the page with the same @id merges
// into the first (the first one's questions stay). Pure, so `node --test`
// drives it.
import type { Faq } from './types.ts'
export interface FaqPageNode {
'@type': 'FAQPage'
'@id': string
url: string
mainEntity: Array<{ '@type': 'Question'; name: string; acceptedAnswer: { '@type': 'Answer'; text: string } }>
[key: string]: unknown
}
const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
/** Plain text of an answer: tags dropped (script and style with their content), entities decoded, whitespace collapsed. */
export function faqAnswerText(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, code: string) => {
if (code[0] === '#') {
const n = code[1] === 'x' || code[1] === 'X' ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10)
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m
}
return NAMED[code.toLowerCase()] ?? m
})
.replace(/\s+/g, ' ')
.trim()
}
/** The FAQPage node of `faq` on the page at `pageUrl`, or null without questions. */
export function faqSchema(faq: Faq | null | undefined, pageUrl: string): FaqPageNode | null {
const items = (faq?.content ?? []).filter((item) => item && typeof item.question === 'string' && item.question.trim() !== '')
if (items.length === 0) return null
const url = pageUrl.split('#')[0] ?? pageUrl
return {
'@type': 'FAQPage',
'@id': `${url}#faq`,
url,
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question.trim(),
acceptedAnswer: { '@type': 'Answer', text: faqAnswerText(item.answer) },
})),
}
}

View File

@@ -4,7 +4,9 @@
// //
// Every path below is absolute and built from this file's directory: a // Every path below is absolute and built from this file's directory: a
// relative or ~ path in a layer config resolves against the consuming // relative or ~ path in a layer config resolves against the consuming
// project, not the layer. The layer declares no Nuxt modules. // project, not the layer. The layer declares no Nuxt modules; it extends
// vue-seo-layer, which owns the page head and the shared site cache purge
// route POST /api/__site__/cache/purge.
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const currentDir = fileURLToPath(new URL('.', import.meta.url)) const currentDir = fileURLToPath(new URL('.', import.meta.url))
@@ -13,6 +15,9 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: false }, devtools: { enabled: false },
// Resolved from this layer's own node_modules.
extends: ['vue-seo-layer'],
// Tokens first, then the neutral styles that use them. The app's own css // Tokens first, then the neutral styles that use them. The app's own css
// array loads after these, so its token overrides win. // array loads after these, so its token overrides win.
css: [`${currentDir}app/assets/css/faq-tokens.css`, `${currentDir}app/assets/css/faq-base.css`], css: [`${currentDir}app/assets/css/faq-tokens.css`, `${currentDir}app/assets/css/faq-base.css`],
@@ -23,19 +28,6 @@ export default defineNuxtConfig({
// from request input. // from request input.
apiBase: 'http://127.0.0.1:8096/_faq/api/v1', apiBase: 'http://127.0.0.1:8096/_faq/api/v1',
}, },
siteCache: {
// Server-only bearer token of the shared site cache purge route
// POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the
// backend's site_cache.purge_token. Shorter than 32 characters, the
// route answers 404.
purgeToken: '',
// NUXT_SITE_CACHE_ALLOW_PROXIED: set it to true when the backend reaches
// the site through a TLS-terminating proxy (an https site_cache.purge_url
// on another host), which adds X-Forwarded-For, X-Real-IP or Forwarded.
// The route then accepts proxied requests and the bearer token is the
// only gate; unset, it answers 404 to any proxied request.
allowProxied: false,
},
public: { public: {
faq: { faq: {
// Same-origin base for browser fetches (NUXT_PUBLIC_FAQ_API_BASE). // Same-origin base for browser fetches (NUXT_PUBLIC_FAQ_API_BASE).
@@ -43,12 +35,4 @@ export default defineNuxtConfig({
}, },
}, },
}, },
// The shared site routes are never route-cached. Nitro wraps every
// handler matching an app rule such as '/**': { swr } in its cached
// handler, which keys by path, does not skip POST and hides request
// headers, so the purge route would answer from the cache without running.
routeRules: {
'/api/__site__/**': { cache: false },
},
}) })

View File

@@ -1,6 +1,6 @@
{ {
"name": "vue-faq-layer", "name": "vue-faq-layer",
"version": "0.3.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./nuxt.config.ts", "main": "./nuxt.config.ts",
@@ -14,6 +14,9 @@
"engines": { "engines": {
"node": ">=22.18" "node": ">=22.18"
}, },
"dependencies": {
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"
},
"peerDependencies": { "peerDependencies": {
"nuxt": "4.4.8" "nuxt": "4.4.8"
}, },

206
pnpm-lock.yaml generated
View File

@@ -7,10 +7,14 @@ settings:
importers: importers:
.: .:
dependencies:
vue-seo-layer:
specifier: git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0
version: git+https://git.golem15.com/golem15/vue-seo-layer.git#3868f3d74dcebb7a35af43f1defcd68cfe2933b7(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))
devDependencies: devDependencies:
nuxt: nuxt:
specifier: 4.4.8 specifier: 4.4.8
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
typescript: typescript:
specifier: 5.9.3 specifier: 5.9.3
version: 5.9.3 version: 5.9.3
@@ -3256,6 +3260,13 @@ packages:
vite: vite:
optional: true optional: true
vue-seo-layer@git+https://git.golem15.com/golem15/vue-seo-layer.git#3868f3d74dcebb7a35af43f1defcd68cfe2933b7:
resolution: {commit: 3868f3d74dcebb7a35af43f1defcd68cfe2933b7, repo: https://git.golem15.com/golem15/vue-seo-layer.git, type: git}
version: 0.2.0
engines: {node: '>=22.18'}
peerDependencies:
nuxt: 4.4.8
vue@3.5.35: vue@3.5.35:
resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==} resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==}
peerDependencies: peerDependencies:
@@ -3350,20 +3361,20 @@ snapshots:
'@babel/compat-data@7.29.7': {} '@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7': '@babel/core@7.29.7(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8 '@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.29.7 '@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/helpers': 7.29.10 '@babel/helpers': 7.29.10
'@babel/parser': 7.29.9 '@babel/parser': 7.29.9
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
'@babel/types': 7.29.8 '@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -3390,41 +3401,41 @@ snapshots:
lru-cache: 5.1.1 lru-cache: 5.1.1
semver: 6.3.1 semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2)
'@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2)
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
semver: 6.3.1 semver: 6.3.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-globals@7.29.7': {} '@babel/helper-globals@7.29.7': {}
'@babel/helper-member-expression-to-functions@7.29.7': '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
'@babel/types': 7.29.8 '@babel/types': 7.29.8
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-module-imports@7.29.7': '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
'@babel/types': 7.29.8 '@babel/types': 7.29.8
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-module-imports': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2)
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3434,18 +3445,18 @@ snapshots:
'@babel/helper-plugin-utils@7.29.7': {} '@babel/helper-plugin-utils@7.29.7': {}
'@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2)
'@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7': '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
'@babel/types': 7.29.8 '@babel/types': 7.29.8
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3473,24 +3484,24 @@ snapshots:
dependencies: dependencies:
'@babel/types': 8.0.6 '@babel/types': 8.0.6
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2)
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3500,7 +3511,7 @@ snapshots:
'@babel/parser': 7.29.9 '@babel/parser': 7.29.9
'@babel/types': 7.29.8 '@babel/types': 7.29.8
'@babel/traverse@7.29.10': '@babel/traverse@7.29.10(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8 '@babel/generator': 7.29.8
@@ -3508,7 +3519,7 @@ snapshots:
'@babel/parser': 7.29.9 '@babel/parser': 7.29.9
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/types': 7.29.8 '@babel/types': 7.29.8
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3690,19 +3701,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
'@kwsites/file-exists@1.1.1': '@kwsites/file-exists@1.1.1(supports-color@10.2.2)':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@kwsites/promise-deferred@1.1.1': {} '@kwsites/promise-deferred@1.1.1': {}
'@mapbox/node-pre-gyp@2.0.3': '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)':
dependencies: dependencies:
consola: 3.4.2 consola: 3.4.2
detect-libc: 2.1.2 detect-libc: 2.1.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
node-fetch: 2.7.0 node-fetch: 2.7.0
nopt: 8.1.0 nopt: 8.1.0
semver: 7.8.5 semver: 7.8.5
@@ -3733,7 +3744,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.3 fastq: 1.20.3
'@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)': '@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)(supports-color@10.2.2)':
dependencies: dependencies:
'@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2) '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2)
'@clack/prompts': 1.8.1 '@clack/prompts': 1.8.1
@@ -3741,7 +3752,7 @@ snapshots:
citty: 0.2.2 citty: 0.2.2
confbox: 0.2.4 confbox: 0.2.4
consola: 3.4.2 consola: 3.4.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
defu: 6.1.7 defu: 6.1.7
exsolve: 1.1.3 exsolve: 1.1.3
fuse.js: 7.5.0 fuse.js: 7.5.0
@@ -3802,7 +3813,7 @@ snapshots:
pkg-types: 2.3.3 pkg-types: 2.3.3
semver: 7.8.5 semver: 7.8.5
'@nuxt/devtools@3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': '@nuxt/devtools@3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(supports-color@10.2.2)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))':
dependencies: dependencies:
'@nuxt/devtools-kit': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) '@nuxt/devtools-kit': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
'@nuxt/devtools-wizard': 3.4.2 '@nuxt/devtools-wizard': 3.4.2
@@ -3828,11 +3839,11 @@ snapshots:
perfect-debounce: 2.1.0 perfect-debounce: 2.1.0
pkg-types: 2.3.3 pkg-types: 2.3.3
semver: 7.8.5 semver: 7.8.5
simple-git: 3.36.0 simple-git: 3.36.0(supports-color@10.2.2)
sirv: 3.0.2 sirv: 3.0.2
structured-clone-es: 2.0.1 structured-clone-es: 2.0.1
tinyglobby: 0.2.17 tinyglobby: 0.2.17
unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vite: 7.3.7(jiti@2.7.0)(terser@5.51.2)
vite-plugin-inspect: 11.4.1(@nuxt/kit@4.6.0(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) vite-plugin-inspect: 11.4.1(@nuxt/kit@4.6.0(@nuxt/schema@4.4.8)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
vite-plugin-vue-tracer: 1.5.0(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) vite-plugin-vue-tracer: 1.5.0(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))
@@ -3930,7 +3941,7 @@ snapshots:
- magic-string - magic-string
- unplugin - unplugin
'@nuxt/nitro-server@4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))': '@nuxt/nitro-server@4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))':
dependencies: dependencies:
'@nuxt/devalue': 2.0.2 '@nuxt/devalue': 2.0.2
'@nuxt/kit': 4.4.8(magicast@0.5.5) '@nuxt/kit': 4.4.8(magicast@0.5.5)
@@ -3947,8 +3958,8 @@ snapshots:
impound: 1.2.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) impound: 1.2.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
klona: 2.0.6 klona: 2.0.6
mocked-exports: 0.1.1 mocked-exports: 0.1.1
nitropack: 2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) nitropack: 2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
nypm: 0.6.10 nypm: 0.6.10
ohash: 2.0.12 ohash: 2.0.12
pathe: 2.0.3 pathe: 2.0.3
@@ -3956,12 +3967,12 @@ snapshots:
std-env: 4.3.0 std-env: 4.3.0
ufo: 1.6.4 ufo: 1.6.4
unctx: 2.5.0 unctx: 2.5.0
unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
vue: 3.5.35(typescript@5.9.3) vue: 3.5.35(typescript@5.9.3)
vue-bundle-renderer: 2.4.0 vue-bundle-renderer: 2.4.0
vue-devtools-stub: 0.1.0 vue-devtools-stub: 0.1.0
optionalDependencies: optionalDependencies:
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
transitivePeerDependencies: transitivePeerDependencies:
- '@azure/app-configuration' - '@azure/app-configuration'
- '@azure/cosmos' - '@azure/cosmos'
@@ -4026,12 +4037,12 @@ snapshots:
rc9: 3.1.0 rc9: 3.1.0
std-env: 4.3.0 std-env: 4.3.0
'@nuxt/vite-builder@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))': '@nuxt/vite-builder@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))':
dependencies: dependencies:
'@nuxt/kit': 4.4.8(magicast@0.5.5) '@nuxt/kit': 4.4.8(magicast@0.5.5)
'@rollup/plugin-replace': 6.0.3(rollup@4.64.2) '@rollup/plugin-replace': 6.0.3(rollup@4.64.2)
'@vitejs/plugin-vue': 6.0.9(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) '@vitejs/plugin-vue': 6.0.9(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 5.1.6(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))
autoprefixer: 10.6.1(postcss@8.5.29) autoprefixer: 10.6.1(postcss@8.5.29)
consola: 3.4.2 consola: 3.4.2
cssnano: 8.0.10(postcss@8.5.29) cssnano: 8.0.10(postcss@8.5.29)
@@ -4044,7 +4055,7 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
mlly: 1.8.2 mlly: 1.8.2
mocked-exports: 0.1.1 mocked-exports: 0.1.1
nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
nypm: 0.6.10 nypm: 0.6.10
pathe: 2.0.3 pathe: 2.0.3
pkg-types: 2.3.3 pkg-types: 2.3.3
@@ -4053,13 +4064,14 @@ snapshots:
std-env: 4.3.0 std-env: 4.3.0
ufo: 1.6.4 ufo: 1.6.4
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
unplugin: 2.3.11
vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vite: 7.3.7(jiti@2.7.0)(terser@5.51.2)
vite-node: 5.3.0(jiti@2.7.0)(terser@5.51.2) vite-node: 5.3.0(jiti@2.7.0)(terser@5.51.2)
vite-plugin-checker: 0.14.5(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) vite-plugin-checker: 0.14.5(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
vue: 3.5.35(typescript@5.9.3) vue: 3.5.35(typescript@5.9.3)
vue-bundle-renderer: 2.4.0 vue-bundle-renderer: 2.4.0
optionalDependencies: optionalDependencies:
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
rollup-plugin-visualizer: 7.1.1(rollup@4.64.2) rollup-plugin-visualizer: 7.1.1(rollup@4.64.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@biomejs/biome' - '@biomejs/biome'
@@ -4467,9 +4479,9 @@ snapshots:
unhead: 2.1.17 unhead: 2.1.17
vue: 3.5.35(typescript@5.9.3) vue: 3.5.35(typescript@5.9.3)
'@vercel/nft@1.11.0(rollup@4.64.2)': '@vercel/nft@1.11.0(rollup@4.64.2)(supports-color@10.2.2)':
dependencies: dependencies:
'@mapbox/node-pre-gyp': 2.0.3 '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2)
'@rollup/pluginutils': 5.4.0(rollup@4.64.2) '@rollup/pluginutils': 5.4.0(rollup@4.64.2)
acorn: 8.19.0 acorn: 8.19.0
acorn-import-attributes: 1.9.5(acorn@8.19.0) acorn-import-attributes: 1.9.5(acorn@8.19.0)
@@ -4486,13 +4498,13 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
'@vitejs/plugin-vue-jsx@5.1.6(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))': '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
'@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7) '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
vite: 7.3.7(jiti@2.7.0)(terser@5.51.2) vite: 7.3.7(jiti@2.7.0)(terser@5.51.2)
vue: 3.5.35(typescript@5.9.3) vue: 3.5.35(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
@@ -4516,27 +4528,27 @@ snapshots:
'@vue/babel-helper-vue-transform-on@2.0.1': {} '@vue/babel-helper-vue-transform-on@2.0.1': {}
'@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7)': '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/helper-module-imports': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/traverse': 7.29.10 '@babel/traverse': 7.29.10(supports-color@10.2.2)
'@babel/types': 7.29.8 '@babel/types': 7.29.8
'@vue/babel-helper-vue-transform-on': 2.0.1 '@vue/babel-helper-vue-transform-on': 2.0.1
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7) '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@vue/shared': 3.5.43 '@vue/shared': 3.5.43
optionalDependencies: optionalDependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7)': '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/core': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-module-imports': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/parser': 7.29.9 '@babel/parser': 7.29.9
'@vue/compiler-sfc': 3.5.43 '@vue/compiler-sfc': 3.5.43
@@ -4999,9 +5011,11 @@ snapshots:
db0@0.3.4: {} db0@0.3.4: {}
debug@4.4.3: debug@4.4.3(supports-color@10.2.2):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
@@ -5288,10 +5302,10 @@ snapshots:
http-shutdown@1.2.2: {} http-shutdown@1.2.2: {}
https-proxy-agent@7.0.6: https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5328,11 +5342,11 @@ snapshots:
ini@4.1.1: {} ini@4.1.1: {}
ioredis@5.11.1: ioredis@5.11.1(supports-color@10.2.2):
dependencies: dependencies:
'@ioredis/commands': 1.10.0 '@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1 cluster-key-slot: 1.1.1
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
denque: 2.1.0 denque: 2.1.0
redis-errors: 1.2.0 redis-errors: 1.2.0
redis-parser: 3.0.0 redis-parser: 3.0.0
@@ -5541,7 +5555,7 @@ snapshots:
nanotar@0.3.0: {} nanotar@0.3.0: {}
nitropack@2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): nitropack@2.13.4(oxc-parser@0.133.0)(srvx@0.11.22)(supports-color@10.2.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)):
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/kv-asset-handler': 0.4.2
'@rollup/plugin-alias': 6.0.0(rollup@4.64.2) '@rollup/plugin-alias': 6.0.0(rollup@4.64.2)
@@ -5551,7 +5565,7 @@ snapshots:
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.64.2) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.64.2)
'@rollup/plugin-replace': 6.0.3(rollup@4.64.2) '@rollup/plugin-replace': 6.0.3(rollup@4.64.2)
'@rollup/plugin-terser': 1.0.0(rollup@4.64.2) '@rollup/plugin-terser': 1.0.0(rollup@4.64.2)
'@vercel/nft': 1.11.0(rollup@4.64.2) '@vercel/nft': 1.11.0(rollup@4.64.2)(supports-color@10.2.2)
archiver: 7.0.1 archiver: 7.0.1
c12: 3.3.4(magicast@0.5.5) c12: 3.3.4(magicast@0.5.5)
chokidar: 5.0.0 chokidar: 5.0.0
@@ -5575,7 +5589,7 @@ snapshots:
h3: 1.15.11 h3: 1.15.11
hookable: 5.5.3 hookable: 5.5.3
httpxy: 0.5.5 httpxy: 0.5.5
ioredis: 5.11.1 ioredis: 5.11.1(supports-color@10.2.2)
jiti: 2.7.0 jiti: 2.7.0
klona: 2.0.6 klona: 2.0.6
knitwork: 1.3.0 knitwork: 1.3.0
@@ -5598,7 +5612,7 @@ snapshots:
scule: 1.3.0 scule: 1.3.0
semver: 7.8.5 semver: 7.8.5
serve-placeholder: 2.0.2 serve-placeholder: 2.0.2
serve-static: 2.2.1 serve-static: 2.2.1(supports-color@10.2.2)
source-map: 0.7.6 source-map: 0.7.6
std-env: 4.3.0 std-env: 4.3.0
ufo: 1.6.4 ufo: 1.6.4
@@ -5608,7 +5622,7 @@ snapshots:
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
unimport: 6.5.0(esbuild@0.28.2)(oxc-parser@0.133.0)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) unimport: 6.5.0(esbuild@0.28.2)(oxc-parser@0.133.0)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
unplugin-utils: 0.3.2 unplugin-utils: 0.3.2
unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))
untyped: 2.0.0 untyped: 2.0.0
unwasm: 0.5.3 unwasm: 0.5.3
youch: 4.1.1 youch: 4.1.1
@@ -5689,16 +5703,16 @@ snapshots:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)): nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)):
dependencies: dependencies:
'@dxup/nuxt': 0.4.1(magicast@0.5.5)(typescript@5.9.3) '@dxup/nuxt': 0.4.1(magicast@0.5.5)(typescript@5.9.3)
'@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5) '@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.5)(supports-color@10.2.2)
'@nuxt/devtools': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3)) '@nuxt/devtools': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(supports-color@10.2.2)(unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.2)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))(vue@3.5.35(typescript@5.9.3))
'@nuxt/kit': 4.4.8(magicast@0.5.5) '@nuxt/kit': 4.4.8(magicast@0.5.5)
'@nuxt/nitro-server': 4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1)(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)) '@nuxt/nitro-server': 4.4.8(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(db0@0.3.4)(esbuild@0.28.2)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(oxc-parser@0.133.0)(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
'@nuxt/schema': 4.4.8 '@nuxt/schema': 4.4.8
'@nuxt/telemetry': 2.9.1(@nuxt/kit@4.4.8(magicast@0.5.5)) '@nuxt/telemetry': 2.9.1(@nuxt/kit@4.4.8(magicast@0.5.5))
'@nuxt/vite-builder': 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1)(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) '@nuxt/vite-builder': 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(magicast@0.5.5)(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2)))(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
'@unhead/vue': 2.1.17(vue@3.5.35(typescript@5.9.3)) '@unhead/vue': 2.1.17(vue@3.5.35(typescript@5.9.3))
'@vue/shared': 3.5.43 '@vue/shared': 3.5.43
chokidar: 5.0.0 chokidar: 5.0.0
@@ -6276,9 +6290,9 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
send@1.2.1: send@1.2.1(supports-color@10.2.2):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
@@ -6300,12 +6314,12 @@ snapshots:
dependencies: dependencies:
defu: 6.1.7 defu: 6.1.7
serve-static@2.2.1: serve-static@2.2.1(supports-color@10.2.2):
dependencies: dependencies:
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
parseurl: 1.3.3 parseurl: 1.3.3
send: 1.2.1 send: 1.2.1(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6323,13 +6337,13 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-git@3.36.0: simple-git@3.36.0(supports-color@10.2.2):
dependencies: dependencies:
'@kwsites/file-exists': 1.1.1 '@kwsites/file-exists': 1.1.1(supports-color@10.2.2)
'@kwsites/promise-deferred': 1.1.1 '@kwsites/promise-deferred': 1.1.1
'@simple-git/args-pathspec': 1.0.4 '@simple-git/args-pathspec': 1.0.4
'@simple-git/argv-parser': 1.1.1 '@simple-git/argv-parser': 1.1.1
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6600,7 +6614,7 @@ snapshots:
escape-string-regexp: 5.0.0 escape-string-regexp: 5.0.0
ufo: 1.6.4 ufo: 1.6.4
unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1): unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)):
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
chokidar: 5.0.0 chokidar: 5.0.0
@@ -6612,7 +6626,7 @@ snapshots:
ufo: 1.6.4 ufo: 1.6.4
optionalDependencies: optionalDependencies:
db0: 0.3.4 db0: 0.3.4
ioredis: 5.11.1 ioredis: 5.11.1(supports-color@10.2.2)
untun@0.2.2: {} untun@0.2.2: {}
@@ -6765,6 +6779,10 @@ snapshots:
- unloader - unloader
- webpack - webpack
vue-seo-layer@git+https://git.golem15.com/golem15/vue-seo-layer.git#3868f3d74dcebb7a35af43f1defcd68cfe2933b7(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))):
dependencies:
nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.133.0)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.5)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(jiti@2.7.0)(terser@5.51.2))
vue@3.5.35(typescript@5.9.3): vue@3.5.35(typescript@5.9.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.35 '@vue/compiler-dom': 3.5.35

View File

@@ -1,44 +0,0 @@
// POST /api/__site__/cache/purge: removes every Nitro route-cache entry
// (pages, _payload.json and feeds), so an admin save shows on the next page
// view. The backend calls it on loopback after the save commits, with
// `Authorization: Bearer <runtimeConfig.siteCache.purgeToken>` and the body
// {"kind":"<kind>","slug":"<slug>"}; the answer is {"purged":<n>}. The kind
// and slug only reach the log line.
//
// 404 without a token of at least 32 characters, and for any request that
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
// unless runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED)
// is true or "true": a backend that reaches the site through a TLS-terminating
// proxy opts in, and the bearer token is then the only gate. 401 for a
// missing or wrong bearer token. The '/api/__site__/**' route rule keeps this
// route out of the route cache.
//
// Shared: several layers ship this file byte for byte, and Nitro keeps one
// handler per route and method.
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache } from '../../../lib/siteCachePurge.ts'
const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store')
const config = useRuntimeConfig(event)
const siteCache = config.siteCache as { purgeToken?: unknown; allowProxied?: unknown } | undefined
const verdict = authorizeSitePurge({
token: siteCache?.purgeToken,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true',
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
if (verdict === 'unauthorized') {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null))
const purged = await purgeSiteRouteCache(useStorage('cache'))
console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`)
return { purged }
})

View File

@@ -1,98 +0,0 @@
// Shared site cache purge: the authorization rules of the purge route
// POST /api/__site__/cache/purge, the purge of Nitro's route cache and the
// parsing of the purge request body. Pure TypeScript outside Nitro's and
// Nuxt's auto-import directories, so several layers can ship this file
// byte for byte, and `node --test` and the route import it directly.
import { createHash, timingSafeEqual } from 'node:crypto'
/** The shortest purge token the route accepts; with a shorter one it is off. */
export const MIN_SITE_PURGE_TOKEN_LENGTH = 32
/** The cache storage base of Nitro's route cache (routeRules cache and swr). */
export const SITE_ROUTE_CACHE_BASE = 'nitro:routes'
/** Longest slug the purge log line carries. */
const MAX_SLUG_LENGTH = 200
/** A purge kind: lower-case letters and underscores, up to 40 characters. */
const KIND_PATTERN = /^[a-z_]{1,40}$/
export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
export interface SitePurgeAuthInput {
/** The configured token (runtimeConfig.siteCache.purgeToken). */
token: unknown
/** The request's Authorization header, if any. */
authorization?: string | null
/** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */
proxied: boolean
/**
* runtimeConfig.siteCache.allowProxied: when true a proxied request is not
* refused, and the bearer token is the only gate. For a backend that reaches
* the site through a TLS-terminating proxy, which adds forwarding headers.
*/
allowProxied?: boolean
}
function digest(value: string): Buffer {
return createHash('sha256').update(value, 'utf8').digest()
}
/**
* Decides a purge request: `disabled` when no token of at least
* MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token) unless
* allowProxied is true, `ok` when the Bearer value equals the token (compared
* in constant time over sha256 digests) and `unauthorized` otherwise. With
* allowProxied the token is the only gate, so it must stay long and secret.
*/
export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict {
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
if (proxied && allowProxied !== true) return 'proxied'
const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '')
if (!m) return 'unauthorized'
return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized'
}
/** The part of a Nitro/unstorage storage the purge uses. */
export interface SitePurgeStorage {
getKeys(base?: string): Promise<string[]> | string[]
removeItem(key: string): Promise<unknown> | unknown
}
/**
* Removes every entry of Nitro's route cache (pages, their _payload.json and
* feeds) from storage, which is `useStorage('cache')`, and returns how many
* it removed. Other cache groups (cached functions, handlers) stay.
*/
export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise<number> {
const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE)
let removed = 0
for (const key of keys) {
if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue
await storage.removeItem(key)
removed++
}
return removed
}
export interface SitePurgeEvent {
kind: string
slug: string
}
/**
* Reads the kind and slug of a purge request body for the log line: kind is
* kept when it is lower-case letters and underscores up to 40 characters
* (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug
* loses control characters and is cut to 200 characters.
*/
export function parseSitePurgeEvent(body: unknown): SitePurgeEvent {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' }
const raw = body as Record<string, unknown>
const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown'
// eslint-disable-next-line no-control-regex
const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : ''
return { kind, slug }
}

View File

@@ -12,3 +12,11 @@
200 / contains=<p class="playground-fallback">No FAQ</p> 200 / contains=<p class="playground-fallback">No FAQ</p>
200 / notcontains=>Empty</h 200 / notcontains=>Empty</h
200 / notcontains=FaqSection 200 / notcontains=FaqSection
# A widget block (headingLevel 0) renders its title as a styled p that still
# labels the section (UI-SPEC L6); the page keeps one heading per content block.
200 / contains=<p id= contains=class="faq-block__title">Acme questions</p>
200 / count=<h2:1 count=<h3:1
# One JSON-LD script with one FAQPage node (two blocks with schema share its
# @id; the widget has schema off); the answers are in the HTML inside details.
200 / count=application/ld+json:1 count="@type":"FAQPage":1 contains="acceptedAnswer":{"@type":"Answer","text":"Two days. Poland Europe"}
200 / contains=<details class="faq-item" name= contains=<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>

View File

@@ -5,9 +5,12 @@
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]... // node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
// //
// Expectation grammar, one check per line ('#' starts a comment line): // Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] // STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the // A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
// end of the line, so it may contain spaces. Requests are plain GETs that do // ` type=` or the end of the line, so it may contain spaces. Every option
// may repeat on a line and every occurrence applies; count= wants exactly N
// occurrences of TEXT in the body (N after the last colon), as in
// vue-seo-layer's testing/smoke-ssr.mjs. Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards // not follow redirects and carry `X-Forwarded-For: 203.0.113.7`; afterwards
// at least one fixture API request must have carried that address (the SSR // at least one fixture API request must have carried that address (the SSR
// fetches forward the visitor's x-forwarded-for). // fetches forward the visitor's x-forwarded-for).
@@ -50,17 +53,19 @@ function parseArgs(argv) {
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out) return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
} }
/**
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
*/
/** /**
* @param {string} line * @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} * @returns {ExpectCheck}
*/ */
export function parseExpectLine(line) { export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim()) const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"') if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3] const rest = m[3]
const re = /\s(contains|notcontains|type)=/g const re = /\s(contains|notcontains|count|type)=/g
/** @type {Array<{ key: string, start: number, valueStart: number }>} */ /** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = [] const marks = []
let hit let hit
@@ -69,13 +74,42 @@ export function parseExpectLine(line) {
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') { if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`) throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
} }
/** @type {Record<string, string>} */ const options = marks.map((mark, i) => {
const options = {}
marks.forEach((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim() return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
}) })
return { ...check, ...options } return { status: Number(m[1]), path: m[2], options }
}
/** @param {string} body @param {string} text */
function occurrences(body, text) {
if (text === '') return 0
let n = 0
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
return n
}
/**
* The failures of one response against its check.
* @param {ExpectCheck} check @param {{ status: number, type: string, body: string }} res
* @returns {string[]}
*/
export function checkResponse(check, res) {
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
/** @type {string[]} */
const out = []
for (const { key, value } of check.options) {
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
else if (key === 'type' && !res.type.startsWith(value)) out.push(`content-type "${res.type}", want prefix "${value}"`)
else if (key === 'count') {
const colon = value.lastIndexOf(':')
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
if (!Number.isInteger(want)) out.push(`count=${value}: want TEXT:N`)
else if (occurrences(res.body, value.slice(0, colon)) !== want) out.push(`body has ${occurrences(res.body, value.slice(0, colon))} of "${value.slice(0, colon)}", want ${want}`)
}
}
return out
} }
/** @returns {Promise<number>} */ /** @returns {Promise<number>} */
@@ -144,7 +178,13 @@ async function main() {
let nuxt = null let nuxt = null
const failures = /** @type {string[]} */ ([]) const failures = /** @type {string[]} */ ([])
try { try {
nuxt = await startNuxtServer({ outputDir: args.output, env: { [API_ENV]: `${api.url}${API_PREFIX}`, ...args.env } }) // vue-seo-layer resolves every page through the SEO API; the fixture API
// has none and answers 404, so the layer renders without a CMS answer
// (fail open) and never reaches a backend on the default port.
nuxt = await startNuxtServer({
outputDir: args.output,
env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_API_BASE: `${api.url}/_seo/api/v1`, ...args.env },
})
for (const line of lines) { for (const line of lines) {
let check let check
try { try {
@@ -166,10 +206,7 @@ async function main() {
} }
const body = await res.text() const body = await res.text()
const type = res.headers.get('content-type') ?? '' const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`) for (const f of checkResponse(check, { status: res.status, type, body })) failures.push(`${line}: ${f}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
} }
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) { if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address') failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')

120
tests/block.test.ts Normal file
View File

@@ -0,0 +1,120 @@
// FaqBlock (D-52, UI-SPEC L6) rendered on the server: the SFC is compiled
// with vue/compiler-sfc and rendered with vue/server-renderer, with the
// Nuxt composables it calls (useFaq, useSeoAnswer, useRequestURL,
// useSchemaNode) stubbed on globalThis and FaqList stubbed as a component.
// headingLevel picks the title's element (0 a <p>), the section stays
// labelled by the title, and the FAQPage node it hands to the page graph
// carries the answers as plain text.
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, test } from 'node:test'
import { pathToFileURL } from 'node:url'
import { compileScript, parse } from 'vue/compiler-sfc'
import { createSSRApp, h, ref, type Component } from 'vue'
import { renderToString } from 'vue/server-renderer'
const root = new URL('..', import.meta.url)
const dir = mkdtempSync(join(tmpdir(), 'faq-block-'))
after(() => rmSync(dir, { recursive: true, force: true }))
async function loadBlock(): Promise<Component> {
const source = readFileSync(new URL('app/components/Faq/Block.vue', root), 'utf8')
const { descriptor } = parse(source, { filename: 'Block.vue' })
const script = compileScript(descriptor, { id: 'faq-block', inlineTemplate: true, templateOptions: { ssr: true } })
const code = script.content
.replaceAll(`from 'vue'`, `from '${import.meta.resolve('vue')}'`)
.replaceAll(`from "vue"`, `from '${import.meta.resolve('vue')}'`)
.replaceAll(`from 'vue/server-renderer'`, `from '${import.meta.resolve('vue/server-renderer')}'`)
.replaceAll(`from "vue/server-renderer"`, `from '${import.meta.resolve('vue/server-renderer')}'`)
.replaceAll(`'../../utils/faqSchema.ts'`, `'${new URL('app/utils/faqSchema.ts', root).href}'`)
const file = join(dir, 'Block.ts')
writeFileSync(file, code)
return (await import(pathToFileURL(file).href)).default as Component
}
const faq = {
id: 1,
slug: 'grzyby',
name: 'Pytania o grzyby',
description: '<p>Najczęstsze pytania</p>',
content: [
{ question: ' Czy chaga jest bezpieczna? ', answer: '<p>Tak, <strong>w&nbsp;umiarkowanych</strong> dawkach.</p><script>x()</script>' },
{ question: 'Jak parzyć?', answer: '<ul><li>Woda 80&#176;C &amp; 5 min</li></ul>' },
],
}
const g = globalThis as Record<string, unknown>
let nodes: unknown[] = []
function stub(data: unknown, error: unknown = null) {
nodes = []
Object.assign(g, {
useFaq: async () => ({ data: ref(data), error: ref(error) }),
useSeoAnswer: () => ref({ action: 'ok', url: 'https://example.test/faq#top' }),
useRequestURL: () => new URL('https://fallback.test/page'),
useSchemaNode: (fn: () => unknown) => nodes.push(fn()),
})
}
const FaqList: Component = { props: ['items', 'exclusive'], setup: (p: { items: unknown[] }) => () => h('div', { class: 'faq-list' }, String(p.items.length)) }
const Block = await loadBlock()
async function render(props: Record<string, unknown>): Promise<string> {
const app = createSSRApp({ render: () => h(Block, { slug: 'grzyby', ...props }, { fallback: () => h('em', 'fallback') }) })
app.component('FaqList', FaqList)
return renderToString(app)
}
test('headingLevel 2 to 4 renders that heading, out-of-range levels are clamped', async () => {
stub(faq)
for (const [level, tag] of [[2, 'h2'], [3, 'h3'], [4, 'h4'], [6, 'h4'], [1, 'h2']] as const) {
const html = await render({ headingLevel: level })
assert.match(html, new RegExp(`<${tag} id="([^"]+)" class="faq-block__title">Pytania o grzyby</${tag}>`), `level ${level}`)
}
})
test('headingLevel 0 renders the title as a <p> that still labels the section', async () => {
stub(faq)
const html = await render({ headingLevel: 0 })
const title = html.match(/<p id="([^"]+)" class="faq-block__title">Pytania o grzyby<\/p>/)
assert.ok(title, html)
assert.match(html, new RegExp(`<section class="faq-block" aria-labelledby="${title![1]}"`))
assert.doesNotMatch(html, /<h[1-6]/)
})
test('without a heading the section is labelled by the FAQ name', async () => {
stub(faq)
const html = await render({ showHeading: false })
assert.match(html, /<section class="faq-block" aria-label="Pytania o grzyby"/)
assert.doesNotMatch(html, /faq-block__title/)
})
test('the FAQPage node: the page URL without fragment, questions trimmed, answers as plain text', async () => {
stub(faq)
await render({})
assert.equal(nodes.length, 1)
assert.deepEqual(nodes[0], {
'@type': 'FAQPage',
'@id': 'https://example.test/faq#faq',
url: 'https://example.test/faq',
mainEntity: [
{ '@type': 'Question', name: 'Czy chaga jest bezpieczna?', acceptedAnswer: { '@type': 'Answer', text: 'Tak, w umiarkowanych dawkach.' } },
{ '@type': 'Question', name: 'Jak parzyć?', acceptedAnswer: { '@type': 'Answer', text: 'Woda 80°C & 5 min' } },
],
})
})
test('schema false, a missing FAQ or one without items: no node; the fallback slot instead of an empty block', async () => {
stub(faq)
await render({ schema: false })
assert.deepEqual(nodes, [null])
stub(null, new Error('404'))
const missing = await render({})
assert.match(missing, /<em>fallback<\/em>/)
assert.doesNotMatch(missing, /faq-block/)
assert.deepEqual(nodes, [null])
stub({ ...faq, content: [] })
assert.match(await render({}), /<em>fallback<\/em>/)
})

43
tests/faqSchema.test.ts Normal file
View File

@@ -0,0 +1,43 @@
// The FAQPage node of a FAQ block (D-43): one Question per item with the
// answer as plain text, @id <page URL>#faq.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { faqAnswerText, faqSchema } from '../app/utils/faqSchema.ts'
const FAQ = {
name: 'Acme questions',
slug: 'acme',
locale: 'en',
description: '<p>About Acme.</p>',
content: [
{ question: 'How long does shipping take?', answer: '<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>' },
{ question: 'Can I return it?', answer: '<p>Yes &amp; <a href="/returns">free</a>&nbsp;of charge &lt;30 days&gt;.</p>' },
],
}
test('faqSchema: a FAQPage with a Question and acceptedAnswer per item', () => {
assert.deepEqual(faqSchema(FAQ, 'https://acme.test/help'), {
'@type': 'FAQPage',
'@id': 'https://acme.test/help#faq',
url: 'https://acme.test/help',
mainEntity: [
{ '@type': 'Question', name: 'How long does shipping take?', acceptedAnswer: { '@type': 'Answer', text: 'Two days. Poland Europe' } },
{ '@type': 'Question', name: 'Can I return it?', acceptedAnswer: { '@type': 'Answer', text: 'Yes & free of charge <30 days>.' } },
],
})
})
test('faqSchema: no FAQ, no items or blank questions give null or skip the item', () => {
assert.equal(faqSchema(null, 'https://acme.test/'), null)
assert.equal(faqSchema({ ...FAQ, content: [] }, 'https://acme.test/'), null)
const node = faqSchema({ ...FAQ, content: [{ question: ' ', answer: '<p>x</p>' }, { question: 'Q', answer: '' }] }, 'https://acme.test/x#top')
assert.equal(node?.['@id'], 'https://acme.test/x#faq')
assert.deepEqual(node?.mainEntity, [{ '@type': 'Question', name: 'Q', acceptedAnswer: { '@type': 'Answer', text: '' } }])
})
test('faqAnswerText: markup stripped, entities decoded, scripts and styles dropped, whitespace collapsed', () => {
assert.equal(faqAnswerText('<p>a</p>\n<p>b<br>c</p>'), 'a b c')
assert.equal(faqAnswerText('<script>alert(1)</script><style>p{}</style><p>ok</p>'), 'ok')
assert.equal(faqAnswerText('&quot;x&quot; &#39;y&#39; &#x41; &#66;'), '"x" \'y\' A B')
assert.equal(faqAnswerText(null), '')
})

View File

@@ -1,247 +0,0 @@
// Shared site cache purge: route authorization, the route-cache purge, the
// purge body parsing and the byte-identity of the files every layer ships.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import {
MIN_SITE_PURGE_TOKEN_LENGTH,
SITE_ROUTE_CACHE_BASE,
authorizeSitePurge,
parseSitePurgeEvent,
purgeSiteRouteCache,
} from '../server/lib/siteCachePurge.ts'
// The sha256 of the two shared files. vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship them byte for byte; a change lands in all three with
// new values here.
const SHARED_LIB_SHA256 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e'
const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c'
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
function sha256(relative: string): string {
return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex')
}
test('sitePurge: the shared files match the pinned hashes', () => {
assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts')
assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts')
})
test('sitePurge: a missing or short token disables the route', () => {
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
}
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32)
})
test('sitePurge: a proxied request is refused before the token is checked', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
})
test('sitePurge: authorize ok with the token and unauthorized otherwise', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
}
})
/** A fake unstorage prefix storage over a plain key list. */
function fakeStorage(keys: string[]) {
const live = new Set(keys)
const removed: string[] = []
return {
removed,
live,
async getKeys(base?: string) {
return [...live].filter((k) => !base || k === base || k.startsWith(base + ':'))
},
async removeItem(key: string) {
live.delete(key)
removed.push(key)
},
}
}
test('sitePurge: purge removes only nitro:routes: keys', async () => {
const routes = [
`${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`,
`${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`,
`${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
]
const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
const storage = fakeStorage([...routes, ...others])
assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes')
assert.equal(await purgeSiteRouteCache(storage), routes.length)
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
assert.deepEqual([...storage.live].sort(), [...others].sort())
assert.equal(await purgeSiteRouteCache(storage), 0)
})
test('sitePurge: purge event kinds and slugs', () => {
for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) {
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' })
}
for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) {
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind))
}
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' })
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200)
for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) {
assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
}
})
test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => {
// timingSafeEqual on raw strings throws on a length mismatch; over digests
// every length compares in constant time and answers unauthorized.
for (const length of [1, 31, 33, 64, 4096]) {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`)
}
const long = 'k'.repeat(200)
assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized')
})
test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => {
const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json'])
const removed: string[] = []
const storage = {
getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')),
removeItem: (key: string) => {
live.delete(key)
removed.push(key)
},
}
assert.equal(await purgeSiteRouteCache(storage), 1)
assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`])
assert.ok(live.has(SITE_ROUTE_CACHE_BASE))
assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0)
})
test('sitePurge: purge event edge cases', () => {
assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' })
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200)
// Control characters are stripped before the cut.
const slug = '\u0001'.repeat(50) + 'z'.repeat(200)
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200))
assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' })
})
/** One call of the shared route with Nitro's auto-imports stubbed. */
async function callRoute(opts: { token?: unknown; allowProxied?: unknown; headers?: Record<string, string>; body?: unknown; bodyError?: boolean; keys?: string[] }) {
const responseHeaders: Record<string, string> = {}
const logs: string[] = []
const live = new Set(opts.keys ?? [])
const g = globalThis as Record<string, unknown>
g.defineEventHandler = (fn: unknown) => fn
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
responseHeaders[name] = value
}
g.useRuntimeConfig = () => ({ siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied } })
g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name]
g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input)
g.readBody = async () => {
if (opts.bodyError) throw new Error('invalid JSON')
return opts.body
}
g.useStorage = (base: string) => {
assert.equal(base, 'cache')
return {
getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')),
removeItem: async (key: string) => {
live.delete(key)
},
}
}
const info = console.info
console.info = (line: string) => {
logs.push(line)
}
try {
const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise<unknown>
try {
return { result: await route({}), status: 200, responseHeaders, logs, live }
} catch (err) {
return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live }
}
} finally {
console.info = info
}
}
test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
for (const token of [undefined, '', 'short']) {
const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys })
assert.equal(r.status, 404, `token ${JSON.stringify(token)}`)
assert.equal(r.live.size, 1)
}
for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) {
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys })
assert.equal(r.status, 404, name)
assert.equal(r.live.size, 1)
}
for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) {
const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys })
assert.equal(r.status, 401, String(authorization))
assert.equal(r.live.size, 1)
assert.equal(r.responseHeaders['cache-control'], 'no-store')
}
})
test('sitePurge: route purges, answers the count and logs kind and slug only', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json']
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys })
assert.equal(r.status, 200)
assert.deepEqual(r.result, { purged: 2 })
assert.deepEqual([...r.live], ['nitro:functions:_:x.json'])
assert.equal(r.responseHeaders['cache-control'], 'no-store')
assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)'])
assert.ok(!r.logs.join('').includes(TOKEN))
const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] })
assert.equal(bad.status, 200)
assert.deepEqual(bad.result, { purged: 0 })
assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )'])
})
test('sitePurge: allowProxied lets a proxied request reach the token check', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled')
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled')
// Without the flag, or with anything but true, a proxied request stays refused.
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
})
test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`]
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' }
for (const allowProxied of [true, 'true']) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys })
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.deepEqual(r.result, { purged: 2 })
assert.equal(r.live.size, 0)
}
const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
assert.equal(wrong.status, 401)
assert.equal(wrong.live.size, 2)
for (const allowProxied of [false, undefined, 'false', 1]) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, keys })
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.equal(r.live.size, 2)
}
})