@lupinum/ginko-content 0.3.6 → 0.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +74 -93
  3. package/dist/cms-contract/build.js +18 -3
  4. package/dist/cms-contract/index.d.ts +2 -1
  5. package/dist/cms-contract/mdc.d.ts +3 -1
  6. package/dist/cms-contract/render-policy.d.ts +24 -0
  7. package/dist/cms-contract/render-policy.js +102 -26
  8. package/dist/cms-contract/validate.js +9 -0
  9. package/dist/core/content/graph.js +3 -1
  10. package/dist/core/content/path.d.ts +3 -1
  11. package/dist/core/content/path.js +29 -5
  12. package/dist/core/markdown/builtin-render-contracts.d.ts +35 -0
  13. package/dist/core/markdown/builtin-render-contracts.js +25 -0
  14. package/dist/core/markdown/html-tags.d.ts +1 -0
  15. package/dist/{integrations/vue → core/markdown}/html-tags.js +2 -2
  16. package/dist/core/markdown/normalize-comark.d.ts +9 -1
  17. package/dist/core/markdown/normalize-comark.js +57 -5
  18. package/dist/core/markdown/parse-comark.d.ts +6 -2
  19. package/dist/core/markdown/parse-comark.js +6 -7
  20. package/dist/core/markdown/tree.d.ts +4 -4
  21. package/dist/core/markdown/tree.js +1 -3
  22. package/dist/features/agent/agent-paths.js +1 -1
  23. package/dist/features/agent/walker.js +12 -4
  24. package/dist/features/navigation/resolve.js +2 -1
  25. package/dist/features/search/options.d.ts +4 -0
  26. package/dist/features/search/options.js +30 -0
  27. package/dist/integrations/vue/content-components.js +3 -3
  28. package/dist/module.d.mts +27 -25
  29. package/dist/module.json +2 -2
  30. package/dist/module.mjs +256 -185
  31. package/dist/parsers/markdown-plugin-options.d.ts +1 -0
  32. package/dist/parsers/markdown-plugin-options.js +8 -0
  33. package/dist/parsers/markdown-plugins.d.ts +1 -0
  34. package/dist/parsers/markdown-plugins.js +10 -63
  35. package/dist/parsers/markdown.d.ts +4 -0
  36. package/dist/parsers/markdown.js +19 -6
  37. package/dist/portability/assets.js +25 -10
  38. package/dist/portability/mdc.js +5 -49
  39. package/dist/portability/yaml.js +29 -1
  40. package/dist/public/client.d.ts +1 -1
  41. package/dist/public/provider-document.js +1 -2
  42. package/dist/public/server.d.ts +1 -1
  43. package/dist/runtime/app/components/ContentRenderer.d.vue.ts +0 -9
  44. package/dist/runtime/app/components/ContentRenderer.vue +1 -4
  45. package/dist/runtime/app/components/ContentRenderer.vue.d.ts +0 -9
  46. package/dist/runtime/app/components/ContentRendererInline.d.vue.ts +1 -1
  47. package/dist/runtime/app/components/ContentRendererInline.vue +14 -23
  48. package/dist/runtime/app/components/ContentRendererInline.vue.d.ts +1 -1
  49. package/dist/runtime/app/components/Prose/ProsePre.d.vue.ts +1 -1
  50. package/dist/runtime/app/components/Prose/ProsePre.vue.d.ts +1 -1
  51. package/dist/runtime/app/components/internal/ContentRendererMarkdown.d.vue.ts +1 -1
  52. package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue +13 -3
  53. package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue.d.ts +1 -1
  54. package/dist/runtime/app/components/internal/MarkdownRenderer.d.ts +1 -1
  55. package/dist/runtime/app/components/internal/MarkdownRenderer.js +4 -4
  56. package/dist/runtime/app/composables/search.js +43 -86
  57. package/dist/runtime/app/plugins/hot-reload.js +1 -1
  58. package/dist/runtime/markdown/plugins.d.ts +1 -3
  59. package/dist/runtime/markdown/plugins.js +3 -30
  60. package/dist/runtime/server/agent-markdown.js +1 -1
  61. package/dist/runtime/server/api/sitemap.js +1 -1
  62. package/dist/runtime/server/navigation-query.js +2 -2
  63. package/dist/runtime/server/plugins/sitemap.js +2 -2
  64. package/dist/runtime/server/sitemap-provider.js +2 -2
  65. package/dist/runtime/shared/search.js +2 -24
  66. package/dist/runtime/utils/content-components.d.ts +1 -4
  67. package/dist/runtime/utils/content-components.js +5 -16
  68. package/dist/testing/provider-fixture.js +1 -1
  69. package/dist/types/content.d.ts +20 -1
  70. package/dist/types/module.d.ts +3 -3
  71. package/dist/types/search.d.ts +1 -1
  72. package/dist/types/virtual.d.ts +10 -0
  73. package/dist/web-types.json +1 -9
  74. package/package.json +20 -10
  75. package/dist/integrations/vue/html-tags.d.ts +0 -2
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026-present Lupinum OG
3
+ Copyright (c) 2026-present Lupinum OG and contributors
4
4
  Copyright (c) Nuxt Project
