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.
@@ -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
- const TARGETS = ['react', 'html', 'markdown'];
7
- const REGISTRY_TEMPLATE = `// Custom blocks for this project. The CLI and your app share this module:
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.mjs
20
+ // contentbit validate "content/**/*.md" --registry ./blocks/registry.ts
10
21
  //
11
- // Define blocks with @contentbit/core and default-export them as an array.
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
- // import { defineBlock, pipeRows } from '@contentbit/core'
15
- // import { z } from 'zod'
16
- //
17
- // const pricingTable = defineBlock({
18
- // name: 'pricing-table',
19
- // description: 'Compares product plans.',
20
- // props: z.object({ currency: z.enum(['usd', 'eur']).default('usd') }),
21
- // content: pipeRows({ columns: ['plan', 'price'], minRows: 2 }),
22
- // authoring: {
23
- // useWhen: ['Comparing pricing plans'],
24
- // example: ':::pricing-table\\n- Starter | $0\\n- Pro | $12/mo\\n:::',
25
- // },
26
- // })
27
-
28
- export default []
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
- const REACT_COMPONENT = `import { genericBlocks } from '@contentbit/blocks'
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
- import { ContentBlocks } from '@contentbit/react'
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
- <ContentBlocks
132
+ <${renderer}
54
133
  document={result.document}
55
- // TODO: plug your Markdown library in here, e.g. react-markdown.
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
- function detectPackageManager() {
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 detected = hasReact ? 'react' : 'html';
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.mjs', REGISTRY_TEMPLATE],
465
+ ['blocks/registry.ts', REGISTRY_TEMPLATE],
167
466
  ['content/example.md', EXAMPLE_CONTENT],
168
467
  ];
169
- if (target === 'react')
170
- files.push(['components/content-blocks.tsx', REACT_COMPONENT]);
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.mjs';
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
- const registry = await loadRegistry();
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
- io.stdout(' 2. Render it: import { Content } from "./components/content-blocks"');
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: contentbit render content/example.md --target html');
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,3 @@
1
+ import type { Io } from '../run.js';
2
+ export declare function statsCommand(args: string[], io: Io): Promise<number>;
3
+ //# sourceMappingURL=stats.d.ts.map
@@ -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":"AAKA,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,CAmC7E"}
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
- const result = validateDocument(parseDocument(source), registry);
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,CAchF"}
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"}