@lad-sapienza/scms-core 0.1.0-alpha.0 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,12 @@ Register the integration in `astro.config.mjs`:
18
18
 
19
19
  ```js
20
20
  import { defineConfig } from 'astro/config';
21
- import { scms } from '@lad-sapienza/scms-core';
21
+ // Import from the `/scms` subpath, not the bare `@lad-sapienza/scms-core`
22
+ // specifier — the bare specifier resolves to index.ts, which re-exports
23
+ // .astro components (Gallery, TableOfContents). Astro can't compile .astro
24
+ // files yet at config-load time, before its own Vite plugin is registered,
25
+ // so a bare import here breaks with a Vite parse error.
26
+ import { scms } from '@lad-sapienza/scms-core/scms';
22
27
 
23
28
  export default defineConfig({
24
29
  integrations: [
@@ -60,6 +65,17 @@ import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoa
60
65
 
61
66
  See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
62
67
 
68
+ ## Scaffolding CLI
69
+
70
+ This package also ships the interactive scaffolding tools sCMS sites use to add content, as `bin` commands — run from the consuming site's project root:
71
+
72
+ ```bash
73
+ npx scms-add-collection # scaffold a new Astro content collection under usr/
74
+ npx scms-add-content # add a new content file to an existing collection
75
+ ```
76
+
77
+ A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json`.
78
+
63
79
  ## Development
64
80
 