5
5
 
6
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
package/README.md CHANGED
@@ -1,47 +1,62 @@
1
- # @lupinum/ginko-content
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/lupinum-dev/ginko-content/main/docs/public/icon.png" width="128" alt="Ginko Content">
3
+ </p>
2
4
 
3
- Filesystem-first content for Nuxt 4.
5
+ <h1 align="center">@lupinum/ginko-content</h1>
4
6
 
5
- Write Markdown and data files in `content/`, define collections once in
6
- `content.config.ts`, then use those collection handles for route pages, lists,
7
- navigation, search, i18n, and sitemap output.
7
+ <p align="center">
8
+ Use one typed content model for Nuxt pages, queries, navigation, search, localization, and build output.
9
+ </p>
8
10
 
9
- Use it when you want content files to stay simple, but your Nuxt app still
10
- needs explicit APIs for route resolution, typed frontmatter, localized content,
11
- and server-side reads.
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@lupinum/ginko-content"><img src="https://img.shields.io/npm/v/@lupinum/ginko-content?color=315d3b" alt="npm version"></a>
13
+ <a href="https://github.com/lupinum-dev/ginko-content/actions/workflows/ci.yml"><img src="https://github.com/lupinum-dev/ginko-content/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
14
+ <a href="https://github.com/lupinum-dev/ginko-content/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-315d3b" alt="MIT license"></a>
15
+ </p>
12
16
 
13
- This README describes the stable `0.3` line.
17
+ > [!WARNING]
18
+ > Version `0.4.0-rc.2` is a release candidate. Install it from npm's `next`
19
+ > channel. The stable `0.3` line remains on `latest`.
20
+
21
+ ## Why use this package?
22
+
23
+ Content features become difficult to maintain when routes, lists, search, and
24
+ localization use separate data. This package makes the collection definition
25
+ the source of truth for those features.
26
+
27
+ Keep Markdown and data files in `content/`. Use typed collection handles in the
28
+ Nuxt application. The same handles also drive server queries and generated
29
+ output.
14
30
 
15
31
  ## Requirements
16
32
 
17
33
  - Node.js 22.18–22.x, 24.11–24.x, or 26+
18
- - Nuxt 4.4.7 through Nuxt 4.x
34
+ - Nuxt 4.5.1 through Nuxt 4.x
19
35
  - Vue 3.5.35 through Vue 3.x
20
36
  - ESM; CommonJS `require()` is not supported
21
37
 
22
- ## Install
38
+ ## Installation
39
+
40
+ Use the Nuxt CLI:
23
41
 
24
42
  ```bash
25
- npx nuxi module add @lupinum/ginko-content
43
+ npx nuxi module add @lupinum/ginko-content@0.4.0-rc.2
26
44
  ```
27
45
 
28
- The Nuxt CLI installs the package and registers the module in `nuxt.config.ts`.
29
- If you prefer to install by hand:
46
+ Or install and register the module by hand:
30
47
 
31
48
  ```bash
32
- pnpm add @lupinum/ginko-content
49
+ pnpm add @lupinum/ginko-content@0.4.0-rc.2
33
50
  ```
34
51
 
35
52
  ```ts
53
+ // nuxt.config.ts
36
54
  export default defineNuxtConfig({
37
55
  modules: ['@lupinum/ginko-content'],
38
- imports: {
39
- autoImport: true
40
- }
41
56
  })
42
57
  ```
43
58
 
44
- ## Quick Start
59
+ ## Quick start
45
60
 
46
61
  Define a collection:
47
62
 
@@ -51,13 +66,11 @@ import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/co
51
66
 
52
67
  export const pages = defineCollection({
53
68
  type: 'page',
54
- source: '**/*.md'
69
+ source: '**/*.md',
55
70
  })
56
71
 
57
72
  export default defineContentConfig({
58
- collections: {
59
- pages
60
- }
73
+ collections: { pages },
61
74
  })
62
75
  ```
63
76
 
@@ -69,18 +82,22 @@ title: Welcome
69
82
  ---
70
83
 
71
84
  # Welcome
72
-
73
- This file renders at `/`.
74
85
  ```
75
86
 
76
- Render the current route through the collection:
87
+ Render the active route:
77
88
 
