@lad-sapienza/scms-core 0.1.0-alpha.8 → 0.1.0
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 +129 -4
- package/bin/add-collection.mjs +37 -6
- package/bin/add-content.mjs +88 -13
- package/bin/create.mjs +1 -1
- package/bin/lib/optimize-images.mjs +440 -0
- package/bin/optimize-images.mjs +95 -0
- package/components/Map/Map.tsx +57 -4
- package/components/Map/MapMdx.tsx +10 -2
- package/components/Map/README.md +37 -1
- package/components/Map/index.ts +0 -1
- package/components/Map/types.ts +30 -0
- package/components/Map/utils.ts +19 -1
- package/components/Record/RecordFetcher.tsx +1 -1
- package/components/Record/useRecordFetcher.ts +1 -1
- package/components/i18n/builtinDictionary.ts +1 -0
- package/index.ts +1 -1
- package/package.json +27 -4
- package/scms.ts +25 -16
- package/template/README.md.tmpl +13 -0
- package/template/package.json.tmpl +2 -1
package/README.md
CHANGED
|
@@ -10,7 +10,20 @@ This package supplies the `core/` framework layer that used to live inside the s
|
|
|
10
10
|
npm install @lad-sapienza/scms-core
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Peer dependencies (install alongside): `astro`, `react`, `react-dom`, `maplibre-gl
|
|
13
|
+
Peer dependencies (install alongside): `astro`, `react`, `react-dom`, `maplibre-gl` (v6+), `@types/react`, `@types/react-dom`.
|
|
14
|
+
|
|
15
|
+
### Requirements
|
|
16
|
+
|
|
17
|
+
scms-core is an **Astro** package. It ships TypeScript, React and `.astro` sources
|
|
18
|
+
that the host site's Astro/Vite pipeline compiles; it is not usable from plain
|
|
19
|
+
Node, or from tooling that doesn't understand `.astro` and `.tsx`. Astro is a
|
|
20
|
+
requirement of the package, not an optional extra.
|
|
21
|
+
|
|
22
|
+
| Dependency | Minimum |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `astro` | 7.2.4 |
|
|
25
|
+
| `react`, `react-dom` | 19.2.4 |
|
|
26
|
+
| `maplibre-gl` | 6.7.0 (needs WebGL2) |
|
|
14
27
|
|
|
15
28
|
## Usage
|
|
16
29
|
|
|
@@ -37,7 +50,7 @@ export default defineConfig({
|
|
|
37
50
|
});
|
|
38
51
|
```
|
|
39
52
|
|
|
40
|
-
`scms()` bundles content-asset serving, the Gallery virtual module, Expressive Code, MDX, React, and sitemap integrations into one call, and self-registers the Vite `dedupe`/`optimizeDeps`
|
|
53
|
+
`scms()` bundles content-asset serving, the Gallery virtual module, Expressive Code, MDX, React, and sitemap integrations into one call, and self-registers the Vite config the components need — `dedupe`/`optimizeDeps` for React and `@tanstack/react-table` (avoids duplicate-module "Invalid hook call" errors) and `ssr.noExternal` for `maplibre-gl` (so the `Map` component's MapLibre v6 worker import resolves in the SSR pass). No extra Vite config required in the consumer.
|
|
41
54
|
|
|
42
55
|
Import components from the package root in any `.mdx` file:
|
|
43
56
|
|
|
@@ -48,7 +61,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
|
|
|
48
61
|
| Component | Description |
|
|
49
62
|
|---|---|
|
|
50
63
|
| `DataTb` | Sortable, filterable, paginated data table |
|
|
51
|
-
| `
|
|
64
|
+
| `Map` | Interactive map with MapLibre GL JS |
|
|
52
65
|
| `Gallery` | Responsive image gallery with lightbox |
|
|
53
66
|
| `TableOfContents` | Auto-generated TOC from headings |
|
|
54
67
|
| `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
|
|
@@ -56,7 +69,27 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
|
|
|
56
69
|
| `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
|
|
57
70
|
| `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
Only the entry points below are public API: the package declares an
|
|
73
|
+
[`exports`](https://nodejs.org/api/packages.html#exports) map, and importing any
|
|
74
|
+
other path (e.g. `.../utils/...`, `.../components/DataTb/DataTbMdx`) fails to
|
|
75
|
+
resolve. Everything is also available from the root barrel except the `.astro`
|
|
76
|
+
components and the ones listed here:
|
|
77
|
+
|
|
78
|
+
| Import path | Provides |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `@lad-sapienza/scms-core` | root barrel — `DataTb`, `Map`, `Gallery`, `Record*`, `RedirectPage`, `ZoteroGeoViewer`, i18n helpers |
|
|
81
|
+
| `@lad-sapienza/scms-core/scms` | `scms()` — use this (not the root) in `astro.config.mjs` |
|
|
82
|
+
| `.../components/Map` | `Map`, map types, `defaultBasemaps` |
|
|
83
|
+
| `.../components/DataTb`, `.../components/DataTb/types` | `DataTb` and the source components; table types |
|
|
84
|
+
| `.../components/Gallery`, `.../components/Gallery/Gallery.astro` | `Gallery` (named export / `.astro` default) |
|
|
85
|
+
| `.../components/Record` | `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` |
|
|
86
|
+
| `.../components/ZoteroGeoViewer` | `ZoteroGeoViewer` |
|
|
87
|
+
| `.../components/TableOfContents/TableOfContents.astro` | `TableOfContents` |
|
|
88
|
+
| `.../components/SEO/SEO.astro` | `SEO` |
|
|
89
|
+
| `.../components/BSNavbar`, `.../components/LocaleSwitcher`, `.../components/ThemeToggle` | layout components (default exports) |
|
|
90
|
+
| `.../components/RedirectPage/RedirectPage.astro` | `RedirectPage` |
|
|
91
|
+
| `.../components/i18n`, `.../components/i18n/I18nInit.astro` | translation helpers; `I18nInit` |
|
|
92
|
+
| `.../integrations/directusLoader` | `directusLoader` for `content.config.ts` |
|
|
60
93
|
|
|
61
94
|
```js
|
|
62
95
|
import SEO from '@lad-sapienza/scms-core/components/SEO/SEO.astro';
|
|
@@ -87,6 +120,98 @@ npx scms-add-content # add a new content file to an existing collection
|
|
|
87
120
|
|
|
88
121
|
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
122
|
|
|
123
|
+
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`.
|
|
124
|
+
|
|
125
|
+
## Image optimization
|
|
126
|
+
|
|
127
|
+
`scms-optimize-images` is a maintenance command (also a `bin`, run from the site's project root) that keeps the images in your content light: it converts every JPG/JPEG/PNG under the content folder to WebP, scales them down to a maximum size, deletes the originals and rewrites every reference to them in your `.md`/`.mdx` files. It uses [sharp](https://sharp.pixelplumbing.com/), so nothing else needs to be installed on the machine.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx scms-optimize-images # convert, delete the originals, rewrite references
|
|
131
|
+
npx scms-optimize-images --dry-run # print the estimated savings, touch nothing
|
|
132
|
+
npx scms-optimize-images --check # touch nothing; exit 1 if something is left to do
|
|
133
|
+
npx scms-optimize-images --only a.jpg b.png # limit the work to these files
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Wire it up in the site's `package.json`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"scripts": {
|
|
141
|
+
"images": "scms-optimize-images",
|
|
142
|
+
"prebuild": "scms-optimize-images --check"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`--check` never changes anything — the build must not modify or delete source files. It exits with a non-zero code (and lists the offenders) if it finds a JPG/PNG that could be converted, a WebP larger than the maximum size, or a stale reference from code (see below), so a forgotten image fails the build or the CI instead of being published at 5 MB. Fix it locally with `npm run images`, review the diff, commit.
|
|
148
|
+
|
|
149
|
+
### What it does
|
|
150
|
+
|
|
151
|
+
- **Resize**: the long side is limited to `maxSize` (2000 px); images are only ever scaled down. EXIF orientation is applied and metadata stripped.
|
|
152
|
+
- **JPG → lossy WebP** at quality 82. **PNG → lossless WebP**; if the result is still larger than 50% of the original *and* the original is over 500 KB, it falls back to lossy WebP (quality 85, alpha quality 100).
|
|
153
|
+
- **Not lighter, not converted**: when the WebP would not be smaller than the original, the original is left as it is. The verdict is stored (file hash + settings) in `.scms-optimize-images.json` at the project root — **commit it** — so such images are neither re-encoded on every run nor reported again by `--check`. Replace the file, or change a setting, and it is evaluated again.
|
|
154
|
+
- **References** in `.md`/`.mdx` files (frontmatter such as `img:`, ``, relative and absolute paths) are rewritten. Absolute paths are rooted at the content folder, as in `contentAssetsIntegration`. `%20` is decoded and names are compared case-insensitively.
|
|
155
|
+
- **Languages**: pages in a non-default language usually have no assets of their own and reuse those of the default-language twin (`blog/en/post/index.md` citing `a.jpg` that lives in `blog/it/post/`). Such references are resolved by swapping the language folder for the default one — using the languages declared in `userConfig.i18n` (below), not fixed codes.
|
|
156
|
+
- **Name conflicts**: `bdus.jpg` + `bdus.png` would both become `bdus.webp`. The one cited by a `.md`/`.mdx` in the same folder (or its twin in another language) is kept, the other is deleted; if that does not single one out, both are skipped and reported.
|
|
157
|
+
- **WebP already larger than `maxSize`** are scaled down in place (lossless stays lossless, lossy is re-encoded at `quality`), and reported by `--check`.
|
|
158
|
+
- **Idempotent**: a second run changes nothing (and takes a fraction of a second).
|
|
159
|
+
- The report lists converted files, skipped ones (with the reason), deleted duplicates and converted WebP that nothing cites (images in `gallery/` folders are not expected to be cited).
|
|
160
|
+
|
|
161
|
+
### References in code
|
|
162
|
+
|
|
163
|
+
Only `.md`/`.mdx` files are rewritten. Images can also be referenced from `.astro`, `.jsx`, `.tsx`, `.ts`, `.js` and `.mjs` files under `src/` (`<img src="/didattica/cover.jpg">`, `import x from '@content/…'`): those are **never rewritten** — every one that points at an image that is, or was, converted is reported with file and line, and the command exits with an error:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
src/pages/[locale]/didattica/index.astro:22 /didattica/cover.jpg is now broken: the image was converted → use /didattica/cover.webp
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The report keeps coming back (also from `--check`) until the code is fixed, so a broken link cannot slip through silently. Paths are resolved from the content folder (`/…`), from the file (`./…`, `../…`) and through the `@content/` and `@user/` aliases.
|
|
170
|
+
|
|
171
|
+
### Configuration
|
|
172
|
+
|
|
173
|
+
All optional; defaults shown. In `src/user.config.mjs`:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
export const userConfig = {
|
|
177
|
+
// Languages of the site. Pages in a language other than defaultLocale may reuse
|
|
178
|
+
// the images of the defaultLocale twin. Omit for a monolingual site.
|
|
179
|
+
i18n: {
|
|
180
|
+
defaultLocale: 'it',
|
|
181
|
+
locales: ['it', 'en'],
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
images: {
|
|
185
|
+
contentDir: 'src/content', // scanned for images and .md/.mdx files
|
|
186
|
+
srcDir: 'src', // scanned for references from code
|
|
187
|
+
maxSize: 2000, // px, long side
|
|
188
|
+
quality: 82, // WebP quality for JPGs (and for re-encoded lossy WebP)
|
|
189
|
+
pngLossyRatio: 0.5, // PNG: go lossy if lossless WebP > this fraction of the original...
|
|
190
|
+
pngLossyMinSize: 500, // ...and the original is larger than this many KB
|
|
191
|
+
pngLossyQuality: 85, // quality of that lossy fallback
|
|
192
|
+
exclude: [], // folders left alone: paths relative to contentDir, or plain folder names
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Keep `i18n` in sync with the site's own locale list (e.g. `LOCALES`/`DEFAULT_LOCALE` in `src/utils/i18n.ts`). Images outside the content folder (e.g. `public/`) are never touched.
|
|
198
|
+
|
|
199
|
+
### Optional: pre-commit hook
|
|
200
|
+
|
|
201
|
+
Nothing is installed automatically. If you want images checked at commit time, add `.git/hooks/pre-commit` (or the equivalent in husky/lefthook) and make it executable. It only looks at the images staged in the commit:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
#!/bin/sh
|
|
205
|
+
# Block the commit if a staged image is not optimized
|
|
206
|
+
git diff --cached --name-only --diff-filter=AM -z -- '*.jpg' '*.jpeg' '*.png' '*.JPG' '*.JPEG' '*.PNG' '*.webp' \
|
|
207
|
+
| xargs -0 npx scms-optimize-images --check --only || {
|
|
208
|
+
echo "Run 'npm run images', review the changes and stage them." >&2
|
|
209
|
+
exit 1
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
An empty list is fine: `--only` with no files does nothing and exits 0. To convert instead of just blocking, run `npx scms-optimize-images --only <files>` and then `git add` the converted `.webp` files and the rewritten `.md`/`.mdx` — the command never stages anything itself.
|
|
214
|
+
|
|
90
215
|
## Development
|
|
91
216
|
|
|
92
217
|
```bash
|
package/bin/add-collection.mjs
CHANGED
|
@@ -468,6 +468,27 @@ async function main() {
|
|
|
468
468
|
const typeRaw = (await ask('Choose type [1/2/3] (default: 3): ')).trim() || '3';
|
|
469
469
|
const colType = typeRaw === '1' ? 'blog' : typeRaw === '2' ? 'docs' : 'generic';
|
|
470
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(', ')}`);
|
|
471
492
|
rl.close();
|
|
472
493
|
|
|
473
494
|
// ─── 1. Update src/content.config.ts ──────────────────────────────────────
|
|
@@ -505,10 +526,9 @@ async function main() {
|
|
|
505
526
|
writeFileSync(CONFIG_FILE, before + after, 'utf8');
|
|
506
527
|
ok('src/content.config.ts updated');
|
|
507
528
|
|
|
508
|
-
// ─── 2. Sample content file
|
|
529
|
+
// ─── 2. Sample content file(s) ───────────────────────────────────────────
|
|
509
530
|
const today = new Date().toISOString().slice(0, 10);
|
|
510
531
|
const contentDir = join(ROOT_DIR, 'src', 'content', colName);
|
|
511
|
-
mkdirSync(contentDir, { recursive: true });
|
|
512
532
|
|
|
513
533
|
const SAMPLE_NAME = { blog: 'sample-post.md', docs: 'sample-doc.md', generic: 'sample.md' };
|
|
514
534
|
const SAMPLE_BODY = {
|
|
@@ -529,9 +549,16 @@ async function main() {
|
|
|
529
549
|
`# Sample Entry\n\nThis is a sample entry. Replace this file with real content.\n`,
|
|
530
550
|
};
|
|
531
551
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
}
|
|
535
562
|
|
|
536
563
|
// ─── 3. Page templates ────────────────────────────────────────────────────
|
|
537
564
|
const pagesDir = join(ROOT_DIR, 'src', 'pages', colName);
|
|
@@ -553,7 +580,11 @@ async function main() {
|
|
|
553
580
|
console.log(` ${B}Next steps:${X}`);
|
|
554
581
|
console.log(` 1. Open ${B}src/content.config.ts${X} and review the schema for '${colName}'`);
|
|
555
582
|
console.log(` 2. Edit the page files in ${B}src/pages/${colName}/${X} to customise the UI`);
|
|
556
|
-
|
|
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`);
|
|
557
588
|
console.log(` 4. Run ${B}npm run dev${X} and visit ${B}/${colName}${X}`);
|
|
558
589
|
console.log('');
|
|
559
590
|
}
|
package/bin/add-content.mjs
CHANGED
|
@@ -75,6 +75,36 @@ function hasMdx(dir) {
|
|
|
75
75
|
} catch { return false; }
|
|
76
76
|
}
|
|
77
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
|
+
|
|
78
108
|
/** Extract z.object field lines for a named collection from config.ts */
|
|
79
109
|
function parseSchemaFields(src, colName) {
|
|
80
110
|
const pat = new RegExp(
|
|
@@ -163,9 +193,37 @@ async function main() {
|
|
|
163
193
|
}
|
|
164
194
|
ok(`Collection: ${collection}`);
|
|
165
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
|
+
|
|
166
225
|
// ─── Prompt: file format ─────────────────────────────────────────────────
|
|
167
|
-
const
|
|
168
|
-
const defaultExt = hasMdx(colDir) ? 'mdx' : 'md';
|
|
226
|
+
const defaultExt = targetLocales.some(loc => hasMdx(join(colDir, loc))) ? 'mdx' : 'md';
|
|
169
227
|
console.log('');
|
|
170
228
|
const extRaw = (await ask(`${B}File format${X} [md/mdx] (default: ${defaultExt}): `)).trim() || defaultExt;
|
|
171
229
|
const ext = (extRaw === 'md' || extRaw === 'mdx') ? extRaw : defaultExt;
|
|
@@ -183,8 +241,9 @@ async function main() {
|
|
|
183
241
|
warn('Slug must contain only lowercase letters, numbers, hyphens, underscores, and forward slashes.');
|
|
184
242
|
continue;
|
|
185
243
|
}
|
|
186
|
-
|
|
187
|
-
|
|
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}`))}`);
|
|
188
247
|
continue;
|
|
189
248
|
}
|
|
190
249
|
slug = raw;
|
|
@@ -234,11 +293,7 @@ async function main() {
|
|
|
234
293
|
|
|
235
294
|
rl.close();
|
|
236
295
|
|
|
237
|
-
// ─── Write file
|
|
238
|
-
const targetDir = join(colDir, slug);
|
|
239
|
-
const targetPath = join(targetDir, `index.${ext}`);
|
|
240
|
-
mkdirSync(targetDir, { recursive: true });
|
|
241
|
-
|
|
296
|
+
// ─── Write file(s) ────────────────────────────────────────────────────────
|
|
242
297
|
const content = [
|
|
243
298
|
'---',
|
|
244
299
|
...fmLines,
|
|
@@ -250,19 +305,39 @@ async function main() {
|
|
|
250
305
|
'',
|
|
251
306
|
].join('\n');
|
|
252
307
|
|
|
253
|
-
|
|
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
|
+
}
|
|
254
319
|
|
|
255
320
|
console.log('');
|
|
256
|
-
ok(`Created: ${
|
|
321
|
+
for (const p of created) ok(`Created: ${p}`);
|
|
257
322
|
|
|
258
323
|
// ─── Summary ──────────────────────────────────────────────────────────────
|
|
324
|
+
const multilingual = targetLocales.some(Boolean);
|
|
259
325
|
console.log('');
|
|
260
326
|
console.log(`${G}${B}Done!${X}`);
|
|
261
327
|
console.log('');
|
|
262
328
|
console.log(` ${B}Next steps:${X}`);
|
|
263
|
-
|
|
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
|
+
}
|
|
264
335
|
console.log(` 2. Set ${B}draft: false${X} when the content is ready to publish`);
|
|
265
|
-
|
|
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
|
+
}
|
|
266
341
|
console.log('');
|
|
267
342
|
}
|
|
268
343
|
|
package/bin/create.mjs
CHANGED
|
@@ -115,7 +115,7 @@ async function main() {
|
|
|
115
115
|
__ASTRO_VERSION__: bareVersion(peers.astro ?? '7.0.0'),
|
|
116
116
|
__REACT_VERSION__: bareVersion(peers.react ?? '19.0.0'),
|
|
117
117
|
__REACT_DOM_VERSION__: bareVersion(peers['react-dom'] ?? '19.0.0'),
|
|
118
|
-
__MAPLIBRE_VERSION__: bareVersion(peers['maplibre-gl'] ?? '
|
|
118
|
+
__MAPLIBRE_VERSION__: bareVersion(peers['maplibre-gl'] ?? '6.0.0'),
|
|
119
119
|
__TYPES_REACT_VERSION__: bareVersion(peers['@types/react'] ?? '19.0.0'),
|
|
120
120
|
__TYPES_REACT_DOM_VERSION__: bareVersion(peers['@types/react-dom'] ?? '19.0.0'),
|
|
121
121
|
};
|