@contentbit/astro 0.1.0
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 +21 -0
- package/README.md +52 -0
- package/components/ContentBlocks.astro +48 -0
- package/components/RenderNodes.astro +66 -0
- package/components/index.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/markdown.d.ts +3 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +18 -0
- package/dist/render-block.d.ts +21 -0
- package/dist/render-block.d.ts.map +1 -0
- package/dist/render-block.js +39 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 contentbit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @contentbit/astro
|
|
2
|
+
|
|
3
|
+
Astro renderer for [Content Blocks](https://contentbit.dev): `.astro` components
|
|
4
|
+
for rendering validated documents, with per-block component overrides.
|
|
5
|
+
|
|
6
|
+
Load content with Astro's built-in `glob()` loader, then parse and validate
|
|
7
|
+
with `@contentbit/core`:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/content.config.ts
|
|
11
|
+
import { defineCollection } from 'astro:content'
|
|
12
|
+
import { glob } from 'astro/loaders'
|
|
13
|
+
|
|
14
|
+
export const collections = {
|
|
15
|
+
articles: defineCollection({
|
|
16
|
+
loader: glob({ pattern: '**/*.md', base: './content' }),
|
|
17
|
+
}),
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```astro
|
|
22
|
+
---
|
|
23
|
+
import { genericBlocks } from '@contentbit/blocks'
|
|
24
|
+
import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
|
|
25
|
+
import { ContentBlocks } from '@contentbit/astro/components'
|
|
26
|
+
import { getEntry } from 'astro:content'
|
|
27
|
+
|
|
28
|
+
const entry = await getEntry('articles', 'example')
|
|
29
|
+
if (!entry?.body) throw new Error('Entry "example" not found.')
|
|
30
|
+
|
|
31
|
+
const registry = createBlockRegistry().use(genericBlocks())
|
|
32
|
+
const { document } = validateDocument(parseDocument(entry.body), registry)
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<ContentBlocks document={document} />
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For static pages this runs at build time, so throwing on diagnostics fails the
|
|
39
|
+
build. To validate every content file in CI with `file:line:col` diagnostics,
|
|
40
|
+
run the CLI: `contentbit validate "content/**/*.md"`.
|
|
41
|
+
|
|
42
|
+
Override any block with your own Astro component via
|
|
43
|
+
`components={{ callout: MyCallout }}`. Overrides receive the block's validated
|
|
44
|
+
props as component props, a reserved `node` prop with the full block node
|
|
45
|
+
(`node.data` holds the parsed content), and the block's nested content via
|
|
46
|
+
`<slot />`.
|
|
47
|
+
|
|
48
|
+
The default prose pipeline is marked (GFM) with raw HTML escaped — the same
|
|
49
|
+
safe-by-default stance as every other render target. Pass your own
|
|
50
|
+
`renderMarkdown` to opt into raw HTML for trusted content.
|
|
51
|
+
|
|
52
|
+
Docs: https://contentbit.dev/docs
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { DocumentNode } from '@contentbit/core'
|
|
3
|
+
import type { HtmlBlockRenderer } from '@contentbit/html'
|
|
4
|
+
|
|
5
|
+
import { genericHtmlRenderers } from '@contentbit/html'
|
|
6
|
+
|
|
7
|
+
import { defaultRenderMarkdown } from '../dist/markdown.js'
|
|
8
|
+
import RenderNodes from './RenderNodes.astro'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** A validated document: validateDocument(parseDocument(entry.body), registry).document. */
|
|
12
|
+
document: DocumentNode
|
|
13
|
+
/**
|
|
14
|
+
* Per-block Astro component overrides, keyed by block name. An override
|
|
15
|
+
* receives the block's validated props spread as component props, plus a
|
|
16
|
+
* reserved `node` prop (the full ValidatedBlockNode); nested content
|
|
17
|
+
* arrives through the default <slot />. A block prop literally named
|
|
18
|
+
* "node" is shadowed by the reserved prop.
|
|
19
|
+
*/
|
|
20
|
+
components?: Record<string, unknown>
|
|
21
|
+
/** Per-block string renderers, merged over the generic html defaults. */
|
|
22
|
+
renderers?: Record<string, HtmlBlockRenderer>
|
|
23
|
+
/** CSS class prefix for default renderers. Default "cb-". */
|
|
24
|
+
classPrefix?: string
|
|
25
|
+
/** Prose pipeline. Default: marked (GFM) with raw HTML escaped. */
|
|
26
|
+
renderMarkdown?: (md: string) => string
|
|
27
|
+
/** Invalid-block behavior: throw, visible dev box, or escaped body. Default "annotated". */
|
|
28
|
+
onInvalid?: 'strict' | 'annotated' | 'fallback'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
document,
|
|
33
|
+
components = {},
|
|
34
|
+
renderers,
|
|
35
|
+
classPrefix = 'cb-',
|
|
36
|
+
renderMarkdown = defaultRenderMarkdown,
|
|
37
|
+
onInvalid = 'annotated',
|
|
38
|
+
} = Astro.props
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<RenderNodes
|
|
42
|
+
nodes={document.children}
|
|
43
|
+
components={components}
|
|
44
|
+
renderers={{ ...genericHtmlRenderers, ...renderers }}
|
|
45
|
+
classPrefix={classPrefix}
|
|
46
|
+
renderMarkdown={renderMarkdown}
|
|
47
|
+
onInvalid={onInvalid}
|
|
48
|
+
/>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ContentNode, ValidatedBlockNode } from '@contentbit/core'
|
|
3
|
+
import type { HtmlBlockRenderer } from '@contentbit/html'
|
|
4
|
+
|
|
5
|
+
import { isValidatedBlock } from '@contentbit/core'
|
|
6
|
+
import { fallbackMarkdown, invalidBlockHtml, unrenderableBlockError } from '@contentbit/html'
|
|
7
|
+
|
|
8
|
+
import { renderBlockShell } from '../dist/render-block.js'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
nodes: ContentNode[]
|
|
12
|
+
components: Record<string, unknown>
|
|
13
|
+
renderers: Record<string, HtmlBlockRenderer>
|
|
14
|
+
classPrefix: string
|
|
15
|
+
renderMarkdown: (md: string) => string
|
|
16
|
+
onInvalid: 'strict' | 'annotated' | 'fallback'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { nodes, components, renderers, classPrefix, renderMarkdown, onInvalid } = Astro.props
|
|
20
|
+
const childProps = { components, renderers, classPrefix, renderMarkdown, onInvalid }
|
|
21
|
+
|
|
22
|
+
type OverrideComponent = (props: Record<string, unknown>) => unknown
|
|
23
|
+
|
|
24
|
+
type Plan =
|
|
25
|
+
| { kind: 'html'; html: string }
|
|
26
|
+
| { kind: 'override'; node: ValidatedBlockNode<unknown>; Component: OverrideComponent }
|
|
27
|
+
| { kind: 'shell'; parts: string[]; childSlots: ContentNode[][] }
|
|
28
|
+
|
|
29
|
+
const plans: Plan[] = nodes.map((node) => {
|
|
30
|
+
if (node.type === 'markdown') return { kind: 'html', html: renderMarkdown(node.value) }
|
|
31
|
+
if (isValidatedBlock(node)) {
|
|
32
|
+
const override = components[node.name]
|
|
33
|
+
if (override) return { kind: 'override', node, Component: override as OverrideComponent }
|
|
34
|
+
const shell = renderBlockShell(node, { classPrefix, renderMarkdown, renderers })
|
|
35
|
+
if (shell) return { kind: 'shell', ...shell }
|
|
36
|
+
}
|
|
37
|
+
// Not validated, or validated with no renderer and no override. The
|
|
38
|
+
// invalid-block policy (markup, message, fallback escaping) is shared with
|
|
39
|
+
// renderToHtml so the two targets cannot drift.
|
|
40
|
+
if (onInvalid === 'strict') throw unrenderableBlockError(node.name)
|
|
41
|
+
if (onInvalid === 'annotated') {
|
|
42
|
+
return { kind: 'html', html: invalidBlockHtml(node, classPrefix) }
|
|
43
|
+
}
|
|
44
|
+
return { kind: 'html', html: fallbackMarkdown(node.body) }
|
|
45
|
+
})
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
plans.map((plan) => {
|
|
50
|
+
if (plan.kind === 'html') return <Fragment set:html={plan.html} />
|
|
51
|
+
if (plan.kind === 'override') {
|
|
52
|
+
const Override = plan.Component
|
|
53
|
+
return (
|
|
54
|
+
<Override {...plan.node.props} node={plan.node}>
|
|
55
|
+
<Astro.self nodes={plan.node.children} {...childProps} />
|
|
56
|
+
</Override>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return plan.parts.map((part, i) => (
|
|
60
|
+
<>
|
|
61
|
+
<Fragment set:html={part} />
|
|
62
|
+
{i < plan.childSlots.length && <Astro.self nodes={plan.childSlots[i]} {...childProps} />}
|
|
63
|
+
</>
|
|
64
|
+
))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ContentBlocks } from './ContentBlocks.astro'
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAgBA,4FAA4F;AAC5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE5D"}
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { escapeHtml } from '@contentbit/html';
|
|
2
|
+
import { Marked } from 'marked';
|
|
3
|
+
// marked passes raw HTML through verbatim; every other contentbit target
|
|
4
|
+
// escapes by default, and content often comes from CMS users rather than
|
|
5
|
+
// trusted committers. Escaping the html tokens (block and inline) keeps the
|
|
6
|
+
// default pipeline XSS-safe; pass your own renderMarkdown to opt into raw
|
|
7
|
+
// HTML for trusted content.
|
|
8
|
+
const md = new Marked({
|
|
9
|
+
renderer: {
|
|
10
|
+
html({ text }) {
|
|
11
|
+
return escapeHtml(text);
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
/** Default prose pipeline: marked with GFM (its default), raw HTML escaped, synchronous. */
|
|
16
|
+
export function defaultRenderMarkdown(source) {
|
|
17
|
+
return md.parse(source, { async: false });
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ContentNode, ValidatedBlockNode } from '@contentbit/core';
|
|
2
|
+
import type { HtmlBlockRenderer } from '@contentbit/html';
|
|
3
|
+
export interface RenderBlockOptions {
|
|
4
|
+
classPrefix: string;
|
|
5
|
+
renderMarkdown: (md: string) => string;
|
|
6
|
+
renderers: Record<string, HtmlBlockRenderer>;
|
|
7
|
+
}
|
|
8
|
+
export interface BlockShell {
|
|
9
|
+
/** HTML fragments; childSlots[i] renders between parts[i] and parts[i + 1]. */
|
|
10
|
+
parts: string[];
|
|
11
|
+
/** Node groups handed back to Astro for recursive rendering, in output order. */
|
|
12
|
+
childSlots: ContentNode[][];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Render one validated block through its string renderer, capturing every
|
|
16
|
+
* ctx.renderNodes() call as a placeholder so nested content can recurse
|
|
17
|
+
* through Astro (where component overrides apply). Returns null when no
|
|
18
|
+
* renderer is registered.
|
|
19
|
+
*/
|
|
20
|
+
export declare function renderBlockShell(node: ValidatedBlockNode<unknown>, opts: RenderBlockOptions): BlockShell | null;
|
|
21
|
+
//# sourceMappingURL=render-block.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-block.d.ts","sourceRoot":"","sources":["../src/render-block.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AACvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAIzD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;CAC7C;AAED,MAAM,WAAW,UAAU;IACzB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,iFAAiF;IACjF,UAAU,EAAE,WAAW,EAAE,EAAE,CAAA;CAC5B;AAOD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,kBAAkB,CAAC,OAAO,CAAC,EACjC,IAAI,EAAE,kBAAkB,GACvB,UAAU,GAAG,IAAI,CA2BnB"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { escapeHtml } from '@contentbit/html';
|
|
2
|
+
// NUL is impossible in renderer output, so it cannot collide with real HTML.
|
|
3
|
+
const slotToken = (i) => `\u0000cb:${i}\u0000`;
|
|
4
|
+
// oxlint-disable-next-line no-control-regex -- NUL is the one byte that cannot appear in renderer output
|
|
5
|
+
const SLOT_RE = /\u0000cb:(\d+)\u0000/g;
|
|
6
|
+
/**
|
|
7
|
+
* Render one validated block through its string renderer, capturing every
|
|
8
|
+
* ctx.renderNodes() call as a placeholder so nested content can recurse
|
|
9
|
+
* through Astro (where component overrides apply). Returns null when no
|
|
10
|
+
* renderer is registered.
|
|
11
|
+
*/
|
|
12
|
+
export function renderBlockShell(node, opts) {
|
|
13
|
+
const renderer = opts.renderers[node.name];
|
|
14
|
+
if (!renderer)
|
|
15
|
+
return null;
|
|
16
|
+
const slots = [];
|
|
17
|
+
const html = renderer(node, {
|
|
18
|
+
cls: (name) => `${opts.classPrefix}${name}`,
|
|
19
|
+
escape: escapeHtml,
|
|
20
|
+
renderMarkdown: opts.renderMarkdown,
|
|
21
|
+
renderNodes(nodes) {
|
|
22
|
+
slots.push(nodes);
|
|
23
|
+
return slotToken(slots.length - 1);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const parts = [];
|
|
27
|
+
const childSlots = [];
|
|
28
|
+
let last = 0;
|
|
29
|
+
for (const m of html.matchAll(SLOT_RE)) {
|
|
30
|
+
const slot = slots[Number(m[1])];
|
|
31
|
+
if (slot === undefined)
|
|
32
|
+
continue; // token-looking text in real content: leave it literal
|
|
33
|
+
parts.push(html.slice(last, m.index));
|
|
34
|
+
childSlots.push(slot);
|
|
35
|
+
last = m.index + m[0].length;
|
|
36
|
+
}
|
|
37
|
+
parts.push(html.slice(last));
|
|
38
|
+
return { parts, childSlots };
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contentbit/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Astro renderer for Content Blocks: .astro components for rendering validated documents, with per-block component overrides.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"astro",
|
|
7
|
+
"astro-component",
|
|
8
|
+
"content-blocks",
|
|
9
|
+
"markdown",
|
|
10
|
+
"renderer",
|
|
11
|
+
"withastro"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://contentbit.dev",
|
|
14
|
+
"bugs": "https://github.com/agonist/contentbit/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/agonist/contentbit.git",
|
|
19
|
+
"directory": "packages/astro"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"components"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./components": "./components/index.ts"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.build.json",
|
|
39
|
+
"test": "pnpm build && vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@contentbit/blocks": "workspace:*",
|
|
44
|
+
"@contentbit/core": "workspace:*",
|
|
45
|
+
"@contentbit/html": "workspace:*",
|
|
46
|
+
"marked": "^18.0.5"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^25.9.2",
|
|
50
|
+
"astro": "^6.4.6",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^4.0.17"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"astro": "^5.0.0 || ^6.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|