@lad-sapienza/scms-core 0.1.0-alpha.1 → 0.1.0-alpha.4
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 +16 -6
- package/bin/add-collection.mjs +37 -23
- package/bin/add-content.mjs +14 -7
- 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 +1 -1
- 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 +8 -6
- package/components/Map/Map.tsx +3 -1
- 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/README.md +3 -3
- package/components/Record/RecordFetcher.tsx +5 -2
- package/components/Record/useRecordFetcher.ts +2 -2
- package/components/TableOfContents/TableOfContents.astro +1 -2
- package/components/ThemeToggle.tsx +145 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +46 -27
- 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 +5 -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
|
});
|
|
@@ -65,16 +65,26 @@ import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoa
|
|
|
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
67
|
|
|
68
|
+
## Creating a new site
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx --package=@lad-sapienza/scms-core scms-create my-site
|
|
72
|
+
cd my-site
|
|
73
|
+
npm run dev
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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`.
|
|
77
|
+
|
|
68
78
|
## Scaffolding CLI
|
|
69
79
|
|
|
70
|
-
This package also ships the interactive scaffolding tools
|
|
80
|
+
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
81
|
|
|
72
82
|
```bash
|
|
73
|
-
npx scms-add-collection # scaffold a new Astro content collection under
|
|
83
|
+
npx scms-add-collection # scaffold a new Astro content collection under src/
|
|
74
84
|
npx scms-add-content # add a new content file to an existing collection
|
|
75
85
|
```
|
|
76
86
|
|
|
77
|
-
A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json
|
|
87
|
+
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).
|
|
78
88
|
|
|
79
89
|
## Development
|
|
80
90
|
|
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
|
|
|
@@ -465,14 +470,14 @@ async function main() {
|
|
|
465
470
|
ok(`Type: ${colType}`);
|
|
466
471
|
rl.close();
|
|
467
472
|
|
|
468
|
-
// ─── 1. Update
|
|
473
|
+
// ─── 1. Update src/content.config.ts ──────────────────────────────────────
|
|
469
474
|
const newBlock =
|
|
470
475
|
`\n// Schema for ${colName} entries [${colType} type]\n` +
|
|
471
476
|
`// TODO: customize the schema fields to match your content structure\n` +
|
|
472
477
|
`const ${varName} = defineCollection({\n` +
|
|
473
478
|
` loader: glob({\n` +
|
|
474
479
|
` pattern: '**/*.{md,mdx}',\n` +
|
|
475
|
-
` base: './
|
|
480
|
+
` base: './src/content/${colName}',\n` +
|
|
476
481
|
` }),\n` +
|
|
477
482
|
` schema: z.object({\n` +
|
|
478
483
|
`${SCHEMA[colType]}\n` +
|
|
@@ -480,20 +485,29 @@ async function main() {
|
|
|
480
485
|
`});\n`;
|
|
481
486
|
|
|
482
487
|
const MARKER = '// Export all collections';
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
488
|
+
const cfg = readFileSync(CONFIG_FILE, 'utf8');
|
|
489
|
+
const markerIndex = cfg.indexOf(MARKER);
|
|
490
|
+
if (markerIndex === -1) error(`Marker '${MARKER}' not found in config.ts`);
|
|
491
|
+
|
|
492
|
+
// Only ever scan/edit the file from the marker onward — the real `export
|
|
493
|
+
// const collections = {...}` always lives there. Scanning the whole file
|
|
494
|
+
// for that pattern is fragile: doc-comment examples earlier in the file
|
|
495
|
+
// (e.g. in the template this script ships against) can contain the exact
|
|
496
|
+
// same text, and a plain first-match regex would silently edit the
|
|
497
|
+
// comment instead of the real export.
|
|
498
|
+
const before = cfg.slice(0, markerIndex);
|
|
499
|
+
const after = (newBlock + cfg.slice(markerIndex)).replace(
|
|
500
|
+
// [^}]* (zero or more), not [^}]+ — a fresh scms-create site starts with
|
|
501
|
+
// an empty `export const collections = {};`, and `+` would fail to match it.
|
|
502
|
+
/(export const collections\s*=\s*\{)([^}]*)(\};)/s,
|
|
489
503
|
(_, open, inner, close) => `${open}${inner.trimEnd()}\n ${toObjectKey(colName)}: ${varName},\n${close}`
|
|
490
504
|
);
|
|
491
|
-
writeFileSync(CONFIG_FILE,
|
|
492
|
-
ok('
|
|
505
|
+
writeFileSync(CONFIG_FILE, before + after, 'utf8');
|
|
506
|
+
ok('src/content.config.ts updated');
|
|
493
507
|
|
|
494
508
|
// ─── 2. Sample content file ───────────────────────────────────────────────
|
|
495
509
|
const today = new Date().toISOString().slice(0, 10);
|
|
496
|
-
const contentDir = join(ROOT_DIR, '
|
|
510
|
+
const contentDir = join(ROOT_DIR, 'src', 'content', colName);
|
|
497
511
|
mkdirSync(contentDir, { recursive: true });
|
|
498
512
|
|
|
499
513
|
const SAMPLE_NAME = { blog: 'sample-post.md', docs: 'sample-doc.md', generic: 'sample.md' };
|
|
@@ -520,7 +534,7 @@ async function main() {
|
|
|
520
534
|
ok(relative(ROOT_DIR, samplePath));
|
|
521
535
|
|
|
522
536
|
// ─── 3. Page templates ────────────────────────────────────────────────────
|
|
523
|
-
const pagesDir = join(ROOT_DIR, '
|
|
537
|
+
const pagesDir = join(ROOT_DIR, 'src', 'pages', colName);
|
|
524
538
|
mkdirSync(pagesDir, { recursive: true });
|
|
525
539
|
|
|
526
540
|
const sub = (s) => s.replaceAll('__COL__', colName);
|
|
@@ -537,9 +551,9 @@ async function main() {
|
|
|
537
551
|
console.log(`${G}${B}Collection '${colName}' scaffolded successfully!${X}`);
|
|
538
552
|
console.log('');
|
|
539
553
|
console.log(` ${B}Next steps:${X}`);
|
|
540
|
-
console.log(` 1. Open ${B}
|
|
541
|
-
console.log(` 2. Edit the page files in ${B}
|
|
542
|
-
console.log(` 3. Replace the sample file in ${B}
|
|
554
|
+
console.log(` 1. Open ${B}src/content.config.ts${X} and review the schema for '${colName}'`);
|
|
555
|
+
console.log(` 2. Edit the page files in ${B}src/pages/${colName}/${X} to customise the UI`);
|
|
556
|
+
console.log(` 3. Replace the sample file in ${B}src/content/${colName}/${X} with real content`);
|
|
543
557
|
console.log(` 4. Run ${B}npm run dev${X} and visit ${B}/${colName}${X}`);
|
|
544
558
|
console.log('');
|
|
545
559
|
}
|
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.
|
|
@@ -71,7 +78,7 @@ function hasMdx(dir) {
|
|
|
71
78
|
/** Extract z.object field lines for a named collection from config.ts */
|
|
72
79
|
function parseSchemaFields(src, colName) {
|
|
73
80
|
const pat = new RegExp(
|
|
74
|
-
`defineCollection\\(\\{.*?base\\s*:\\s*['"]\\./
|
|
81
|
+
`defineCollection\\(\\{.*?base\\s*:\\s*['"]\\./src/content/${colName}['"].*?z\\.object\\(\\{(.*?)\\}\\)`,
|
|
75
82
|
's'
|
|
76
83
|
);
|
|
77
84
|
const m = src.match(pat);
|
|
@@ -177,7 +184,7 @@ async function main() {
|
|
|
177
184
|
continue;
|
|
178
185
|
}
|
|
179
186
|
if (existsSync(join(colDir, raw, `index.${ext}`))) {
|
|
180
|
-
warn(`File already exists:
|
|
187
|
+
warn(`File already exists: src/content/${collection}/${raw}/index.${ext}`);
|
|
181
188
|
continue;
|
|
182
189
|
}
|
|
183
190
|
slug = raw;
|
|
@@ -253,7 +260,7 @@ async function main() {
|
|
|
253
260
|
console.log(`${G}${B}Done!${X}`);
|
|
254
261
|
console.log('');
|
|
255
262
|
console.log(` ${B}Next steps:${X}`);
|
|
256
|
-
console.log(` 1. Open ${B}
|
|
263
|
+
console.log(` 1. Open ${B}src/content/${collection}/${slug}/index.${ext}${X} and write your content`);
|
|
257
264
|
console.log(` 2. Set ${B}draft: false${X} when the content is ready to publish`);
|
|
258
265
|
console.log(` 3. Run ${B}npm run dev${X} and visit ${B}/${collection}/${slug}${X}`);
|
|
259
266
|
console.log('');
|
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)));
|
package/components/BSNavbar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect } from "react";
|
|
2
2
|
import { Menu, X, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useTranslation } from "./i18n";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A single navigation link entry.
|
|
@@ -43,7 +44,7 @@ interface CssClasses {
|
|
|
43
44
|
* Active state is computed internally from `currentPath` — no need to set
|
|
44
45
|
* `isActive` on individual menu items.
|
|
45
46
|
*
|
|
46
|
-
* Menu data can be loaded from a YAML file (e.g. `
|
|
47
|
+
* Menu data can be loaded from a YAML file (e.g. `src/content/data/menu.yaml`)
|
|
47
48
|
* and passed straight to `menuItems`.
|
|
48
49
|
*
|
|
49
50
|
* Must be hydrated with `client:load` when used inside an Astro component.
|
|
@@ -63,7 +64,7 @@ interface CssClasses {
|
|
|
63
64
|
* />
|
|
64
65
|
* ```
|
|
65
66
|
*
|
|
66
|
-
* @example YAML menu structure (`
|
|
67
|
+
* @example YAML menu structure (`src/content/data/menu.yaml`)
|
|
67
68
|
* ```yaml
|
|
68
69
|
* - href: /
|
|
69
70
|
* label: Home
|
|
@@ -100,7 +101,10 @@ interface BSNavbarProps {
|
|
|
100
101
|
function isItemActive(item: MenuItem, currentPath: string): boolean {
|
|
101
102
|
const target = item.match ?? item.href;
|
|
102
103
|
if (target) {
|
|
103
|
-
|
|
104
|
+
// A target ending in "/" is an index page (e.g. "/" itself, or a
|
|
105
|
+
// locale-prefixed home like "/it/") — match it exactly, otherwise
|
|
106
|
+
// startsWith would make it "active" for every page beneath it too.
|
|
107
|
+
const active = target.endsWith("/") ? currentPath === target : currentPath.startsWith(target);
|
|
104
108
|
if (active) return true;
|
|
105
109
|
}
|
|
106
110
|
return item.children?.some(child => isItemActive(child, currentPath)) ?? false;
|
|
@@ -159,11 +163,7 @@ const DropdownItem: React.FC<{ item: MenuItem; currentPath: string }> = ({ item,
|
|
|
159
163
|
</button>
|
|
160
164
|
{isOpen && (
|
|
161
165
|
<ul
|
|
162
|
-
className=
|
|
163
|
-
style={openLeft
|
|
164
|
-
? { position: "absolute", right: "100%", left: "auto", top: 0, minWidth: "12rem" }
|
|
165
|
-
: { position: "absolute", left: "100%", top: 0, minWidth: "12rem" }
|
|
166
|
-
}
|
|
166
|
+
className={`dropdown-menu show scms-nested-dropdown${openLeft ? " scms-nested-dropdown--left" : ""}`}
|
|
167
167
|
>
|
|
168
168
|
{item.children!.map((child, i) => (
|
|
169
169
|
<DropdownItem key={child.href ?? i} item={child} currentPath={currentPath} />
|
|
@@ -229,10 +229,44 @@ const NavItem: React.FC<{ item: MenuItem; currentPath: string; liClass?: string
|
|
|
229
229
|
// BSNavbar — root component
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
const BSNavbar: React.FC<BSNavbarProps> = ({ menuItems, currentPath = "/", cssClasses = {}, brand }) => {
|
|
232
|
+
const t = useTranslation();
|
|
232
233
|
const [isOpen, setIsOpen] = useState(false);
|
|
233
234
|
|
|
234
235
|
return (
|
|
235
236
|
<nav className={`navbar navbar-expand-md ${cssClasses.nav ?? ""}`}>
|
|
237
|
+
{/*
|
|
238
|
+
Third-level (and deeper) dropdowns cascade sideways on desktop
|
|
239
|
+
(position: absolute; left/right: 100% — see DropdownItem below).
|
|
240
|
+
Below Bootstrap's md breakpoint the navbar collapses into a
|
|
241
|
+
single narrow column (navbar-expand-md), and a sideways flyout
|
|
242
|
+
has nowhere to go but off the edge of the screen — unreachable,
|
|
243
|
+
not just visually broken. Below md, this flips the same markup
|
|
244
|
+
to flow inline instead, indented to show the hierarchy.
|
|
245
|
+
*/}
|
|
246
|
+
<style>{`
|
|
247
|
+
@media (max-width: 767.98px) {
|
|
248
|
+
.scms-nested-dropdown {
|
|
249
|
+
position: static !important;
|
|
250
|
+
width: 100% !important;
|
|
251
|
+
min-width: 0 !important;
|
|
252
|
+
box-shadow: none !important;
|
|
253
|
+
border: none !important;
|
|
254
|
+
padding-left: 1rem;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
@media (min-width: 768px) {
|
|
258
|
+
.scms-nested-dropdown {
|
|
259
|
+
position: absolute;
|
|
260
|
+
left: 100%;
|
|
261
|
+
top: 0;
|
|
262
|
+
min-width: 12rem;
|
|
263
|
+
}
|
|
264
|
+
.scms-nested-dropdown.scms-nested-dropdown--left {
|
|
265
|
+
left: auto;
|
|
266
|
+
right: 100%;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
`}</style>
|
|
236
270
|
<div className="container">
|
|
237
271
|
{brand && (
|
|
238
272
|
<a className={`navbar-brand ${brand.cssClasses ?? ""}`} href={brand.link}>
|
|
@@ -245,9 +279,9 @@ const BSNavbar: React.FC<BSNavbarProps> = ({ menuItems, currentPath = "/", cssCl
|
|
|
245
279
|
onClick={() => setIsOpen(prev => !prev)}
|
|
246
280
|
aria-controls="navbarCollapse"
|
|
247
281
|
aria-expanded={isOpen}
|
|
248
|
-
aria-label=
|
|
282
|
+
aria-label={t('navbar.toggleNavigation')}
|
|
249
283
|
>
|
|
250
|
-
{isOpen ? <X size={24}
|
|
284
|
+
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
|
251
285
|
</button>
|
|
252
286
|
<div className={`collapse navbar-collapse${isOpen ? " show" : ""}`} id="navbarCollapse">
|
|
253
287
|
<ul className={`navbar-nav ${cssClasses.ul ?? ""}`}>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* headless data-loaders (they render nothing; they call onDataLoad/onLoadingChange/
|
|
18
18
|
* onError) for building a fully custom table UI — they are not children of
|
|
19
19
|
* <DataTb>, which has no children/composition API. See the "Headless Source
|
|
20
|
-
* Components" section of
|
|
20
|
+
* Components" section of DataTb/README.md.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
// Re-export for convenience. `DataTb` itself is deliberately not re-exported
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ColumnFiltersState,
|
|
18
18
|
} from '@tanstack/react-table';
|
|
19
19
|
import { fetchData } from '../../utils/data-fetcher';
|
|
20
|
+
import { useTranslation } from '../i18n';
|
|
20
21
|
import type { DataRow, DataTbProps } from './types';
|
|
21
22
|
import {
|
|
22
23
|
autoDetectColumns,
|
|
@@ -33,13 +34,18 @@ export function DataTb({
|
|
|
33
34
|
initialSort,
|
|
34
35
|
className = '',
|
|
35
36
|
source,
|
|
36
|
-
loadingMessage
|
|
37
|
-
emptyMessage
|
|
38
|
-
errorMessage
|
|
37
|
+
loadingMessage,
|
|
38
|
+
emptyMessage,
|
|
39
|
+
errorMessage,
|
|
39
40
|
truncateContent = true,
|
|
40
41
|
truncateMaxWidth = '20rem',
|
|
41
42
|
contentSize = 'sm',
|
|
42
43
|
}: DataTbProps) {
|
|
44
|
+
const t = useTranslation();
|
|
45
|
+
const resolvedLoadingMessage = loadingMessage ?? t('dataTb.loading');
|
|
46
|
+
const resolvedEmptyMessage = emptyMessage ?? t('dataTb.empty');
|
|
47
|
+
const resolvedErrorMessage = errorMessage ?? t('dataTb.error');
|
|
48
|
+
|
|
43
49
|
const [data, setData] = useState<DataRow[]>([]);
|
|
44
50
|
const [loading, setLoading] = useState(true);
|
|
45
51
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -148,7 +154,7 @@ export function DataTb({
|
|
|
148
154
|
return (
|
|
149
155
|
<div className="datatb-container">
|
|
150
156
|
<div className="datatb-loading p-4 text-center text-secondary">
|
|
151
|
-
{
|
|
157
|
+
{resolvedLoadingMessage}
|
|
152
158
|
</div>
|
|
153
159
|
</div>
|
|
154
160
|
);
|
|
@@ -159,7 +165,7 @@ export function DataTb({
|
|
|
159
165
|
return (
|
|
160
166
|
<div className="datatb-container">
|
|
161
167
|
<div className="datatb-error p-4 text-center text-danger">
|
|
162
|
-
{
|
|
168
|
+
{resolvedErrorMessage}: {error.message}
|
|
163
169
|
</div>
|
|
164
170
|
</div>
|
|
165
171
|
);
|
|
@@ -170,7 +176,7 @@ export function DataTb({
|
|
|
170
176
|
return (
|
|
171
177
|
<div className="datatb-container">
|
|
172
178
|
<div className="datatb-empty p-4 text-center text-secondary">
|
|
173
|
-
{
|
|
179
|
+
{resolvedEmptyMessage}
|
|
174
180
|
</div>
|
|
175
181
|
</div>
|
|
176
182
|
);
|
|
@@ -178,6 +184,12 @@ export function DataTb({
|
|
|
178
184
|
|
|
179
185
|
return (
|
|
180
186
|
<div className={`datatb-container ${className}`}>
|
|
187
|
+
<style>{`
|
|
188
|
+
.datatb-thead {
|
|
189
|
+
--bs-table-bg: var(--bs-tertiary-bg);
|
|
190
|
+
--bs-table-color: var(--bs-emphasis-color);
|
|
191
|
+
}
|
|
192
|
+
`}</style>
|
|
181
193
|
|
|
182
194
|
{/* Search input */}
|
|
183
195
|
{searchable && (
|
|
@@ -186,7 +198,7 @@ export function DataTb({
|
|
|
186
198
|
type="text"
|
|
187
199
|
value={globalFilter ?? ''}
|
|
188
200
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
189
|
-
placeholder=
|
|
201
|
+
placeholder={t('dataTb.searchPlaceholder')}
|
|
190
202
|
className="form-control form-control-sm"
|
|
191
203
|
style={{ maxWidth: '24rem' }}
|
|
192
204
|
/>
|
|
@@ -196,7 +208,7 @@ export function DataTb({
|
|
|
196
208
|
{/* Table */}
|
|
197
209
|
<div className="datatb-table-wrapper table-responsive border rounded shadow-sm">
|
|
198
210
|
<table className="datatb-table table table-sm table-hover mb-0">
|
|
199
|
-
<thead className="
|
|
211
|
+
<thead className="datatb-thead">
|
|
200
212
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
201
213
|
<tr key={headerGroup.id}>
|
|
202
214
|
{headerGroup.headers.map((header, index) => (
|
|
@@ -309,7 +321,10 @@ export function DataTb({
|
|
|
309
321
|
|
|
310
322
|
<div className="d-flex align-items-center gap-2 flex-nowrap">
|
|
311
323
|
<span className="small text-secondary text-nowrap">
|
|
312
|
-
|
|
324
|
+
{t('dataTb.paginationSummary', {
|
|
325
|
+
page: table.getState().pagination.pageIndex + 1,
|
|
326
|
+
totalPages: table.getPageCount(),
|
|
327
|
+
})}
|
|
313
328
|
</span>
|
|
314
329
|
|
|
315
330
|
{paginationConfig.showPageSize !== false && (
|
|
@@ -330,7 +345,7 @@ export function DataTb({
|
|
|
330
345
|
)}
|
|
331
346
|
|
|
332
347
|
<span className="small text-secondary text-nowrap">
|
|
333
|
-
({data.length}
|
|
348
|
+
{t('dataTb.rowsTotal', { count: data.length })}
|
|
334
349
|
</span>
|
|
335
350
|
</div>
|
|
336
351
|
</div>
|
|
@@ -11,7 +11,7 @@ A powerful, flexible data table component for Astro with support for multiple da
|
|
|
11
11
|
- 🎨 **Auto-detection**: Automatically detects columns from data
|
|
12
12
|
- ⚙️ **Customizable**: Override columns, formatting, and rendering
|
|
13
13
|
- 🎯 **TypeScript**: Full type safety
|
|
14
|
-
- 💅 **Bootstrap Styled**: Uses the same Bootstrap 5 classes as the rest of s:CMS, themeable via `
|
|
14
|
+
- 💅 **Bootstrap Styled**: Uses the same Bootstrap 5 classes as the rest of s:CMS, themeable via your site's `src/styles/global.css`
|
|
15
15
|
|
|
16
16
|
## Basic Usage
|
|
17
17
|
|
|
@@ -272,4 +272,4 @@ Data fetching is handled internally based on the `source`/`csv`/`json`/`api`/`di
|
|
|
272
272
|
|
|
273
273
|
## Examples
|
|
274
274
|
|
|
275
|
-
See the [DataTb docs page](
|
|
275
|
+
See the [DataTb docs page](https://scms.lad-sapienza.it/docs/components/datatb) on the s:CMS demo site for live, interactive examples of every prop and source type.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* for how auto-loading works without each caller needing its own glob).
|
|
8
8
|
*
|
|
9
9
|
* <Gallery /> colocated: ./gallery/ next to this page/content file
|
|
10
|
-
* <Gallery name="scavi-2024" /> shared:
|
|
10
|
+
* <Gallery name="scavi-2024" /> shared: src/galleries/scavi-2024/
|
|
11
11
|
* <Gallery images={[...]} /> explicit: pass images directly (remote, custom order, ...)
|
|
12
12
|
*/
|
|
13
13
|
import { Gallery as GalleryClient } from './Gallery';
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Gallery as PhotoSwipeGallery, Item } from 'react-photoswipe-gallery';
|
|
9
9
|
import type { GalleryImage } from './types';
|
|
10
|
+
import { useTranslation } from '../i18n';
|
|
10
11
|
import 'photoswipe/dist/photoswipe.css';
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
@@ -43,6 +44,8 @@ export function Gallery({
|
|
|
43
44
|
columns = { min: 200, max: 1 },
|
|
44
45
|
className = '',
|
|
45
46
|
}: Props) {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
|
|
46
49
|
if (images.length === 0) {
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
@@ -112,7 +115,7 @@ export function Gallery({
|
|
|
112
115
|
tabIndex={0}
|
|
113
116
|
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: '4px' }}
|
|
114
117
|
data-caption={image.caption || image.alt}
|
|
115
|
-
aria-label={
|
|
118
|
+
aria-label={t('gallery.openImage', { alt: image.alt })}
|
|
116
119
|
>
|
|
117
120
|
<img
|
|
118
121
|
src={image.thumb}
|