@lupinum/ginko-content 0.3.6 → 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 +102 -26
  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 +246 -175
  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;
@@ -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.`);
@@ -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);
@@ -1,9 +1,9 @@
1
1
  import type { MarkdownNode, MarkdownRoot, Toc } from '../../types/content';
2
- type ComarkTupleNode = string | [string | null, Record<string, unknown>, ...ComarkTupleNode[]];
2
+ /** The exact normalized tuple shape accepted at the Comark boundary. */
3
+ export type NormalizedComarkNode = string | [string, Record<string, unknown>, ...NormalizedComarkNode[]];
3
4
  export declare function isMarkdownRoot(value: unknown): value is MarkdownRoot;
4
- export declare function toMarkdownNode(node: ComarkTupleNode): MarkdownNode;
5
- export declare function toMarkdownRoot(nodes: ComarkTupleNode[], toc?: Toc): MarkdownRoot;
5
+ export declare function toMarkdownNode(node: NormalizedComarkNode): MarkdownNode;
6
+ export declare function toMarkdownRoot(nodes: NormalizedComarkNode[], toc?: Toc): MarkdownRoot;
6
7
  export declare function mapMarkdownNodes(nodes: MarkdownNode[], mapNode: (node: MarkdownNode) => MarkdownNode): MarkdownNode[];
7
8
  export declare function mapMarkdownNode(node: MarkdownNode, mapNode: (node: MarkdownNode) => MarkdownNode): MarkdownNode;
8
9
  export declare function extractMarkdownText(node: MarkdownNode, ignoredTags?: string[]): string;
9
- export {};
@@ -13,9 +13,7 @@ export function toMarkdownNode(node) {
13
13
  const [tag, props = {}, ...children] = node;
14
14
  return {
15
15
  type: "element",
16
- // `undefined` is not a JSON-pure value: omit `tag` entirely
17
- // rather than set it to `undefined` when the tuple carries no tag.
18
- ...tag ? { tag } : {},
16
+ tag,
19
17
  props: { ...props },
20
18
  children: children.map((child) => toMarkdownNode(child))
21
19
  };