@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.
- package/README.md +19 -6
- package/dist/cms-contract/build.js +18 -3
- package/dist/cms-contract/index.d.ts +2 -1
- package/dist/cms-contract/mdc.d.ts +3 -1
- package/dist/cms-contract/render-policy.d.ts +24 -0
- package/dist/cms-contract/render-policy.js +102 -26
- package/dist/cms-contract/validate.js +9 -0
- package/dist/core/markdown/builtin-render-contracts.d.ts +35 -0
- package/dist/core/markdown/builtin-render-contracts.js +25 -0
- package/dist/core/markdown/html-tags.d.ts +1 -0
- package/dist/{integrations/vue → core/markdown}/html-tags.js +2 -2
- package/dist/core/markdown/normalize-comark.d.ts +9 -1
- package/dist/core/markdown/normalize-comark.js +57 -5
- package/dist/core/markdown/parse-comark.d.ts +6 -2
- package/dist/core/markdown/parse-comark.js +6 -7
- package/dist/core/markdown/tree.d.ts +4 -4
- package/dist/core/markdown/tree.js +1 -3
- package/dist/features/agent/walker.js +11 -3
- package/dist/features/search/options.d.ts +4 -0
- package/dist/features/search/options.js +30 -0
- package/dist/integrations/vue/content-components.js +3 -3
- package/dist/module.d.mts +27 -25
- package/dist/module.json +1 -1
- package/dist/module.mjs +246 -175
- package/dist/parsers/markdown-plugin-options.d.ts +1 -0
- package/dist/parsers/markdown-plugin-options.js +8 -0
- package/dist/parsers/markdown-plugins.d.ts +1 -0
- package/dist/parsers/markdown-plugins.js +10 -63
- package/dist/parsers/markdown.d.ts +4 -0
- package/dist/parsers/markdown.js +19 -6
- package/dist/portability/assets.js +25 -10
- package/dist/portability/mdc.js +5 -49
- package/dist/public/client.d.ts +1 -1
- package/dist/public/server.d.ts +1 -1
- package/dist/runtime/app/components/ContentRenderer.d.vue.ts +0 -9
- package/dist/runtime/app/components/ContentRenderer.vue +1 -4
- package/dist/runtime/app/components/ContentRenderer.vue.d.ts +0 -9
- package/dist/runtime/app/components/ContentRendererInline.d.vue.ts +1 -1
- package/dist/runtime/app/components/ContentRendererInline.vue +14 -23
- package/dist/runtime/app/components/ContentRendererInline.vue.d.ts +1 -1
- package/dist/runtime/app/components/Prose/ProsePre.d.vue.ts +1 -1
- package/dist/runtime/app/components/Prose/ProsePre.vue.d.ts +1 -1
- package/dist/runtime/app/components/internal/ContentRendererMarkdown.d.vue.ts +1 -1
- package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue +13 -3
- package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue.d.ts +1 -1
- package/dist/runtime/app/components/internal/MarkdownRenderer.d.ts +1 -1
- package/dist/runtime/app/components/internal/MarkdownRenderer.js +4 -4
- package/dist/runtime/app/composables/search.js +43 -86
- package/dist/runtime/markdown/plugins.d.ts +1 -3
- package/dist/runtime/markdown/plugins.js +3 -30
- package/dist/runtime/server/api/sitemap.js +1 -1
- package/dist/runtime/server/navigation-query.js +2 -2
- package/dist/runtime/server/plugins/sitemap.js +2 -2
- package/dist/runtime/server/sitemap-provider.js +2 -2
- package/dist/runtime/shared/search.js +2 -24
- package/dist/runtime/utils/content-components.d.ts +1 -4
- package/dist/runtime/utils/content-components.js +5 -16
- package/dist/types/content.d.ts +20 -1
- package/dist/types/module.d.ts +3 -3
- package/dist/types/search.d.ts +1 -1
- package/dist/types/virtual.d.ts +10 -0
- package/dist/web-types.json +1 -9
- package/package.json +14 -5
- 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
|
|
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
|
|
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/
|
|
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/
|
|
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 [
|
|
378
|
-
if (!
|
|
379
|
-
throw new Error(`Invalid portable component name "${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
215
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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,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[]
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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 {
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
5
|
-
export declare function toMarkdownRoot(nodes:
|
|
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
|
-
|
|
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
|
};
|