@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 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'
@@ -0,0 +1,3 @@
1
+ export { defaultRenderMarkdown } from './markdown.js';
2
+ export { renderBlockShell, type BlockShell, type RenderBlockOptions } from './render-block.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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,2 @@
1
+ export { defaultRenderMarkdown } from './markdown.js';
2
+ export { renderBlockShell } from './render-block.js';
@@ -0,0 +1,3 @@
1
+ /** Default prose pipeline: marked with GFM (its default), raw HTML escaped, synchronous. */
2
+ export declare function defaultRenderMarkdown(source: string): string;
3
+ //# sourceMappingURL=markdown.d.ts.map
@@ -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"}
@@ -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
+ }