@lad-sapienza/scms-core 0.1.0-alpha.1 → 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 +20 -7
  2. package/bin/add-collection.mjs +73 -28
  3. package/bin/add-content.mjs +100 -18
  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 +4 -2
  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
@@ -29,9 +29,9 @@ export default defineConfig({
29
29
  integrations: [
30
30
  ...scms({
31
31
  // all optional, default to the paths below
32
- contentDir: 'usr/content',
33
- pagesDir: 'usr/pages',
34
- galleriesDir: 'usr/galleries',
32
+ contentDir: 'src/content',
33
+ pagesDir: 'src/pages',
34
+ galleriesDir: 'src/galleries',
35
35
  }),
36
36
  ],
37
37
  });
@@ -51,6 +51,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
51
51
  | `MapComponent` (`Map`) | Interactive map with MapLibre GL JS |
52
52
  | `Gallery` | Responsive image gallery with lightbox |
53
53
  | `TableOfContents` | Auto-generated TOC from headings |
54
+ | `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
54
55
  | `ZoteroGeoViewer` | Zotero library visualised on a map |
55
56
  | `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
56
57
  | `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
@@ -63,18 +64,30 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
63
64
  import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
64
65
  ```
65
66
 
66
- 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`.
67
78
 
68
79
  ## Scaffolding CLI
69
80
 
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:
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:
71
82
 
72
83
  ```bash
73
- npx scms-add-collection # scaffold a new Astro content collection under usr/
84
+ npx scms-add-collection # scaffold a new Astro content collection under src/
74
85
  npx scms-add-content # add a new content file to an existing collection
75
86
  ```
76
87
 
77
- A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json`.
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`.
78
91
 
79
92
  ## Development
80
93
 
@@ -11,7 +11,7 @@ import { join, relative } from 'node:path';
11
11
  // own location says nothing about the consuming site's layout. ROOT_DIR must
12
12
  // be the invoking project's root, i.e. wherever the CLI was run from.
13
13
  const ROOT_DIR = process.cwd();
14
- const CONFIG_FILE = join(ROOT_DIR, 'usr', 'content.config.ts');
14
+ const CONFIG_FILE = join(ROOT_DIR, 'src', 'content.config.ts');
15
15
 
16
16
  // ─── Color helpers ────────────────────────────────────────────────────────────
17
17
  const G = '\x1b[32m', Y = '\x1b[33m', R = '\x1b[31m';
@@ -150,7 +150,7 @@ const entries = (await getCollection('__COL__'))
150
150
  {entries.length === 0 && (
151
151
  <p class="text-center text-muted mt-5">
152
152
  No entries yet — add Markdown files to{' '}
153
- <code>usr/content/__COL__/</code> and set <code>draft: false</code>.
153
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
154
154
  </p>
155
155
  )}
156
156
  </main>
@@ -207,7 +207,7 @@ const entries = (await getCollection('__COL__'))
207
207
  {entries.length === 0 && (
208
208
  <p class="text-center text-muted mt-5">
209
209
  No entries yet — add Markdown files to{' '}
210
- <code>usr/content/__COL__/</code> and set <code>draft: false</code>.
210
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
211
211
  </p>
212
212
  )}
213
213
  </main>
@@ -254,7 +254,7 @@ const entries = (await getCollection('__COL__'))
254
254
  {entries.length === 0 && (
255
255
  <p class="text-center text-muted mt-5">
256
256
  No entries yet — add Markdown files to{' '}
257
- <code>usr/content/__COL__/</code> and set <code>draft: false</code>.
257
+ <code>src/content/__COL__/</code> and set <code>draft: false</code>.
258
258
  </p>
259
259
  )}
260
260
  </main>
@@ -420,10 +420,15 @@ const { Content } = await render(entry);
420
420
 
421
421
  // ─── Main ─────────────────────────────────────────────────────────────────────
422
422
  async function main() {
423
- // Parse existing collections
424
- const cfgSrc = readFileSync(CONFIG_FILE, 'utf8');
425
- const exportM = cfgSrc.match(/export const collections\s*=\s*\{([^}]+)\}/s);
426
- const existing = exportM
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
427
432
  ? [...exportM[1].matchAll(/(?:['"]([\w-]+)['"]|(\w+))\s*:/g)].map(m => m[1] ?? m[2])
428
433
  : [];
429
434
 
@@ -463,16 +468,37 @@ async function main() {
463
468
  const typeRaw = (await ask('Choose type [1/2/3] (default: 3): ')).trim() || '3';
464
469
  const colType = typeRaw === '1' ? 'blog' : typeRaw === '2' ? 'docs' : 'generic';
465
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(', ')}`);
466
492
  rl.close();