78
89
  ```vue
79
90
  <!-- pages/[...slug].vue -->
80
91
  <script setup lang="ts">
81
92
  import { pages } from '~~/content.config'
82
93
 
94
+ definePageMeta({ key: route => route.path })
95
+
83
96
  const { page } = await useContentPage(pages)
97
+
98
+ if (!page.value) {
99
+ throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
100
+ }
84
101
  </script>
85
102
 
86
103
  <template>
@@ -88,73 +105,37 @@ const { page } = await useContentPage(pages)
88
105
  </template>
89
106
  ```
90
107
 
91
- ## Features
92
-
93
- - file-authored pages and navigation from `content/`
94
- - collection definitions as the source of truth for content shape and source
95
- files
96
- - Markdown, Comark component tags, YAML, JSON, and CSV ingestion
97
- - locale-aware content routing
98
- - route-aware page loading with `useContentPage(pages)`
99
- - semantic previous/next route-page data through `useContentPage(pages, { surround })`
100
- - server reads through `one`, `many`, `paginate`, `resolveOne`, `navigation`,
101
- and `surround`
102
- - route and search composables, plus pure client query functions for other
103
- reads
104
- - search helpers for MiniSearch, Pagefind, and provider-owned search
105
- - sitemap integration for public content routes
106
- - a server-side provider contract for advanced custom sources
107
-
108
- ## I18n, Sitemap, and Prerender Ownership
109
-
110
- For localized Nuxt apps, keep one source of truth per route type:
111
-
112
- - Static Nuxt page paths belong in Nuxt I18n `i18n.pages`.
113
- - Content page paths belong in Ginko collection routes and content files.
114
- - Sitemap XML is generated by `@nuxtjs/sitemap`.
115
- - Ginko registers the content sitemap source and contributes content prerender
116
- routes.
117
-
118
- Do not duplicate docs, blog, pricing, privacy, or translated locale paths in
119
- `sitemap.urls` or app-owned `nitro.prerender.routes` arrays. Use
120
- `@nuxtjs/sitemap >= 8.0.15 < 9` when translated static app slugs such as `/preise`
121
- and `/en/pricing` need cross-locale sitemap alternates.
122
-
123
- See the public guide:
124
- [Sitemap and prerender](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/4.guides/4.routing-and-seo/3.sitemap-and-prerender.md).
125
-
126
- ## Integration Dependencies
127
-
128
- | Feature | Dependency model |
129
- | --- | --- |
130
- | Markdown highlighting and built-in Shiki transformers | Included as runtime dependencies of `@lupinum/ginko-content`. |
131
- | MiniSearch backend | Included as a runtime dependency and used by the default search backend. |
132
- | Pagefind backend | Install the optional `pagefind` peer when `content.search.engine` is `'pagefind'`. |
133
- | Provider-owned search | No extra package. The active content provider must advertise and implement `search`. |
134
- | Content i18n | Install and configure `@nuxtjs/i18n` 10.x when the app uses Nuxt locale routes. |
135
- | Sitemap XML output | Install and configure `@nuxtjs/sitemap`; Ginko contributes the content sitemap source. |
136
-
137
- ## Scope
138
-
139
- The default provider reads files from your Nuxt project. The package does not
140
- include a CMS UI, Studio, admin panel, or content editing workflow.
141
-
142
- ## Project links
143
-
144
- - Documentation source: [Getting started](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/3.get-started/1.installation.md)
145
- - Repository: [github.com/lupinum-dev/ginko-content](https://github.com/lupinum-dev/ginko-content)
146
- - Issues: [GitHub issues](https://github.com/lupinum-dev/ginko-content/issues)
147
- - Contributing: [CONTRIBUTING.md](https://github.com/lupinum-dev/ginko-content/blob/main/CONTRIBUTING.md)
148
- - Security: [SECURITY.md](https://github.com/lupinum-dev/ginko-content/blob/main/SECURITY.md)
149
-
150
- ## Credits
151
-
152
- Ginko Content is an independently maintained fork derived from
153
- [Nuxt Content](https://content.nuxt.com/). It has substantially diverged while
154
- retaining upstream-derived parser, MDC, and rendering foundations. Credits also
155
- go to [Nuxt UI](https://ui.nuxt.com/) and [Comark](https://comark.dev/), the
156
- successor to the previous MDC work.
108
+ ## Main capabilities
109
+
110
+ - Markdown and Comark component rendering.
111
+ - YAML, JSON, and CSV ingestion.
112
+ - Typed collections and frontmatter.
113
+ - Route-aware pages and server-side queries.
114
+ - Locale-aware routes and fallback rules.
115
+ - Navigation, search, sitemap, and prerender helpers.
116
+ - A provider contract for remote or database-backed sources.
117
+
118
+ Optional features stay explicit. Install `pagefind`, `katex`, or
119
+ `beautiful-mermaid` only when the application uses the related search, math, or
120
+ diagram integration.
121
+
122
+ ## Documentation
123
+
124
+ - [Documentation](https://ginko-content.lupinum.com)
125
+ - [Quick start](https://ginko-content.lupinum.com/docs/get-started/quickstart)
126
+ - [Guides](https://ginko-content.lupinum.com/docs/guides)
127
+ - [API reference](https://ginko-content.lupinum.com/docs/reference)
128
+ - [Repository](https://github.com/lupinum-dev/ginko-content)
129
+
130
+ ## Support and security
131
+
132
+ Open a [GitHub issue](https://github.com/lupinum-dev/ginko-content/issues) for a
133
+ reproducible defect. Join the [Lupinum OSS Discord](https://discord.gg/RPH6SeA36N)
134
+ for usage questions.
135
+
136
+ Report vulnerabilities through [GitHub private vulnerability reporting](https://github.com/lupinum-dev/ginko-content/security/advisories/new).
157
137
 
158
138
  ## License
159
139
 
160
- [MIT](./LICENSE)
140
+ This package is available under the [MIT License](https://github.com/lupinum-dev/ginko-content/blob/main/LICENSE).
141
+ It is developed by [Lupinum OG](https://lupinum.com).
@@ -1,6 +1,11 @@
1
1
  import { getObjectShape, getReferenceDescriptor, getSchemaDef, getSchemaTypeName, unwrapSchema } from "../core/references/schema.js";
2
2
  import { getContentFieldMetadata } from "../types/fields.js";
3
3
  import { canonicalJsonBytes } from "./hash.js";
4
+ import {
5
+ canonicalizePortableComponentName,
6
+ isReservedPortableComponentName,
7
+ isValidPortableComponentName
8
+ } from "./render-policy.js";
4
9
  export const RESOLVED_CONTENT_CONTRACT_VERSION = 1;
5
10
  export function buildResolvedContentContract(config, options) {
6
11
  const locales = unique(options.locales);
@@ -374,9 +379,19 @@ function portableMediaTypes(values) {
374
379
  }
375
380
  function normalizeComponentPolicy(policy) {
376
381
  const components = {};
377
- for (const [componentName, component] of Object.entries(policy.components)) {
378
- if (!componentName || eventLike(componentName) || /[:@]/.test(componentName)) {
379
- throw new Error(`Invalid portable component name "${componentName}".`);
382
+ for (const [authoredName, component] of Object.entries(policy.components)) {
383
+ if (!authoredName || eventLike(authoredName) || /[:@]/.test(authoredName)) {
384
+ throw new Error(`Invalid portable component name "${authoredName}".`);
385
+ }
386
+ const componentName = canonicalizePortableComponentName(authoredName);
387
+ if (!isValidPortableComponentName(componentName)) {
388
+ throw new Error(`Invalid portable component name "${authoredName}".`);
389
+ }
390
+ if (isReservedPortableComponentName(authoredName)) {
391
+ throw new Error(`Portable component name "${authoredName}" is reserved.`);
392
+ }
393
+ if (!componentName || ["__proto__", "prototype", "constructor"].includes(authoredName.toLowerCase()) || Object.prototype.hasOwnProperty.call(components, componentName)) {
394
+ throw new Error(`Portable component name "${authoredName}" conflicts after canonicalization.`);
380
395
  }
381
396
  const props = {};
382
397
  for (const [propName, prop] of Object.entries(component.props)) {
@@ -15,7 +15,8 @@
15
15
  * (`unwrapSchema`, `getObjectShape`, `getReferenceDescriptor`, ...),
16
16
  * - generate paths consistently with how the filesystem provider does
17
17
  * (`generatePath`, `generateCanonicalKey`, `slugifyUrlSegment`),
18
- * - parse MDC into the same AST the filesystem provider uses (`parseMdcBody`).
18
+ * - parse MDC with the fixed portable-baseline profile (`parseMdcBody`); site
19
+ * filesystem plugins may intentionally produce an enriched AST.
19
20
  *
20
21
  * If a future change introduces a Node/Nuxt dependency anywhere in this
21
22
  * tree, isolate builds will break loudly. That's intentional.
@@ -17,7 +17,9 @@ export interface ParseMdcBodyResult {
17
17
  searchText: string;
18
18
  }
19
19
  /**
20
- * Parse a raw MDC string into a normalized AST + TOC + searchable plaintext.
20
+ * Parse a raw MDC string with Ginko's fixed portable-baseline profile into a
21
+ * normalized AST + TOC + searchable plaintext. Site-configured filesystem
22
+ * plugins are intentionally not applied at this CMS publishing boundary.
21
23
  *
22
24
  * The function is async because comark's parser is async (frontmatter
23
25
  * extraction, plugin pipeline). It is safe to call from a Convex mutation
@@ -17,6 +17,30 @@ export declare class PublicMarkdownValidationError extends Error {
17
17
  readonly issues: PublicMarkdownIssue[];
18
18
  constructor(issues: PublicMarkdownIssue[]);
19
19
  }
20
+ export declare const canonicalizePortableComponentName: (value: string) => string;
21
+ /** Names authored component policies may never claim. */
22
+ export declare const isReservedPortableComponentName: (value: string) => boolean;
23
+ /** Canonical names supported by Comark MDC and Vue component resolution. */
24
+ export declare const isValidPortableComponentName: (value: string) => boolean;
25
+ /** Internal canonical lookup shared by render validation and portable asset traversal. */
26
+ export declare const indexPortableComponentPolicies: (policy: PortableComponentPolicyV1) => Map<string, {
27
+ kind: "block" | "inline";
28
+ props: Record<string, {
29
+ type: "string" | "number" | "boolean" | "json" | "asset";
30
+ required: boolean;
31
+ }>;
32
+ slots: string[];
33
+ media: {
34
+ sourceProp: string;
35
+ altProp: string | null;
36
+ titleProp: string | null;
37
+ filenameProp: string | null;
38
+ } | null;
39
+ }>;
40
+ /** Package-private grammar used only while resolving stored portable assets. */
41
+ export declare const isStoredPortableAssetIdentity: (value: string) => boolean;
20
42
  export declare function isSafePublicMarkdownUrl(value: string, kind?: 'href' | 'asset'): boolean;
21
43
  export declare function validatePublicMarkdownAst(value: unknown, policy?: PortableComponentPolicyV1): PublicMarkdownValidationResult;
44
+ /** Internal portability entry point; intentionally absent from the public facade. */
45
+ export declare function validateStoredPortableMarkdownAst(value: unknown, policy: PortableComponentPolicyV1): PublicMarkdownValidationResult;
22
46
  export declare function assertPublicMarkdownAst(value: unknown, policy?: PortableComponentPolicyV1): asserts value is MarkdownRoot;
@@ -1,3 +1,12 @@
1
+ import {
2
+ isNormalizedTaskCheckboxProps,
3
+ isNormalizedMathProps,
4
+ isNormalizedMermaidProps,
5
+ isSafeCodeHighlights,
6
+ isSafeTableAlignmentStyle
7
+ } from "../core/markdown/normalize-comark.js";
8
+ import { BUILTIN_MARKDOWN_RENDER_CONTRACTS } from "../core/markdown/builtin-render-contracts.js";
9
+ import { HTML_TAGS } from "../core/markdown/html-tags.js";
1
10
  export class PublicMarkdownValidationError extends Error {
2
11
  issues;
3
12
  constructor(issues) {
@@ -128,12 +137,13 @@ const HTML_PROPS = {
128
137
  ins: /* @__PURE__ */ new Set(["cite", "datetime"]),
129
138
  li: /* @__PURE__ */ new Set(["value"]),
130
139
  ol: /* @__PURE__ */ new Set(["start", "reversed", "type"]),
131
- pre: /* @__PURE__ */ new Set(["language", "filename"]),
140
+ pre: /* @__PURE__ */ new Set(["language", "filename", "meta", "highlights"]),
132
141
  td: /* @__PURE__ */ new Set(["colspan", "rowspan", "headers"]),
133
142
  th: /* @__PURE__ */ new Set(["colspan", "rowspan", "headers", "scope"]),
134
143
  time: /* @__PURE__ */ new Set(["datetime"])
135
144
  };
136
145
  const URL_PROPS = /* @__PURE__ */ new Set(["href", "src", "cite"]);
146
+ const UNSUPPORTED_NETWORK_PROPS = /* @__PURE__ */ new Set(["ping", "srcset"]);
137
147
  const FORBIDDEN_PROPS = /* @__PURE__ */ new Set([
138
148
  "__proto__",
139
149
  "prototype",
@@ -146,7 +156,20 @@ const FORBIDDEN_PROPS = /* @__PURE__ */ new Set([
146
156
  "ref",
147
157
  "key"
148
158
  ]);
149
- const componentName = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_.\s]+/g, "-").toLowerCase();
159
+ export const canonicalizePortableComponentName = (value) => value.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_.\s]+/g, "-").toLowerCase();
160
+ const BUILTIN_RENDER_TAGS = new Set(
161
+ Object.values(BUILTIN_MARKDOWN_RENDER_CONTRACTS).map((contract) => contract.tag)
162
+ );
163
+ export const isReservedPortableComponentName = (value) => {
164
+ const name = canonicalizePortableComponentName(value);
165
+ const nativeName = value.toLowerCase();
166
+ return nativeName === "template" || ACTIVE_TAGS.has(nativeName) || BUILTIN_RENDER_TAGS.has(name);
167
+ };
168
+ export const isValidPortableComponentName = (value) => /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value);
169
+ export const indexPortableComponentPolicies = (policy) => new Map(
170
+ Object.entries(policy.components).map(([name, component]) => [canonicalizePortableComponentName(name), component])
171
+ );
172
+ export const isStoredPortableAssetIdentity = (value) => /^[a-z0-9;:_-]{1,512}$/i.test(value) && !/^(?:javascript|vbscript|data|file|https?):/i.test(value);
150
173
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
151
174
  const isSafeBindingValue = (value) => {
152
175
  if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true;
@@ -208,19 +231,52 @@ export function isSafePublicMarkdownUrl(value, kind = "href") {
208
231
  return false;
209
232
  }
210
233
  }
211
- export function validatePublicMarkdownAst(value, policy = { components: {} }) {
234
+ function validateMarkdownAst(value, policy, allowStoredAssets) {
212
235
  const issues = [];
213
236
  const report = (code, path, message) => issues.push({ code, path, message });
214
- const components = new Map(
215
- Object.entries(policy.components).map(([name, component]) => [componentName(name), component])
216
- );
237
+ const components = indexPortableComponentPolicies(policy);
238
+ const validatePropInvariant = (tag, name, value2, path, component) => {
239
+ const declaredAsAsset = component?.props[name]?.type === "asset";
240
+ if (declaredAsAsset && typeof value2 === "string") {
241
+ const storedAsset2 = allowStoredAssets && isStoredPortableAssetIdentity(value2);
242
+ if (!storedAsset2 && !isSafePublicMarkdownUrl(value2, "asset")) {
243
+ report("unsafe_url", path, `Component property "${name}" contains an unsafe URL.`);
244
+ }
245
+ }
246
+ if (!HTML_TAGS.has(tag)) return;
247
+ const lower = name.toLowerCase();
248
+ if (tag === "pre" && (name === "language" || name === "filename" || name === "meta") && typeof value2 !== "string") {
249
+ report("invalid_prop_value", path, `HTML property "${name}" on <pre> must be a string.`);
250
+ }
251
+ if (tag === "pre" && name === "highlights" && !isSafeCodeHighlights(value2)) {
252
+ report("invalid_prop_value", path, 'HTML property "highlights" on <pre> contains invalid line numbers.');
253
+ }
254
+ if (tag === "pre" && name === "meta" && typeof value2 === "string" && value2.length > 2048) {
255
+ report("invalid_prop_value", path, 'HTML property "meta" on <pre> is too long.');
256
+ }
257
+ if (UNSUPPORTED_NETWORK_PROPS.has(lower)) {
258
+ report("unsafe_prop", path, `Native network property "${name}" is not supported by the portable policy.`);
259
+ return;
260
+ }
261
+ if (!URL_PROPS.has(lower)) return;
262
+ if (typeof value2 !== "string") {
263
+ report("invalid_prop_value", path, `HTML URL property "${name}" must be a string.`);
264
+ return;
265
+ }
266
+ if (declaredAsAsset) return;
267
+ const storedAsset = allowStoredAssets && tag === "img" && lower === "src" && isStoredPortableAssetIdentity(value2);
268
+ const kind = lower === "src" ? "asset" : "href";
269
+ if (!storedAsset && !isSafePublicMarkdownUrl(value2, kind)) {
270
+ report("unsafe_url", path, `Property "${name}" contains an unsafe URL.`);
271
+ }
272
+ };
217
273
  const validateProps = (node, path, component) => {
218
274
  const props = node.props ?? {};
219
275
  if (!isRecord(props)) {
220
276
  report("invalid_node", [...path, "props"], "Node props must be an object.");
221
277
  return;
222
278
  }
223
- const tag = String(node.tag);
279
+ const tag = String(node.tag).toLowerCase();
224
280
  for (const [name, propValue] of Object.entries(props)) {
225
281
  const propPath = [...path, "props", name];
226
282
  const lower = name.toLowerCase();
@@ -229,6 +285,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
229
285
  continue;
230
286
  }
231
287
  if (name === "style" && tag === "span" && isSafeShikiStyle(propValue)) continue;
288
+ if ((tag === "th" || tag === "td") && name === "style" && isSafeTableAlignmentStyle(propValue)) continue;
232
289
  if (tag === "blockquote" && name === "data-alert") {
233
290
  if (typeof propValue !== "string" || !["note", "tip", "important", "warning", "caution"].includes(propValue)) report("invalid_prop_value", propPath, "Blockquote alert metadata is invalid.");
234
291
  continue;
@@ -241,6 +298,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
241
298
  report("invalid_prop_value", propPath, `Property "${name}" is not JSON-safe.`);
242
299
  continue;
243
300
  }
301
+ validatePropInvariant(tag, name, propValue, propPath, component);
244
302
  if (component) {
245
303
  const declared = component.props[name];
246
304
  if (!declared) {
@@ -249,9 +307,6 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
249
307
  }
250
308
  const valid = declared.type === "json" || declared.type === "asset" && typeof propValue === "string" && propValue.length > 0 || declared.type !== "asset" && typeof propValue === declared.type;
251
309
  if (!valid) report("invalid_prop_value", propPath, `Component property "${name}" has the wrong type.`);
252
- else if (declared.type === "asset" && !isSafePublicMarkdownUrl(propValue, "asset")) {
253
- report("unsafe_url", propPath, `Component property "${name}" contains an unsafe URL.`);
254
- }
255
310
  continue;
256
311
  }
257
312
  const allowed = COMMON_HTML_PROPS.has(name) || name.startsWith("aria-") || name.startsWith("data-") || HTML_PROPS[tag]?.has(name);
@@ -259,16 +314,6 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
259
314
  report("unknown_prop", propPath, `HTML property "${name}" is not allowed on <${tag}>.`);
260
315
  continue;
261
316
  }
262
- if (tag === "pre" && (name === "language" || name === "filename") && typeof propValue !== "string") {
263
- report("invalid_prop_value", propPath, `HTML property "${name}" on <pre> must be a string.`);
264
- continue;
265
- }
266
- if (URL_PROPS.has(lower) && typeof propValue === "string") {
267
- const kind = lower === "src" ? "asset" : "href";
268
- if (!isSafePublicMarkdownUrl(propValue, kind)) {
269
- report("unsafe_url", propPath, `Property "${name}" contains an unsafe URL.`);
270
- }
271
- }
272
317
  }
273
318
  if (component) {
274
319
  for (const [name, definition] of Object.entries(component.props)) {
@@ -278,7 +323,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
278
323
  }
279
324
  }
280
325
  };
281
- const visit = (node, path) => {
326
+ const visit = (node, path, parentComponent) => {
282
327
  if (!isRecord(node) || typeof node.type !== "string") {
283
328
  report("invalid_node", path, "Markdown nodes must be objects with a type.");
284
329
  return;
@@ -297,15 +342,39 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
297
342
  if (Object.keys(node).some((key) => !["type", "tag", "props", "children"].includes(key))) {
298
343
  report("invalid_node", path, "Element nodes contain unsupported fields.");
299
344
  }
300
- const normalizedTag = componentName(node.tag);
345
+ const normalizedTag = canonicalizePortableComponentName(node.tag);
346
+ const nativeTag = node.tag.toLowerCase();
347
+ if (nativeTag === "template") {
348
+ if (node.tag !== "template") {
349
+ report("unsafe_tag", [...path, "tag"], "Named slot template tags must use the canonical lowercase spelling.");
350
+ node.children.forEach((child, index) => visit(child, [...path, "children", index]));
351
+ return;
352
+ }
353
+ const props = node.props ?? {};
354
+ const slotName = isRecord(props) && Object.keys(props).length === 1 && typeof props.name === "string" ? props.name : void 0;
355
+ if (!parentComponent || !slotName || !parentComponent.slots.includes(slotName)) {
356
+ report("unsafe_tag", [...path, "tag"], "Named slot templates must be direct children of a component and declare an allowed slot name.");
357
+ }
358
+ node.children.forEach((child, index) => visit(child, [...path, "children", index]));
359
+ return;
360
+ }
301
361
  const component = components.get(normalizedTag);
302
- if (ACTIVE_TAGS.has(normalizedTag)) {
362
+ const exactMathNode = isNormalizedMathProps(node.props) && node.children.length === 1 && isExactTextNode(node.children[0], node.props.content);
363
+ const exactMermaidNode = isNormalizedMermaidProps(node.props) && node.children.length === 0;
364
+ if (normalizedTag === BUILTIN_MARKDOWN_RENDER_CONTRACTS.math.tag && component && !exactMathNode) {
365
+ report("invalid_prop_value", path, "Generated Math node is malformed.");
366
+ }
367
+ if (normalizedTag === BUILTIN_MARKDOWN_RENDER_CONTRACTS.mermaid.tag && component && !exactMermaidNode) {
368
+ report("invalid_prop_value", path, "Generated Mermaid node is malformed.");
369
+ }
370
+ const isTaskCheckbox = nativeTag === "input" && isNormalizedTaskCheckboxProps(node.props) && node.children.length === 0;
371
+ if (ACTIVE_TAGS.has(nativeTag) && !isTaskCheckbox) {
303
372
  report("unsafe_tag", [...path, "tag"], `Tag <${node.tag}> is not render-safe.`);
304
- } else if (!SAFE_HTML_TAGS.has(normalizedTag) && !component) {
373
+ } else if (!isTaskCheckbox && !SAFE_HTML_TAGS.has(nativeTag) && !component) {
305
374
  report("unknown_component", [...path, "tag"], `Component <${node.tag}> is not registered.`);
306
375
  }
307
- validateProps(node, path, component);
308
- node.children.forEach((child, index) => visit(child, [...path, "children", index]));
376
+ if (!isTaskCheckbox) validateProps(node, path, component);
377
+ node.children.forEach((child, index) => visit(child, [...path, "children", index], component));
309
378
  };
310
379
  if (!isRecord(value) || value.type !== "root" || !Array.isArray(value.children)) {
311
380
  report("invalid_node", [], "Markdown root must contain a children array.");
@@ -320,7 +389,14 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
320
389
  }
321
390
  return issues.length ? { ok: false, issues } : { ok: true, value };
322
391
  }
392
+ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
393
+ return validateMarkdownAst(value, policy, false);
394
+ }
395
+ export function validateStoredPortableMarkdownAst(value, policy) {
396
+ return validateMarkdownAst(value, policy, true);
397
+ }
323
398
  export function assertPublicMarkdownAst(value, policy = { components: {} }) {
324
399
  const result = validatePublicMarkdownAst(value, policy);
325
400
  if (!result.ok) throw new PublicMarkdownValidationError(result.issues);
326
401
  }
402
+ const isExactTextNode = (value, expected) => isRecord(value) && Object.keys(value).length === 2 && value.type === "text" && value.value === expected;
@@ -1,4 +1,9 @@
1
1
  import { canonicalJsonBytes } from "./hash.js";
2
+ import {
3
+ canonicalizePortableComponentName,
4
+ isReservedPortableComponentName,
5
+ isValidPortableComponentName
6
+ } from "./render-policy.js";
2
7
  const fieldTypes = /* @__PURE__ */ new Set([
3
8
  "text",
4
9
  "textarea",
@@ -163,7 +168,11 @@ function validateFieldPolicy(input, path) {
163
168
  function componentPolicy(value, path) {
164
169
  const input = record(value, path);
165
170
  exact(input, ["components"], path);
171
+ const canonicalNames = /* @__PURE__ */ new Set();
166
172
  for (const [name, rawComponent] of Object.entries(record(input.components, `${path}.components`))) {
173
+ const canonicalName = canonicalizePortableComponentName(name);
174
+ if (!canonicalName || name !== canonicalName || !isValidPortableComponentName(canonicalName) || isReservedPortableComponentName(canonicalName) || ["__proto__", "prototype", "constructor"].includes(name.toLowerCase()) || canonicalNames.has(canonicalName)) throw new Error(`${path}.components.${name} conflicts after canonicalization.`);
175
+ canonicalNames.add(canonicalName);
167
176
  const component = record(rawComponent, `${path}.components.${name}`);
168
177
  exact(component, ["kind", "props", "slots", "media"], `${path}.components.${name}`);
169
178
  if (!["block", "inline"].includes(String(component.kind))) throw new Error(`${path}.components.${name}.kind is invalid.`);
@@ -1,11 +1,13 @@
1
1
  import { isNavigationFile } from "./structural.js";
2
2
  import { normalizeReferenceValue, buildReferenceTargets } from "../references/resolve.js";
3
3
  import { resolveLocaleChain, sortLocalesCanonically } from "./locale.js";
4
+ import { trimTrailingSlashes } from "./path.js";
4
5
  const normalizePath = (path) => {
5
6
  if (!path || path === "/") {
6
7
  return "/";
7
8
  }
8
- return path.startsWith("/") ? path.endsWith("/") ? path.slice(0, -1) || "/" : path : `/${path.replace(/\/+$/, "")}`;
9
+ const normalized = trimTrailingSlashes(path) || "/";
10
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
9
11
  };
10
12
  export const buildContentGraph = (documents, options = {}) => {
11
13
  const byId = {};
@@ -1,7 +1,9 @@
1
+ export declare const trimSlashes: (value: string) => string;
2
+ export declare const trimTrailingSlashes: (value: string) => string;
1
3
  export declare const describeId: (id: string) => {
2
4
  source: string;
3
5
  path: string;
4
- extension: string;
6
+ extension: string | undefined;
5
7
  file: string;
6
8
  basename: string;
7
9
  };