@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.
- package/README.md +20 -7
- package/bin/add-collection.mjs +73 -28
- package/bin/add-content.mjs +100 -18
- package/bin/create.mjs +149 -0
- package/components/BSNavbar.tsx +44 -10
- package/components/DataTb/DataTb.astro +1 -1
- package/components/DataTb/DataTb.tsx +25 -10
- package/components/DataTb/README.md +2 -2
- package/components/Gallery/Gallery.astro +2 -26
- package/components/Gallery/Gallery.tsx +4 -1
- package/components/Gallery/README.md +4 -4
- package/components/Gallery/__tests__/galleryUtils.test.ts +30 -9
- package/components/Gallery/galleryUtils.ts +42 -5
- package/components/Gallery/types.ts +1 -1
- package/components/LocaleSwitcher.tsx +132 -0
- package/components/Map/LayerControl.tsx +36 -33
- package/components/Map/Map.tsx +169 -115
- package/components/Map/MapMdx.tsx +10 -4
- package/components/Map/README.md +201 -0
- package/components/Map/Search/SearchUI.tsx +4 -2
- package/components/Map/Search/SearchUIAdvanced.tsx +40 -15
- package/components/Map/Search/SearchUISimple.tsx +11 -9
- package/components/Map/index.ts +1 -1
- package/components/Map/types.ts +42 -10
- package/components/Map/utils.ts +44 -0
- package/components/README.md +3 -3
- package/components/Record/RecordFetcher.tsx +5 -2
- package/components/Record/useRecordFetcher.ts +2 -2
- package/components/RedirectPage/README.md +119 -0
- package/components/RedirectPage/RedirectPage.astro +34 -0
- package/components/TableOfContents/TableOfContents.astro +1 -2
- package/components/ThemeToggle.tsx +145 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +47 -28
- package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +16 -7
- package/components/i18n/I18nInit.astro +36 -0
- package/components/i18n/__tests__/translator.test.ts +87 -0
- package/components/i18n/builtinDictionary.ts +127 -0
- package/components/i18n/index.ts +7 -0
- package/components/i18n/translator.ts +71 -0
- package/components/i18n/useTranslation.ts +38 -0
- package/components/i18n/window.ts +33 -0
- package/index.ts +6 -0
- package/integrations/__tests__/galleryIntegration.test.ts +13 -13
- package/integrations/contentAssetsIntegration.ts +5 -5
- package/integrations/galleryIntegration.ts +7 -7
- package/package.json +4 -2
- package/scms.ts +28 -3
- package/template/.github/workflows/deploy.yml +51 -0
- package/template/README.md.tmpl +37 -0
- package/template/astro.config.mjs +26 -0
- package/template/package.json.tmpl +31 -0
- package/template/src/content.config.ts +27 -0
- package/template/src/layouts/BaseLayout.astro +64 -0
- package/template/src/pages/index.astro +14 -0
- package/template/src/styles/global.css +8 -0
- package/template/src/user.config.mjs.tmpl +34 -0
- package/template/tsconfig.json +10 -0
- 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: '
|
|
33
|
-
pagesDir: '
|
|
34
|
-
galleriesDir: '
|
|
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
|
|
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
|
|
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
|
|
package/bin/add-collection.mjs
CHANGED
|
@@ -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, '
|
|
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>
|
|
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>
|
|
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>
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
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
|
|
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: './
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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,
|
|
492
|
-
ok('
|
|
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, '
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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, '
|
|
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}
|
|
541
|
-
console.log(` 2. Edit the page files in ${B}
|
|
542
|
-
|
|
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
|
}
|
package/bin/add-content.mjs
CHANGED
|
@@ -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, '
|
|
16
|
-
const CONTENT_BASE = join(ROOT_DIR, '
|
|
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
|
-
/**
|
|
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
|
|
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*['"]\\./
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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)));
|