467
493
 
468
- // ─── 1. Update usr/content.config.ts ──────────────────────────────────────
494
+ // ─── 1. Update src/content.config.ts ──────────────────────────────────────
469
495
  const newBlock =
470
496
  `\n// Schema for ${colName} entries [${colType} type]\n` +
471
497
  `// TODO: customize the schema fields to match your content structure\n` +
472
498
  `const ${varName} = defineCollection({\n` +
473
499
  ` loader: glob({\n` +
474
500
  ` pattern: '**/*.{md,mdx}',\n` +
475
- ` base: './usr/content/${colName}',\n` +
501
+ ` base: './src/content/${colName}',\n` +
476
502
  ` }),\n` +
477
503
  ` schema: z.object({\n` +
478
504
  `${SCHEMA[colType]}\n` +
@@ -480,21 +506,29 @@ async function main() {
480
506
  `});\n`;
481
507
 
482
508
  const MARKER = '// Export all collections';
483
- let cfg = readFileSync(CONFIG_FILE, 'utf8');
484
- if (!cfg.includes(MARKER)) error(`Marker '${MARKER}' not found in config.ts`);
485
-
486
- cfg = cfg.replace(MARKER, newBlock + MARKER);
487
- cfg = cfg.replace(
488
- /(export const collections\s*=\s*\{)([^}]+)(\};)/s,
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,
489
524
  (_, open, inner, close) => `${open}${inner.trimEnd()}\n ${toObjectKey(colName)}: ${varName},\n${close}`
490
525
  );
491
- writeFileSync(CONFIG_FILE, cfg, 'utf8');
492
- ok('usr/content.config.ts updated');
526
+ writeFileSync(CONFIG_FILE, before + after, 'utf8');
527
+ ok('src/content.config.ts updated');
493
528
 
494
- // ─── 2. Sample content file ───────────────────────────────────────────────
529
+ // ─── 2. Sample content file(s) ───────────────────────────────────────────
495
530
  const today = new Date().toISOString().slice(0, 10);
496
- const contentDir = join(ROOT_DIR, 'usr', 'content', colName);
497
- mkdirSync(contentDir, { recursive: true });
531
+ const contentDir = join(ROOT_DIR, 'src', 'content', colName);
498
532
 
499
533
  const SAMPLE_NAME = { blog: 'sample-post.md', docs: 'sample-doc.md', generic: 'sample.md' };
500
534
  const SAMPLE_BODY = {
@@ -515,12 +549,19 @@ async function main() {
515
549
  `# Sample Entry\n\nThis is a sample entry. Replace this file with real content.\n`,
516
550
  };
517
551
 
518
- const samplePath = join(contentDir, SAMPLE_NAME[colType]);
519
- writeFileSync(samplePath, SAMPLE_BODY[colType], 'utf8');
520
- ok(relative(ROOT_DIR, samplePath));
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
+ }
521
562
 
522
563
  // ─── 3. Page templates ────────────────────────────────────────────────────
523
- const pagesDir = join(ROOT_DIR, 'usr', 'pages', colName);
564
+ const pagesDir = join(ROOT_DIR, 'src', 'pages', colName);
524
565
  mkdirSync(pagesDir, { recursive: true });
525
566
 
526
567
  const sub = (s) => s.replaceAll('__COL__', colName);
