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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -465,14 +470,14 @@ async function main() {
465
470
  ok(`Type: ${colType}`);
466
471
  rl.close();
467
472
 
468
- // ─── 1. Update usr/content.config.ts ──────────────────────────────────────
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: './usr/content/${colName}',\n` +
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
- 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,
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, cfg, 'utf8');
492
- ok('usr/content.config.ts updated');
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, 'usr', 'content', colName);
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, 'usr', 'pages', colName);
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}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`);
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
  }
@@ -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.
@@ -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*['"]\\./usr/content/${colName}['"].*?z\\.object\\(\\{(.*?)\\}\\)`,
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: usr/content/${collection}/${raw}/index.${ext}`);
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}usr/content/${collection}/${slug}/index.${ext}${X} and write your content`);
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)));
@@ -43,7 +43,7 @@ interface CssClasses {
43
43
  * Active state is computed internally from `currentPath` — no need to set
44
44
  * `isActive` on individual menu items.
45
45
  *
46
- * Menu data can be loaded from a YAML file (e.g. `usr/content/data/menu.yaml`)
46
+ * Menu data can be loaded from a YAML file (e.g. `src/content/data/menu.yaml`)
47
47
  * and passed straight to `menuItems`.
48
48
  *
49
49
  * Must be hydrated with `client:load` when used inside an Astro component.
@@ -63,7 +63,7 @@ interface CssClasses {
63
63
  * />
64
64
  * ```
65
65
  *
66
- * @example YAML menu structure (`usr/content/data/menu.yaml`)
66
+ * @example YAML menu structure (`src/content/data/menu.yaml`)
67
67
  * ```yaml
68
68
  * - href: /
69
69
  * label: Home
@@ -2,23 +2,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
3
  const manifest = {
4
4
  pagesImages: {
5
- '/usr/pages/my-page/gallery/photo1.jpg': { default: { src: '/_astro/photo1.abc123.jpg', width: 1200, height: 800 } },
5
+ '/src/pages/my-page/gallery/photo1.jpg': { default: { src: '/_astro/photo1.abc123.jpg', width: 1200, height: 800 } },
6
6
  },
7
7
  contentImages: {
8
- '/usr/content/blog/my-article/gallery/photo2.jpg': { default: { src: '/_astro/photo2.def456.jpg', width: 1000, height: 600 } },
9
- '/usr/content/blog/other-article/gallery/photo3.jpg': { default: { src: '/_astro/photo3.ghi789.jpg', width: 900, height: 500 } },
8
+ '/src/content/blog/my-article/gallery/photo2.jpg': { default: { src: '/_astro/photo2.def456.jpg', width: 1000, height: 600 } },
9
+ '/src/content/blog/other-article/gallery/photo3.jpg': { default: { src: '/_astro/photo3.ghi789.jpg', width: 900, height: 500 } },
10
10
  },
11
11
  sharedImages: {
12
- '/usr/galleries/scavi-2024/site-a.jpg': { default: { src: '/_astro/site-a.jkl000.jpg', width: 800, height: 600 } },
13
- '/usr/galleries/scavi-2024/site-b_1920.jpg': { default: { src: '/_astro/site-b_1920.mno111.jpg', width: 800, height: 600 } },
14
- '/usr/galleries/other-name/site-c.jpg': { default: { src: '/_astro/site-c.pqr222.jpg', width: 800, height: 600 } },
12
+ '/src/galleries/scavi-2024/site-a.jpg': { default: { src: '/_astro/site-a.jkl000.jpg', width: 800, height: 600 } },
13
+ '/src/galleries/scavi-2024/site-b_1920.jpg': { default: { src: '/_astro/site-b_1920.mno111.jpg', width: 800, height: 600 } },
14
+ '/src/galleries/other-name/site-c.jpg': { default: { src: '/_astro/site-c.pqr222.jpg', width: 800, height: 600 } },
15
15
  },
16
16
  pagesCaptions: {},
17
17
  contentCaptions: {
18
- '/usr/content/blog/my-article/gallery/captions.json': { default: { 'photo2.jpg': 'A custom caption' } },
18
+ '/src/content/blog/my-article/gallery/captions.json': { default: { 'photo2.jpg': 'A custom caption' } },
19
19
  },
20
20
  sharedCaptions: {
21
- '/usr/galleries/scavi-2024/captions.json': {
21
+ '/src/galleries/scavi-2024/captions.json': {
22
22
  default: {
23
23
  'site-a.jpg': 'Exact match caption',
24
24
  // Deliberately a different resolution suffix than the real file
@@ -146,7 +146,7 @@ describe('getSharedCaptions', () => {
146
146
  function parseMalformedFixture() {
147
147
  // Reuses the real function against a manifest entry shaped like a
148
148
  // malformed captions.json (nested object instead of Record<string,string>).
149
- (manifest.sharedCaptions as any)['/usr/galleries/broken/captions.json'] = { default: { nested: { not: 'a string' } } };
149
+ (manifest.sharedCaptions as any)['/src/galleries/broken/captions.json'] = { default: { nested: { not: 'a string' } } };
150
150
  return getSharedCaptions('broken');
151
151
  }
152
152
  });
@@ -152,7 +152,7 @@ export function getSharedCaptions(name: string): Record<string, string> | undefi
152
152
  /**
153
153
  * Images from a `gallery/` folder colocated with the current page/content
154
154
  * file, matched by comparing the current URL path against the folder
155
- * structure under usr/pages/ or usr/content/. Applies captions.json from
155
+ * structure under src/pages/ or src/content/. Applies captions.json from
156
156
  * the same folder, if present.
157
157
  *
158
158
  * Known limitation (unchanged from before this redesign): if a content
@@ -173,7 +173,7 @@ export function getColocatedGalleryImages(pathname: string): GalleryImage[] {
173
173
  .map(([filepath, mod]) => moduleToGalleryImage(filepath, mod, captionLookup));
174
174
  }
175
175
 
176
- /** Images from a shared gallery at usr/galleries/<name>/. Applies captions.json from the same folder, if present. */
176
+ /** Images from a shared gallery at src/galleries/<name>/. Applies captions.json from the same folder, if present. */
177
177
  export function getSharedGalleryImages(name: string): GalleryImage[] {
178
178
  const captions = getSharedCaptions(name);
179
179
  const captionLookup = captions ? buildCaptionLookup(captions) : undefined;
@@ -13,7 +13,7 @@ export interface GalleryImage {
13
13
 
14
14
  export interface GalleryProps {
15
15
  /**
16
- * Name of a shared gallery under usr/galleries/<name>/
16
+ * Name of a shared gallery under src/galleries/<name>/
17
17
  * If not provided (and `images` isn't either), auto-loads from the
18
18
  * `gallery/` folder colocated with the current page/content file.
19
19
  */
@@ -31,7 +31,7 @@ export interface UseRecordFetcherResult {
31
31
  *
32
32
  * @example
33
33
  * ```tsx
34
- * // usr/components/RecordView.tsx
34
+ * // src/components/RecordView.tsx
35
35
  * import { useRecordFetcher } from '@lad-sapienza/scms-core/components/Record/useRecordFetcher';
36
36
  *
37
37
  * export default function RecordView() {
@@ -45,7 +45,7 @@ export interface UseRecordFetcherResult {
45
45
  * ```
46
46
  *
47
47
  * ```astro
48
- * <!-- usr/pages/record.astro -->
48
+ * <!-- src/pages/record.astro -->
49
49
  * <RecordView client:load />
50
50
  * ```
51
51
  */
@@ -26,17 +26,17 @@ describe('galleryIntegration', () => {
26
26
  expect(plugin.resolveId('some/other/id')).toBeUndefined();
27
27
  });
28
28
 
29
- it('generates a virtual module with one static glob per source root, using the default usr/ paths', () => {
29
+ it('generates a virtual module with one static glob per source root, using the default src/ paths', () => {
30
30
  const plugin = getVitePlugin();
31
31
  const resolvedId = plugin.resolveId(GALLERY_VIRTUAL_MODULE_ID);
32
32
  const source = plugin.load(resolvedId);
33
33
 
34
- expect(source).toContain("import.meta.glob('/usr/pages/**/gallery/*.");
35
- expect(source).toContain("import.meta.glob('/usr/content/**/gallery/*.");
36
- expect(source).toContain("import.meta.glob('/usr/galleries/*/*.");
37
- expect(source).toContain("import.meta.glob('/usr/pages/**/gallery/captions.json'");
38
- expect(source).toContain("import.meta.glob('/usr/content/**/gallery/captions.json'");
39
- expect(source).toContain("import.meta.glob('/usr/galleries/*/captions.json'");
34
+ expect(source).toContain("import.meta.glob('/src/pages/**/gallery/*.");
35
+ expect(source).toContain("import.meta.glob('/src/content/**/gallery/*.");
36
+ expect(source).toContain("import.meta.glob('/src/galleries/*/*.");
37
+ expect(source).toContain("import.meta.glob('/src/pages/**/gallery/captions.json'");
38
+ expect(source).toContain("import.meta.glob('/src/content/**/gallery/captions.json'");
39
+ expect(source).toContain("import.meta.glob('/src/galleries/*/captions.json'");
40
40
 
41
41
  // Every glob call must use { eager: true } — a lazy glob would return
42
42
  // dynamic import() functions instead of already-resolved image metadata.
@@ -50,14 +50,14 @@ describe('galleryIntegration', () => {
50
50
  expect(plugin.load('/some/unrelated/module.js')).toBeUndefined();
51
51
  });
52
52
 
53
- it('honors custom directory options', () => {
54
- const plugin = getVitePlugin({ pagesDir: 'src/pages', contentDir: 'src/content', galleriesDir: 'src/galleries' });
53
+ it('honors custom directory options, overriding the src/ defaults', () => {
54
+ const plugin = getVitePlugin({ pagesDir: 'custom/pages', contentDir: 'custom/content', galleriesDir: 'custom/galleries' });
55
55
  const source = plugin.load(plugin.resolveId(GALLERY_VIRTUAL_MODULE_ID));
56
56
 
57
- expect(source).toContain("import.meta.glob('/src/pages/**/gallery/*.");
58
- expect(source).toContain("import.meta.glob('/src/content/**/gallery/*.");
59
- expect(source).toContain("import.meta.glob('/src/galleries/*/*.");
60
- expect(source).not.toContain('/usr/');
57
+ expect(source).toContain("import.meta.glob('/custom/pages/**/gallery/*.");
58
+ expect(source).toContain("import.meta.glob('/custom/content/**/gallery/*.");
59
+ expect(source).toContain("import.meta.glob('/custom/galleries/*/*.");
60
+ expect(source).not.toContain('/src/');
61
61
  });
62
62
  });
63
63
 
@@ -2,12 +2,12 @@
2
2
  * contentAssetsIntegration
3
3
  *
4
4
  * Astro integration that serves static assets (images, PDFs, etc.) co-located
5
- * in the content directory (usr/content/ by default) without requiring a
5
+ * in the content directory (src/content/ by default) without requiring a
6
6
  * manual copy step.
7
7
  *
8
8
  * - Dev server: a Vite middleware intercepts requests for static file extensions
9
9
  * and serves them directly from the content directory, mapping URL paths 1:1
10
- * (e.g. /blog/post/image.jpg → usr/content/blog/post/image.jpg).
10
+ * (e.g. /blog/post/image.jpg → src/content/blog/post/image.jpg).
11
11
  *
12
12
  * - Production build: assets are copied from the content directory into dist/
13
13
  * with the same relative path structure, so they are available at the
@@ -23,7 +23,7 @@ import { existsSync, statSync, mkdirSync, copyFileSync, readdirSync, createReadS
23
23
  import { fileURLToPath } from 'node:url';
24
24
 
25
25
  export interface ContentAssetsOptions {
26
- /** Path to the content directory, relative to the project root. Defaults to 'usr/content'. */
26
+ /** Path to the content directory, relative to the project root. Defaults to 'src/content'. */
27
27
  contentDir?: string;
28
28
  }
29
29
 
@@ -60,7 +60,7 @@ function collectAssets(dir: string, results: string[] = []): string[] {
60
60
  }
61
61
 
62
62
  export function contentAssetsIntegration(options: ContentAssetsOptions = {}): AstroIntegration {
63
- const contentDirSegment = options.contentDir ?? join('usr', 'content');
63
+ const contentDirSegment = options.contentDir ?? join('src', 'content');
64
64
  // Resolved from Astro's own config root in astro:config:setup below, rather
65
65
  // than assumed from process.cwd() — more portable if the process is ever
66
66
  // invoked from somewhere other than the project root.
@@ -76,7 +76,7 @@ export function contentAssetsIntegration(options: ContentAssetsOptions = {}): As
76
76
  /**
77
77
  * Dev server: add a Connect middleware that serves static assets directly
78
78
  * from the content directory without requiring them to be copied to
79
- * usr/public/.
79
+ * public/.
80
80
  */
81
81
  'astro:server:setup': ({ server, logger }) => {
82
82
  const contentDir = resolve(projectRoot, contentDirSegment);
@@ -22,23 +22,23 @@ const RESOLVED_VIRTUAL_MODULE_ID = '\0' + GALLERY_VIRTUAL_MODULE_ID;
22
22
  const IMAGE_EXTENSIONS = '{jpg,jpeg,png,gif,webp,avif,JPG,JPEG,PNG,GIF,WEBP,AVIF}';
23
23
 
24
24
  export interface GalleryIntegrationOptions {
25
- /** Path to page files, relative to the project root. Defaults to 'usr/pages'. */
25
+ /** Path to page files, relative to the project root. Defaults to 'src/pages'. */
26
26
  pagesDir?: string;
27
- /** Path to content collections, relative to the project root. Defaults to 'usr/content'. */
27
+ /** Path to content collections, relative to the project root. Defaults to 'src/content'. */
28
28
  contentDir?: string;
29
- /** Path to shared galleries, relative to the project root. Defaults to 'usr/galleries'. */
29
+ /** Path to shared galleries, relative to the project root. Defaults to 'src/galleries'. */
30
30
  galleriesDir?: string;
31
31
  }
32
32
 
33
- /** Normalize a directory segment into a Vite root-relative glob prefix, e.g. 'usr/pages' -> '/usr/pages'. */
33
+ /** Normalize a directory segment into a Vite root-relative glob prefix, e.g. 'src/pages' -> '/src/pages'. */
34
34
  function toRootGlobPrefix(dir: string): string {
35
35
  return '/' + dir.replace(/^\/+|\/+$/g, '');
36
36
  }
37
37
 
38
38
  export function buildGalleryVirtualModuleSource(options: GalleryIntegrationOptions = {}): string {
39
- const pages = toRootGlobPrefix(options.pagesDir ?? 'usr/pages');
40
- const content = toRootGlobPrefix(options.contentDir ?? 'usr/content');
41
- const galleries = toRootGlobPrefix(options.galleriesDir ?? 'usr/galleries');
39
+ const pages = toRootGlobPrefix(options.pagesDir ?? 'src/pages');
40
+ const content = toRootGlobPrefix(options.contentDir ?? 'src/content');
41
+ const galleries = toRootGlobPrefix(options.galleriesDir ?? 'src/galleries');
42
42
 
43
43
  return `
44
44
  export const pagesImages = import.meta.glob('${pages}/**/gallery/*.${IMAGE_EXTENSIONS}', { eager: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lad-sapienza/scms-core",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Framework components and Astro integrations for s:CMS — DataTb, Gallery, Map, ZoteroGeoViewer, Record.",
5
5
  "type": "module",
6
6
  "license": "BSD-0-Clause",
@@ -12,12 +12,14 @@
12
12
  "main": "./index.ts",
13
13
  "bin": {
14
14
  "scms-add-collection": "./bin/add-collection.mjs",
15
- "scms-add-content": "./bin/add-content.mjs"
15
+ "scms-add-content": "./bin/add-content.mjs",
16
+ "scms-create": "./bin/create.mjs"
16
17
  },
17
18
  "files": [
18
19
  "index.ts",
19
20
  "scms.ts",
20
21
  "bin",
22
+ "template",
21
23
  "components",
22
24
  "integrations",
23
25
  "utils",
package/scms.ts CHANGED
@@ -30,11 +30,11 @@ import { contentAssetsIntegration } from './integrations/contentAssetsIntegratio
30
30
  import { galleryIntegration } from './integrations/galleryIntegration';
31
31
 
32
32
  export interface ScmsOptions {
33
- /** Path to the content directory, relative to the project root. Defaults to 'usr/content'. */
33
+ /** Path to the content directory, relative to the project root. Defaults to 'src/content'. */
34
34
  contentDir?: string;
35
- /** Path to page files, relative to the project root. Defaults to 'usr/pages'. */
35
+ /** Path to page files, relative to the project root. Defaults to 'src/pages'. */
36
36
  pagesDir?: string;
37
- /** Path to shared galleries, relative to the project root. Defaults to 'usr/galleries'. */
37
+ /** Path to shared galleries, relative to the project root. Defaults to 'src/galleries'. */
38
38
  galleriesDir?: string;
39
39
  }
40
40
 
@@ -0,0 +1,51 @@
1
+ name: Deploy to GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+
14
+ concurrency:
15
+ group: "pages"
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Setup Node
26
+ uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 22
29
+ cache: npm
30
+
31
+ - name: Install dependencies
32
+ run: npm ci
33
+
34
+ - name: Build
35
+ run: npm run build
36
+
37
+ - name: Upload Pages artifact
38
+ uses: actions/upload-pages-artifact@v3
39
+ with:
40
+ path: dist/
41
+
42
+ deploy:
43
+ needs: build
44
+ runs-on: ubuntu-latest
45
+ environment:
46
+ name: github-pages
47
+ url: ${{ steps.deployment.outputs.page_url }}
48
+ steps:
49
+ - name: Deploy to GitHub Pages
50
+ id: deployment
51
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,37 @@
1
+ # __TITLE__
2
+
3
+ __DESCRIPTION__
4
+
5
+ Built with [s:CMS](https://github.com/lad-sapienza/scms-core), on top of [Astro](https://astro.build).
6
+
7
+ ## Commands
8
+
9
+ | Command | Action |
10
+ |---|---|
11
+ | `npm install` | Install dependencies |
12
+ | `npm run dev` | Start the local dev server at `localhost:4321` |
13
+ | `npm run build` | Build the production site to `./dist/` |
14
+ | `npm run preview` | Preview the production build locally |
15
+ | `npm run add-collection` | Scaffold a new content collection |
16
+ | `npm run add-content` | Add a new content file to an existing collection |
17
+
18
+ ## Updating the framework
19
+
20
+ ```bash
21
+ npm update @lad-sapienza/scms-core
22
+ ```
23
+
24
+ ## Project structure
25
+
26
+ ```
27
+ src/
28
+ content.config.ts # Content collection schemas
29
+ user.config.mjs # Site configuration and metadata
30
+ pages/ # Routes
31
+ layouts/ # Layouts
32
+ styles/global.css # Stylesheet
33
+ public/ # Static assets, served as-is
34
+ astro.config.mjs # Registers the scms() integration
35
+ ```
36
+
37
+ See the [s:CMS documentation](https://github.com/lad-sapienza/sCMS) for available components (`DataTb`, `Map`, `Gallery`, `ZoteroGeoViewer`, `Record`, and more).
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import rehypeSlug from 'rehype-slug';
3
+ import { unified } from '@astrojs/markdown-remark';
4
+ import { userConfig } from './src/user.config.mjs';
5
+ import { scms } from '@lad-sapienza/scms-core/scms';
6
+
7
+ export default defineConfig({
8
+ site: userConfig.site,
9
+ base: userConfig.base,
10
+ output: 'static',
11
+
12
+ // rehypeSlug adds an id to every heading, which the TableOfContents
13
+ // component relies on for its anchor links.
14
+ markdown: {
15
+ processor: unified({ rehypePlugins: [rehypeSlug] }),
16
+ },
17
+
18
+ integrations: [
19
+ ...scms(),
20
+ ...(userConfig.integrations || []),
21
+ ],
22
+
23
+ vite: {
24
+ ...(userConfig.vite || {}),
25
+ },
26
+ });
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "version": "0.1.0",
4
+ "description": "__DESCRIPTION__",
5
+ "type": "module",
6
+ "private": true,
7
+ "scripts": {
8
+ "dev": "astro dev",
9
+ "build": "astro check && astro build",
10
+ "preview": "astro preview",
11
+ "astro": "astro",
12
+ "add-collection": "scms-add-collection",
13
+ "add-content": "scms-add-content"
14
+ },
15
+ "dependencies": {
16
+ "@astrojs/check": "^0.9.10",
17
+ "@astrojs/markdown-remark": "^__ASTRO_VERSION__",
18
+ "@lad-sapienza/scms-core": "^__SCMS_VERSION__",
19
+ "astro": "^__ASTRO_VERSION__",
20
+ "bootstrap": "^5.3.8",
21
+ "react": "^__REACT_VERSION__",
22
+ "react-dom": "^__REACT_DOM_VERSION__",
23
+ "maplibre-gl": "^__MAPLIBRE_VERSION__",
24
+ "rehype-slug": "^6.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/react": "^__TYPES_REACT_VERSION__",
28
+ "@types/react-dom": "^__TYPES_REACT_DOM_VERSION__",
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Content Collections Configuration
3
+ *
4
+ * Define your content collections here. Each collection needs a loader
5
+ * (usually `glob` for local Markdown/MDX files) and a Zod schema.
6
+ *
7
+ * Fastest way to add one: `npm run add-collection`.
8
+ *
9
+ * Example — a `blog` collection, added to the `collections` export below:
10
+ *
11
+ * const blog = defineCollection({
12
+ * loader: glob({ pattern: '**\/*.{md,mdx}', base: './src/content/blog' }),
13
+ * schema: z.object({
14
+ * title: z.string(),
15
+ * description: z.string(),
16
+ * date: z.coerce.date(),
17
+ * draft: z.boolean().optional(),
18
+ * }),
19
+ * });
20
+ */
21
+
22
+ import { defineCollection } from 'astro:content';
23
+ import { glob } from 'astro/loaders';
24
+ import { z } from 'zod';
25
+
26
+ // Export all collections
27
+ export const collections = {};
@@ -0,0 +1,62 @@
1
+ ---
2
+ import "../styles/global.css"
3
+ import SEO from "@lad-sapienza/scms-core/components/SEO/SEO.astro"
4
+ import BSNavbar from "@lad-sapienza/scms-core/components/BSNavbar"
5
+ import { siteMetadata } from "../user.config.mjs"
6
+
7
+ interface Props {
8
+ title?: string
9
+ description?: string
10
+ }
11
+
12
+ const { title = siteMetadata.title, description = siteMetadata.description } =
13
+ Astro.props
14
+
15
+ const currentPath = Astro.url.pathname
16
+ ---
17
+
18
+ <!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
23
+ <SEO title={title} description={description} siteMetadata={siteMetadata} />
24
+ <slot name="head" />
25
+ </head>
26
+
27
+ <body class="d-flex flex-column min-vh-100">
28
+ <BSNavbar
29
+ client:load
30
+ menuItems={[]}
31
+ currentPath={currentPath}
32
+ cssClasses={{
33
+ nav: "bg-body-tertiary navbar-expand-md sticky-top shadow-sm border-bottom",
34
+ ul: "ms-auto mb-2 mb-md-0",
35
+ li: "text-center fw-medium",
36
+ }}
37
+ brand={{
38
+ text: siteMetadata.title,
39
+ link: "/",
40
+ cssClasses: "fw-bold",
41
+ }}
42
+ />
43
+
44
+ <main
45
+ class="container flex-grow-1"
46
+ style="padding-top: 6rem; padding-bottom: 3rem;"
47
+ >
48
+ <slot />
49
+ </main>
50
+
51
+ <footer class="py-4 mt-auto border-top">
52
+ <div class="container text-center text-muted small">
53
+ Built with <a href="https://github.com/lad-sapienza/scms-core" target="_blank" rel="noreferrer">s:CMS</a>
54
+ </div>
55
+ </footer>
56
+
57
+ <script
58
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
59
+ defer
60
+ is:inline></script>
61
+ </body>
62
+ </html>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import BaseLayout from '../layouts/BaseLayout.astro';
3
+ ---
4
+
5
+ <BaseLayout>
6
+ <h1>Welcome to your new s:CMS site</h1>
7
+ <p class="lead">
8
+ Edit <code>src/pages/index.astro</code> to change this page.
9
+ </p>
10
+ <p>
11
+ Run <code>npm run add-collection</code> to scaffold your first content
12
+ collection, or start writing components directly in <code>src/</code>.
13
+ </p>
14
+ </BaseLayout>
@@ -0,0 +1,8 @@
1
+ /* Bootstrap, compiled CSS */
2
+ @import "bootstrap/dist/css/bootstrap.min.css";
3
+
4
+ /* Add your own overrides below, e.g.:
5
+ :root {
6
+ --bs-primary: #6366f1;
7
+ }
8
+ */
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Site Configuration
3
+ *
4
+ * Edit this file to configure your site's metadata, integrations, and more.
5
+ */
6
+
7
+ export const userConfig = {
8
+ // Site URL (required for sitemap and canonical URLs)
9
+ site: '__SITE_URL__',
10
+
11
+ // Base path (if deploying to a subdirectory, e.g. GitHub Pages project sites)
12
+ // base: '/my-site',
13
+
14
+ // Additional Astro integrations (merged with scms()'s own)
15
+ integrations: [
16
+ // Add your custom integrations here
17
+ ],
18
+
19
+ // Custom Vite configuration
20
+ vite: {
21
+ // Your custom Vite config
22
+ },
23
+ };
24
+
25
+ /**
26
+ * Site Metadata
27
+ *
28
+ * Used throughout the site for SEO, social media cards, and general info.
29
+ */
30
+ export const siteMetadata = {
31
+ title: '__TITLE__',
32
+ description: '__DESCRIPTION__',
33
+ author: '__AUTHOR__',
34
+ };
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "jsxImportSource": "react",
6
+ "strictNullChecks": true,
7
+ "moduleResolution": "bundler",
8
+ "resolveJsonModule": true
9
+ }
10
+ }
package/utils/content.ts CHANGED
@@ -49,7 +49,7 @@ export async function getSortedCollection(
49
49
  });
50
50
  }
51
51
 
52
- // Menu extraction logic should be implemented in user code (usr/), not in core.
52
+ // Menu extraction logic should be implemented in the consuming site's own code, not here.
53
53
 
54
54
  /**
55
55
  * Parse CSV string to JSON