@lupinum/ginko-content 0.3.5 → 0.4.0-rc.1

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 (64) hide show
  1. package/README.md +19 -6
  2. package/dist/cms-contract/build.js +18 -3
  3. package/dist/cms-contract/index.d.ts +2 -1
  4. package/dist/cms-contract/mdc.d.ts +3 -1
  5. package/dist/cms-contract/render-policy.d.ts +24 -0
  6. package/dist/cms-contract/render-policy.js +106 -27
  7. package/dist/cms-contract/validate.js +9 -0
  8. package/dist/core/markdown/builtin-render-contracts.d.ts +35 -0
  9. package/dist/core/markdown/builtin-render-contracts.js +25 -0
  10. package/dist/core/markdown/html-tags.d.ts +1 -0
  11. package/dist/{integrations/vue → core/markdown}/html-tags.js +2 -2
  12. package/dist/core/markdown/normalize-comark.d.ts +9 -1
  13. package/dist/core/markdown/normalize-comark.js +57 -5
  14. package/dist/core/markdown/parse-comark.d.ts +6 -2
  15. package/dist/core/markdown/parse-comark.js +6 -7
  16. package/dist/core/markdown/tree.d.ts +4 -4
  17. package/dist/core/markdown/tree.js +1 -3
  18. package/dist/features/agent/walker.js +11 -3
  19. package/dist/features/search/options.d.ts +4 -0
  20. package/dist/features/search/options.js +30 -0
  21. package/dist/integrations/vue/content-components.js +3 -3
  22. package/dist/module.d.mts +27 -25
  23. package/dist/module.json +1 -1
  24. package/dist/module.mjs +250 -174
  25. package/dist/parsers/markdown-plugin-options.d.ts +1 -0
  26. package/dist/parsers/markdown-plugin-options.js +8 -0
  27. package/dist/parsers/markdown-plugins.d.ts +1 -0
  28. package/dist/parsers/markdown-plugins.js +10 -63
  29. package/dist/parsers/markdown.d.ts +4 -0
  30. package/dist/parsers/markdown.js +19 -6
  31. package/dist/portability/assets.js +25 -10
  32. package/dist/portability/mdc.js +5 -49
  33. package/dist/public/client.d.ts +1 -1
  34. package/dist/public/server.d.ts +1 -1
  35. package/dist/runtime/app/components/ContentRenderer.d.vue.ts +0 -9
  36. package/dist/runtime/app/components/ContentRenderer.vue +1 -4
  37. package/dist/runtime/app/components/ContentRenderer.vue.d.ts +0 -9
  38. package/dist/runtime/app/components/ContentRendererInline.d.vue.ts +1 -1
  39. package/dist/runtime/app/components/ContentRendererInline.vue +14 -23
  40. package/dist/runtime/app/components/ContentRendererInline.vue.d.ts +1 -1
  41. package/dist/runtime/app/components/Prose/ProsePre.d.vue.ts +1 -1
  42. package/dist/runtime/app/components/Prose/ProsePre.vue.d.ts +1 -1
  43. package/dist/runtime/app/components/internal/ContentRendererMarkdown.d.vue.ts +1 -1
  44. package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue +13 -3
  45. package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue.d.ts +1 -1
  46. package/dist/runtime/app/components/internal/MarkdownRenderer.d.ts +1 -1
  47. package/dist/runtime/app/components/internal/MarkdownRenderer.js +4 -4
  48. package/dist/runtime/app/composables/search.js +43 -86
  49. package/dist/runtime/markdown/plugins.d.ts +1 -3
  50. package/dist/runtime/markdown/plugins.js +3 -30
  51. package/dist/runtime/server/api/sitemap.js +1 -1
  52. package/dist/runtime/server/navigation-query.js +2 -2
  53. package/dist/runtime/server/plugins/sitemap.js +2 -2
  54. package/dist/runtime/server/sitemap-provider.js +2 -2
  55. package/dist/runtime/shared/search.js +2 -24
  56. package/dist/runtime/utils/content-components.d.ts +1 -4
  57. package/dist/runtime/utils/content-components.js +5 -16
  58. package/dist/types/content.d.ts +20 -1
  59. package/dist/types/module.d.ts +3 -3
  60. package/dist/types/search.d.ts +1 -1
  61. package/dist/types/virtual.d.ts +10 -0
  62. package/dist/web-types.json +1 -9
  63. package/package.json +14 -5
  64. package/dist/integrations/vue/html-tags.d.ts +0 -2