@@ -537,9 +578,13 @@ async function main() {
537
578
  console.log(`${G}${B}Collection '${colName}' scaffolded successfully!${X}`);
538
579
  console.log('');
539
580
  console.log(` ${B}Next steps:${X}`);
540
- console.log(` 1. Open ${B}usr/content.config.ts${X} and review the schema for '${colName}'`);
541
- console.log(` 2. Edit the page files in ${B}usr/pages/${colName}/${X} to customise the UI`);
542
- console.log(` 3. Replace the sample file in ${B}usr/content/${colName}/${X} with real content`);
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`);
543
588
  console.log(` 4. Run ${B}npm run dev${X} and visit ${B}/${colName}${X}`);
544
589
  console.log('');
545
590
  }
@@ -12,8 +12,8 @@ import { execSync } from 'node:child_process';
12
12
  // own location says nothing about the consuming site's layout. ROOT_DIR must
13
13
  // be the invoking project's root, i.e. wherever the CLI was run from.
14
14
  const ROOT_DIR = process.cwd();
15
- const CONFIG_FILE = join(ROOT_DIR, 'usr', 'content.config.ts');
16
- const CONTENT_BASE = join(ROOT_DIR, 'usr', 'content');
15
+ const CONFIG_FILE = join(ROOT_DIR, 'src', 'content.config.ts');
16
+ const CONTENT_BASE = join(ROOT_DIR, 'src', 'content');
17
17
 
18
18
  // ─── Color helpers ────────────────────────────────────────────────────────────
19
19
  const G = '\x1b[32m', Y = '\x1b[33m', R = '\x1b[31m';
@@ -43,9 +43,16 @@ const ask = async (prompt) => {
43
43
 
44
44
  // ─── Helpers ──────────────────────────────────────────────────────────────────
45
45
 
46
- /** Parse top-level collection keys from export const collections = { ... } */
46
+ /**
47
+ * Parse top-level collection keys from export const collections = { ... }.
48
+ * Searches only from the `// Export all collections` marker onward (the real
49
+ * export always follows it) — a plain whole-file regex would also match
50
+ * doc-comment examples mentioning the same text earlier in the file.
51
+ */
47
52
  function parseCollections(src) {
48
- const m = src.match(/export const collections\s*=\s*\{([^}]+)\}/s);
53
+ const markerIdx = src.indexOf('// Export all collections');
54
+ const searchFrom = markerIdx === -1 ? src : src.slice(markerIdx);
55
+ const m = searchFrom.match(/export const collections\s*=\s*\{([^}]+)\}/s);
49
56
  if (!m) return [];
50
57
  // Keys may be quoted (e.g. 'my-collection': ...) when the name isn't a
51
58
  // valid bare identifier — see toObjectKey() in add-collection.mjs.
@@ -68,10 +75,40 @@ function hasMdx(dir) {
68
75
  } catch { return false; }
69
76
  }
70
77
 
78
+ /** True if any .md/.mdx file sits directly in dir (not in a sub-folder) */
79
+ function hasRootContentFiles(dir) {
80
+ try {
81
+ return readdirSync(dir, { withFileTypes: true })
82
+ .some(d => d.isFile() && /\.mdx?$/.test(d.name));
83
+ } catch { return false; }
84
+ }
85
+
86
+ /**
87
+ * Locale-code sub-directories directly inside a collection dir — e.g.
88
+ * `en`, `it`, `pt-BR`. Their presence is how a site opts a collection in to
89
+ * per-language content: there is no config flag (s:CMS's i18n layer is
90
+ * structural — it keys off these folders existing). The pattern is a
91
+ * 2-letter base with an optional region/script suffix (`pt-BR`, `zh-Hans`),
92
+ * and — to keep ordinary topic folders (`api`, `guides`) from being taken
93
+ * for languages — this returns nothing unless *every* sub-folder matches.
94
+ * Sorted, for a stable prompt order.
95
+ */
96
+ function detectLocaleDirs(dir) {
97
+ let subdirs;
98
+ try {
99
+ subdirs = readdirSync(dir, { withFileTypes: true })
100
+ .filter(d => d.isDirectory())
101
+ .map(d => d.name);
102
+ } catch { return []; }
103
+ if (subdirs.length === 0) return [];
104
+ const locales = subdirs.filter(n => /^[a-z]{2}(?:[-_][a-z0-9]{2,4})?$/i.test(n));
105
+ return locales.length === subdirs.length ? locales.sort() : [];
106
+ }
107
+
71
108
  /** Extract z.object field lines for a named collection from config.ts */
