contentbit 0.1.0 → 0.2.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/README.md +4 -0
- package/dist/bin.js +1064 -88
- package/dist/commands/agents.d.ts +11 -0
- package/dist/commands/agents.d.ts.map +1 -0
- package/dist/commands/agents.js +197 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +411 -39
- package/dist/commands/stats.d.ts +3 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/stats.js +49 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +5 -2
- package/dist/load-registry.d.ts.map +1 -1
- package/dist/load-registry.js +10 -1
- package/dist/run.d.ts +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +1062 -86
- package/package.json +7 -4
package/dist/commands/init.js
CHANGED
|
@@ -1,32 +1,83 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
6
|
import { loadRegistry } from '../load-registry.js';
|
|
6
|
-
|
|
7
|
-
const
|
|
7
|
+
import { installAgentIntegration } from './agents.js';
|
|
8
|
+
const TARGETS = ['react', 'html', 'markdown', 'astro'];
|
|
9
|
+
/** Markdown library choices per target; the first entry is the default. */
|
|
10
|
+
const MD_CHOICES = {
|
|
11
|
+
react: ['react-markdown', 'none'],
|
|
12
|
+
html: ['marked', 'markdown-it', 'none'],
|
|
13
|
+
markdown: ['none'],
|
|
14
|
+
// @contentbit/astro ships its own marked-based default; nothing to install.
|
|
15
|
+
astro: ['none'],
|
|
16
|
+
};
|
|
17
|
+
const REGISTRY_TEMPLATE = `// Custom block definitions for this project. The CLI and your app share
|
|
18
|
+
// this module — Node 22.18+ imports TypeScript directly:
|
|
8
19
|
//
|
|
9
|
-
// contentbit validate "content/**/*.md" --registry ./blocks/registry.
|
|
20
|
+
// contentbit validate "content/**/*.md" --registry ./blocks/registry.ts
|
|
10
21
|
//
|
|
11
|
-
//
|
|
22
|
+
// Definitions stay framework-free (the CLI and every render target use
|
|
23
|
+
// them); React components live next door in blocks/components.tsx.
|
|
12
24
|
// Docs: https://contentbit.dev/docs/guides/custom-blocks
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
import { defineBlock, markdownBody, type BlockDefinition } from '@contentbit/core'
|
|
26
|
+
import { z } from 'zod'
|
|
27
|
+
|
|
28
|
+
export const quote = defineBlock({
|
|
29
|
+
name: 'quote',
|
|
30
|
+
description: 'A pull quote with an author.',
|
|
31
|
+
props: z.object({
|
|
32
|
+
author: z.string().min(1),
|
|
33
|
+
role: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
content: markdownBody({ minLength: 3 }),
|
|
36
|
+
authoring: {
|
|
37
|
+
useWhen: ['Quoting a person to support a point'],
|
|
38
|
+
avoidWhen: ['Highlighting your own remark, use callout instead'],
|
|
39
|
+
example: ':::quote{author="Ada Lovelace"}\\nThe Analytical Engine weaves algebraic patterns.\\n:::',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export default [quote] satisfies BlockDefinition<unknown>[]
|
|
44
|
+
`;
|
|
45
|
+
/** blocks/components.tsx — React components for custom blocks, next to their definitions. */
|
|
46
|
+
function blockComponentsTemplate(styled) {
|
|
47
|
+
const body = styled
|
|
48
|
+
? ` return (
|
|
49
|
+
<figure className="my-6 border-s-2 ps-4">
|
|
50
|
+
<blockquote className="text-lg italic">{ctx.renderMarkdown(data.markdown)}</blockquote>
|
|
51
|
+
<figcaption className="text-muted-foreground mt-2 text-sm">
|
|
52
|
+
— {String(node.props.author)}
|
|
53
|
+
{node.props.role ? \`, \${String(node.props.role)}\` : null}
|
|
54
|
+
</figcaption>
|
|
55
|
+
</figure>
|
|
56
|
+
)`
|
|
57
|
+
: ` return (
|
|
58
|
+
<figure style={{ margin: '1.5rem 0', borderLeft: '2px solid #d4d4d4', paddingLeft: '1rem' }}>
|
|
59
|
+
<blockquote style={{ fontStyle: 'italic' }}>{ctx.renderMarkdown(data.markdown)}</blockquote>
|
|
60
|
+
<figcaption style={{ marginTop: '0.5rem', fontSize: '0.875rem', opacity: 0.7 }}>
|
|
61
|
+
— {String(node.props.author)}
|
|
62
|
+
{node.props.role ? \`, \${String(node.props.role)}\` : null}
|
|
63
|
+
</figcaption>
|
|
64
|
+
</figure>
|
|
65
|
+
)`;
|
|
66
|
+
return `import type { BlockComponent, BlockComponentProps } from '@contentbit/react'
|
|
67
|
+
|
|
68
|
+
// One React component per custom block, keyed by block name. Definitions
|
|
69
|
+
// live in ./registry.ts — add a block there, add its component here, and
|
|
70
|
+
// the rest of the app never changes.
|
|
71
|
+
function QuoteBlock({ node, ctx }: BlockComponentProps) {
|
|
72
|
+
const data = node.data as { markdown: string }
|
|
73
|
+
${body}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const blockComponents: Record<string, BlockComponent> = {
|
|
77
|
+
quote: QuoteBlock,
|
|
78
|
+
}
|
|
29
79
|
`;
|
|
80
|
+
}
|
|
30
81
|
const EXAMPLE_CONTENT = `# Hello, Content Blocks
|
|
31
82
|
|
|
32
83
|
Regular Markdown works everywhere. Blocks add validated structure:
|
|
@@ -40,26 +91,205 @@ Run the validate script and you will get file:line:col diagnostics.
|
|
|
40
91
|
2. Run \`contentbit validate "content/**/*.md"\`.
|
|
41
92
|
3. Render it with the target you picked at init.
|
|
42
93
|
:::
|
|
94
|
+
|
|
95
|
+
This one is a **custom block**, defined in \`blocks/registry.ts\` and rendered
|
|
96
|
+
by the \`QuoteBlock\` component, in about twenty lines:
|
|
97
|
+
|
|
98
|
+
:::quote{author="Ada Lovelace" role="Notes on the Analytical Engine, 1843"}
|
|
99
|
+
The Analytical Engine weaves algebraic patterns just as the Jacquard loom
|
|
100
|
+
weaves flowers and leaves.
|
|
101
|
+
:::
|
|
43
102
|
`;
|
|
44
|
-
|
|
103
|
+
/** The wrapper component: styled pack or headless, with or without a Markdown lib. */
|
|
104
|
+
function reactComponent(styled, mdWired, blocksImport) {
|
|
105
|
+
const mdImport = mdWired ? "import ReactMarkdown from 'react-markdown'\n" : '';
|
|
106
|
+
const mdProp = mdWired
|
|
107
|
+
? '\n renderMarkdown={(md) => <ReactMarkdown>{md}</ReactMarkdown>}'
|
|
108
|
+
: `\n // TODO: plug your Markdown library in here, e.g. react-markdown.
|
|
109
|
+
// One function renders all prose: https://contentbit.dev/docs/guides/markdown
|
|
110
|
+
// renderMarkdown={(md) => <Markdown source={md} />}`;
|
|
111
|
+
const rendererImport = styled
|
|
112
|
+
? `\n// The styled pack installed by shadcn. Yours to edit.
|
|
113
|
+
import { ContentRenderer } from '@/components/content-blocks/content-renderer'`
|
|
114
|
+
: '';
|
|
115
|
+
const renderer = styled ? 'ContentRenderer' : 'ContentBlocks';
|
|
116
|
+
const reactImport = styled ? '' : "import { ContentBlocks } from '@contentbit/react'\n";
|
|
117
|
+
return `'use client'
|
|
118
|
+
|
|
119
|
+
import { genericBlocks } from '@contentbit/blocks'
|
|
45
120
|
import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
|
|
46
|
-
|
|
121
|
+
${reactImport}${mdImport}${rendererImport}
|
|
122
|
+
// Everything block-related lives in the blocks/ folder: definitions in
|
|
123
|
+
// registry.ts (shared with the validate CLI), components in components.tsx.
|
|
124
|
+
import customBlocks from '${blocksImport}/registry'
|
|
125
|
+
import { blockComponents } from '${blocksImport}/components'
|
|
47
126
|
|
|
48
|
-
const registry = createBlockRegistry().use(genericBlocks())
|
|
127
|
+
const registry = createBlockRegistry().use(genericBlocks()).use(customBlocks)
|
|
49
128
|
|
|
50
129
|
export function Content({ source }: { source: string }) {
|
|
51
130
|
const result = validateDocument(parseDocument(source), registry)
|
|
52
131
|
return (
|
|
53
|
-
|
|
132
|
+
<${renderer}
|
|
54
133
|
document={result.document}
|
|
55
|
-
|
|
56
|
-
// One function renders all prose: https://contentbit.dev/docs/guides/markdown
|
|
57
|
-
// renderMarkdown={(md) => <Markdown source={md} />}
|
|
134
|
+
components={blockComponents}${mdProp}
|
|
58
135
|
/>
|
|
59
136
|
)
|
|
60
137
|
}
|
|
61
138
|
`;
|
|
62
|
-
|
|
139
|
+
}
|
|
140
|
+
function htmlRenderScript(md) {
|
|
141
|
+
const wiring = md === 'marked'
|
|
142
|
+
? `import { marked } from 'marked'
|
|
143
|
+
|
|
144
|
+
const renderMarkdown = (md) => marked.parse(md, { async: false })`
|
|
145
|
+
: md === 'markdown-it'
|
|
146
|
+
? `import MarkdownIt from 'markdown-it'
|
|
147
|
+
|
|
148
|
+
const mdIt = new MarkdownIt() // html: false by default — raw HTML stays escaped
|
|
149
|
+
const renderMarkdown = (md) => mdIt.render(md)`
|
|
150
|
+
: `// TODO: plug a Markdown library in here (marked, markdown-it, remark).
|
|
151
|
+
const renderMarkdown = undefined`;
|
|
152
|
+
return `// Render content/example.md to example.html. Run: node scripts/render-example.mjs
|
|
153
|
+
import { genericBlocks } from '@contentbit/blocks'
|
|
154
|
+
import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
|
|
155
|
+
import { renderToHtml } from '@contentbit/html'
|
|
156
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
157
|
+
${wiring}
|
|
158
|
+
|
|
159
|
+
const source = await readFile('content/example.md', 'utf8')
|
|
160
|
+
const registry = createBlockRegistry().use(genericBlocks())
|
|
161
|
+
const result = validateDocument(parseDocument(source), registry)
|
|
162
|
+
const html = renderToHtml(result.document, { renderMarkdown })
|
|
163
|
+
await writeFile('example.html', html, 'utf8')
|
|
164
|
+
console.log('wrote example.html')
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
/** Where the component and example page belong for the detected framework. */
|
|
168
|
+
function detectFramework(cwd, deps) {
|
|
169
|
+
if ((deps['@tanstack/react-start'] || deps['@tanstack/react-router']) &&
|
|
170
|
+
existsSync(join(cwd, 'src/routes'))) {
|
|
171
|
+
return {
|
|
172
|
+
framework: 'tanstack',
|
|
173
|
+
componentPath: 'src/components/content-blocks.tsx',
|
|
174
|
+
pagePath: 'src/routes/example.tsx',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (deps.next) {
|
|
178
|
+
const appDir = existsSync(join(cwd, 'src/app')) ? 'src/app' : 'app';
|
|
179
|
+
if (existsSync(join(cwd, appDir))) {
|
|
180
|
+
return {
|
|
181
|
+
framework: 'next',
|
|
182
|
+
componentPath: 'components/content-blocks.tsx',
|
|
183
|
+
pagePath: `${appDir}/example/page.tsx`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { framework: null, componentPath: 'components/content-blocks.tsx', pagePath: null };
|
|
188
|
+
}
|
|
189
|
+
const TANSTACK_PAGE = `import { createFileRoute } from '@tanstack/react-router'
|
|
190
|
+
|
|
191
|
+
import { Content } from '../components/content-blocks'
|
|
192
|
+
// Vite's ?raw import inlines the Markdown as a string at build time.
|
|
193
|
+
import source from '../../content/example.md?raw'
|
|
194
|
+
|
|
195
|
+
export const Route = createFileRoute('/example')({ component: ExamplePage })
|
|
196
|
+
|
|
197
|
+
function ExamplePage() {
|
|
198
|
+
return (
|
|
199
|
+
<main style={{ maxWidth: '42rem', margin: '0 auto', padding: '3rem 1.5rem' }}>
|
|
200
|
+
<Content source={source} />
|
|
201
|
+
</main>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
const NEXT_PAGE = `import { readFile } from 'node:fs/promises'
|
|
206
|
+
|
|
207
|
+
// If your project has no "@/" path alias, switch to a relative import.
|
|
208
|
+
import { Content } from '@/components/content-blocks'
|
|
209
|
+
|
|
210
|
+
export default async function ExamplePage() {
|
|
211
|
+
const source = await readFile('content/example.md', 'utf8')
|
|
212
|
+
return (
|
|
213
|
+
<main style={{ maxWidth: '42rem', margin: '0 auto', padding: '3rem 1.5rem' }}>
|
|
214
|
+
<Content source={source} />
|
|
215
|
+
</main>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
const ASTRO_CONTENT_CONFIG = `import { defineCollection } from 'astro:content'
|
|
220
|
+
import { glob } from 'astro/loaders'
|
|
221
|
+
|
|
222
|
+
export const collections = {
|
|
223
|
+
articles: defineCollection({
|
|
224
|
+
// Astro's builtin Markdown loader. Entry bodies are parsed and validated
|
|
225
|
+
// where they render (see src/pages/example.astro); \`contentbit validate\`
|
|
226
|
+
// covers the same files in CI.
|
|
227
|
+
loader: glob({ pattern: '**/*.md', base: './content' }),
|
|
228
|
+
}),
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
const ASTRO_QUOTE_BLOCK = `---
|
|
232
|
+
// The Astro component for the custom \`quote\` block defined in blocks/registry.ts.
|
|
233
|
+
// Block props arrive as component props; nested content arrives via <slot />.
|
|
234
|
+
interface Props {
|
|
235
|
+
author: string
|
|
236
|
+
role?: string
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { author, role } = Astro.props
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
<figure style="margin: 1.5rem 0; border-left: 2px solid #d4d4d4; padding-left: 1rem;">
|
|
243
|
+
<blockquote style="font-style: italic;"><slot /></blockquote>
|
|
244
|
+
<figcaption style="margin-top: 0.5rem; font-size: 0.875rem; opacity: 0.7;">
|
|
245
|
+
— {author}{role ? \`, \${role}\` : null}
|
|
246
|
+
</figcaption>
|
|
247
|
+
</figure>
|
|
248
|
+
`;
|
|
249
|
+
/** The example page: styled pack renderer or the headless ContentBlocks. */
|
|
250
|
+
function astroPage(styled) {
|
|
251
|
+
const importLine = styled
|
|
252
|
+
? "import ContentRenderer from '../components/content-blocks/content-renderer.astro'"
|
|
253
|
+
: "import { ContentBlocks } from '@contentbit/astro/components'";
|
|
254
|
+
const renderer = styled ? 'ContentRenderer' : 'ContentBlocks';
|
|
255
|
+
return `---
|
|
256
|
+
import { genericBlocks } from '@contentbit/blocks'
|
|
257
|
+
import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
|
|
258
|
+
import { getEntry } from 'astro:content'
|
|
259
|
+
|
|
260
|
+
${importLine}
|
|
261
|
+
|
|
262
|
+
// Definitions in blocks/registry.ts are shared with the validate CLI.
|
|
263
|
+
import customBlocks from '../../blocks/registry'
|
|
264
|
+
import QuoteBlock from '../../blocks/QuoteBlock.astro'
|
|
265
|
+
|
|
266
|
+
// Entry ids are the file path relative to the collection base, minus ".md".
|
|
267
|
+
const entry = await getEntry('articles', 'example')
|
|
268
|
+
if (!entry?.body) throw new Error('Entry "example" not found in the articles collection.')
|
|
269
|
+
|
|
270
|
+
const registry = createBlockRegistry().use(genericBlocks()).use(customBlocks)
|
|
271
|
+
// Static pages render at build time, so invalid blocks fail the build here.
|
|
272
|
+
const result = validateDocument(parseDocument(entry.body), registry)
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
<main style="max-width: 42rem; margin: 0 auto; padding: 3rem 1.5rem;">
|
|
276
|
+
<${renderer} document={result.document} components={{ quote: QuoteBlock }} />
|
|
277
|
+
</main>
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
function detectPackageManager(cwd) {
|
|
281
|
+
// The project's lockfile outranks however the CLI itself was launched.
|
|
282
|
+
const locks = [
|
|
283
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
284
|
+
['yarn.lock', 'yarn'],
|
|
285
|
+
['bun.lock', 'bun'],
|
|
286
|
+
['bun.lockb', 'bun'],
|
|
287
|
+
['package-lock.json', 'npm'],
|
|
288
|
+
];
|
|
289
|
+
for (const [file, pm] of locks) {
|
|
290
|
+
if (existsSync(join(cwd, file)))
|
|
291
|
+
return pm;
|
|
292
|
+
}
|
|
63
293
|
const agent = process.env.npm_config_user_agent ?? '';
|
|
64
294
|
for (const pm of ['pnpm', 'yarn', 'bun']) {
|
|
65
295
|
if (agent.startsWith(pm))
|
|
@@ -71,6 +301,15 @@ function installArgs(pm, dev, pkgs) {
|
|
|
71
301
|
const add = pm === 'npm' ? 'install' : 'add';
|
|
72
302
|
return dev ? [add, '-D', ...pkgs] : [add, ...pkgs];
|
|
73
303
|
}
|
|
304
|
+
function dlxCommand(pm) {
|
|
305
|
+
if (pm === 'pnpm')
|
|
306
|
+
return ['pnpm', ['dlx']];
|
|
307
|
+
if (pm === 'yarn')
|
|
308
|
+
return ['yarn', ['dlx']];
|
|
309
|
+
if (pm === 'bun')
|
|
310
|
+
return ['bunx', []];
|
|
311
|
+
return ['npx', ['--yes']];
|
|
312
|
+
}
|
|
74
313
|
function runInstall(pm, args, cwd) {
|
|
75
314
|
return new Promise((resolve) => {
|
|
76
315
|
const child = spawn(pm, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
@@ -78,6 +317,27 @@ function runInstall(pm, args, cwd) {
|
|
|
78
317
|
child.on('error', () => resolve(1));
|
|
79
318
|
});
|
|
80
319
|
}
|
|
320
|
+
/** Wire the @contentbit shadcn registry and install a styled pack. Returns true on success. */
|
|
321
|
+
async function installStyledPack(cwd, pack, noInstall, io) {
|
|
322
|
+
const componentsJsonPath = join(cwd, 'components.json');
|
|
323
|
+
const componentsJson = JSON.parse(await readFile(componentsJsonPath, 'utf8'));
|
|
324
|
+
componentsJson.registries ??= {};
|
|
325
|
+
if (!componentsJson.registries['@contentbit']) {
|
|
326
|
+
componentsJson.registries['@contentbit'] = 'https://contentbit.dev/r/{name}.json';
|
|
327
|
+
await writeFile(componentsJsonPath, `${JSON.stringify(componentsJson, null, 2)}\n`, 'utf8');
|
|
328
|
+
io.stdout('added @contentbit registry to components.json');
|
|
329
|
+
}
|
|
330
|
+
if (noInstall) {
|
|
331
|
+
io.stdout(`skipped: shadcn add ${pack}`);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
const [bin, prefix] = dlxCommand(detectPackageManager(cwd));
|
|
335
|
+
io.stdout(`installing the styled pack: shadcn add ${pack}`);
|
|
336
|
+
const code = await runInstall(bin, [...prefix, 'shadcn@latest', 'add', pack, '--yes'], cwd);
|
|
337
|
+
if (code !== 0)
|
|
338
|
+
io.stderr('styled pack install failed; falling back to headless defaults');
|
|
339
|
+
return code === 0;
|
|
340
|
+
}
|
|
81
341
|
/** Write a file unless it already exists; returns what happened for the summary. */
|
|
82
342
|
async function scaffold(path, content) {
|
|
83
343
|
try {
|
|
@@ -95,9 +355,13 @@ export async function initCommand(args, io) {
|
|
|
95
355
|
args,
|
|
96
356
|
options: {
|
|
97
357
|
target: { type: 'string', short: 't' },
|
|
358
|
+
md: { type: 'string' },
|
|
98
359
|
yes: { type: 'boolean', short: 'y', default: false },
|
|
99
360
|
cwd: { type: 'string', default: process.cwd() },
|
|
100
361
|
'no-install': { type: 'boolean', default: false },
|
|
362
|
+
'no-page': { type: 'boolean', default: false },
|
|
363
|
+
'no-styled': { type: 'boolean', default: false },
|
|
364
|
+
'no-agents': { type: 'boolean', default: false },
|
|
101
365
|
},
|
|
102
366
|
});
|
|
103
367
|
const cwd = values.cwd;
|
|
@@ -113,7 +377,8 @@ export async function initCommand(args, io) {
|
|
|
113
377
|
}
|
|
114
378
|
// Resolve the render target: flag > prompt (interactive) > detection.
|
|
115
379
|
const hasReact = Boolean(pkg.dependencies?.react ?? pkg.devDependencies?.react);
|
|
116
|
-
const
|
|
380
|
+
const hasAstro = Boolean(pkg.dependencies?.astro ?? pkg.devDependencies?.astro);
|
|
381
|
+
const detected = hasAstro ? 'astro' : hasReact ? 'react' : 'html';
|
|
117
382
|
let target;
|
|
118
383
|
if (values.target) {
|
|
119
384
|
if (!TARGETS.includes(values.target)) {
|
|
@@ -129,6 +394,7 @@ export async function initCommand(args, io) {
|
|
|
129
394
|
initialValue: detected,
|
|
130
395
|
options: [
|
|
131
396
|
{ value: 'react', label: 'React', hint: 'ContentBlocks component' },
|
|
397
|
+
{ value: 'astro', label: 'Astro', hint: 'content collections + .astro components' },
|
|
132
398
|
{ value: 'html', label: 'Static HTML', hint: 'renderToHtml, no framework' },
|
|
133
399
|
{ value: 'markdown', label: 'Plain Markdown', hint: 'fallback rendering only' },
|
|
134
400
|
],
|
|
@@ -140,17 +406,50 @@ export async function initCommand(args, io) {
|
|
|
140
406
|
else {
|
|
141
407
|
target = detected;
|
|
142
408
|
}
|
|
409
|
+
// Resolve the Markdown library: flag > prompt (interactive) > target default.
|
|
410
|
+
// The default gives working prose rendering out of the box; 'none' opts out.
|
|
411
|
+
const choices = MD_CHOICES[target];
|
|
412
|
+
let md;
|
|
413
|
+
if (values.md) {
|
|
414
|
+
if (!choices.includes(values.md)) {
|
|
415
|
+
io.stderr(`Unknown markdown library "${values.md}". Use one of: ${choices.join(', ')}`);
|
|
416
|
+
return 2;
|
|
417
|
+
}
|
|
418
|
+
md = values.md;
|
|
419
|
+
}
|
|
420
|
+
else if (choices.length > 1 && !values.yes && process.stdin.isTTY && process.stdout.isTTY) {
|
|
421
|
+
const { isCancel, select } = await import('@clack/prompts');
|
|
422
|
+
const answer = await select({
|
|
423
|
+
message: 'Markdown library for prose rendering?',
|
|
424
|
+
initialValue: choices[0],
|
|
425
|
+
options: choices.map((c) => ({
|
|
426
|
+
value: c,
|
|
427
|
+
label: c,
|
|
428
|
+
hint: c === 'none' ? 'wire one yourself later' : 'installed and wired for you',
|
|
429
|
+
})),
|
|
430
|
+
});
|
|
431
|
+
if (isCancel(answer))
|
|
432
|
+
return 1;
|
|
433
|
+
md = answer;
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
md = choices[0];
|
|
437
|
+
}
|
|
143
438
|
// Install runtime packages plus the CLI as a dev dependency.
|
|
144
|
-
const runtime = ['@contentbit/core', '@contentbit/blocks'];
|
|
439
|
+
const runtime = ['@contentbit/core', '@contentbit/blocks', 'zod'];
|
|
145
440
|
if (target === 'react')
|
|
146
441
|
runtime.push('@contentbit/react');
|
|
147
442
|
if (target === 'html')
|
|
148
443
|
runtime.push('@contentbit/html');
|
|
444
|
+
if (target === 'astro')
|
|
445
|
+
runtime.push('@contentbit/astro');
|
|
446
|
+
if (md !== 'none')
|
|
447
|
+
runtime.push(md);
|
|
149
448
|
if (values['no-install']) {
|
|
150
449
|
io.stdout(`skipped install: ${runtime.join(' ')} + contentbit (dev)`);
|
|
151
450
|
}
|
|
152
451
|
else {
|
|
153
|
-
const pm = detectPackageManager();
|
|
452
|
+
const pm = detectPackageManager(cwd);
|
|
154
453
|
io.stdout(`installing with ${pm}: ${runtime.join(' ')}`);
|
|
155
454
|
if ((await runInstall(pm, installArgs(pm, false, runtime), cwd)) !== 0) {
|
|
156
455
|
io.stderr('install failed');
|
|
@@ -163,11 +462,60 @@ export async function initCommand(args, io) {
|
|
|
163
462
|
}
|
|
164
463
|
// Scaffold project files; never overwrite.
|
|
165
464
|
const files = [
|
|
166
|
-
['blocks/registry.
|
|
465
|
+
['blocks/registry.ts', REGISTRY_TEMPLATE],
|
|
167
466
|
['content/example.md', EXAMPLE_CONTENT],
|
|
168
467
|
];
|
|
169
|
-
|
|
170
|
-
|
|
468
|
+
const layout = detectFramework(cwd, { ...pkg.dependencies, ...pkg.devDependencies });
|
|
469
|
+
// shadcn project? Pull the styled component pack from the contentbit registry.
|
|
470
|
+
let styled = false;
|
|
471
|
+
const componentsJsonPath = join(cwd, 'components.json');
|
|
472
|
+
if (target === 'react' && !values['no-styled'] && existsSync(componentsJsonPath)) {
|
|
473
|
+
styled = await installStyledPack(cwd, '@contentbit/generic-pack', values['no-install'], io);
|
|
474
|
+
}
|
|
475
|
+
if (target === 'react') {
|
|
476
|
+
const depth = layout.componentPath.split('/').length - 1;
|
|
477
|
+
const blocksImport = `${'../'.repeat(depth)}blocks`;
|
|
478
|
+
files.push(['blocks/components.tsx', blockComponentsTemplate(styled)]);
|
|
479
|
+
files.push([
|
|
480
|
+
layout.componentPath,
|
|
481
|
+
reactComponent(styled, md === 'react-markdown', blocksImport),
|
|
482
|
+
]);
|
|
483
|
+
// A visible page in the framework's own routing convention.
|
|
484
|
+
if (!values['no-page'] && layout.pagePath) {
|
|
485
|
+
files.push([layout.pagePath, layout.framework === 'tanstack' ? TANSTACK_PAGE : NEXT_PAGE]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (target === 'html') {
|
|
489
|
+
files.push([
|
|
490
|
+
'scripts/render-example.mjs',
|
|
491
|
+
htmlRenderScript(md),
|
|
492
|
+
]);
|
|
493
|
+
}
|
|
494
|
+
if (target === 'astro') {
|
|
495
|
+
let astroStyled = false;
|
|
496
|
+
if (!values['no-styled'] && existsSync(componentsJsonPath)) {
|
|
497
|
+
astroStyled = await installStyledPack(cwd, '@contentbit/astro-pack', values['no-install'], io);
|
|
498
|
+
}
|
|
499
|
+
files.push(['blocks/QuoteBlock.astro', ASTRO_QUOTE_BLOCK]);
|
|
500
|
+
// Every config filename Astro resolves (src/content.config.* plus the
|
|
501
|
+
// legacy src/content/config.* location), so we never scaffold a second
|
|
502
|
+
// config that Astro would silently ignore.
|
|
503
|
+
const configCandidates = ['ts', 'mts', 'mjs', 'js'].flatMap((ext) => [
|
|
504
|
+
`src/content.config.${ext}`,
|
|
505
|
+
`src/content/config.${ext}`,
|
|
506
|
+
]);
|
|
507
|
+
const existingConfig = configCandidates.find((p) => existsSync(join(cwd, p)));
|
|
508
|
+
if (existingConfig) {
|
|
509
|
+
io.stdout(`content config exists (${existingConfig}); add this collection manually:`);
|
|
510
|
+
io.stdout(ASTRO_CONTENT_CONFIG);
|
|
511
|
+
io.stdout('the example page expects the "articles" collection above');
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
files.push(['src/content.config.ts', ASTRO_CONTENT_CONFIG]);
|
|
515
|
+
}
|
|
516
|
+
if (!values['no-page'])
|
|
517
|
+
files.push(['src/pages/example.astro', astroPage(astroStyled)]);
|
|
518
|
+
}
|
|
171
519
|
for (const [rel, content] of files) {
|
|
172
520
|
const result = await scaffold(join(cwd, rel), content);
|
|
173
521
|
io.stdout(`${result}: ${rel}`);
|
|
@@ -177,24 +525,48 @@ export async function initCommand(args, io) {
|
|
|
177
525
|
fresh.scripts ??= {};
|
|
178
526
|
if (!fresh.scripts['content:check']) {
|
|
179
527
|
fresh.scripts['content:check'] =
|
|
180
|
-
'contentbit validate "content/**/*.md" --registry ./blocks/registry.
|
|
528
|
+
'contentbit validate "content/**/*.md" --registry ./blocks/registry.ts';
|
|
181
529
|
await writeFile(pkgPath, `${JSON.stringify(fresh, null, 2)}\n`, 'utf8');
|
|
182
530
|
io.stdout('added script: content:check');
|
|
183
531
|
}
|
|
184
532
|
// Generate the LLM authoring guide from the registry, ready to paste into a prompt.
|
|
185
|
-
|
|
533
|
+
let registry;
|
|
534
|
+
try {
|
|
535
|
+
// Include the scaffolded custom blocks so the guide covers them too.
|
|
536
|
+
registry = await loadRegistry(join(cwd, 'blocks/registry.ts'));
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
registry = await loadRegistry(); // packages not installed yet (--no-install)
|
|
540
|
+
}
|
|
186
541
|
const guide = registry.toAuthoringGuide({ audience: 'llm', includeExamples: true });
|
|
187
542
|
await writeFile(join(cwd, 'contentbit-guide.md'), guide, 'utf8');
|
|
188
543
|
io.stdout('created: contentbit-guide.md (LLM authoring instructions)');
|
|
544
|
+
// Coding-agent integration: an AGENTS.md block for every agent, plus Claude
|
|
545
|
+
// Code skills when a .claude/ directory exists. `contentbit agents` refreshes.
|
|
546
|
+
if (!values['no-agents']) {
|
|
547
|
+
await installAgentIntegration(cwd, {}, io);
|
|
548
|
+
io.stdout('Agent integration installed — try asking your agent:');
|
|
549
|
+
io.stdout(' "write a blog post about X" or "audit my content"');
|
|
550
|
+
}
|
|
189
551
|
io.stdout('');
|
|
190
552
|
io.stdout('Done. Next steps:');
|
|
191
|
-
io.stdout(` 1. Validate the starter content: ${detectPackageManager()} run content:check`);
|
|
553
|
+
io.stdout(` 1. Validate the starter content: ${detectPackageManager(cwd)} run content:check`);
|
|
192
554
|
if (target === 'react') {
|
|
193
|
-
|
|
555
|
+
if (!values['no-page'] && layout.pagePath) {
|
|
556
|
+
io.stdout(' 2. Start the dev server and open /example to see the article rendered.');
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
io.stdout(' 2. Render it: import { Content } from "./components/content-blocks"');
|
|
560
|
+
io.stdout(' <Content source={...content/example.md as a string} />');
|
|
561
|
+
}
|
|
194
562
|
io.stdout(' 3. Styled components: pnpm dlx shadcn@latest add @contentbit/generic-pack');
|
|
195
563
|
}
|
|
564
|
+
else if (target === 'astro') {
|
|
565
|
+
io.stdout(' 2. Start the dev server and open /example to see the article rendered.');
|
|
566
|
+
io.stdout(' 3. Styled components: pnpm dlx shadcn@latest add @contentbit/astro-pack');
|
|
567
|
+
}
|
|
196
568
|
else if (target === 'html') {
|
|
197
|
-
io.stdout(' 2. Render it:
|
|
569
|
+
io.stdout(' 2. Render it: node scripts/render-example.mjs && open example.html');
|
|
198
570
|
}
|
|
199
571
|
else {
|
|
200
572
|
io.stdout(' 2. Render it: contentbit render content/example.md --target markdown');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/commands/stats.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAuBnC,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA2B1E"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { analyzeDocument, parseDocument, stripFrontmatter, validateDocument, } from '@contentbit/core';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { glob } from 'tinyglobby';
|
|
5
|
+
import { loadRegistry } from '../load-registry.js';
|
|
6
|
+
async function fileStats(file, registry) {
|
|
7
|
+
const source = await readFile(file, 'utf8');
|
|
8
|
+
const stats = analyzeDocument(source, { path: file });
|
|
9
|
+
if (registry) {
|
|
10
|
+
const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
|
|
11
|
+
let errors = 0;
|
|
12
|
+
let warnings = 0;
|
|
13
|
+
for (const d of result.diagnostics) {
|
|
14
|
+
if (d.severity === 'error')
|
|
15
|
+
errors++;
|
|
16
|
+
else if (d.severity === 'warning')
|
|
17
|
+
warnings++;
|
|
18
|
+
}
|
|
19
|
+
stats.validation = { errors, warnings };
|
|
20
|
+
}
|
|
21
|
+
return stats;
|
|
22
|
+
}
|
|
23
|
+
export async function statsCommand(args, io) {
|
|
24
|
+
const { values, positionals } = parseArgs({
|
|
25
|
+
args,
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
registry: { type: 'string' },
|
|
29
|
+
'no-validate': { type: 'boolean', default: false },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
if (positionals.length === 0) {
|
|
33
|
+
io.stderr('stats: provide at least one file or glob.');
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
const files = await glob(positionals, { absolute: true });
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
io.stderr(`stats: no files matched ${positionals.join(' ')}`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const registry = values['no-validate'] ? null : await loadRegistry(values.registry);
|
|
42
|
+
const all = [];
|
|
43
|
+
for (const file of files.sort()) {
|
|
44
|
+
all.push(await fileStats(file, registry));
|
|
45
|
+
}
|
|
46
|
+
// A single file keeps the flat object shape; multiple files emit an array.
|
|
47
|
+
io.stdout(JSON.stringify(all.length === 1 ? all[0] : all, null, 2));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAInC,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAsC7E"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatDiagnostic, parseDocument, validateDocument } from '@contentbit/core';
|
|
1
|
+
import { formatDiagnostic, parseDocument, stripFrontmatter, validateDocument, } from '@contentbit/core';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { glob } from 'tinyglobby';
|
|
@@ -26,7 +26,10 @@ export async function validateCommand(args, io) {
|
|
|
26
26
|
let warnings = 0;
|
|
27
27
|
for (const file of files.sort()) {
|
|
28
28
|
const source = await readFile(file, 'utf8');
|
|
29
|
-
|
|
29
|
+
// Frontmatter is metadata, not content: blanked (positions preserved) so
|
|
30
|
+
// block syntax inside YAML never produces diagnostics — matching what
|
|
31
|
+
// frontmatter-aware consumers like Astro validate from entry bodies.
|
|
32
|
+
const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
|
|
30
33
|
for (const d of result.diagnostics) {
|
|
31
34
|
io.stderr(formatDiagnostic(d, file));
|
|
32
35
|
if (d.severity === 'error')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load-registry.d.ts","sourceRoot":"","sources":["../src/load-registry.ts"],"names":[],"mappings":"AACA,OAAO,EAA6C,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAGhG,+EAA+E;AAC/E,wBAAsB,YAAY,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,
|
|
1
|
+
{"version":3,"file":"load-registry.d.ts","sourceRoot":"","sources":["../src/load-registry.ts"],"names":[],"mappings":"AACA,OAAO,EAA6C,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAGhG,+EAA+E;AAC/E,wBAAsB,YAAY,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAsBhF"}
|