From 0219d15e87d14a0d6bfbde7846fd13a75ff681d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 30 Oct 2026 10:58:35 +0000 Subject: [PATCH 01/10] feat: ship a comark-linter config for sites lint.mjs lists the components the layer renders (its page maps plus every u- Nuxt UI component), parses emoji shortcodes as the layer does, and resolves links to pages through comark-content, so sites lint content/ with a one-line config. Amp-Thread-ID: https://ampcode.com/threads/T-01a18c64-cf17-72b6-afc0-24d664666b30 --- README.md | 19 ++++++++++++++++++ lint.mjs | 36 +++++++++++++++++++++++++++++++++++ package.json | 1 + test/lint.test.ts | 14 ++++++++++++++ utils/markdown-components.mjs | 18 ++++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 lint.mjs create mode 100644 test/lint.test.ts create mode 100644 utils/markdown-components.mjs diff --git a/README.md b/README.md index 9181142..6969145 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,25 @@ From there, the docs cover everything: - [Architecture](https://docs-template.comark.dev/concepts/architecture) and [versioned previews](https://docs-template.comark.dev/concepts/versioned-previews) - [Deploying on Vercel](https://docs-template.comark.dev/deployment/vercel) — webhook, Ignored Build Step, content rollback +## Linting content + +`lint.mjs` is a [comark-linter](https://comark-ui.onmax.me/lint/) config that knows the components the layer renders and resolves links to your pages the way comark-content does, so it reports unknown components, unclosed blocks, and broken links and anchors in `content/`: + +```bash +pnpm add -D comark-linter +``` + +```ts +// comark-lint.config.ts +export { default } from 'comark-docs/lint.mjs' +``` + +```bash +pnpm lint-comark "content/**/*.md" -c comark-lint.config.ts +``` + +Run it from the site's root, where `content/` is. + ## Agent Skills Drop skills into a `skills/` directory at the app root and [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery) serves them at `/.well-known/skills/`, following the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with: diff --git a/lint.mjs b/lint.mjs new file mode 100644 index 0000000..cf74ac0 --- /dev/null +++ b/lint.mjs @@ -0,0 +1,36 @@ +// @ts-check +// A comark-linter config for sites extending this layer. Run `lint-comark` from the site's root: +// lint-comark "content/**/*.md" -c comark-lint.config.ts +// with a config that re-exports it: `export { default } from 'comark-docs/lint.mjs'`. +// Plain JavaScript, since Node won't strip types from a file in node_modules. +import { readdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { comarkContent } from 'comark-content' +import fs from 'comark-content/sources/fs' +import emoji from 'comark/plugins/emoji' +import { components } from './utils/markdown-components.mjs' + +// Every Nuxt UI component also resolves with the `u-` prefix, such as `::u-button`. +const nuxtUi = dirname(fileURLToPath(import.meta.resolve('@nuxt/ui/components/Button.vue'))) +const nuxtUiComponents = readdirSync(nuxtUi) + .filter(file => file.endsWith('.vue')) + .map(file => `u-${file.slice(0, -4).replace(/[A-Z]/g, (c, i) => (i ? '-' : '') + c.toLowerCase())}`) + +// Each page's URL, mapped to its file the way comark-content derives the URL from it. +const pages = new Map() +for (const item of await comarkContent({ source: fs('content') }).list()) { + pages.set(item.path, `content/${item.meta.stem}${item.meta.extension}`) +} + +/** @type {import('comark-linter').Config} */ +export default { + components: [...components, ...nuxtUiComponents], + // The layer parses `:smile:` shortcodes as emoji, not as inline components. + plugins: [emoji()], + links: { + // Guides link a local dev server on purpose, for readers running it. + skip: ['localhost'], + routes: path => pages.get(path.replace(/\/$/, '') || '/'), + }, +} diff --git a/package.json b/package.json index 95d698d..3fb7822 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "nuxt.config.ts", "nuxt.schema.ts", "index.d.ts", + "lint.mjs", "README.md" ], "scripts": { diff --git a/test/lint.test.ts b/test/lint.test.ts new file mode 100644 index 0000000..8ff26de --- /dev/null +++ b/test/lint.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { components } from '../utils/markdown-components.mjs' + +const kebab = (name: string) => name.replace(/[A-Z]/g, (c, i) => (i ? '-' : '') + c.toLowerCase()) + +// Sites lint with this list, so a component a page renders but the list misses lints as unknown. +it('lists every component the pages render from Markdown', () => { + const rendered = ['app/pages/[...slug].vue', 'app/pages/index.vue'].flatMap((page) => { + const map = readFileSync(page, 'utf8').match(/const \w*omponents = \{([^}]*)\}/)![1]! + return map.split(/[\s,]+/).filter(Boolean).map(name => kebab(name.replace(/^Prose/, ''))) + }) + expect([...components].sort()).toEqual([...rendered].sort()) +}) diff --git a/utils/markdown-components.mjs b/utils/markdown-components.mjs new file mode 100644 index 0000000..06f3771 --- /dev/null +++ b/utils/markdown-components.mjs @@ -0,0 +1,18 @@ +// @ts-check +/** + * The components Markdown can use: the `components` maps of `app/pages/[...slug].vue` and + * `app/pages/index.vue`, in kebab case and without the `Prose` prefix, as the renderer resolves them. + * `lint.mjs` lints sites against it. + */ +export const components = [ + // Docs pages + 'a', 'accordion', 'accordion-item', 'badge', 'blockquote', 'callout', 'card', 'card-group', + 'caution', 'code', 'code-collapse', 'code-group', 'code-icon', 'code-preview', 'code-tree', + 'collapsible', 'em', 'field', 'field-group', 'h1', 'h2', 'h3', 'h4', 'hr', 'icon', 'img', 'kbd', + 'li', 'note', 'ol', 'p', 'pre', 'prompt', 'script', 'steps', 'strong', 'table', 'tabs', + 'tabs-item', 'tbody', 'td', 'th', 'thead', 'tip', 'tr', 'ul', 'warning', 'mermaid', + 'code-explorer', 'browser', + // Landing page + 'landing-hero', 'landing-stack', 'landing-tabs', 'landing-features', 'landing-feature-card', + 'landing-faq', 'landing-cta', 'landing-hero-demo', 'button', +] From 51b7a1d3f601ea02ff02f44cd9bcc8b91789583e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 30 Oct 2026 11:08:49 +0000 Subject: [PATCH 02/10] docs: fix playground anchors broken by flat heading ids Amp-Thread-ID: https://ampcode.com/threads/T-01a18c64-cf17-72b6-afc0-24d664666b30 --- playground/content/2.writing/3.components.md | 2 +- playground/content/2.writing/4.landing-page.md | 2 +- playground/content/3.concepts/1.architecture.md | 2 +- playground/content/4.deployment/1.vercel.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/playground/content/2.writing/3.components.md b/playground/content/2.writing/3.components.md index aa4d267..eb85995 100644 --- a/playground/content/2.writing/3.components.md +++ b/playground/content/2.writing/3.components.md @@ -206,7 +206,7 @@ default-value: index.md | `default-value` | `string` | first file | Path of the file selected on load. | ::warning -The server only fetches from your own content repository by default. To embed another repository, add it to [`comarkDocs.codeExplorer.allowRepos`](/getting-started/configuration#nuxtconfigts-comarkdocs-options). +The server only fetches from your own content repository by default. To embed another repository, add it to [`comarkDocs.codeExplorer.allowRepos`](/getting-started/configuration#comarkdocs-options). :: ## Browser diff --git a/playground/content/2.writing/4.landing-page.md b/playground/content/2.writing/4.landing-page.md index 9b5aa48..26baadc 100644 --- a/playground/content/2.writing/4.landing-page.md +++ b/playground/content/2.writing/4.landing-page.md @@ -219,4 +219,4 @@ Props: `id` (optional anchor). Slots: `title`, `description`, `links`. ## Structured data -Beyond the FAQ JSON-LD, the landing page can emit a [schema.org SoftwareApplication](https://schema.org/SoftwareApplication) identity. Configure it once in `app.config.ts` under [`docs.schemaOrg`](/getting-started/configuration#appconfigts-all-keys); nothing is emitted when unset. +Beyond the FAQ JSON-LD, the landing page can emit a [schema.org SoftwareApplication](https://schema.org/SoftwareApplication) identity. Configure it once in `app.config.ts` under [`docs.schemaOrg`](/getting-started/configuration#all-keys); nothing is emitted when unset. diff --git a/playground/content/3.concepts/1.architecture.md b/playground/content/3.concepts/1.architecture.md index 93e0ab8..e3da0b7 100644 --- a/playground/content/3.concepts/1.architecture.md +++ b/playground/content/3.concepts/1.architecture.md @@ -85,4 +85,4 @@ A request for a page that doesn't exist returns a real HTTP 404 with a short Mar ## No redeploys for content -Since content never ships in the build, a content-only push doesn't need a deployment at all. On Vercel, an [Ignored Build Step](/deployment/vercel#setup-skip-builds-for-content-pushes) cancels builds for pushes that only touch `content/` — the webhook handles those. Code pushes build and deploy as usual. +Since content never ships in the build, a content-only push doesn't need a deployment at all. On Vercel, an [Ignored Build Step](/deployment/vercel#skip-builds-for-content-pushes) cancels builds for pushes that only touch `content/` — the webhook handles those. Code pushes build and deploy as usual. diff --git a/playground/content/4.deployment/1.vercel.md b/playground/content/4.deployment/1.vercel.md index e713256..ab6eaf0 100644 --- a/playground/content/4.deployment/1.vercel.md +++ b/playground/content/4.deployment/1.vercel.md @@ -83,7 +83,7 @@ Copy the full commit SHA for the content version you want to serve. In the store } ``` -New server renders now read content from that commit. Pages already cached by ISR update as they expire, which takes up to the configured [`comarkDocs.isr`](/getting-started/configuration#nuxtconfigts-comarkdocs-options) duration (300 seconds by default). +New server renders now read content from that commit. Pages already cached by ISR update as they expire, which takes up to the configured [`comarkDocs.isr`](/getting-started/configuration#comarkdocs-options) duration (300 seconds by default). ### Remove or move the pin @@ -99,7 +99,7 @@ Replace `contentSha` with another full commit SHA to move the pin. Delete the it The layer generates ISR route rules for every top-level content section, the landing page, previews, and the machine-readable routes (`/raw/**`, `/llms.txt`, `/llms-full.txt`, `/sitemap.md`, `/rss.xml`, `/openapi.json`). Pages expire after 300 seconds by default, or immediately when the webhook purges them. -Tune or disable this with [`comarkDocs.isr`](/getting-started/configuration#nuxtconfigts-comarkdocs-options) in `nuxt.config.ts`. Your own `routeRules` take precedence over the generated ones. +Tune or disable this with [`comarkDocs.isr`](/getting-started/configuration#comarkdocs-options) in `nuxt.config.ts`. Your own `routeRules` take precedence over the generated ones. Client-side navigation is unaffected by that expiration window: it fetches each page's data separately (`_payload.json`), which the browser revalidates on every navigation rather than caching for the ISR duration — so a webhook purge is visible there immediately too, at the cost of a conditional request the edge answers without re-sending the body. From 65f403c42a7efeb03c3d14356e46332476c65206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 30 Oct 2026 11:08:49 +0000 Subject: [PATCH 03/10] ci: lint playground content with the shipped config pnpm lint now runs lint-comark on playground/content through lint.mjs, so CI checks the playground docs and the config sites use on every PR. Amp-Thread-ID: https://ampcode.com/threads/T-01a18c64-cf17-72b6-afc0-24d664666b30 --- package.json | 4 +- playground/comark-lint.config.ts | 2 + pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 playground/comark-lint.config.ts diff --git a/package.json b/package.json index 3fb7822..550504f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "build": "nuxt build playground", "generate": "nuxt generate playground", "typecheck": "nuxt typecheck playground", - "lint": "eslint .", + "lint": "eslint . && pnpm lint:content", + "lint:content": "cd playground && lint-comark \"content/**/*.md\" -c comark-lint.config.ts", "lint:fix": "eslint . --fix", "test": "vitest run --project unit", "test:e2e": "vitest run --project e2e", @@ -100,6 +101,7 @@ "@opentelemetry/resources": "^2.12.0", "@opentelemetry/sdk-trace-base": "^2.12.0", "@opentelemetry/sdk-trace-node": "^2.12.0", + "comark-linter": "^0.7.2", "es-module-lexer": "^3.0.2", "eslint": "^10.13.0", "nuxt": "^4.6.2", diff --git a/playground/comark-lint.config.ts b/playground/comark-lint.config.ts new file mode 100644 index 0000000..67655ec --- /dev/null +++ b/playground/comark-lint.config.ts @@ -0,0 +1,2 @@ +// Lints this playground with the config the layer ships to sites. +export { default } from '../lint.mjs' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c893b90..2a98710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@opentelemetry/sdk-trace-node': specifier: ^2.12.0 version: 2.12.0(@opentelemetry/api@1.9.1) + comark-linter: + specifier: ^0.7.2 + version: 0.7.2(comark@0.8.1(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.5.0))(eslint@10.13.0(jiti@2.7.0)(supports-color@10.2.2)) es-module-lexer: specifier: ^3.0.2 version: 3.0.2 @@ -3404,6 +3407,155 @@ packages: forges: optional: true + comark-linter@0.7.2: + resolution: {integrity: sha512-cfc+SDv8FzBnQjazqBP7nwlrPVifxL7ViPIveRP/QqcqWuwJKG46VyMSABxV7n7B76Z13TQF+qEWV67UZ7HRPQ==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@eslint/markdown': ^8.0.0 + comark: ^0.8.0 + comark-annotate: ^0.7.2 + comark-badge: ^0.7.2 + comark-cards: ^0.7.2 + comark-carousel: ^0.7.2 + comark-cast: ^0.7.2 + comark-charts: ^0.7.2 + comark-chips: ^0.7.2 + comark-cjk: ^0.7.2 + comark-code: ^0.7.2 + comark-code-notes: ^0.7.2 + comark-colors: ^0.7.2 + comark-critic: ^0.7.2 + comark-deflist: ^0.7.2 + comark-details: ^0.7.2 + comark-diagrams: ^0.7.2 + comark-diff: ^0.7.2 + comark-embed: ^0.7.2 + comark-fields: ^0.7.2 + comark-figure: ^0.7.2 + comark-file-tree: ^0.7.2 + comark-footnotes: ^0.7.2 + comark-github: ^0.7.2 + comark-icon: ^0.7.2 + comark-keys: ^0.7.2 + comark-links: ^0.7.2 + comark-magic-move: ^0.7.2 + comark-mark: ^0.7.2 + comark-mathml: ^0.7.2 + comark-media: ^0.7.2 + comark-metrics: ^0.7.2 + comark-outline: ^0.7.2 + comark-pele: ^1.1.0 + comark-properties: ^0.7.2 + comark-qr: ^0.7.2 + comark-quiz: ^0.7.2 + comark-reading-time: ^0.7.2 + comark-sources: ^0.7.2 + comark-steps: ^0.7.2 + comark-table: ^0.7.2 + comark-tabs: ^0.7.2 + comark-tests: ^0.7.2 + comark-ui: ^0.7.2 + comark-xref: ^0.7.2 + comark-zoom: ^0.7.2 + eslint: ^9.15.0 || ^10.0.0 + vite-doctor: ^0.1.0 + peerDependenciesMeta: + '@eslint/markdown': + optional: true + comark-annotate: + optional: true + comark-badge: + optional: true + comark-cards: + optional: true + comark-carousel: + optional: true + comark-cast: + optional: true + comark-charts: + optional: true + comark-chips: + optional: true + comark-cjk: + optional: true + comark-code: + optional: true + comark-code-notes: + optional: true + comark-colors: + optional: true + comark-critic: + optional: true + comark-deflist: + optional: true + comark-details: + optional: true + comark-diagrams: + optional: true + comark-diff: + optional: true + comark-embed: + optional: true + comark-fields: + optional: true + comark-figure: + optional: true + comark-file-tree: + optional: true + comark-footnotes: + optional: true + comark-github: + optional: true + comark-icon: + optional: true + comark-keys: + optional: true + comark-links: + optional: true + comark-magic-move: + optional: true + comark-mark: + optional: true + comark-mathml: + optional: true + comark-media: + optional: true + comark-metrics: + optional: true + comark-outline: + optional: true + comark-pele: + optional: true + comark-properties: + optional: true + comark-qr: + optional: true + comark-quiz: + optional: true + comark-reading-time: + optional: true + comark-sources: + optional: true + comark-steps: + optional: true + comark-table: + optional: true + comark-tabs: + optional: true + comark-tests: + optional: true + comark-ui: + optional: true + comark-xref: + optional: true + comark-zoom: + optional: true + eslint: + optional: true + vite-doctor: + optional: true + comark@0.8.1: resolution: {integrity: sha512-oz2Nrt3o/e5SRYVgd3PlBwDUp5P4PWYZbVggORI02rLF63WEjHYkPJkxZh2Hwfpl0J4jDLspe6aeUraNplAsAw==} peerDependencies: @@ -9952,6 +10104,12 @@ snapshots: - shiki - uploadthing + comark-linter@0.7.2(comark@0.8.1(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.5.0))(eslint@10.13.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + comark: 0.8.1(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.5.0) + optionalDependencies: + eslint: 10.13.0(jiti@2.7.0)(supports-color@10.2.2) + comark@0.8.1(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.5.0): dependencies: entities: 8.1.0 From 00f631c563671658f79c8aee9507c09de0f7d254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 30 Oct 2026 11:45:03 +0000 Subject: [PATCH 04/10] refactor: move Markdown components into app/components/prose @comark/nuxt registers each layer's components/prose/ as global components, the folder Markdown resolves names from. ::landing-hero is a re-export of Nuxt UI's page hero. The pages keep importing them for eager resolution. Amp-Thread-ID: https://ampcode.com/threads/T-01a18c64-cf17-72b6-afc0-24d664666b30 --- app/components/{ => prose}/Browser.vue | 0 app/components/{ => prose}/CodeExplorer.vue | 0 .../{landing => prose}/LandingCta.vue | 0 .../{landing => prose}/LandingFaq.vue | 0 .../{landing => prose}/LandingFeatureCard.vue | 0 .../{landing => prose}/LandingFeatures.vue | 0 app/components/prose/LandingHero.ts | 2 ++ .../{landing => prose}/LandingHeroDemo.vue | 0 .../{landing => prose}/LandingStack.vue | 0 .../{landing => prose}/LandingTabs.vue | 0 app/pages/[...slug].vue | 4 ++-- app/pages/index.vue | 14 +++++++------- eslint.config.mjs | 6 +++--- test/lint.test.ts | 14 -------------- utils/markdown-components.mjs | 18 ------------------ 15 files changed, 14 insertions(+), 44 deletions(-) rename app/components/{ => prose}/Browser.vue (100%) rename app/components/{ => prose}/CodeExplorer.vue (100%) rename app/components/{landing => prose}/LandingCta.vue (100%) rename app/components/{landing => prose}/LandingFaq.vue (100%) rename app/components/{landing => prose}/LandingFeatureCard.vue (100%) rename app/components/{landing => prose}/LandingFeatures.vue (100%) create mode 100644 app/components/prose/LandingHero.ts rename app/components/{landing => prose}/LandingHeroDemo.vue (100%) rename app/components/{landing => prose}/LandingStack.vue (100%) rename app/components/{landing => prose}/LandingTabs.vue (100%) delete mode 100644 test/lint.test.ts delete mode 100644 utils/markdown-components.mjs diff --git a/app/components/Browser.vue b/app/components/prose/Browser.vue similarity index 100% rename from app/components/Browser.vue rename to app/components/prose/Browser.vue diff --git a/app/components/CodeExplorer.vue b/app/components/prose/CodeExplorer.vue similarity index 100% rename from app/components/CodeExplorer.vue rename to app/components/prose/CodeExplorer.vue diff --git a/app/components/landing/LandingCta.vue b/app/components/prose/LandingCta.vue similarity index 100% rename from app/components/landing/LandingCta.vue rename to app/components/prose/LandingCta.vue diff --git a/app/components/landing/LandingFaq.vue b/app/components/prose/LandingFaq.vue similarity index 100% rename from app/components/landing/LandingFaq.vue rename to app/components/prose/LandingFaq.vue diff --git a/app/components/landing/LandingFeatureCard.vue b/app/components/prose/LandingFeatureCard.vue similarity index 100% rename from app/components/landing/LandingFeatureCard.vue rename to app/components/prose/LandingFeatureCard.vue diff --git a/app/components/landing/LandingFeatures.vue b/app/components/prose/LandingFeatures.vue similarity index 100% rename from app/components/landing/LandingFeatures.vue rename to app/components/prose/LandingFeatures.vue diff --git a/app/components/prose/LandingHero.ts b/app/components/prose/LandingHero.ts new file mode 100644 index 0000000..3b2f0d4 --- /dev/null +++ b/app/components/prose/LandingHero.ts @@ -0,0 +1,2 @@ +// `::landing-hero` is Nuxt UI's page hero. +export { default } from '@nuxt/ui/components/PageHero.vue' diff --git a/app/components/landing/LandingHeroDemo.vue b/app/components/prose/LandingHeroDemo.vue similarity index 100% rename from app/components/landing/LandingHeroDemo.vue rename to app/components/prose/LandingHeroDemo.vue diff --git a/app/components/landing/LandingStack.vue b/app/components/prose/LandingStack.vue similarity index 100% rename from app/components/landing/LandingStack.vue rename to app/components/prose/LandingStack.vue diff --git a/app/components/landing/LandingTabs.vue b/app/components/prose/LandingTabs.vue similarity index 100% rename from app/components/landing/LandingTabs.vue rename to app/components/prose/LandingTabs.vue diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue index 3a0168f..3ecfaa1 100644 --- a/app/pages/[...slug].vue +++ b/app/pages/[...slug].vue @@ -51,8 +51,8 @@ import { ProseUl, ProseWarning, } from '#components' -import CodeExplorer from '../components/CodeExplorer.vue' -import Browser from '../components/Browser.vue' +import CodeExplorer from '../components/prose/CodeExplorer.vue' +import Browser from '../components/prose/Browser.vue' definePageMeta({ path: '/:slug(.+)', diff --git a/app/pages/index.vue b/app/pages/index.vue index 82f6b76..50aa37b 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,12 +1,12 @@