@lupinum/ginko-content 0.3.6 → 0.4.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +74 -93
- 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/content/graph.js +3 -1
- package/dist/core/content/path.d.ts +3 -1
- package/dist/core/content/path.js +29 -5
- 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/agent-paths.js +1 -1
- package/dist/features/agent/walker.js +12 -4
- package/dist/features/navigation/resolve.js +2 -1
- 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 +2 -2
- package/dist/module.mjs +256 -185
- 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/portability/yaml.js +29 -1
- package/dist/public/client.d.ts +1 -1
- package/dist/public/provider-document.js +1 -2
- 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/app/plugins/hot-reload.js +1 -1
- package/dist/runtime/markdown/plugins.d.ts +1 -3
- package/dist/runtime/markdown/plugins.js +3 -30
- package/dist/runtime/server/agent-markdown.js +1 -1
- 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/testing/provider-fixture.js +1 -1
- 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 +20 -10
- package/dist/integrations/vue/html-tags.d.ts +0 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,47 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/lupinum-dev/ginko-content/main/docs/public/icon.png" width="128" alt="Ginko Content">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">@lupinum/ginko-content</h1>
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
Use one typed content model for Nuxt pages, queries, navigation, search, localization, and build output.
|
|
9
|
+
</p>
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@lupinum/ginko-content"><img src="https://img.shields.io/npm/v/@lupinum/ginko-content?color=315d3b" alt="npm version"></a>
|
|
13
|
+
<a href="https://github.com/lupinum-dev/ginko-content/actions/workflows/ci.yml"><img src="https://github.com/lupinum-dev/ginko-content/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
14
|
+
<a href="https://github.com/lupinum-dev/ginko-content/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-315d3b" alt="MIT license"></a>
|
|
15
|
+
</p>
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
> [!WARNING]
|
|
18
|
+
> Version `0.4.0-rc.2` is a release candidate. Install it from npm's `next`
|
|
19
|
+
> channel. The stable `0.3` line remains on `latest`.
|
|
20
|
+
|
|
21
|
+
## Why use this package?
|
|
22
|
+
|
|
23
|
+
Content features become difficult to maintain when routes, lists, search, and
|
|
24
|
+
localization use separate data. This package makes the collection definition
|
|
25
|
+
the source of truth for those features.
|
|
26
|
+
|
|
27
|
+
Keep Markdown and data files in `content/`. Use typed collection handles in the
|
|
28
|
+
Nuxt application. The same handles also drive server queries and generated
|
|
29
|
+
output.
|
|
14
30
|
|
|
15
31
|
## Requirements
|
|
16
32
|
|
|
17
33
|
- Node.js 22.18–22.x, 24.11–24.x, or 26+
|
|
18
|
-
- Nuxt 4.
|
|
34
|
+
- Nuxt 4.5.1 through Nuxt 4.x
|
|
19
35
|
- Vue 3.5.35 through Vue 3.x
|
|
20
36
|
- ESM; CommonJS `require()` is not supported
|
|
21
37
|
|
|
22
|
-
##
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Use the Nuxt CLI:
|
|
23
41
|
|
|
24
42
|
```bash
|
|
25
|
-
npx nuxi module add @lupinum/ginko-content
|
|
43
|
+
npx nuxi module add @lupinum/ginko-content@0.4.0-rc.2
|
|
26
44
|
```
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
If you prefer to install by hand:
|
|
46
|
+
Or install and register the module by hand:
|
|
30
47
|
|
|
31
48
|
```bash
|
|
32
|
-
pnpm add @lupinum/ginko-content
|
|
49
|
+
pnpm add @lupinum/ginko-content@0.4.0-rc.2
|
|
33
50
|
```
|
|
34
51
|
|
|
35
52
|
```ts
|
|
53
|
+
// nuxt.config.ts
|
|
36
54
|
export default defineNuxtConfig({
|
|
37
55
|
modules: ['@lupinum/ginko-content'],
|
|
38
|
-
imports: {
|
|
39
|
-
autoImport: true
|
|
40
|
-
}
|
|
41
56
|
})
|
|
42
57
|
```
|
|
43
58
|
|
|
44
|
-
## Quick
|
|
59
|
+
## Quick start
|
|
45
60
|
|
|
46
61
|
Define a collection:
|
|
47
62
|
|
|
@@ -51,13 +66,11 @@ import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/co
|
|
|
51
66
|
|
|
52
67
|
export const pages = defineCollection({
|
|
53
68
|
type: 'page',
|
|
54
|
-
source: '**/*.md'
|
|
69
|
+
source: '**/*.md',
|
|
55
70
|
})
|
|
56
71
|
|
|
57
72
|
export default defineContentConfig({
|
|
58
|
-
collections: {
|
|
59
|
-
pages
|
|
60
|
-
}
|
|
73
|
+
collections: { pages },
|
|
61
74
|
})
|
|
62
75
|
```
|
|
63
76
|
|
|
@@ -69,18 +82,22 @@ title: Welcome
|
|
|
69
82
|
---
|
|
70
83
|
|
|
71
84
|
# Welcome
|
|
72
|
-
|
|
73
|
-
This file renders at `/`.
|
|
74
85
|
```
|
|
75
86
|
|
|
76
|
-
Render the
|
|
87
|
+
Render the active route:
|
|
77
88
|
|
|
78
89
|
```vue
|
|
79
90
|
<!-- pages/[...slug].vue -->
|
|
80
91
|
<script setup lang="ts">
|
|
81
92
|
import { pages } from '~~/content.config'
|
|
82
93
|
|
|
94
|
+
definePageMeta({ key: route => route.path })
|
|
95
|
+
|
|
83
96
|
const { page } = await useContentPage(pages)
|
|
97
|
+
|
|
98
|
+
if (!page.value) {
|
|
99
|
+
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
100
|
+
}
|
|
84
101
|
</script>
|
|
85
102
|
|
|
86
103
|
<template>
|
|
@@ -88,73 +105,37 @@ const { page } = await useContentPage(pages)
|
|
|
88
105
|
</template>
|
|
89
106
|
```
|
|
90
107
|
|
|
91
|
-
##
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`@nuxtjs/sitemap >= 8.0.15 < 9` when translated static app slugs such as `/preise`
|
|
121
|
-
and `/en/pricing` need cross-locale sitemap alternates.
|
|
122
|
-
|
|
123
|
-
See the public guide:
|
|
124
|
-
[Sitemap and prerender](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/4.guides/4.routing-and-seo/3.sitemap-and-prerender.md).
|
|
125
|
-
|
|
126
|
-
## Integration Dependencies
|
|
127
|
-
|
|
128
|
-
| Feature | Dependency model |
|
|
129
|
-
| --- | --- |
|
|
130
|
-
| Markdown highlighting and built-in Shiki transformers | Included as runtime dependencies of `@lupinum/ginko-content`. |
|
|
131
|
-
| MiniSearch backend | Included as a runtime dependency and used by the default search backend. |
|
|
132
|
-
| Pagefind backend | Install the optional `pagefind` peer when `content.search.engine` is `'pagefind'`. |
|
|
133
|
-
| Provider-owned search | No extra package. The active content provider must advertise and implement `search`. |
|
|
134
|
-
| Content i18n | Install and configure `@nuxtjs/i18n` 10.x when the app uses Nuxt locale routes. |
|
|
135
|
-
| Sitemap XML output | Install and configure `@nuxtjs/sitemap`; Ginko contributes the content sitemap source. |
|
|
136
|
-
|
|
137
|
-
## Scope
|
|
138
|
-
|
|
139
|
-
The default provider reads files from your Nuxt project. The package does not
|
|
140
|
-
include a CMS UI, Studio, admin panel, or content editing workflow.
|
|
141
|
-
|
|
142
|
-
## Project links
|
|
143
|
-
|
|
144
|
-
- Documentation source: [Getting started](https://github.com/lupinum-dev/ginko-content/blob/main/docs/content/docs/3.get-started/1.installation.md)
|
|
145
|
-
- Repository: [github.com/lupinum-dev/ginko-content](https://github.com/lupinum-dev/ginko-content)
|
|
146
|
-
- Issues: [GitHub issues](https://github.com/lupinum-dev/ginko-content/issues)
|
|
147
|
-
- Contributing: [CONTRIBUTING.md](https://github.com/lupinum-dev/ginko-content/blob/main/CONTRIBUTING.md)
|
|
148
|
-
- Security: [SECURITY.md](https://github.com/lupinum-dev/ginko-content/blob/main/SECURITY.md)
|
|
149
|
-
|
|
150
|
-
## Credits
|
|
151
|
-
|
|
152
|
-
Ginko Content is an independently maintained fork derived from
|
|
153
|
-
[Nuxt Content](https://content.nuxt.com/). It has substantially diverged while
|
|
154
|
-
retaining upstream-derived parser, MDC, and rendering foundations. Credits also
|
|
155
|
-
go to [Nuxt UI](https://ui.nuxt.com/) and [Comark](https://comark.dev/), the
|
|
156
|
-
successor to the previous MDC work.
|
|
108
|
+
## Main capabilities
|
|
109
|
+
|
|
110
|
+
- Markdown and Comark component rendering.
|
|
111
|
+
- YAML, JSON, and CSV ingestion.
|
|
112
|
+
- Typed collections and frontmatter.
|
|
113
|
+
- Route-aware pages and server-side queries.
|
|
114
|
+
- Locale-aware routes and fallback rules.
|
|
115
|
+
- Navigation, search, sitemap, and prerender helpers.
|
|
116
|
+
- A provider contract for remote or database-backed sources.
|
|
117
|
+
|
|
118
|
+
Optional features stay explicit. Install `pagefind`, `katex`, or
|
|
119
|
+
`beautiful-mermaid` only when the application uses the related search, math, or
|
|
120
|
+
diagram integration.
|
|
121
|
+
|
|
122
|
+
## Documentation
|
|
123
|
+
|
|
124
|
+
- [Documentation](https://ginko-content.lupinum.com)
|
|
125
|
+
- [Quick start](https://ginko-content.lupinum.com/docs/get-started/quickstart)
|
|
126
|
+
- [Guides](https://ginko-content.lupinum.com/docs/guides)
|
|
127
|
+
- [API reference](https://ginko-content.lupinum.com/docs/reference)
|
|
128
|
+
- [Repository](https://github.com/lupinum-dev/ginko-content)
|
|
129
|
+
|
|
130
|
+
## Support and security
|
|
131
|
+
|
|
132
|
+
Open a [GitHub issue](https://github.com/lupinum-dev/ginko-content/issues) for a
|
|
133
|
+
reproducible defect. Join the [Lupinum OSS Discord](https://discord.gg/RPH6SeA36N)
|
|
134
|
+
for usage questions.
|
|
135
|
+
|
|
136
|
+
Report vulnerabilities through [GitHub private vulnerability reporting](https://github.com/lupinum-dev/ginko-content/security/advisories/new).
|
|
157
137
|
|
|
158
138
|
## License
|
|
159
139
|
|
|
160
|
-
[MIT](
|
|
140
|
+
This package is available under the [MIT License](https://github.com/lupinum-dev/ginko-content/blob/main/LICENSE).
|
|
141
|
+
It is developed by [Lupinum OG](https://lupinum.com).
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { getObjectShape, getReferenceDescriptor, getSchemaDef, getSchemaTypeName, unwrapSchema } from "../core/references/schema.js";
|
|
2
2
|
import { getContentFieldMetadata } from "../types/fields.js";
|
|
3
3
|
import { canonicalJsonBytes } from "./hash.js";
|
|
4
|
+
import {
|
|
5
|
+
canonicalizePortableComponentName,
|
|
6
|
+
isReservedPortableComponentName,
|
|
7
|
+
isValidPortableComponentName
|
|
8
|
+
} from "./render-policy.js";
|
|
4
9
|
export const RESOLVED_CONTENT_CONTRACT_VERSION = 1;
|
|
5
10
|
export function buildResolvedContentContract(config, options) {
|
|
6
11
|
const locales = unique(options.locales);
|
|
@@ -374,9 +379,19 @@ function portableMediaTypes(values) {
|
|
|
374
379
|
}
|
|
375
380
|
function normalizeComponentPolicy(policy) {
|
|
376
381
|
const components = {};
|
|
377
|
-
for (const [
|
|
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.`);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { isNavigationFile } from "./structural.js";
|
|
2
2
|
import { normalizeReferenceValue, buildReferenceTargets } from "../references/resolve.js";
|
|
3
3
|
import { resolveLocaleChain, sortLocalesCanonically } from "./locale.js";
|
|
4
|
+
import { trimTrailingSlashes } from "./path.js";
|
|
4
5
|
const normalizePath = (path) => {
|
|
5
6
|
if (!path || path === "/") {
|
|
6
7
|
return "/";
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
+
const normalized = trimTrailingSlashes(path) || "/";
|
|
10
|
+
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
9
11
|
};
|
|
10
12
|
export const buildContentGraph = (documents, options = {}) => {
|
|
11
13
|
const byId = {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export declare const trimSlashes: (value: string) => string;
|
|
2
|
+
export declare const trimTrailingSlashes: (value: string) => string;
|
|
1
3
|
export declare const describeId: (id: string) => {
|
|
2
4
|
source: string;
|
|
3
5
|
path: string;
|
|
4
|
-
extension: string;
|
|
6
|
+
extension: string | undefined;
|
|
5
7
|
file: string;
|
|
6
8
|
basename: string;
|
|
7
9
|
};
|