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

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.
Files changed (58) hide show
  1. package/README.md +34 -5
  2. package/bin/add-collection.mjs +592 -0
  3. package/bin/add-content.mjs +344 -0
  4. package/bin/create.mjs +149 -0
  5. package/components/BSNavbar.tsx +44 -10
  6. package/components/DataTb/DataTb.astro +1 -1
  7. package/components/DataTb/DataTb.tsx +25 -10
  8. package/components/DataTb/README.md +2 -2
  9. package/components/Gallery/Gallery.astro +2 -26
  10. package/components/Gallery/Gallery.tsx +4 -1
  11. package/components/Gallery/README.md +4 -4
  12. package/components/Gallery/__tests__/galleryUtils.test.ts +30 -9
  13. package/components/Gallery/galleryUtils.ts +42 -5
  14. package/components/Gallery/types.ts +1 -1
  15. package/components/LocaleSwitcher.tsx +132 -0
  16. package/components/Map/LayerControl.tsx +36 -33
  17. package/components/Map/Map.tsx +169 -115
  18. package/components/Map/MapMdx.tsx +10 -4
  19. package/components/Map/README.md +201 -0
  20. package/components/Map/Search/SearchUI.tsx +4 -2
  21. package/components/Map/Search/SearchUIAdvanced.tsx +40 -15
  22. package/components/Map/Search/SearchUISimple.tsx +11 -9
  23. package/components/Map/index.ts +1 -1
  24. package/components/Map/types.ts +42 -10
  25. package/components/Map/utils.ts +44 -0
  26. package/components/README.md +3 -3
  27. package/components/Record/RecordFetcher.tsx +5 -2
  28. package/components/Record/useRecordFetcher.ts +2 -2
  29. package/components/RedirectPage/README.md +119 -0
  30. package/components/RedirectPage/RedirectPage.astro +34 -0
  31. package/components/TableOfContents/TableOfContents.astro +1 -2
  32. package/components/ThemeToggle.tsx +145 -0
  33. package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +47 -28
  34. package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +16 -7
  35. package/components/i18n/I18nInit.astro +36 -0
  36. package/components/i18n/__tests__/translator.test.ts +87 -0
  37. package/components/i18n/builtinDictionary.ts +127 -0
  38. package/components/i18n/index.ts +7 -0
  39. package/components/i18n/translator.ts +71 -0
  40. package/components/i18n/useTranslation.ts +38 -0
  41. package/components/i18n/window.ts +33 -0
  42. package/index.ts +6 -0
  43. package/integrations/__tests__/galleryIntegration.test.ts +13 -13
  44. package/integrations/contentAssetsIntegration.ts +5 -5
  45. package/integrations/galleryIntegration.ts +7 -7
  46. package/package.json +8 -1
  47. package/scms.ts +28 -3
  48. package/template/.github/workflows/deploy.yml +51 -0
  49. package/template/README.md.tmpl +37 -0
  50. package/template/astro.config.mjs +26 -0
  51. package/template/package.json.tmpl +31 -0
  52. package/template/src/content.config.ts +27 -0
  53. package/template/src/layouts/BaseLayout.astro +64 -0
  54. package/template/src/pages/index.astro +14 -0
  55. package/template/src/styles/global.css +8 -0
  56. package/template/src/user.config.mjs.tmpl +34 -0
  57. package/template/tsconfig.json +10 -0
  58. package/utils/content.ts +1 -1
package/README.md CHANGED
@@ -18,15 +18,20 @@ 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: [
25
30
  ...scms({
26
31
  // all optional, default to the paths below
27
- contentDir: 'usr/content',
28
- pagesDir: 'usr/pages',
29
- galleriesDir: 'usr/galleries',
32
+ contentDir: 'src/content',
33
+ pagesDir: 'src/pages',
34
+ galleriesDir: 'src/galleries',
30
35
  }),
31
36
  ],
32
37
  });
@@ -46,6 +51,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
46
51
  | `MapComponent` (`Map`) | Interactive map with MapLibre GL JS |
47
52
  | `Gallery` | Responsive image gallery with lightbox |
48
53
  | `TableOfContents` | Auto-generated TOC from headings |
54
+ | `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
49
55
  | `ZoteroGeoViewer` | Zotero library visualised on a map |
50
56
  | `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
51
57
  | `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
@@ -58,7 +64,30 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
58
64
  import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
59
65
  ```
60
66
 