72
109
  function parseSchemaFields(src, colName) {
73
110
  const pat = new RegExp(
74
- `defineCollection\\(\\{.*?base\\s*:\\s*['"]\\./usr/content/${colName}['"].*?z\\.object\\(\\{(.*?)\\}\\)`,
111
+ `defineCollection\\(\\{.*?base\\s*:\\s*['"]\\./src/content/${colName}['"].*?z\\.object\\(\\{(.*?)\\}\\)`,
75
112
  's'
76
113
  );
77
114
  const m = src.match(pat);
@@ -156,9 +193,37 @@ async function main() {
156
193
  }
157
194
  ok(`Collection: ${collection}`);
158
195
 
196
+ const colDir = join(CONTENT_BASE, collection);
197
+
198
+ // ─── Prompt: language folder (multilingual collections only) ─────────────
199
+ // A collection counts as multilingual purely by its layout: content split
200
+ // into locale-named sub-folders (src/content/<col>/<locale>/…) with nothing
201
+ // loose at the collection root. When that's the case, ask which locale
202
+ // folder(s) the new file belongs in — one, a comma-separated subset, or
203
+ // `all`. Collections that aren't organised this way skip the step entirely
204
+ // and behave exactly as before.
205
+ const localeDirs = hasRootContentFiles(colDir) ? [] : detectLocaleDirs(colDir);
206
+ let targetLocales = ['']; // '' → straight into the collection root (single-language)
207
+ if (localeDirs.length > 0) {
208
+ console.log('');
209
+ console.log(` This collection is organised by language folder: ${B}${localeDirs.join(', ')}${X}`);
210
+ console.log(' Enter one language, a comma-separated list, or "all".');
211
+ console.log('');
212
+ while (true) {
213
+ const raw = (await ask(`${B}Language${X} [${[...localeDirs, 'all'].join(' / ')}]: `)).trim().toLowerCase();
214
+ if (!raw) { warn('Please choose a language (or "all").'); continue; }
215
+ if (raw === 'all') { targetLocales = [...localeDirs]; break; }
216
+ const picked = raw.split(',').map(s => s.trim()).filter(Boolean);
217
+ const unknown = picked.filter(p => !localeDirs.includes(p));
218
+ if (unknown.length) { warn(`Not an available language: ${unknown.join(', ')}`); continue; }
219
+ targetLocales = [...new Set(picked)];
220
+ break;
221
+ }
222
+ ok(`Language: ${targetLocales.join(', ')}`);
223
+ }
224
+
159
225
  // ─── Prompt: file format ─────────────────────────────────────────────────
160
- const colDir = join(CONTENT_BASE, collection);
161
- const defaultExt = hasMdx(colDir) ? 'mdx' : 'md';
226
+ const defaultExt = targetLocales.some(loc => hasMdx(join(colDir, loc))) ? 'mdx' : 'md';
162
227
  console.log('');
163
228
  const extRaw = (await ask(`${B}File format${X} [md/mdx] (default: ${defaultExt}): `)).trim() || defaultExt;
164
229
  const ext = (extRaw === 'md' || extRaw === 'mdx') ? extRaw : defaultExt;
@@ -176,8 +241,9 @@ async function main() {
176
241
  warn('Slug must contain only lowercase letters, numbers, hyphens, underscores, and forward slashes.');
177
242
  continue;
178
243
  }
179
- if (existsSync(join(colDir, raw, `index.${ext}`))) {
180
- warn(`File already exists: usr/content/${collection}/${raw}/index.${ext}`);
244
+ const clash = targetLocales.find(loc => existsSync(join(colDir, loc, raw, `index.${ext}`)));
245
+ if (clash !== undefined) {
246
+ warn(`File already exists: ${relative(ROOT_DIR, join(colDir, clash, raw, `index.${ext}`))}`);
181
247
  continue;
182
248
  }
183
249
  slug = raw;
@@ -227,11 +293,7 @@ async function main() {
227
293
 
228
294
  rl.close();
229
295
 
230
- // ─── Write file ───────────────────────────────────────────────────────────
231
- const targetDir = join(colDir, slug);
232
- const targetPath = join(targetDir, `index.${ext}`);
233
- mkdirSync(targetDir, { recursive: true });
234
-
296
+ // ─── Write file(s) ────────────────────────────────────────────────────────
235
297
  const content = [
236
298
  '---',
237
299
  ...fmLines,
@@ -243,19 +305,39 @@ async function main() {
243
305
  '',
244
306
  ].join('\n');
245
307
 
246
- writeFileSync(targetPath, content, 'utf8');
308
+ // One copy per chosen language folder (just one entry, '', for a
309
+ // single-language collection). Each copy is an identical starting point —
310
+ // the per-locale translations are written afterwards, by hand.
311
+ const created = [];
312
+ for (const loc of targetLocales) {
313
+ const targetDir = join(colDir, loc, slug);
314
+ const targetPath = join(targetDir, `index.${ext}`);
315
+ mkdirSync(targetDir, { recursive: true });
316
+ writeFileSync(targetPath, content, 'utf8');
317
+ created.push(relative(ROOT_DIR, targetPath));
318
+ }
247
319
 
248
320
  console.log('');
249
- ok(`Created: ${relative(ROOT_DIR, targetPath)}`);
321
+ for (const p of created) ok(`Created: ${p}`);
250
322
 
251
323
  // ─── Summary ──────────────────────────────────────────────────────────────
324
+ const multilingual = targetLocales.some(Boolean);
252
325
  console.log('');
253
326
  console.log(`${G}${B}Done!${X}`);
254
327
  console.log('');
255
328
  console.log(` ${B}Next steps:${X}`);
256
- console.log(` 1. Open ${B}usr/content/${collection}/${slug}/index.${ext}${X} and write your content`);
329
+ if (multilingual) {
330
+ console.log(` 1. Open the new file${created.length > 1 ? 's' : ''} and write ${created.length > 1 ? 'each translation' : 'your content'}:`);
331
+ for (const p of created) console.log(` ${B}${p}${X}`);
332
+ } else {
333
+ console.log(` 1. Open ${B}${created[0]}${X} and write your content`);
334
+ }
257
335
  console.log(` 2. Set ${B}draft: false${X} when the content is ready to publish`);
258
- console.log(` 3. Run ${B}npm run dev${X} and visit ${B}/${collection}/${slug}${X}`);
336
+ if (multilingual) {
337
+ console.log(` 3. Run ${B}npm run dev${X} and visit the new page (e.g. ${B}/${targetLocales[0]}/${collection}/${slug}${X}, depending on your locale routing)`);
338
+ } else {
339
+ console.log(` 3. Run ${B}npm run dev${X} and visit ${B}/${collection}/${slug}${X}`);
340
+ }
259
341
  console.log('');
260
342
  }
261
343
 
package/bin/create.mjs ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // create.mjs — Guided scaffold of a new s:CMS site.
3
+ // Usage: npx --package=@lad-sapienza/scms-core scms-create [directory]
4
+
5
+ import { createInterface } from 'node:readline';
6
+ import {
7
+ existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync,
8
+ } from 'node:fs';
9
+ import { join, dirname, relative } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { spawnSync, execSync } from 'node:child_process';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const PACKAGE_ROOT = join(__dirname, '..');
15
+ const TEMPLATE_DIR = join(PACKAGE_ROOT, 'template');
16
+ const OWN_PACKAGE = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
17
+
18
+ // ─── Color helpers ────────────────────────────────────────────────────────────
19
+ const G = '\x1b[32m', Y = '\x1b[33m', R = '\x1b[31m';
20
+ const B = '\x1b[1m', X = '\x1b[0m';
21
+ const ok = (...a) => console.log(`${G}✔${X} ${a.join(' ')}`);
22
+ const warn = (...a) => console.log(`${Y}⚠${X} ${a.join(' ')}`);
23
+ const error = (...a) => { console.error(`${R}✖${X} ${a.join(' ')}`); process.exit(1); };
24
+
25
+ // ─── Readline helper ──────────────────────────────────────────────────────────
26
+ // Same async-iterator pattern as add-collection.mjs / add-content.mjs — avoids
27
+ // the readline hang on piped/pasted (non-TTY) input those scripts already fixed.
28
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
29
+ rl.on('SIGINT', () => { console.log(''); process.exit(0); });
30
+ const rlIterator = rl[Symbol.asyncIterator]();
31
+ const ask = async (prompt) => {
32
+ process.stdout.write(prompt);
33
+ const { value, done } = await rlIterator.next();
34
+ if (done) { console.log(''); process.exit(0); }
35
+ return value;
36
+ };
37
+
38
+ /** Strip a leading semver range operator (^, ~, >=, etc.) to get a bare version. */
39
+ const bareVersion = (range) => range.replace(/^[^\d]*/, '');
40
+
41
+ /** Recursively copy `template/`, substituting placeholders and stripping .tmpl suffixes. */
42
+ function copyTemplate(srcDir, destDir, replacements) {
43
+ mkdirSync(destDir, { recursive: true });
44
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
45
+ const srcPath = join(srcDir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ copyTemplate(srcPath, join(destDir, entry.name), replacements);
48
+ continue;
49
+ }
50
+ const isTemplate = entry.name.endsWith('.tmpl');
51
+ const destName = isTemplate ? entry.name.slice(0, -'.tmpl'.length) : entry.name;
52
+ const destPath = join(destDir, destName);
53
+ let content = readFileSync(srcPath, 'utf8');
54
+ if (isTemplate) {
55
+ for (const [placeholder, value] of Object.entries(replacements)) {
56
+ content = content.replaceAll(placeholder, value);
57
+ }
58
+ }
59
+ writeFileSync(destPath, content, 'utf8');
60
+ }
61
+ }
62
+
63
+ async function main() {
64
+ console.log('');
65
+ console.log(`${B}Create a new s:CMS site${X}`);
66
+ console.log('');
67
+
68
+ // ─── Prompt: target directory ────────────────────────────────────────────
69
+ const argDir = process.argv[2];
70
+ let targetDir;
71
+ if (argDir) {
72
+ targetDir = argDir;
73
+ } else {
74
+ while (true) {
75
+ const raw = (await ask(`${B}Directory${X} (will be created): `)).trim();
76
+ if (!raw) { warn('Please enter a directory name.'); continue; }
77
+ targetDir = raw;
78
+ break;
79
+ }
80
+ }
81
+ const targetPath = join(process.cwd(), targetDir);
82
+ if (existsSync(targetPath) && readdirSync(targetPath).length > 0) {
83
+ error(`${targetDir}/ already exists and is not empty.`);
84
+ }
85
+
86
+ // ─── Prompt: site info ────────────────────────────────────────────────────
87
+ const dirBasedTitle = targetDir.split('/').pop().replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
88
+ const title = (await ask(`${B}Site title${X} [${dirBasedTitle}]: `)).trim() || dirBasedTitle;
89
+
90
+ const description = (await ask(`${B}Description${X}: `)).trim() || `A site built with s:CMS.`;
91
+
92
+ let gitAuthor = '';
93
+ try {
94
+ gitAuthor = execSync('git config user.name', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
95
+ } catch { /* git not available or no config */ }
96
+ const author = (await ask(`${B}Author${X}${gitAuthor ? ` [${gitAuthor}]` : ''}: `)).trim() || gitAuthor;
97
+
98
+ const siteUrl = (await ask(`${B}Site URL${X} (for sitemap/canonical URLs, e.g. https://example.com): `)).trim();
99
+
100
+ const installNow = (await ask(`${B}Run npm install now?${X} [Y/n]: `)).trim().toLowerCase() !== 'n';
101
+
102
+ rl.close();
103
+
104
+ // ─── Copy + customize the template ────────────────────────────────────────
105
+ const npmPackageName = targetDir.split('/').pop().toLowerCase().replace(/[^a-z0-9-]/g, '-');
106
+ const peers = OWN_PACKAGE.peerDependencies || {};
107
+
108
+ const replacements = {
109
+ __NAME__: npmPackageName,
110
+ __TITLE__: title,
111
+ __DESCRIPTION__: description,
112
+ __AUTHOR__: author,
113
+ __SITE_URL__: siteUrl,
114
+ __SCMS_VERSION__: OWN_PACKAGE.version,
115
+ __ASTRO_VERSION__: bareVersion(peers.astro ?? '7.0.0'),
116
+ __REACT_VERSION__: bareVersion(peers.react ?? '19.0.0'),
117
+ __REACT_DOM_VERSION__: bareVersion(peers['react-dom'] ?? '19.0.0'),
118
+ __MAPLIBRE_VERSION__: bareVersion(peers['maplibre-gl'] ?? '5.0.0'),
119
+ __TYPES_REACT_VERSION__: bareVersion(peers['@types/react'] ?? '19.0.0'),
120
+ __TYPES_REACT_DOM_VERSION__: bareVersion(peers['@types/react-dom'] ?? '19.0.0'),
121
+ };
122
+
123
+ copyTemplate(TEMPLATE_DIR, targetPath, replacements);
124
+ ok(`Created ${relative(process.cwd(), targetPath) || '.'}/`);
125
+
126
+ // ─── Install ───────────────────────────────────────────────────────────────
127
+ if (installNow) {
128
+ console.log('');
129
+ console.log(`${B}Installing dependencies...${X}`);
130
+ const result = spawnSync('npm', ['install'], { cwd: targetPath, stdio: 'inherit' });
131
+ if (result.status !== 0) {
132
+ warn('npm install failed — run it yourself once you\'ve reviewed the error above.');
133
+ } else {
134
+ ok('Dependencies installed');
135
+ }
136
+ }
137
+
138
+ // ─── Summary ──────────────────────────────────────────────────────────────
139
+ console.log('');
140
+ console.log(`${G}${B}Site scaffolded successfully!${X}`);
141
+ console.log('');
142
+ console.log(` ${B}Next steps:${X}`);
143
+ console.log(` 1. cd ${targetDir}`);
144
+ if (!installNow) console.log(` 2. npm install`);
145
+ console.log(` ${installNow ? 2 : 3}. npm run dev`);
146
+ console.log('');
147
+ }
148
+
149
+ main().catch((err) => error(String(err)));