65
81
  ```bash
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ // add-collection.mjs — Scaffold a new Astro content collection in s:CMS
3
+ // Usage: npx scms-add-collection
4
+ // or: npm run add-collection (from a consuming site's package.json)
5
+
6
+ import { createInterface } from 'node:readline';
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
9
+
10
+ // This file ships inside the @lad-sapienza/scms-core package (bin/), so its
11
+ // own location says nothing about the consuming site's layout. ROOT_DIR must
12
+ // be the invoking project's root, i.e. wherever the CLI was run from.
13
+ const ROOT_DIR = process.cwd();
14
+ const CONFIG_FILE = join(ROOT_DIR, 'src', 'content.config.ts');
15
+
16
+ // ─── Color helpers ────────────────────────────────────────────────────────────
17
+ const G = '\x1b[32m', Y = '\x1b[33m', R = '\x1b[31m';
18
+ const B = '\x1b[1m', X = '\x1b[0m';
19
+ const ok = (...a) => console.log(`${G}✔${X} ${a.join(' ')}`);
20
+ const warn = (...a) => console.log(`${Y}⚠${X} ${a.join(' ')}`);
21
+ const error = (...a) => { console.error(`${R}✖${X} ${a.join(' ')}`); process.exit(1); };
22
+
23
+ if (!existsSync(CONFIG_FILE)) error(`Cannot find ${CONFIG_FILE} — are you running from the project root?`);
24
+
25
+ // ─── Object key helper ─────────────────────────────────────────────────────
26
+ // Collection names may contain hyphens (the prompt below explicitly allows
27
+ // them), which aren't valid in a bare JS/TS object-key identifier — e.g.
28
+ // `my-collection: fooCollection` is a syntax error (parsed as subtraction).
29
+ // Quote the key whenever it isn't a valid identifier on its own.
30
+ const toObjectKey = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name}'`;
31
+
32
+ // ─── Readline helper ──────────────────────────────────────────────────────────
33
+ // Uses the readline interface as an async iterator rather than repeated
34
+ // rl.question() calls: with piped/pasted (non-TTY) input, multiple lines can
35
+ // arrive in a single chunk, and question()'s one-shot 'line' listener isn't
36
+ // registered in time to catch answers past the first — they're silently
37
+ // dropped and the prompt hangs forever with no error. Iterating the interface
38
+ // consumes lines through its internal queue instead, so it doesn't race.
39
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
40
+ rl.on('SIGINT', () => { console.log(''); process.exit(0); });
41
+ const rlIterator = rl[Symbol.asyncIterator]();
42
+ const ask = async (prompt) => {
43
+ process.stdout.write(prompt);
44
+ const { value, done } = await rlIterator.next();
45
+ if (done) { console.log(''); process.exit(0); }
46
+ return value;
47
+ };
48
+
49
+ // ─── Schema fields per collection type ───────────────────────────────────────
50
+ const SCHEMA = {
51
+ blog: [
52
+ ' title: z.string(),',
53
+ ' description: z.string(),',
54
+ ' date: z.coerce.date(),',
55
+ ' author: z.string().optional(),',
56
+ ' tags: z.array(z.string()).optional(),',
57
+ ' image: z.string().optional(),',
58
+ ' draft: z.boolean().optional(),',
59
+ ].join('\n'),
60
+ docs: [
61
+ ' title: z.string(),',
62
+ ' description: z.string().optional(),',
63
+ ' order: z.number().optional(),',
64
+ ' category: z.string().optional(),',
65
+ ' draft: z.boolean().optional(),',
66
+ ].join('\n'),
67
+ generic: [
68
+ ' title: z.string(),',
69
+ ' description: z.string().optional(),',
70
+ ' draft: z.boolean().optional(),',
71
+ ].join('\n'),
72
+ };
73
+
74
+ // ─── Page templates ───────────────────────────────────────────────────────────
75
+ // __COL__ is substituted with the collection name after the template is defined.
76
+ // Inside templates: \` → ` and \${ → ${ so JS doesn't interpolate them.
77
+ // \\ → \ in the output (needed for JS regex /\.mdx?$/ inside Astro files).
78
+
79
+ const INDEX = {
80
+
81
+ blog: `---
82
+ import { getCollection } from 'astro:content';
83
+ import BaseLayout from '../../layouts/BaseLayout.astro';
84
+
85
+ export const prerender = true;
86
+
87
+ // TODO: customise title and description below
88
+ const title = '__COL__';
89
+ const description = 'All entries in the __COL__ collection';
90
+
91
+ const entries = (await getCollection('__COL__'))
92
+ .filter(e => !e.data.draft)
93
+ .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
94
+ ---
95
+
96
+ <BaseLayout title={title} description={description}>
97
+ <main class="container py-5">
98
+ <div class="mb-5">
99
+ <h1 class="mb-0">{title}</h1>
100
+ <p class="lead">{description}</p>
101
+ </div>
102
+
103
+ <div class="row g-4">
104
+ {entries.map(entry => (
105
+ <div class="col-12 col-md-6 col-lg-4">
106
+ <article class="card h-100 shadow-sm">
107
+ {entry.data.image && (
108
+ <img
109
+ src={entry.data.image}
110
+ alt={entry.data.title}
111
+ class="card-img-top"
112
+ style="height:200px;object-fit:cover;"
113
+ />
114
+ )}
115
+ <div class="card-body d-flex flex-column">
116
+ <h5 class="card-title">
117
+ <a
118
+ href={\`/__COL__/\${entry.id.replace(/\\.mdx?$/, '')}\`}
119
+ class="text-decoration-none text-dark"
120
+ >
121
+ {entry.data.title}
122
+ </a>
123
+ </h5>
124
+ <p class="card-text text-muted small flex-grow-1">
125
+ {entry.data.description}
126
+ </p>
127
+ {entry.data.tags && (
128
+ <div class="d-flex flex-wrap gap-1 mt-2">
129
+ {entry.data.tags.map(tag => (
130
+ <span class="badge bg-primary-subtle text-primary-emphasis">
131
+ {tag}
132
+ </span>
133
+ ))}
134
+ </div>
135
+ )}
136
+ </div>
137
+ <div class="card-footer text-muted small">
138
+ <time datetime={entry.data.date.toISOString()}>
139
+ {entry.data.date.toLocaleDateString('en-US', {
140
+ year: 'numeric', month: 'long', day: 'numeric',
141
+ })}
142
+ </time>
143
+ {entry.data.author && <span> · {entry.data.author}</span>}
144
+ </div>
145
+ </article>
146
+ </div>
147
+ ))}
148
+ </div>
149
+
150
+ {entries.length === 0 && (
151
+ <p class="text-center text-muted mt-5">
152
+ No entries yet — add Markdown files to{' '}
153
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
154
+ </p>
155
+ )}
156
+ </main>
157
+ </BaseLayout>
158
+ `,
159
+
160
+ docs: `---
161
+ import { getCollection, type CollectionEntry } from 'astro:content';
162
+ import BaseLayout from '../../layouts/BaseLayout.astro';
163
+
164
+ export const prerender = true;
165
+
166
+ // TODO: customise title, description, and category groupings below
167
+ const title = '__COL__';
168
+ const description = 'Documentation for __COL__';
169
+
170
+ type Entry = CollectionEntry<'__COL__'>;
171
+
172
+ const entries = (await getCollection('__COL__'))
173
+ .filter((e: Entry) => !e.data.draft)
174
+ .sort((a: Entry, b: Entry) => (a.data.order ?? 999) - (b.data.order ?? 999));
175
+ ---
176
+
177
+ <BaseLayout title={title} description={description}>
178
+ <main class="container py-5">
179
+ <div class="mb-5">
180
+ <h1 class="mb-0">{title}</h1>
181
+ <p class="lead text-secondary">{description}</p>
182
+ </div>
183
+
184
+ <div class="row g-3">
185
+ {entries.map(entry => (
186
+ <div class="col-12 col-md-6">
187
+ <a
188
+ href={\`/__COL__/\${entry.id.replace(/\\.mdx?$/, '')}\`}
189
+ class="card p-4 h-100 text-decoration-none border shadow-sm d-block"
190
+ >
191
+ <h3 class="h5 fw-semibold text-body mb-2 mt-0">
192
+ {entry.data.title}
193
+ </h3>
194
+ {entry.data.description && (
195
+ <p class="text-secondary mb-0 small">{entry.data.description}</p>
196
+ )}
197
+ {entry.data.category && (
198
+ <span class="badge bg-secondary-subtle text-secondary-emphasis mt-2">
199
+ {entry.data.category}
200
+ </span>
201
+ )}
202
+ </a>
203
+ </div>
204
+ ))}
205
+ </div>
206
+
207
+ {entries.length === 0 && (
208
+ <p class="text-center text-muted mt-5">
209
+ No entries yet — add Markdown files to{' '}
210
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
211
+ </p>
212
+ )}
213
+ </main>
214
+ </BaseLayout>
215
+ `,
216
+
217
+ generic: `---
218
+ import { getCollection } from 'astro:content';
219
+ import BaseLayout from '../../layouts/BaseLayout.astro';
220
+
221
+ export const prerender = true;
222
+
223
+ // TODO: customise title and description below
224
+ const title = '__COL__';
225
+ const description = 'All entries in the __COL__ collection';
226
+
227
+ const entries = (await getCollection('__COL__'))
228
+ .filter(e => !e.data.draft);
229
+ ---
230
+
231
+ <BaseLayout title={title} description={description}>
232
+ <main class="container py-5">
233
+ <div class="mb-5">
234
+ <h1 class="mb-0">{title}</h1>
235
+ <p class="lead">{description}</p>
236
+ </div>
237
+
238
+ <ul class="list-group list-group-flush">
239
+ {entries.map(entry => (
240
+ <li class="list-group-item">
241
+ <a
242
+ href={\`/__COL__/\${entry.id.replace(/\\.mdx?$/, '')}\`}
243
+ class="text-decoration-none fw-semibold"
244
+ >
245
+ {entry.data.title}
246
+ </a>
247
+ {entry.data.description && (
248
+ <p class="mb-0 text-muted small">{entry.data.description}</p>
249
+ )}
250
+ </li>
251
+ ))}
252
+ </ul>
253
+
254
+ {entries.length === 0 && (
255
+ <p class="text-center text-muted mt-5">
256
+ No entries yet — add Markdown files to{' '}
257
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
258
+ </p>
259
+ )}
260
+ </main>
261
+ </BaseLayout>
262
+ `,
263
+ };
264
+
265
+ const SLUG = {
266
+
267
+ blog: `---
268
+ import { getCollection, render } from 'astro:content';
269
+ import { type CollectionEntry } from 'astro:content';
270
+ import BaseLayout from '../../layouts/BaseLayout.astro';
271
+
272
+ export const prerender = true;
273
+
274
+ export async function getStaticPaths() {
275
+ const entries = await getCollection('__COL__');
276
+ return entries.map(entry => ({
277
+ params: { slug: entry.id.replace(/\\.mdx?$/, '') },
278
+ props: { entry },
279
+ }));
280
+ }
281
+
282
+ interface Props { entry: CollectionEntry<'__COL__'>; }
283
+
284
+ const { entry } = Astro.props;
285
+ const { Content } = await render(entry);
286
+
287
+ const formattedDate = entry.data.date.toLocaleDateString('en-US', {
288
+ year: 'numeric', month: 'long', day: 'numeric',
289
+ });
290
+ ---
291
+
292
+ <BaseLayout title={entry.data.title} description={entry.data.description}>
293
+ <main class="container py-5">
294
+ <article>
295
+ <div class="mb-4 text-end">
296
+ <a href="/__COL__" class="btn btn-outline-secondary btn-sm">
297
+ ← Back to __COL__
298
+ </a>
299
+ </div>
300
+
301
+ <h1 class="mb-3">{entry.data.title}</h1>
302
+ <p class="lead text-muted mb-4">{entry.data.description}</p>
303
+
304
+ <div class="d-flex flex-wrap align-items-center gap-3 small text-muted mb-4 pb-4 border-bottom">
305
+ <time datetime={entry.data.date.toISOString()}>{formattedDate}</time>
306
+ {entry.data.author && <span>· By {entry.data.author}</span>}
307
+ {entry.data.tags && entry.data.tags.map(tag => (
308
+ <span class="badge bg-primary-subtle text-primary-emphasis">{tag}</span>
309
+ ))}
310
+ </div>
311
+
312
+ {entry.data.image && (
313
+ <img
314
+ src={entry.data.image}
315
+ alt={entry.data.title}
316
+ class="img-fluid rounded mb-5 w-100"
317
+ style="max-height:400px;object-fit:cover;"
318
+ />
319
+ )}
320
+
321
+ <div class="markdown-content">
322
+ <Content />
323
+ </div>
324
+ </article>
325
+ </main>
326
+ </BaseLayout>
327
+ `,
328
+
329
+ docs: `---
330
+ import { getCollection, render } from 'astro:content';
331
+ import { type CollectionEntry } from 'astro:content';
332
+ import BaseLayout from '../../layouts/BaseLayout.astro';
333
+
334
+ export const prerender = true;
335
+
336
+ export async function getStaticPaths() {
337
+ const entries = await getCollection('__COL__');
338
+ return entries.map(entry => ({
339
+ params: { slug: entry.id.replace(/\\.mdx?$/, '') },
340
+ props: { entry },
341
+ }));
342
+ }
343
+
344
+ interface Props { entry: CollectionEntry<'__COL__'>; }
345
+
346
+ const { entry } = Astro.props;
347
+ const { Content } = await render(entry);
348
+ ---
349
+
350
+ <BaseLayout title={entry.data.title} description={entry.data.description}>
351
+ <main class="container py-4">
352
+ <article>
353
+ <div class="mb-4 text-end">
354
+ <a href="/__COL__" class="small btn btn-outline-secondary btn-sm">
355
+ ← Back to __COL__
356
+ </a>
357
+ </div>
358
+
359
+ <h1 class="mb-2">{entry.data.title}</h1>
360
+ {entry.data.description && (
361
+ <p class="lead text-muted mb-4">{entry.data.description}</p>
362
+ )}
363
+ {entry.data.category && (
364
+ <span class="badge bg-secondary-subtle text-secondary-emphasis mb-4 d-inline-block">
365
+ {entry.data.category}
366
+ </span>
367
+ )}
368
+
369
+ <div class="markdown-content mt-4">
370
+ <Content />
371
+ </div>
372
+ </article>
373
+ </main>
374
+ </BaseLayout>
375
+ `,
376
+
377
+ generic: `---
378
+ import { getCollection, render } from 'astro:content';
379
+ import { type CollectionEntry } from 'astro:content';
380
+ import BaseLayout from '../../layouts/BaseLayout.astro';
381
+
382
+ export const prerender = true;
383
+
384
+ export async function getStaticPaths() {
385
+ const entries = await getCollection('__COL__');
386
+ return entries.map(entry => ({
387
+ params: { slug: entry.id.replace(/\\.mdx?$/, '') },
388
+ props: { entry },
389
+ }));
390
+ }
391
+
392
+ interface Props { entry: CollectionEntry<'__COL__'>; }
393
+
394
+ const { entry } = Astro.props;
395
+ const { Content } = await render(entry);
396
+ ---
397
+
398
+ <BaseLayout title={entry.data.title} description={entry.data.description}>
399
+ <main class="container py-5">
400
+ <article>
401
+ <div class="mb-4 text-end">
402
+ <a href="/__COL__" class="btn btn-outline-secondary btn-sm">
403
+ ← Back to __COL__
404
+ </a>
405
+ </div>
406
+
407
+ <h1 class="mb-3">{entry.data.title}</h1>
408
+ {entry.data.description && (
409
+ <p class="lead text-muted mb-4">{entry.data.description}</p>
410
+ )}
411
+
412
+ <div class="markdown-content mt-4">
413
+ <Content />
414
+ </div>
415
+ </article>
416
+ </main>
417
+ </BaseLayout>
418
+ `,
419
+ };
420
+
421
+ // ─── Main ─────────────────────────────────────────────────────────────────────
422
+ async function main() {
423
+ // Parse existing collections. Search only from the `// Export all
424
+ // collections` marker onward (the real export always follows it) — a plain
425
+ // whole-file regex would also match doc-comment examples mentioning the
426
+ // same "export const collections = {...}" text earlier in the file.
427
+ const cfgSrc = readFileSync(CONFIG_FILE, 'utf8');
428
+ const markerIdx = cfgSrc.indexOf('// Export all collections');
429
+ const searchFrom = markerIdx === -1 ? cfgSrc : cfgSrc.slice(markerIdx);
430
+ const exportM = searchFrom.match(/export const collections\s*=\s*\{([^}]+)\}/s);
431
+ const existing = exportM
432
+ ? [...exportM[1].matchAll(/(?:['"]([\w-]+)['"]|(\w+))\s*:/g)].map(m => m[1] ?? m[2])
433
+ : [];
434
+
435
+ console.log('');
436
+ console.log(`${B}Existing collections:${X}`);
437
+ if (existing.length === 0) console.log(' (none)');
438
+ else for (const c of existing) console.log(` • ${c}`);
439
+ console.log('');
440
+
441
+ // ─── Prompt: collection name ────────────────────────────────────────────────
442
+ let colName;
443
+ while (true) {
444
+ const raw = (await ask(`${B}New collection name${X} (lowercase letters, numbers, hyphens): `)).replace(/\s/g, '');
445
+ if (!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(raw)) {
446
+ warn('Name must start with a letter and contain only lowercase letters, numbers, and hyphens.');
447
+ continue;
448
+ }
449
+ if (existing.includes(raw)) {
450
+ warn(`Collection '${raw}' already exists. Choose a different name.`);
451
+ continue;
452
+ }
453
+ colName = raw;
454
+ break;
455
+ }
456
+
457
+ // ─── Derive camelCase variable name: my-news → myNewsCollection ──────────
458
+ const parts = colName.split('-');
459
+ const varName = parts[0] + parts.slice(1).map(p => p[0].toUpperCase() + p.slice(1)).join('') + 'Collection';
460
+
461
+ // ─── Prompt: collection type ──────────────────────────────────────────────
462
+ console.log('');
463
+ console.log(`${B}Collection type:${X}`);
464
+ console.log(' 1) blog — articles (date, author, tags, image, draft)');
465
+ console.log(' 2) docs — documentation pages (order, category, draft)');
466
+ console.log(' 3) generic — simple entries (title, optional description, draft)');
467
+ console.log('');
468
+ const typeRaw = (await ask('Choose type [1/2/3] (default: 3): ')).trim() || '3';
469
+ const colType = typeRaw === '1' ? 'blog' : typeRaw === '2' ? 'docs' : 'generic';
470
+ ok(`Type: ${colType}`);
471
+ rl.close();
472
+
473
+ // ─── 1. Update src/content.config.ts ──────────────────────────────────────
474
+ const newBlock =
475
+ `\n// Schema for ${colName} entries [${colType} type]\n` +
476
+ `// TODO: customize the schema fields to match your content structure\n` +
477
+ `const ${varName} = defineCollection({\n` +
478
+ ` loader: glob({\n` +
479
+ ` pattern: '**/*.{md,mdx}',\n` +
480
+ ` base: './src/content/${colName}',\n` +
481
+ ` }),\n` +
482
+ ` schema: z.object({\n` +
483
+ `${SCHEMA[colType]}\n` +
484
+ ` }),\n` +
485
+ `});\n`;
486
+
487
+ const MARKER = '// Export all collections';
488
+ const cfg = readFileSync(CONFIG_FILE, 'utf8');
489
+ const markerIndex = cfg.indexOf(MARKER);
490
+ if (markerIndex === -1) error(`Marker '${MARKER}' not found in config.ts`);
491
+
492
+ // Only ever scan/edit the file from the marker onward — the real `export
493
+ // const collections = {...}` always lives there. Scanning the whole file
494
+ // for that pattern is fragile: doc-comment examples earlier in the file
495
+ // (e.g. in the template this script ships against) can contain the exact
496
+ // same text, and a plain first-match regex would silently edit the
497
+ // comment instead of the real export.
498
+ const before = cfg.slice(0, markerIndex);
499
+ const after = (newBlock + cfg.slice(markerIndex)).replace(
500
+ // [^}]* (zero or more), not [^}]+ — a fresh scms-create site starts with
501
+ // an empty `export const collections = {};`, and `+` would fail to match it.
502
+ /(export const collections\s*=\s*\{)([^}]*)(\};)/s,
503
+ (_, open, inner, close) => `${open}${inner.trimEnd()}\n ${toObjectKey(colName)}: ${varName},\n${close}`
504
+ );
505
+ writeFileSync(CONFIG_FILE, before + after, 'utf8');
506
+ ok('src/content.config.ts updated');
507
+
508
+ // ─── 2. Sample content file ───────────────────────────────────────────────
509
+ const today = new Date().toISOString().slice(0, 10);
510
+ const contentDir = join(ROOT_DIR, 'src', 'content', colName);
511
+ mkdirSync(contentDir, { recursive: true });
512
+
513
+ const SAMPLE_NAME = { blog: 'sample-post.md', docs: 'sample-doc.md', generic: 'sample.md' };
514
+ const SAMPLE_BODY = {
515
+ blog:
516
+ `---\ntitle: "Sample Post"\n` +
517
+ `description: "A sample post in the ${colName} collection."\n` +
518
+ `date: ${today}\nauthor: "Your Name"\ntags: ["sample"]\ndraft: true\n---\n\n` +
519
+ `# Sample Post\n\nThis is a sample post. Replace this file with real content.\n`,
520
+ docs:
521
+ `---\ntitle: "Sample Document"\n` +
522
+ `description: "A sample document in the ${colName} collection."\n` +
523
+ `order: 1\ncategory: "general"\ndraft: true\n---\n\n` +
524
+ `# Sample Document\n\nThis is a sample document. Replace this file with real content.\n`,
525
+ generic:
526
+ `---\ntitle: "Sample Entry"\n` +
527
+ `description: "A sample entry in the ${colName} collection."\n` +
528
+ `draft: true\n---\n\n` +
529
+ `# Sample Entry\n\nThis is a sample entry. Replace this file with real content.\n`,
530
+ };
531
+
532
+ const samplePath = join(contentDir, SAMPLE_NAME[colType]);
533
+ writeFileSync(samplePath, SAMPLE_BODY[colType], 'utf8');
534
+ ok(relative(ROOT_DIR, samplePath));
535
+
536
+ // ─── 3. Page templates ────────────────────────────────────────────────────
537
+ const pagesDir = join(ROOT_DIR, 'src', 'pages', colName);
538
+ mkdirSync(pagesDir, { recursive: true });
539
+
540
+ const sub = (s) => s.replaceAll('__COL__', colName);
541
+ const indexPath = join(pagesDir, 'index.astro');
542
+ const slugPath = join(pagesDir, '[...slug].astro');
543
+
544
+ writeFileSync(indexPath, sub(INDEX[colType]), 'utf8');
545
+ writeFileSync(slugPath, sub(SLUG[colType]), 'utf8');
546
+ ok(relative(ROOT_DIR, indexPath));
547
+ ok(relative(ROOT_DIR, slugPath));
548
+
549
+ // ─── Summary ──────────────────────────────────────────────────────────────
550
+ console.log('');
551
+ console.log(`${G}${B}Collection '${colName}' scaffolded successfully!${X}`);
552
+ console.log('');
553
+ console.log(` ${B}Next steps:${X}`);
554
+ console.log(` 1. Open ${B}src/content.config.ts${X} and review the schema for '${colName}'`);
555
+ console.log(` 2. Edit the page files in ${B}src/pages/${colName}/${X} to customise the UI`);
556
+ console.log(` 3. Replace the sample file in ${B}src/content/${colName}/${X} with real content`);
557
+ console.log(` 4. Run ${B}npm run dev${X} and visit ${B}/${colName}${X}`);
558
+ console.log('');
559
+ }
560
+
561
+ main().catch(err => error(String(err)));