package/README.md CHANGED
@@ -10,7 +10,8 @@ Use it when you want content files to stay simple, but your Nuxt app still
10
10
  needs explicit APIs for route resolution, typed frontmatter, localized content,
11
11
  and server-side reads.
12
12
 
13
- This README describes the stable `0.3` line.
13
+ This README describes the `0.4.0-rc.1` release candidate. Install it from npm's
14
+ `next` channel; the stable line remains `0.3` until the RC is promoted.
14
15
 
15
16
  ## Requirements
16
17
 
@@ -22,14 +23,14 @@ This README describes the stable `0.3` line.
22
23
  ## Install
23
24
 
24
25
  ```bash
25
- npx nuxi module add @lupinum/ginko-content
26
+ npx nuxi module add @lupinum/ginko-content@0.4.0-rc.1
26
27
  ```
27
28
 
28
29
  The Nuxt CLI installs the package and registers the module in `nuxt.config.ts`.
29
30
  If you prefer to install by hand:
30
31
 
31
32
  ```bash
32
- pnpm add @lupinum/ginko-content
33
+ pnpm add @lupinum/ginko-content@0.4.0-rc.1
33
34
  ```
34
35
 
35
36
  ```ts
@@ -80,7 +81,13 @@ Render the current route through the collection:
80
81
  <script setup lang="ts">
81
82
  import { pages } from '~~/content.config'
82
83
 
84
+ definePageMeta({ key: route => route.path })
85
+
83
86
  const { page } = await useContentPage(pages)
87
+
88
+ if (!page.value) {
89
+ throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
90
+ }
84
91
  </script>
85
92
 
86
93
  <template>
@@ -99,7 +106,7 @@ const { page } = await useContentPage(pages)
99
106
  - semantic previous/next route-page data through `useContentPage(pages, { surround })`
100
107
  - server reads through `one`, `many`, `paginate`, `resolveOne`, `navigation`,
101
108
  and `surround`
102
- - route and search composables, plus pure client query functions for other
109
+ - route and search composables, plus one-shot async client query functions for other
103
110
  reads
104
111
  - search helpers for MiniSearch, Pagefind, and provider-owned search
105
112
  - sitemap integration for public content routes
@@ -121,19 +128,25 @@ Do not duplicate docs, blog, pricing, privacy, or translated locale paths in
121
128
  and `/en/pricing` need cross-locale sitemap alternates.
122
129
 
123
130
  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).
131
+ [Sitemap and prerender](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/4.guides/9.sitemap-and-prerender.md).
125
132
 
126
133
  ## Integration Dependencies
127
134
 
128
135
  | Feature | Dependency model |
129
136
  | --- | --- |
130
137
  | Markdown highlighting and built-in Shiki transformers | Included as runtime dependencies of `@lupinum/ginko-content`. |
138
+ | Math Markdown | Install the optional `katex` peer and add `katex/dist/katex.min.css` to Nuxt's `css` array. |
139
+ | Mermaid Markdown | Install the optional `beautiful-mermaid` peer. |
131
140
  | MiniSearch backend | Included as a runtime dependency and used by the default search backend. |
132
141
  | Pagefind backend | Install the optional `pagefind` peer when `content.search.engine` is `'pagefind'`. |
133
142
  | Provider-owned search | No extra package. The active content provider must advertise and implement `search`. |
134
143
  | Content i18n | Install and configure `@nuxtjs/i18n` 10.x when the app uses Nuxt locale routes. |
135
144
  | Sitemap XML output | Install and configure `@nuxtjs/sitemap`; Ginko contributes the content sitemap source. |
136
145
 
146
+ Markdown plugins are opt-in. Omitting `content.markdown.plugins` is identical
147
+ to `plugins: []`; add `shiki`, `math`, `mermaid`, `toc`, or `summary` only when
148
+ the application needs that behavior.
149
+
137
150
  ## Scope
138
151
 
139
152
  The default provider reads files from your Nuxt project. The package does not
@@ -141,7 +154,7 @@ include a CMS UI, Studio, admin panel, or content editing workflow.
141
154
 
142
155
  ## Project links
143
156
 
144
- - Documentation source: [Getting started](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/3.get-started/1.installation.md)
157
+ - Documentation source: [Getting started](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/1.get-started/1.quickstart.md)
145
158
  - Repository: [github.com/lupinum-dev/ginko-content](https://github.com/lupinum-dev/ginko-content)
146
159
  - Issues: [GitHub issues](https://github.com/lupinum-dev/ginko-content/issues)
147
160
  - Contributing: [CONTRIBUTING.md](https://github.com/lupinum-dev/ginko-content/blob/main/CONTRIBUTING.md)
@@ -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;
@@ -158,6 +181,10 @@ const isSafeBindingValue = (value) => {
158
181
  };
159
182
  const SHIKI_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
160
183
  const SHIKI_STYLE_VALUES = {
184
+ // Shiki marks inline code with `display: inline`; @shikijs/transformers marks
185
+ // highlighted and diff lines with `display: inline-block`. Nothing else is emitted,
186
+ // so nothing else is accepted.
187
+ display: /^(?:inline|inline-block)$/,
161
188
  color: SHIKI_COLOR,
162
189
  "--shiki-light": SHIKI_COLOR,
163
190
  "--shiki-dark": SHIKI_COLOR,
@@ -170,7 +197,6 @@ const SHIKI_STYLE_VALUES = {
170
197
  };
171
198
  const isSafeShikiStyle = (value) => {
172
199
  if (typeof value !== "string") return false;
173
- if (value.trim() === "display: inline") return true;
174
200
  const declarations = value.split(";");
175
201
  if (!declarations.length || declarations.some((declaration) => !declaration)) return false;
176
202
  const seen = /* @__PURE__ */ new Set();
@@ -205,19 +231,52 @@ export function isSafePublicMarkdownUrl(value, kind = "href") {
205
231
  return false;
206
232
  }
207
233
  }
208
- export function validatePublicMarkdownAst(value, policy = { components: {} }) {
234
+ function validateMarkdownAst(value, policy, allowStoredAssets) {
209
235
  const issues = [];
210
236
  const report = (code, path, message) => issues.push({ code, path, message });
211
- const components = new Map(
212
- Object.entries(policy.components).map(([name, component]) => [componentName(name), component])
213
- );
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
+ };
214
273
  const validateProps = (node, path, component) => {
215
274
  const props = node.props ?? {};
216
275
  if (!isRecord(props)) {
217
276
  report("invalid_node", [...path, "props"], "Node props must be an object.");
218
277
  return;
219
278
  }
220
- const tag = String(node.tag);
279
+ const tag = String(node.tag).toLowerCase();
221
280
  for (const [name, propValue] of Object.entries(props)) {
222
281
  const propPath = [...path, "props", name];
223
282
  const lower = name.toLowerCase();
@@ -226,6 +285,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
226
285
  continue;
227
286
  }
228
287
  if (name === "style" && tag === "span" && isSafeShikiStyle(propValue)) continue;
288
+ if ((tag === "th" || tag === "td") && name === "style" && isSafeTableAlignmentStyle(propValue)) continue;
229
289
  if (tag === "blockquote" && name === "data-alert") {
230
290
  if (typeof propValue !== "string" || !["note", "tip", "important", "warning", "caution"].includes(propValue)) report("invalid_prop_value", propPath, "Blockquote alert metadata is invalid.");
231
291
  continue;
@@ -238,6 +298,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
238
298
  report("invalid_prop_value", propPath, `Property "${name}" is not JSON-safe.`);
239
299
  continue;
240
300
  }
301
+ validatePropInvariant(tag, name, propValue, propPath, component);
241
302
  if (component) {
242
303
  const declared = component.props[name];
243
304
  if (!declared) {
@@ -246,9 +307,6 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
246
307
  }
247
308
  const valid = declared.type === "json" || declared.type === "asset" && typeof propValue === "string" && propValue.length > 0 || declared.type !== "asset" && typeof propValue === declared.type;
248
309
  if (!valid) report("invalid_prop_value", propPath, `Component property "${name}" has the wrong type.`);
249
- else if (declared.type === "asset" && !isSafePublicMarkdownUrl(propValue, "asset")) {
250
- report("unsafe_url", propPath, `Component property "${name}" contains an unsafe URL.`);
251
- }
252
310
  continue;
253
311
  }
254
312
  const allowed = COMMON_HTML_PROPS.has(name) || name.startsWith("aria-") || name.startsWith("data-") || HTML_PROPS[tag]?.has(name);
@@ -256,16 +314,6 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
256
314
  report("unknown_prop", propPath, `HTML property "${name}" is not allowed on <${tag}>.`);
257
315
  continue;
258
316
  }
259
- if (tag === "pre" && (name === "language" || name === "filename") && typeof propValue !== "string") {
260
- report("invalid_prop_value", propPath, `HTML property "${name}" on <pre> must be a string.`);
261
- continue;
262
- }
263
- if (URL_PROPS.has(lower) && typeof propValue === "string") {
264
- const kind = lower === "src" ? "asset" : "href";
265
- if (!isSafePublicMarkdownUrl(propValue, kind)) {
266
- report("unsafe_url", propPath, `Property "${name}" contains an unsafe URL.`);
267
- }
268
- }
269
317
  }
270
318
  if (component) {
271
319
  for (const [name, definition] of Object.entries(component.props)) {
@@ -275,7 +323,7 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
275
323
  }
276
324
  }
277
325
  };
278
- const visit = (node, path) => {
326
+ const visit = (node, path, parentComponent) => {
279
327
  if (!isRecord(node) || typeof node.type !== "string") {
280
328
  report("invalid_node", path, "Markdown nodes must be objects with a type.");
281
329
  return;
@@ -294,15 +342,39 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
294
342
  if (Object.keys(node).some((key) => !["type", "tag", "props", "children"].includes(key))) {
295
343
  report("invalid_node", path, "Element nodes contain unsupported fields.");
296
344
  }
297
- 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
+ }
298
361
  const component = components.get(normalizedTag);
299
- 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) {
300
372
  report("unsafe_tag", [...path, "tag"], `Tag <${node.tag}> is not render-safe.`);
301
- } else if (!SAFE_HTML_TAGS.has(normalizedTag) && !component) {
373
+ } else if (!isTaskCheckbox && !SAFE_HTML_TAGS.has(nativeTag) && !component) {
302
374
  report("unknown_component", [...path, "tag"], `Component <${node.tag}> is not registered.`);
303
375
  }
304
- validateProps(node, path, component);
305
- 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));
306
378
  };
307
379
  if (!isRecord(value) || value.type !== "root" || !Array.isArray(value.children)) {
308
380
  report("invalid_node", [], "Markdown root must contain a children array.");
@@ -317,7 +389,14 @@ export function validatePublicMarkdownAst(value, policy = { components: {} }) {
317
389
  }
318
390
  return issues.length ? { ok: false, issues } : { ok: true, value };
319
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
+ }
320
398
  export function assertPublicMarkdownAst(value, policy = { components: {} }) {
321
399
  const result = validatePublicMarkdownAst(value, policy);
322
400
  if (!result.ok) throw new PublicMarkdownValidationError(result.issues);
323
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.`);
@@ -0,0 +1,35 @@
1
+ /** Framework-neutral public shapes emitted by configured built-in parsers. */
2
+ export declare const BUILTIN_MARKDOWN_RENDER_CONTRACTS: {
3
+ readonly math: {
4
+ readonly tag: "ginko-math";
5
+ readonly componentPolicy: {
6
+ kind: "inline";
7
+ props: {
8
+ class: {
9
+ type: "string";
10
+ required: true;
11
+ };
12
+ content: {
13
+ type: "string";
14
+ required: true;
15
+ };
16
+ };
17
+ slots: never[];
18
+ media: null;
19
+ };
20
+ };
21
+ readonly mermaid: {
22
+ readonly tag: "ginko-mermaid";
23
+ readonly componentPolicy: {
24
+ kind: "block";
25
+ props: {
26
+ content: {
27
+ type: "string";
28
+ required: true;
29
+ };
30
+ };
31
+ slots: never[];
32
+ media: null;
33
+ };
34
+ };
35
+ };
@@ -0,0 +1,25 @@
1
+ export const BUILTIN_MARKDOWN_RENDER_CONTRACTS = {
2
+ math: {
3
+ tag: "ginko-math",
4
+ componentPolicy: {
5
+ kind: "inline",
6
+ props: {
7
+ class: { type: "string", required: true },
8
+ content: { type: "string", required: true }
9
+ },
10
+ slots: [],
11
+ media: null
12
+ }
13
+ },
14
+ mermaid: {
15
+ tag: "ginko-mermaid",
16
+ componentPolicy: {
17
+ kind: "block",
18
+ props: {
19
+ content: { type: "string", required: true }
20
+ },
21
+ slots: [],
22
+ media: null
23
+ }
24
+ }
25
+ };
@@ -0,0 +1 @@
1
+ export declare const HTML_TAGS: ReadonlySet<string>;
@@ -1,4 +1,4 @@
1
- export default [
1
+ export const HTML_TAGS = /* @__PURE__ */ new Set([
2
2
  "a",
3
3
  "abbr",
4
4
  "address",
@@ -116,4 +116,4 @@ export default [
116
116
  "var",
117
117
  "video",
118
118
  "wbr"
119
- ];
119
+ ]);
@@ -1,6 +1,14 @@
1
+ import type { NormalizedComarkNode } from './tree';
1
2
  /**
2
3
  * Normalizes parser-owned Comark metadata into the inert public AST contract.
3
4
  * Keep this deliberately narrow: authored structural props such as `as` remain
4
5
  * untouched and are rejected by the render and portability policies.
5
6
  */
6
- export declare function normalizeComarkNodes(nodes: unknown[]): unknown[];
7
+ export declare function normalizeComarkNodes(nodes: unknown[], options?: {
8
+ enabledPlugins?: Iterable<string>;
9
+ }): NormalizedComarkNode[];
10
+ export declare const isNormalizedMathProps: (value: unknown) => boolean;
11
+ export declare const isNormalizedMermaidProps: (value: unknown) => boolean;
12
+ export declare const isNormalizedTaskCheckboxProps: (value: unknown) => boolean;
13
+ export declare const isSafeTableAlignmentStyle: (value: unknown) => boolean;
14
+ export declare const isSafeCodeHighlights: (value: unknown) => boolean;
@@ -1,15 +1,67 @@
1
1
  const GFM_ALERTS = /* @__PURE__ */ new Set(["note", "tip", "important", "warning", "caution"]);
2
- export function normalizeComarkNodes(nodes) {
2
+ const TASK_CHECKBOX_CLASS = "task-list-item-checkbox";
3
+ const TABLE_ALIGNMENT = /^text-align:(?:left|center|right)$/;
4
+ const MAX_CODE_HIGHLIGHTS = 256;
5
+ const MAX_CODE_LINE = 1e6;
6
+ export function normalizeComarkNodes(nodes, options = {}) {
7
+ const enabledPlugins = new Set(options.enabledPlugins);
3
8
  const normalize = (node) => {
4
- if (!Array.isArray(node)) return node;
9
+ if (typeof node === "string") return node;
10
+ if (!Array.isArray(node)) throw new TypeError("Comark emitted a malformed Markdown node.");
5
11
  const [tag, rawProps, ...children] = node;
6
- const props = isRecord(rawProps) ? { ...rawProps } : rawProps;
12
+ if (tag === null) return void 0;
13
+ if (typeof tag !== "string" || rawProps !== void 0 && !isRecord(rawProps)) {
14
+ throw new TypeError("Comark emitted a malformed Markdown tuple.");
15
+ }
16
+ const props = isRecord(rawProps) ? { ...rawProps } : {};
7
17
  if (tag === "blockquote" && isRecord(props) && typeof props.as === "string" && GFM_ALERTS.has(props.as.toLowerCase())) {
8
18
  props["data-alert"] = props.as.toLowerCase();
9
19
  delete props.as;
10
20
  }
11
- return [tag, props, ...children.map(normalize)];
21
+ if (tag === "input" && isComarkTaskCheckboxProps(props)) {
22
+ delete props[":checked"];
23
+ delete props[":disabled"];
24
+ props.disabled = true;
25
+ if (rawProps && isRecord(rawProps) && rawProps[":checked"] === "true") props.checked = true;
26
+ }
27
+ if (tag === "math" && enabledPlugins.has("math") && isComarkMathNode(props, children)) {
28
+ return ["ginko-math", props, ...children];
29
+ }
30
+ if (tag === "mermaid" && enabledPlugins.has("mermaid") && isComarkMermaidNode(props, children)) {
31
+ return ["ginko-mermaid", props];
32
+ }
33
+ return [tag, props, ...children.map(normalize).filter(isPresent)];
12
34
  };
13
- return nodes.map(normalize);
35
+ return nodes.map(normalize).filter(isPresent);
14
36
  }
37
+ export const isNormalizedMathProps = (value) => {
38
+ if (!isRecord(value)) return false;
39
+ const keys = Object.keys(value).sort();
40
+ return keys.length === 2 && keys[0] === "class" && keys[1] === "content" && (value.class === "math inline" || value.class === "math block") && typeof value.content === "string";
41
+ };
42
+ export const isNormalizedMermaidProps = (value) => {
43
+ if (!isRecord(value)) return false;
44
+ const keys = Object.keys(value);
45
+ return keys.length === 1 && keys[0] === "content" && typeof value.content === "string";
46
+ };
47
+ export const isNormalizedTaskCheckboxProps = (value) => {
48
+ if (!isRecord(value)) return false;
49
+ const keys = Object.keys(value).sort();
50
+ const expected = value.checked === true ? ["checked", "class", "disabled", "type"] : ["class", "disabled", "type"];
51
+ return keys.length === expected.length && keys.every((key, index) => key === expected[index]) && value.type === "checkbox" && value.class === TASK_CHECKBOX_CLASS && value.disabled === true;
52
+ };
53
+ export const isSafeTableAlignmentStyle = (value) => typeof value === "string" && TABLE_ALIGNMENT.test(value);
54
+ export const isSafeCodeHighlights = (value) => Array.isArray(value) && value.length <= MAX_CODE_HIGHLIGHTS && value.every(
55
+ (line) => Number.isSafeInteger(line) && Number(line) > 0 && Number(line) <= MAX_CODE_LINE
56
+ );
57
+ const isComarkTaskCheckboxProps = (value) => {
58
+ if (!isRecord(value)) return false;
59
+ const keys = Object.keys(value).sort();
60
+ const checked = value[":checked"] === "true";
61
+ const expected = checked ? [":checked", ":disabled", "class", "type"] : [":disabled", "class", "type"];
62
+ return keys.length === expected.length && keys.every((key, index) => key === expected[index]) && value[":disabled"] === "true" && value.type === "checkbox" && value.class === TASK_CHECKBOX_CLASS;
63
+ };
64
+ const isComarkMathNode = (props, children) => isNormalizedMathProps(props) && children.length === 1 && children[0] === props.content;
65
+ const isComarkMermaidNode = (props, children) => isNormalizedMermaidProps(props) && children.length === 0;
66
+ const isPresent = (value) => value !== void 0;
15
67
  const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -1,3 +1,7 @@
1
+ import { createMarkdownParser } from 'comark';
1
2
  import type { ComarkPlugin } from 'comark';
2
- /** The single configured Comark entry point used by every parsing boundary. */
3
- export declare const parseComark: (markdown: string, plugins?: readonly ComarkPlugin[]) => Promise<import("comark").ComarkTree<Record<string, any>, Record<string, any>>>;
3
+ export type ComarkParser = ReturnType<typeof createMarkdownParser>;
4
+ /** Create one parser for one resolved plugin-profile lifecycle. */
5
+ export declare const createComarkParser: (plugins?: readonly ComarkPlugin[]) => import("comark").ComarkParseFn<Record<string, any>, Record<string, any>>;
6
+ /** The fixed-profile Comark entry point used by baseline parsing boundaries. */
7
+ export declare const parseComark: (markdown: string) => Promise<import("comark").MarkdownDocument<Record<string, any>, Record<string, any>>>;
@@ -1,4 +1,4 @@
1
- import { createParse, defineComarkPlugin, parseFrontmatter } from "comark";
1
+ import { createMarkdownParser, defineComarkPlugin, parseFrontmatter } from "comark";
2
2
  const typedComponentFrontmatter = defineComarkPlugin(() => ({
3
3
  name: "ginko-typed-component-frontmatter",
4
4
  markdownItPlugins: [
@@ -22,9 +22,8 @@ const typedComponentFrontmatter = defineComarkPlugin(() => ({
22
22
  }
23
23
  ]
24
24
  }));
25
- export const parseComark = async (markdown, plugins = []) => {
26
- const parse = createParse({
27
- plugins: [typedComponentFrontmatter(), ...plugins]
28
- });
29
- return await parse(markdown);
30
- };
25
+ export const createComarkParser = (plugins = []) => createMarkdownParser({
26
+ plugins: [typedComponentFrontmatter(), ...plugins]
27
+ });
28
+ const baselineComarkParser = createComarkParser();
29
+ export const parseComark = async (markdown) => await baselineComarkParser(markdown);