61
- See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
67
+ See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/Map`, `components/RedirectPage`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
68
+
69
+ ## Creating a new site
70
+
71
+ ```bash
72
+ npx --package=@lad-sapienza/scms-core scms-create my-site
73
+ cd my-site
74
+ npm run dev
75
+ ```
76
+
77
+ Prompts for a title, description, author, and site URL, then scaffolds a minimal, ready-to-run Astro + s:CMS project (`src/content.config.ts`, `src/pages/index.astro`, `astro.config.mjs`, etc.) and runs `npm install`.
78
+
79
+ ## Scaffolding CLI
80
+
81
+ This package also ships the interactive scaffolding tools s:CMS sites use to add content, as `bin` commands — run from the consuming site's project root:
82
+
83
+ ```bash
84
+ npx scms-add-collection # scaffold a new Astro content collection under src/
85
+ npx scms-add-content # add a new content file to an existing collection
86
+ ```
87
+
88
+ A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json` (this is what `scms-create` sets up automatically).
89
+
90
+ Both commands are multilingual-aware. `scms-add-collection` optionally takes a list of locale codes and scaffolds one sample per `src/content/<name>/<locale>/` folder; `scms-add-content` detects those language folders (every sub-folder is a locale code, nothing loose at the collection root) and asks which language the new file belongs in — a single locale, a comma-separated subset, or `all`.
62
91
 
63
92
  ## Development
64
93
 
