@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.
- 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 +106 -27
- 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 +250 -174
- 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;
|
|
@@ -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
|
-
|
|
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 =
|
|
212
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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,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);
|