@@ -0,0 +1,592 @@
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
+
472
+ // ─── Prompt: language folders (multilingual collection) ──────────────────
473
+ // A site can organise a collection's content per language, as
474
+ // src/content/<col>/<locale>/… — s:CMS's i18n layer is structural, it keys
475
+ // off those folders existing rather than a config flag. Ask up front so the
476
+ // sample file(s) land in the right place. (The page templates written below
477
+ // are single-language starting points regardless — see the closing notes.)
478
+ console.log('');
479
+ console.log(`${B}Language folders${X} — for a multilingual collection, list the locale`);
480
+ console.log(` codes to scaffold (e.g. ${B}en, it${X}). Leave blank for a single-language collection.`);
481
+ let locales = [];
482
+ while (true) {
483
+ const raw = (await ask(' Locale codes [blank = none]: ')).trim();
484
+ if (!raw) break;
485
+ const parsed = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
486
+ const bad = parsed.filter(p => !/^[a-z]{2}(?:[-_][a-z0-9]{2,4})?$/i.test(p));
487
+ if (bad.length) { warn(`Not a locale code: ${bad.join(', ')} (expected e.g. en, it, pt-BR)`); continue; }
488
+ locales = [...new Set(parsed)];
489
+ break;
490
+ }
491
+ if (locales.length) ok(`Language folders: ${locales.join(', ')}`);
492
+ rl.close();
493
+
494
+ // ─── 1. Update src/content.config.ts ──────────────────────────────────────
495
+ const newBlock =
496
+ `\n// Schema for ${colName} entries [${colType} type]\n` +
497
+ `// TODO: customize the schema fields to match your content structure\n` +
498
+ `const ${varName} = defineCollection({\n` +
499
+ ` loader: glob({\n` +
500
+ ` pattern: '**/*.{md,mdx}',\n` +
501
+ ` base: './src/content/${colName}',\n` +
502
+ ` }),\n` +
503
+ ` schema: z.object({\n` +
504
+ `${SCHEMA[colType]}\n` +
505
+ ` }),\n` +
506
+ `});\n`;
507
+
508
+ const MARKER = '// Export all collections';
509
+ const cfg = readFileSync(CONFIG_FILE, 'utf8');
510
+ const markerIndex = cfg.indexOf(MARKER);
511
+ if (markerIndex === -1) error(`Marker '${MARKER}' not found in config.ts`);
512
+
513
+ // Only ever scan/edit the file from the marker onward — the real `export
514
+ // const collections = {...}` always lives there. Scanning the whole file
515
+ // for that pattern is fragile: doc-comment examples earlier in the file
516
+ // (e.g. in the template this script ships against) can contain the exact
517
+ // same text, and a plain first-match regex would silently edit the
518
+ // comment instead of the real export.
519
+ const before = cfg.slice(0, markerIndex);
520
+ const after = (newBlock + cfg.slice(markerIndex)).replace(
521
+ // [^}]* (zero or more), not [^}]+ — a fresh scms-create site starts with
522
+ // an empty `export const collections = {};`, and `+` would fail to match it.
523
+ /(export const collections\s*=\s*\{)([^}]*)(\};)/s,
524
+ (_, open, inner, close) => `${open}${inner.trimEnd()}\n ${toObjectKey(colName)}: ${varName},\n${close}`
525
+ );
526
+ writeFileSync(CONFIG_FILE, before + after, 'utf8');
527
+ ok('src/content.config.ts updated');
528
+
529
+ // ─── 2. Sample content file(s) ───────────────────────────────────────────
530
+ const today = new Date().toISOString().slice(0, 10);
531
+ const contentDir = join(ROOT_DIR, 'src', 'content', colName);
532
+
533
+ const SAMPLE_NAME = { blog: 'sample-post.md', docs: 'sample-doc.md', generic: 'sample.md' };
534
+ const SAMPLE_BODY = {
535
+ blog:
536
+ `---\ntitle: "Sample Post"\n` +
537
+ `description: "A sample post in the ${colName} collection."\n` +
538
+ `date: ${today}\nauthor: "Your Name"\ntags: ["sample"]\ndraft: true\n---\n\n` +
539
+ `# Sample Post\n\nThis is a sample post. Replace this file with real content.\n`,
540
+ docs:
541
+ `---\ntitle: "Sample Document"\n` +
542
+ `description: "A sample document in the ${colName} collection."\n` +
543
+ `order: 1\ncategory: "general"\ndraft: true\n---\n\n` +
544
+ `# Sample Document\n\nThis is a sample document. Replace this file with real content.\n`,
545
+ generic:
546
+ `---\ntitle: "Sample Entry"\n` +
547
+ `description: "A sample entry in the ${colName} collection."\n` +
548
+ `draft: true\n---\n\n` +
549
+ `# Sample Entry\n\nThis is a sample entry. Replace this file with real content.\n`,
550
+ };
551
+
552
+ // One sample per language folder for a multilingual collection, otherwise a
553
+ // single sample in the collection root. The copies are identical starting
554
+ // points — translations get written by hand afterwards.
555
+ const sampleDirs = locales.length ? locales.map(loc => join(contentDir, loc)) : [contentDir];
556
+ for (const dir of sampleDirs) {
557
+ mkdirSync(dir, { recursive: true });
558
+ const samplePath = join(dir, SAMPLE_NAME[colType]);
559
+ writeFileSync(samplePath, SAMPLE_BODY[colType], 'utf8');
560
+ ok(relative(ROOT_DIR, samplePath));
561
+ }
562
+
563
+ // ─── 3. Page templates ────────────────────────────────────────────────────
564
+ const pagesDir = join(ROOT_DIR, 'src', 'pages', colName);
565
+ mkdirSync(pagesDir, { recursive: true });
566
+
567
+ const sub = (s) => s.replaceAll('__COL__', colName);
568
+ const indexPath = join(pagesDir, 'index.astro');
569
+ const slugPath = join(pagesDir, '[...slug].astro');
570
+
571
+ writeFileSync(indexPath, sub(INDEX[colType]), 'utf8');
572
+ writeFileSync(slugPath, sub(SLUG[colType]), 'utf8');
573
+ ok(relative(ROOT_DIR, indexPath));
574
+ ok(relative(ROOT_DIR, slugPath));
575
+
576
+ // ─── Summary ──────────────────────────────────────────────────────────────
577
+ console.log('');
578
+ console.log(`${G}${B}Collection '${colName}' scaffolded successfully!${X}`);
579
+ console.log('');
580
+ console.log(` ${B}Next steps:${X}`);
581
+ console.log(` 1. Open ${B}src/content.config.ts${X} and review the schema for '${colName}'`);
582
+ console.log(` 2. Edit the page files in ${B}src/pages/${colName}/${X} to customise the UI`);
583
+ if (locales.length) {
584
+ console.log(` ${Y}These render a single language — adapt them to your locale`);
585
+ console.log(` routing (e.g. move them under src/pages/[locale]/${colName}/).${X}`);
586
+ }
587
+ console.log(` 3. Replace the sample file${locales.length ? 's' : ''} in ${B}src/content/${colName}/${X} with real content`);
588
+ console.log(` 4. Run ${B}npm run dev${X} and visit ${B}/${colName}${X}`);
589
+ console.log('');
590
+ }
591
+
592
+ main().catch(err => error(String(err)));