caracms 0.1.0 → 0.2.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/bin/init.mjs +1 -0
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +12 -0
- package/skills/caracms-adopt/look-same.mjs +16 -7
- package/skills/caracms-blocks/SKILL.md +49 -2
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +1 -0
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +26 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/types.ts +2 -0
- package/src/fields.ts +63 -15
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +7 -3
- package/src/links.ts +35 -0
- package/src/live/README.md +1 -0
- package/src/live/backend.ts +41 -23
- package/src/live/branches.ts +19 -0
- package/src/live/mode.ts +46 -0
- package/src/live/runtime.ts +5 -2
- package/src/media.ts +14 -6
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tina → caracms: the content conversion (the reference script of the caracms-tina skill).
|
|
4
|
+
*
|
|
5
|
+
* Copy this file into the site's own `scripts/`, adapt the table under "ADAPT HERE", and
|
|
6
|
+
* run it from the site's root against a **built** checkout of the Tina site's `main`:
|
|
7
|
+
*
|
|
8
|
+
* node scripts/tina-to-caracms.mjs --from <a built Tina checkout> [--to .]
|
|
9
|
+
* [--recipients <address>] [--scaffold-config [--force]]
|
|
10
|
+
*
|
|
11
|
+
* - `--from` must hold `content/` and `tina/__generated__/_schema.json` (Tina generates the
|
|
12
|
+
* schema when the site builds: `pnpm build:local` there, or the site's own build).
|
|
13
|
+
* - `--to` is the caracms site's root (default: the current directory). It needs caracms
|
|
14
|
+
* installed: the script borrows the package's image sniffer and S3 signer (bin/media.mjs).
|
|
15
|
+
* - `--recipients <address>`: every form's recipients in Site Settings become this one
|
|
16
|
+
* address. For a branch environment whose test submissions mustn't reach the client.
|
|
17
|
+
* The run at the switch leaves it out.
|
|
18
|
+
* - `--scaffold-config`: also write a starting `caracms.config.ts` from Tina's schema, with
|
|
19
|
+
* Tina's labels and help, over `caracms init`'s starting config. It refuses any other
|
|
20
|
+
* existing config unless `--force`. Do it once: after that the config and the block
|
|
21
|
+
* components are the site's own, hand-maintained, and a normal run never touches them.
|
|
22
|
+
*
|
|
23
|
+
* WHAT IT OWNS: `content/`, nothing else. Each run deletes `content/` and writes it again
|
|
24
|
+
* from Tina's files, so the same input gives byte-identical output, and a run on newer
|
|
25
|
+
* Tina content leaves nothing of an older run behind. Files are laid out the way the
|
|
26
|
+
* editor saves them (two-space JSON, a media value on one line, rich text one block per
|
|
27
|
+
* line, a list of strings on one line), so `caracms validate` finds nothing to reformat.
|
|
28
|
+
* Beside `content/` it only ever adds to two things:
|
|
29
|
+
* - `scripts/tina-media-sizes.json`, the images' `w`/`h` by bucket key (commit it). It's
|
|
30
|
+
* read first; an image missing from it is fetched once (its Tina URL over HTTP, else
|
|
31
|
+
* the bucket with the site's S3_* settings) and the file is rewritten, sorted;
|
|
32
|
+
* - the media bucket, for a file link (`/files/<name>`): the object is copied to a
|
|
33
|
+
* caracms file key, `uploads/<12 hex of SHA-256 of the source key>/<name>`, and the copy
|
|
34
|
+
* is skipped when that key exists. Without S3_* settings the content is still written,
|
|
35
|
+
* and the copies still to make are listed.
|
|
36
|
+
*
|
|
37
|
+
* How each Tina field kind maps: `field-kinds.md` beside this script (the skill's mapping
|
|
38
|
+
* table). Everything is driven by Tina's schema; nothing below is written per block.
|
|
39
|
+
*/
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
42
|
+
import { createRequire } from 'node:module';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { pathToFileURL } from 'node:url';
|
|
45
|
+
|
|
46
|
+
// ── ADAPT HERE: the site's own choices (everything below this table is generic) ──────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} SiteTable
|
|
50
|
+
* @property {Record<string, string>} blockRenames
|
|
51
|
+
* @property {Record<string, unknown>} fieldDefaults
|
|
52
|
+
* @property {string[]} addressFields
|
|
53
|
+
* @property {string[]} headingFields
|
|
54
|
+
* @property {string[]} docTitleAlt
|
|
55
|
+
* @property {{ pattern: RegExp, sourceKey: (name: string) => string }} fileLinks
|
|
56
|
+
* @property {string} homeFile
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** @type {SiteTable} */
|
|
60
|
+
export const SITE = {
|
|
61
|
+
/** Tina template → caracms block key, where it differs from the camelCase → kebab-case rule (`textMedia` → `text-media`). The approved block table. */
|
|
62
|
+
blockRenames: {
|
|
63
|
+
// features: 'steps',
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Values Tina's files leave out but the site's mapper code reads as set (the trial's
|
|
67
|
+
* TOGGLE_DEFAULT_ON, a per-block background default that isn't the first option).
|
|
68
|
+
* By schema path: `blocks.<template>.<field>`, `<collection>.<field>`, `siteSettings.<field>`,
|
|
69
|
+
* nested with dots. Filled into the content when blank, and the select's or toggle's
|
|
70
|
+
* `default` in a scaffolded config.
|
|
71
|
+
*/
|
|
72
|
+
fieldDefaults: {
|
|
73
|
+
// 'blocks.textMedia.background': 'cream',
|
|
74
|
+
// 'jobs.live': true,
|
|
75
|
+
},
|
|
76
|
+
/** Strings that are an address with no words (`link({ addressOnly: true })`), by schema path. A string named `href` always is. */
|
|
77
|
+
addressFields: [
|
|
78
|
+
// 'siteSettings.socials.instagram',
|
|
79
|
+
],
|
|
80
|
+
/** Text fields, by name, whose text is a heading (`heading: true`: it may hold the page's h1). Scaffold only. */
|
|
81
|
+
headingFields: ['heading'],
|
|
82
|
+
/** Images with no `…Alt` sibling that take the document's title as their alt (a client's logo is its name), by schema path. Others are decorative. */
|
|
83
|
+
docTitleAlt: [
|
|
84
|
+
// 'clientLogos.logo',
|
|
85
|
+
],
|
|
86
|
+
/** A file link the Tina site serves itself (`/files/<name>`), and the bucket key it serves it from. */
|
|
87
|
+
fileLinks: { pattern: /^\/files\/([^/?#]+)$/, sourceKey: (name) => `uploads/${name}` },
|
|
88
|
+
/** Tina's home page file (it becomes `index`). */
|
|
89
|
+
homeFile: 'home',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── The template's conventions (the same in every site made from it) ────────────────────
|
|
93
|
+
|
|
94
|
+
const SETTINGS = 'siteSettings';
|
|
95
|
+
/** Site Settings' fields that are caracms's own keys, not site fields. */
|
|
96
|
+
const OWN_SETTINGS = new Set(['formNotifications', 'adminAccess', 'adminBranding', 'defaultSeo', 'googleTag', 'submissionsLink']);
|
|
97
|
+
const SIZES_FILE = 'scripts/tina-media-sizes.json';
|
|
98
|
+
|
|
99
|
+
// ── Names ──────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** camelCase (or anything else) → kebab-case: `jumpLinks` → `jump-links`, `CTA2` → `cta2`. */
|
|
102
|
+
export function kebab(name) {
|
|
103
|
+
return String(name)
|
|
104
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
105
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
106
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
107
|
+
.replace(/^-+|-+$/g, '')
|
|
108
|
+
.toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A Tina template's caracms block key. */
|
|
112
|
+
export const blockKey = (template, site = SITE) => site.blockRenames[template] ?? kebab(template);
|
|
113
|
+
|
|
114
|
+
/** The 12 hex a copied file's caracms key takes: the first 12 of the source key's SHA-256. */
|
|
115
|
+
export const fileHash = (sourceKey) => createHash('sha256').update(sourceKey).digest('hex').slice(0, 12);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A Tina-served file link → its caracms file key and address, or null when it isn't one.
|
|
119
|
+
* `slugify` is the package's (bin/media.mjs), so the name part follows the upload route's rule.
|
|
120
|
+
*/
|
|
121
|
+
export function fileLink(href, slugify, site = SITE) {
|
|
122
|
+
if (typeof href !== 'string') return null;
|
|
123
|
+
const m = site.fileLinks.pattern.exec(href);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
let name;
|
|
126
|
+
try {
|
|
127
|
+
name = decodeURIComponent(m[1]);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const dot = name.lastIndexOf('.');
|
|
132
|
+
if (dot <= 0) return null;
|
|
133
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
134
|
+
const stem = slugify(name.slice(0, dot)) || 'file';
|
|
135
|
+
const sourceKey = site.fileLinks.sourceKey(name);
|
|
136
|
+
const hash = fileHash(sourceKey);
|
|
137
|
+
return { sourceKey, key: `uploads/${hash}/${stem}.${ext}`, address: `/files/${hash}/${encodeURIComponent(`${stem}.${ext}`)}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── The field kinds (Tina field → caracms field), from the schema ─────────────────────────
|
|
141
|
+
|
|
142
|
+
const fieldNames = (f) => (f.fields ?? []).map((g) => g.name);
|
|
143
|
+
const optionValues = (f) => f.options.map((o) => (typeof o === 'string' ? o : o.value));
|
|
144
|
+
const isImagePair = (f, siblings) => f.type === 'image' && siblings.some((g) => g.name === `${f.name}Alt` && g.type === 'string' && !g.list);
|
|
145
|
+
const hasLinkPair = (siblings) => siblings.some((f) => f.name === 'linkLabel' && f.type === 'string') && siblings.some((f) => f.name === 'href' && f.type === 'string');
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Tina's fields at one level → caracms's, in order: each a spec `{ name, kind, tina, … }`.
|
|
149
|
+
* `at` is the schema path of the level (`blocks.columns.items`). The pairs merge here: an
|
|
150
|
+
* image with its `…Alt` becomes one media field (the alt field goes), and `linkLabel` +
|
|
151
|
+
* `href` become one `link` field where `linkLabel` stood.
|
|
152
|
+
*/
|
|
153
|
+
export function compileFields(fields, at, site = SITE) {
|
|
154
|
+
const out = [];
|
|
155
|
+
const pair = hasLinkPair(fields);
|
|
156
|
+
for (const f of fields) {
|
|
157
|
+
if (f.type === 'displayOnly' || f.isTitle) continue;
|
|
158
|
+
if (fields.some((g) => isImagePair(g, fields) && f.name === `${g.name}Alt`)) continue;
|
|
159
|
+
if (pair && f.name === 'href') continue;
|
|
160
|
+
if (pair && f.name === 'linkLabel') {
|
|
161
|
+
out.push({ name: 'link', kind: 'link', label: 'Link', pair: true, at: `${at}.link` });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
out.push(compileField(f, fields, `${at}.${f.name}`, site));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One Tina field → its spec. */
|
|
170
|
+
export function compileField(f, siblings, at, site = SITE) {
|
|
171
|
+
const base = { name: f.name, tina: f, at, label: f.label ?? f.name, help: f.description };
|
|
172
|
+
if (f.type === 'image') {
|
|
173
|
+
const alt = isImagePair(f, siblings) ? `${f.name}Alt` : null;
|
|
174
|
+
return { ...base, kind: 'media', only: 'image', alt, titleAlt: site.docTitleAlt.includes(at) };
|
|
175
|
+
}
|
|
176
|
+
if (f.type === 'object' && !f.list && f.fields?.some((g) => g.name === 'kind') && f.fields?.some((g) => g.name === 'src' && g.type === 'image')) {
|
|
177
|
+
const clips = f.fields.some((g) => g.name === 'playbackId');
|
|
178
|
+
return { ...base, kind: 'media', object: true, only: clips ? undefined : 'image' };
|
|
179
|
+
}
|
|
180
|
+
if (f.type === 'rich-text') {
|
|
181
|
+
const o = f.overrides;
|
|
182
|
+
const headings = o?.headingLevels ? o.headingLevels.map((h) => (typeof h === 'number' ? `h${h}` : String(h))).filter((h) => h === 'h2' || h === 'h3') : ['h2', 'h3'];
|
|
183
|
+
const links = o?.toolbar ? o.toolbar.includes('link') : true;
|
|
184
|
+
return { ...base, kind: 'richText', headings, links };
|
|
185
|
+
}
|
|
186
|
+
if (f.type === 'reference') return { ...base, kind: 'reference', to: f.collections?.[0] };
|
|
187
|
+
if (f.type === 'boolean') return { ...base, kind: 'toggle', default: site.fieldDefaults[at] };
|
|
188
|
+
if (f.type === 'number') return { ...base, kind: 'number' };
|
|
189
|
+
if (f.type === 'datetime') return { ...base, kind: 'date' };
|
|
190
|
+
if (f.type === 'string') {
|
|
191
|
+
if (f.options && f.list) return { ...base, kind: 'list', item: { kind: 'select', options: f.options } };
|
|
192
|
+
if (f.options) return { ...base, kind: 'select', options: f.options, default: site.fieldDefaults[at] };
|
|
193
|
+
if (f.list) return { ...base, kind: 'list', item: { kind: 'text' } };
|
|
194
|
+
if (f.name === 'href' || site.addressFields.includes(at)) return { ...base, kind: 'address' };
|
|
195
|
+
return { ...base, kind: 'text', multiline: f.ui?.component === 'textarea', heading: site.headingFields.includes(f.name) && at.startsWith('blocks.') };
|
|
196
|
+
}
|
|
197
|
+
if (f.type === 'object' && f.list && !f.templates) {
|
|
198
|
+
const names = fieldNames(f).join();
|
|
199
|
+
if (names === 'label,href,style' && f.fields[2].options) return { ...base, kind: 'buttons', styles: optionValues(f.fields[2]) };
|
|
200
|
+
if (names === 'label,href') return { ...base, kind: 'list', item: { kind: 'link' } };
|
|
201
|
+
const inner = compileFields(f.fields, at, site);
|
|
202
|
+
// A list of one-field objects is a list of that field: [{ line: 'a' }] → ['a'].
|
|
203
|
+
if (inner.length === 1 && ['text', 'reference', 'media'].includes(inner[0].kind) && !inner[0].object) return { ...base, kind: 'list', item: inner[0], unwrap: inner[0].name };
|
|
204
|
+
return { ...base, kind: 'list', fields: inner };
|
|
205
|
+
}
|
|
206
|
+
if (f.type === 'object' && !f.list && !f.templates) return { ...base, kind: 'group', fields: compileFields(f.fields, at, site) };
|
|
207
|
+
throw new Error(`${at}: no caracms field for Tina's "${f.type}"${f.list ? ' list' : ''}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Values ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/** An image URL → its bucket key (`uploads/x.jpg`), the part after `s3://<bucket>/`; null when it isn't one. */
|
|
213
|
+
export function imageKey(src) {
|
|
214
|
+
if (typeof src !== 'string' || src.trim() === '') return null;
|
|
215
|
+
const m = src.match(/\/plain\/s3:\/\/[^/]+\/([^?#]+)$/) ?? src.match(/^s3:\/\/[^/]+\/([^?#]+)$/);
|
|
216
|
+
if (!m) return null;
|
|
217
|
+
try {
|
|
218
|
+
return decodeURI(m[1]);
|
|
219
|
+
} catch {
|
|
220
|
+
return m[1];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Tina's inline Markdown → caracms's: Tina's escapes undone (caracms escapes only * [ ] \), its line breaks as `\n`, `_`/`__` as `*`/`**`. */
|
|
225
|
+
export function inlineMarkdown(s) {
|
|
226
|
+
return (
|
|
227
|
+
s
|
|
228
|
+
// Tina's two forms of a line break: a trailing backslash, or two spaces.
|
|
229
|
+
.replace(/(?:\\| {2,})\n/g, '\n')
|
|
230
|
+
// Tina escapes punctuation Markdown would read (`A\&M`, `mailto\:`).
|
|
231
|
+
// Left to right, so an escaped backslash (`\\`) stays one escape.
|
|
232
|
+
.replace(/\\([^\n])/g, (pair, c) => ('*[]\\'.includes(c) ? pair : c))
|
|
233
|
+
.replace(/(^|[^\w\\*])__(?=\S)([^\n]*?\S)__(?!\w)/g, '$1**$2**')
|
|
234
|
+
.replace(/(^|[^\w\\*])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1*$2*')
|
|
235
|
+
.trim()
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One Tina rich-text value (Markdown) → caracms's stored rich text: `[{ "p": … }, { "ul": [ … ] }, { "h2": … }]`.
|
|
241
|
+
* `rewriteHref` turns each link's address (a Tina file link becomes a caracms one).
|
|
242
|
+
*/
|
|
243
|
+
/** @param {unknown} md @param {{ rewriteHref?: (href: string) => string, warn?: (message: string) => void }} [options] */
|
|
244
|
+
export function richText(md, { rewriteHref = (h) => h, warn = () => {} } = {}) {
|
|
245
|
+
if (typeof md !== 'string') return [];
|
|
246
|
+
const out = [];
|
|
247
|
+
const links = (s) => s.replace(/\]\(([^)\s]+)\)/g, (_, href) => `](${rewriteHref(href)})`);
|
|
248
|
+
const unit = (s) => links(inlineMarkdown(s));
|
|
249
|
+
for (const chunk of md.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n/)) {
|
|
250
|
+
const lines = chunk.split('\n');
|
|
251
|
+
let para = [];
|
|
252
|
+
let list = null;
|
|
253
|
+
const flushPara = () => {
|
|
254
|
+
const text = unit(para.join('\n'));
|
|
255
|
+
if (text) out.push({ p: text });
|
|
256
|
+
para = [];
|
|
257
|
+
};
|
|
258
|
+
const flushList = () => {
|
|
259
|
+
if (list && list.items.length) out.push({ [list.kind]: list.items });
|
|
260
|
+
list = null;
|
|
261
|
+
};
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const bullet = /^(\s*)[*+-]\s+(.*)$/.exec(line);
|
|
264
|
+
const numbered = /^(\s*)\d+[.)]\s+(.*)$/.exec(line);
|
|
265
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
266
|
+
if (heading) {
|
|
267
|
+
flushPara();
|
|
268
|
+
flushList();
|
|
269
|
+
const level = heading[1].length;
|
|
270
|
+
if (level === 2 || level === 3) out.push({ [`h${level}`]: unit(heading[2]) });
|
|
271
|
+
else {
|
|
272
|
+
warn(`an h${level} in rich text, kept as a paragraph`);
|
|
273
|
+
out.push({ p: unit(heading[2]) });
|
|
274
|
+
}
|
|
275
|
+
} else if (bullet || numbered) {
|
|
276
|
+
flushPara();
|
|
277
|
+
const kind = bullet ? 'ul' : 'ol';
|
|
278
|
+
const m = bullet ?? numbered;
|
|
279
|
+
if (m[1].length > 0) warn('a nested list item, flattened into its list (caracms has no nested lists)');
|
|
280
|
+
if (list && list.kind !== kind) flushList();
|
|
281
|
+
list ??= { kind, items: [] };
|
|
282
|
+
const item = unit(m[2]);
|
|
283
|
+
if (item) list.items.push(item);
|
|
284
|
+
} else if (list && /^\s+\S/.test(line)) {
|
|
285
|
+
// A list item's continuation line.
|
|
286
|
+
list.items[list.items.length - 1] = unit(`${list.items[list.items.length - 1]}\n${line.trim()}`);
|
|
287
|
+
} else {
|
|
288
|
+
flushList();
|
|
289
|
+
if (line.trim() !== '' || para.length) para.push(line);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
flushPara();
|
|
293
|
+
flushList();
|
|
294
|
+
}
|
|
295
|
+
return out.filter((b) => {
|
|
296
|
+
const v = Object.values(b)[0];
|
|
297
|
+
return Array.isArray(v) ? v.length > 0 : v !== '';
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A media value in caracms's key order (media-value.ts → mediaToJson). */
|
|
302
|
+
function imageValue(key, alt, { decorative = false, focal } = {}) {
|
|
303
|
+
const out = { key, alt: decorative ? '' : (alt ?? '') };
|
|
304
|
+
if (decorative) out.decorative = true;
|
|
305
|
+
if (focal) out.focal = focal;
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Convert documents, by their compiled specs. One converter per run: it collects the
|
|
311
|
+
* images (for their sizes) and the file links (for their copies) as it goes.
|
|
312
|
+
*/
|
|
313
|
+
export function converter({ collections, slugify, site = SITE }) {
|
|
314
|
+
const warnings = [];
|
|
315
|
+
const images = []; // { value, url } — `value` gets w/h later
|
|
316
|
+
const files = new Map(); // key → { sourceKey, key }
|
|
317
|
+
const warn = (where, message) => warnings.push(`${where}: ${message}`);
|
|
318
|
+
|
|
319
|
+
const rewriteHref = (href) => {
|
|
320
|
+
const f = fileLink(href, slugify, site);
|
|
321
|
+
if (!f) return href;
|
|
322
|
+
files.set(f.key, f);
|
|
323
|
+
return f.address;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** A reference (`content/games/x.json`) → the document's path in its collection (`x`). */
|
|
327
|
+
const refPath = (value, to, where) => {
|
|
328
|
+
if (typeof value !== 'string' || !value) return undefined;
|
|
329
|
+
const c = collections[to];
|
|
330
|
+
const folder = c ? `${c.tina.path.replace(/\/+$/, '')}/` : '';
|
|
331
|
+
let rel = value.startsWith(folder) ? value.slice(folder.length) : path.posix.basename(value);
|
|
332
|
+
rel = rel.replace(/\.[^./]+$/, '');
|
|
333
|
+
if (c?.sections && rel === site.homeFile) rel = 'index';
|
|
334
|
+
if (!c) warn(where, `a reference to "${value}" in an unknown collection`);
|
|
335
|
+
return rel;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const image = (src, alt, where, opts) => {
|
|
339
|
+
const key = imageKey(src);
|
|
340
|
+
if (!key) {
|
|
341
|
+
if (typeof src === 'string' && src.trim()) warn(where, `"${src}" isn't an image in the media bucket, so it's left out`);
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
const value = imageValue(key, alt, opts);
|
|
345
|
+
images.push({ value, url: src });
|
|
346
|
+
return value;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function value(spec, raw, parent, where, doc) {
|
|
350
|
+
const at = spec.at;
|
|
351
|
+
if (spec.kind === 'link' && spec.pair) {
|
|
352
|
+
const label = parent?.linkLabel ?? '';
|
|
353
|
+
const href = parent?.href ?? '';
|
|
354
|
+
if (!label && !href) return undefined;
|
|
355
|
+
return { label, href: rewriteHref(href) };
|
|
356
|
+
}
|
|
357
|
+
if (raw === undefined || raw === null || (raw === '' && spec.kind !== 'text')) {
|
|
358
|
+
const fallback = site.fieldDefaults[at];
|
|
359
|
+
if (fallback !== undefined && (spec.kind === 'select' || spec.kind === 'toggle' || spec.kind === 'text' || spec.kind === 'number')) return fallback;
|
|
360
|
+
if (spec.kind === 'richText' && raw === '') return [];
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
switch (spec.kind) {
|
|
364
|
+
case 'text':
|
|
365
|
+
if (typeof raw !== 'string') warn(where, `expected text, found ${JSON.stringify(raw)}`);
|
|
366
|
+
return raw;
|
|
367
|
+
case 'select': {
|
|
368
|
+
const ok = optionValues({ options: spec.options }).includes(raw);
|
|
369
|
+
if (!ok) {
|
|
370
|
+
warn(where, `"${raw}" isn't one of the options, so it's left out (the field's default applies)`);
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
return raw;
|
|
374
|
+
}
|
|
375
|
+
case 'toggle':
|
|
376
|
+
case 'number':
|
|
377
|
+
case 'date':
|
|
378
|
+
return raw;
|
|
379
|
+
case 'address':
|
|
380
|
+
return { href: rewriteHref(String(raw)) };
|
|
381
|
+
case 'richText':
|
|
382
|
+
return richText(raw, { rewriteHref, warn: (m) => warn(where, m) });
|
|
383
|
+
case 'reference':
|
|
384
|
+
return refPath(raw, spec.to, where);
|
|
385
|
+
case 'buttons':
|
|
386
|
+
return Array.isArray(raw) ? raw.map((b) => ({ label: b?.label ?? '', href: rewriteHref(b?.href ?? ''), style: b?.style ?? spec.styles[0] })) : undefined;
|
|
387
|
+
case 'media': {
|
|
388
|
+
if (spec.object) return mediaObject(raw, where);
|
|
389
|
+
const alt = spec.alt ? (parent?.[spec.alt] ?? '') : spec.titleAlt ? (doc.title ?? '') : '';
|
|
390
|
+
return image(raw, alt, where, { decorative: !spec.alt && !spec.titleAlt });
|
|
391
|
+
}
|
|
392
|
+
case 'list': {
|
|
393
|
+
if (!Array.isArray(raw)) return undefined;
|
|
394
|
+
if (spec.item?.kind === 'link') return raw.map((r) => ({ label: r?.label ?? '', href: rewriteHref(r?.href ?? '') }));
|
|
395
|
+
// The unwrap comes first (M9 port friction 9): a list of one-text-field objects
|
|
396
|
+
// (`facts: [{ label: 'age 9+' }]`) has a text item too, and isn't a list of strings.
|
|
397
|
+
if (spec.unwrap) {
|
|
398
|
+
return raw.map((r, i) => value(spec.item, r?.[spec.unwrap], r, `${where}[${i}]`, doc)).filter((v) => v !== undefined && v !== '');
|
|
399
|
+
}
|
|
400
|
+
if (spec.item?.kind === 'select' || spec.item?.kind === 'text') return raw.filter((s) => typeof s === 'string');
|
|
401
|
+
return raw.map((r, i) => object(spec.fields, r, `${where}[${i}]`, doc));
|
|
402
|
+
}
|
|
403
|
+
case 'group':
|
|
404
|
+
return object(spec.fields, raw, where, doc);
|
|
405
|
+
default:
|
|
406
|
+
throw new Error(`${at}: no conversion for "${spec.kind}"`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function mediaObject(raw, where) {
|
|
411
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
412
|
+
const kind = raw.kind ?? (raw.src ? 'image' : raw.playbackId ? 'video' : undefined);
|
|
413
|
+
if (kind === 'image') {
|
|
414
|
+
const focal = Number.isFinite(raw.focusX) && Number.isFinite(raw.focusY) ? [raw.focusX / 100, raw.focusY / 100] : undefined;
|
|
415
|
+
return image(raw.src, raw.alt ?? '', where, { focal });
|
|
416
|
+
}
|
|
417
|
+
if (kind === 'video' && raw.playbackId) {
|
|
418
|
+
const out = { video: raw.playbackId, play: raw.playAs === 'player' ? 'player' : 'loop' };
|
|
419
|
+
if (Number.isFinite(raw.posterTime) && raw.posterTime >= 0) out.poster = raw.posterTime;
|
|
420
|
+
if (raw.name) out.name = raw.name;
|
|
421
|
+
if (Number.isFinite(raw.duration) && raw.duration > 0) out.duration = raw.duration;
|
|
422
|
+
if (raw.mono) warn(where, 'a black-and-white clip: caracms has no mono, so it plays in colour');
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** One level of fields → caracms's object, keys in schema order. Unknown keys in Tina's file are dropped with a warning. */
|
|
429
|
+
function object(specs, raw, where, doc) {
|
|
430
|
+
const out = {};
|
|
431
|
+
for (const spec of specs) {
|
|
432
|
+
const v = value(spec, raw?.[spec.name], raw, `${where}.${spec.name}`, doc);
|
|
433
|
+
if (v !== undefined) out[spec.name] = v;
|
|
434
|
+
}
|
|
435
|
+
if (raw && typeof raw === 'object') {
|
|
436
|
+
const known = new Set(specs.flatMap((s) => [s.name, ...(s.pair ? ['linkLabel', 'href'] : []), ...(s.alt ? [s.alt] : [])]));
|
|
437
|
+
for (const k of Object.keys(raw)) if (!known.has(k) && k !== '_template' && raw[k] !== null) warn(where, `"${k}" isn't in Tina's schema any more, so it's left out`);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { warnings, warn, images, files, object, value, rewriteHref, image };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Layout: the editor's save (serialize.ts → savedForm, toJson), written out again ──────
|
|
446
|
+
|
|
447
|
+
const pad = (depth) => ' '.repeat(depth);
|
|
448
|
+
const objectText = (members, depth) => (members.length === 0 ? '{}' : `{\n${members.map(([k, v]) => `${pad(depth + 1)}${JSON.stringify(k)}: ${v}`).join(',\n')}\n${pad(depth)}}`);
|
|
449
|
+
const arrayText = (items, depth) => (items.length === 0 ? '[]' : `[\n${items.map((v) => pad(depth + 1) + v).join(',\n')}\n${pad(depth)}]`);
|
|
450
|
+
|
|
451
|
+
/** Canonical JSON: two-space indent, one member per line, a list of strings on one line. */
|
|
452
|
+
export function toJson(value, depth = 0) {
|
|
453
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
454
|
+
if (Array.isArray(value)) {
|
|
455
|
+
if (value.length === 0) return '[]';
|
|
456
|
+
if (value.every((v) => typeof v === 'string')) return `[${value.map((v) => JSON.stringify(v)).join(', ')}]`;
|
|
457
|
+
return arrayText(value.map((v) => toJson(v, depth + 1)), depth);
|
|
458
|
+
}
|
|
459
|
+
return objectText(Object.entries(value).filter(([, v]) => v !== undefined).map(([k, v]) => [k, toJson(v, depth + 1)]), depth);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function inlineText(value) {
|
|
463
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
464
|
+
if (Array.isArray(value)) return `[${value.map(inlineText).join(', ')}]`;
|
|
465
|
+
const entries = Object.entries(value);
|
|
466
|
+
return entries.length ? `{ ${entries.map(([k, v]) => `${JSON.stringify(k)}: ${inlineText(v)}`).join(', ')} }` : '{}';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const PLAIN = { kind: 'plain' };
|
|
470
|
+
const INLINE = { kind: 'inline' };
|
|
471
|
+
|
|
472
|
+
/** How a field's value is laid out (serialize.ts → shapeOf). */
|
|
473
|
+
export function shapeOf(spec) {
|
|
474
|
+
switch (spec.kind) {
|
|
475
|
+
case 'media':
|
|
476
|
+
return INLINE;
|
|
477
|
+
case 'richText':
|
|
478
|
+
return { kind: 'rich' };
|
|
479
|
+
case 'group':
|
|
480
|
+
return { kind: 'object', keys: shapesOf(spec.fields) };
|
|
481
|
+
case 'list':
|
|
482
|
+
if (spec.item) return spec.item.kind === 'media' ? { kind: 'list', item: INLINE } : spec.item.kind === 'richText' ? { kind: 'list', item: { kind: 'rich' } } : PLAIN;
|
|
483
|
+
return { kind: 'list', item: { kind: 'object', keys: shapesOf(spec.fields) } };
|
|
484
|
+
default:
|
|
485
|
+
return PLAIN;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export const shapesOf = (specs) => new Map(specs.map((s) => [s.name, shapeOf(s)]));
|
|
489
|
+
|
|
490
|
+
/** A value laid out by its shape (serialize.ts → layout). A list's `item` may be a function of the item (a block's shape is its type's). */
|
|
491
|
+
export function layout(value, shape, depth = 0) {
|
|
492
|
+
if (value === null || typeof value !== 'object') return toJson(value, depth);
|
|
493
|
+
switch (shape.kind) {
|
|
494
|
+
case 'inline':
|
|
495
|
+
return inlineText(value);
|
|
496
|
+
case 'rich':
|
|
497
|
+
if (!Array.isArray(value)) return toJson(value, depth);
|
|
498
|
+
return arrayText(value.map((e) => (e && typeof e === 'object' && !Array.isArray(e) ? `{ ${Object.entries(e).map(([k, v]) => `${JSON.stringify(k)}: ${toJson(v)}`).join(', ')} }` : toJson(e))), depth);
|
|
499
|
+
case 'list': {
|
|
500
|
+
if (!Array.isArray(value)) return toJson(value, depth);
|
|
501
|
+
const item = typeof shape.item === 'function' ? shape.item : () => shape.item;
|
|
502
|
+
return arrayText(value.map((v) => layout(v, item(v), depth + 1)), depth);
|
|
503
|
+
}
|
|
504
|
+
case 'object':
|
|
505
|
+
if (Array.isArray(value)) return toJson(value, depth);
|
|
506
|
+
return objectText(Object.entries(value).map(([k, v]) => [k, layout(v, shape.keys.get(k) ?? PLAIN, depth + 1)]), depth);
|
|
507
|
+
default:
|
|
508
|
+
return toJson(value, depth);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Reading Tina's schema and content ──────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Tina's schema → what the conversion and the scaffold need: each collection (sections
|
|
516
|
+
* or fields) with its compiled fields, the blocks by caracms key, and Site Settings.
|
|
517
|
+
*/
|
|
518
|
+
export function readSchema(schema, site = SITE) {
|
|
519
|
+
const collections = {};
|
|
520
|
+
const blocks = new Map(); // key → { template, label, specs }
|
|
521
|
+
const warnings = [];
|
|
522
|
+
for (const c of schema.collections) {
|
|
523
|
+
if (c.name === SETTINGS) continue;
|
|
524
|
+
const folder = c.path.replace(/^content\/?/, '').replace(/\/+$/, '');
|
|
525
|
+
const key = kebab(folder);
|
|
526
|
+
const templates = c.fields.find((f) => f.type === 'object' && f.list && f.templates);
|
|
527
|
+
const own = c.fields.filter((f) => f !== templates && !f.isTitle && !(templates && (f.name === 'seo' || (f.name === 'hidden' && f.type === 'boolean'))) && !(!templates && f.name === 'seo'));
|
|
528
|
+
const entry = {
|
|
529
|
+
tina: c,
|
|
530
|
+
key,
|
|
531
|
+
label: c.label ?? c.name,
|
|
532
|
+
sections: !!templates,
|
|
533
|
+
blocksField: templates?.name,
|
|
534
|
+
titleField: c.fields.find((f) => f.isTitle)?.name,
|
|
535
|
+
hasSeo: c.fields.some((f) => f.name === 'seo' && f.type === 'object'),
|
|
536
|
+
hasHidden: !!templates && c.fields.some((f) => f.name === 'hidden' && f.type === 'boolean'),
|
|
537
|
+
specs: compileFields(own, c.name, site),
|
|
538
|
+
blockKeys: [],
|
|
539
|
+
};
|
|
540
|
+
if (templates) {
|
|
541
|
+
for (const t of templates.templates) {
|
|
542
|
+
const k = blockKey(t.name, site);
|
|
543
|
+
const description = templateDescription(t);
|
|
544
|
+
const specs = withoutBlockDescription(compileFields(t.fields, `blocks.${t.name}`, site), description);
|
|
545
|
+
if (specs.some((s) => s.name === 'type')) throw new Error(`blocks.${t.name}: a field named "type" clashes with caracms's block type`);
|
|
546
|
+
const known = blocks.get(k);
|
|
547
|
+
if (known && known.template !== t.name) throw new Error(`Tina templates "${known.template}" and "${t.name}" both become the block "${k}": rename one in SITE.blockRenames`);
|
|
548
|
+
if (known && JSON.stringify(known.specs.map(strip)) !== JSON.stringify(specs.map(strip))) warnings.push(`blocks.${t.name}: its fields differ between collections; the first one's are used`);
|
|
549
|
+
if (!known) blocks.set(k, { template: t.name, label: t.label ?? t.name, help: blockHelp(description), specs });
|
|
550
|
+
entry.blockKeys.push(k);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
collections[c.name] = entry;
|
|
554
|
+
}
|
|
555
|
+
const settings = schema.collections.find((c) => c.name === SETTINGS);
|
|
556
|
+
const settingsSpecs = settings ? compileFields(settings.fields.filter((f) => !OWN_SETTINGS.has(f.name)), SETTINGS, site) : [];
|
|
557
|
+
return { collections, blocks, settings, settingsSpecs, warnings };
|
|
558
|
+
}
|
|
559
|
+
/** A Tina template's own description (`ui.description`, or `description`), or ''. */
|
|
560
|
+
const templateDescription = (t) => String(t.ui?.description ?? t.description ?? '').trim();
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* A template's description → the block's help (M6c G8): its first sentence, and the next
|
|
564
|
+
* while the two stay short, since the block's help sits at the top of its sidebar level.
|
|
565
|
+
* Undefined when the template has none.
|
|
566
|
+
*/
|
|
567
|
+
export function blockHelp(description) {
|
|
568
|
+
const text = String(description ?? '').replace(/\s+/g, ' ').trim();
|
|
569
|
+
if (!text) return undefined;
|
|
570
|
+
const sentences = text.match(/[^.!?]+(?:[.!?]+["”’)]*|$)/g)?.map((x) => x.trim()).filter(Boolean) ?? [text];
|
|
571
|
+
let help = sentences[0];
|
|
572
|
+
if (sentences[1] && help.length + sentences[1].length + 1 <= 160) help = `${help} ${sentences[1]}`;
|
|
573
|
+
return help;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* A template's fields with the template's description taken out of their help. Some Tina
|
|
578
|
+
* sites (the template's \`aboutText\`) put it in front of the first field's own
|
|
579
|
+
* description ("About this block: <description> <the field's help>"), the only place
|
|
580
|
+
* Tina's editor showed it; in caracms it's the block's help, so each field keeps its own.
|
|
581
|
+
*/
|
|
582
|
+
export function withoutBlockDescription(specs, description) {
|
|
583
|
+
if (!description) return specs;
|
|
584
|
+
return specs.map((spec) => {
|
|
585
|
+
const at = typeof spec.help === 'string' ? spec.help.indexOf(description) : -1;
|
|
586
|
+
if (at < 0) return spec;
|
|
587
|
+
const own = spec.help.slice(at + description.length).trim();
|
|
588
|
+
const { help: _h, ...rest } = spec;
|
|
589
|
+
return own ? { ...rest, help: own } : rest;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const strip = ({ tina: _t, at: _a, ...rest }) => JSON.stringify(rest, (k, v) => (k === 'tina' || k === 'at' ? undefined : v));
|
|
594
|
+
|
|
595
|
+
/** Every `.json` file under a folder, as posix paths relative to it, sorted. */
|
|
596
|
+
function jsonFiles(root, rel = '') {
|
|
597
|
+
const dir = path.join(root, rel);
|
|
598
|
+
if (!existsSync(dir)) return [];
|
|
599
|
+
const out = [];
|
|
600
|
+
for (const name of readdirSync(dir).sort()) {
|
|
601
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
602
|
+
if (statSync(path.join(root, r)).isDirectory()) out.push(...jsonFiles(root, r));
|
|
603
|
+
else if (name.endsWith('.json')) out.push(r);
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const seoOf = (raw, conv, where) => {
|
|
609
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
610
|
+
const out = {};
|
|
611
|
+
if (raw.metaTitle) out.title = raw.metaTitle;
|
|
612
|
+
if (raw.metaDescription) out.description = raw.metaDescription;
|
|
613
|
+
if (raw.ogImage) {
|
|
614
|
+
const image = conv.image(raw.ogImage, raw.ogImageAlt ?? '', `${where}.ogImage`);
|
|
615
|
+
if (image) out.image = image;
|
|
616
|
+
}
|
|
617
|
+
return Object.keys(out).length ? out : undefined;
|
|
618
|
+
};
|
|
619
|
+
const SEO_SHAPE = { kind: 'object', keys: new Map([['image', INLINE]]) };
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Convert every document. Returns `{ files: Map<relPath, text>, conv }`; nothing is written.
|
|
623
|
+
* `recipients`: the `--recipients` address, or undefined.
|
|
624
|
+
*/
|
|
625
|
+
export function convertAll({ from, schema, slugify, site = SITE, recipients }) {
|
|
626
|
+
const read = readSchema(schema, site);
|
|
627
|
+
const conv = converter({ collections: read.collections, slugify, site });
|
|
628
|
+
conv.warnings.push(...read.warnings);
|
|
629
|
+
const docs = []; // { rel, data, shape }
|
|
630
|
+
|
|
631
|
+
for (const c of Object.values(read.collections)) {
|
|
632
|
+
const folder = path.join(from, c.tina.path);
|
|
633
|
+
for (const file of jsonFiles(folder)) {
|
|
634
|
+
const where = `${c.tina.path}/${file}`;
|
|
635
|
+
const raw = JSON.parse(readFileSync(path.join(folder, file), 'utf8'));
|
|
636
|
+
let name = file.replace(/\.json$/, '');
|
|
637
|
+
if (c.sections && name === site.homeFile) name = 'index';
|
|
638
|
+
const rel = `content/${c.key}/${name}.json`;
|
|
639
|
+
const title = (c.titleField ? raw[c.titleField] : undefined) ?? name;
|
|
640
|
+
const doc = { title };
|
|
641
|
+
const data = { $schema: `${'../'.repeat(rel.split('/').length - 1)}schemas/${c.key}.schema.json`, title };
|
|
642
|
+
if (c.hasHidden && raw.hidden === true) {
|
|
643
|
+
if (name === 'index') conv.warn(where, "the home page can't be hidden in caracms, so it's shown");
|
|
644
|
+
else data.hidden = true;
|
|
645
|
+
}
|
|
646
|
+
const own = Object.fromEntries(Object.entries(raw).filter(([k]) => ![c.titleField, c.blocksField, ...(c.hasSeo ? ['seo'] : []), ...(c.hasHidden ? ['hidden'] : [])].includes(k)));
|
|
647
|
+
Object.assign(data, conv.object(c.specs, own, where, doc));
|
|
648
|
+
if (c.sections) {
|
|
649
|
+
const blocksRaw = raw[c.blocksField] ?? [];
|
|
650
|
+
data.blocks = blocksRaw.map((b, i) => {
|
|
651
|
+
const w = `${where} ${c.blocksField}[${i}]`;
|
|
652
|
+
const key = blockKey(b?._template, site);
|
|
653
|
+
const block = read.blocks.get(key);
|
|
654
|
+
if (!block) throw new Error(`${w}: no template "${b?._template}" in Tina's schema`);
|
|
655
|
+
return { type: key, ...conv.object(block.specs, b, w, doc) };
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
const seo = c.hasSeo ? seoOf(raw.seo, conv, `${where}.seo`) : undefined;
|
|
659
|
+
if (seo) data.seo = seo;
|
|
660
|
+
const keys = shapesOf(c.specs);
|
|
661
|
+
keys.set('seo', SEO_SHAPE);
|
|
662
|
+
if (c.sections) keys.set('blocks', { kind: 'list', item: (b) => ({ kind: 'object', keys: shapesOf(read.blocks.get(b.type)?.specs ?? []) }) });
|
|
663
|
+
docs.push({ rel, data, shape: { kind: 'object', keys } });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (read.settings) {
|
|
668
|
+
const folder = path.join(from, read.settings.path);
|
|
669
|
+
const [file] = jsonFiles(folder);
|
|
670
|
+
if (file) {
|
|
671
|
+
const where = `${read.settings.path}/${file}`;
|
|
672
|
+
const raw = JSON.parse(readFileSync(path.join(folder, file), 'utf8'));
|
|
673
|
+
const rows = (list, key) => (Array.isArray(list) ? list.map((r) => r?.[key]).filter((v) => typeof v === 'string' && v.trim() !== '') : []);
|
|
674
|
+
const forms = raw.formNotifications && typeof raw.formNotifications === 'object' ? raw.formNotifications : {};
|
|
675
|
+
const brand = { name: raw.adminBranding?.name || raw.defaultSeo?.metaTitle?.trim() || 'Site' };
|
|
676
|
+
if (raw.adminBranding?.accent) brand.accent = raw.adminBranding.accent;
|
|
677
|
+
if (raw.adminBranding?.logo || raw.adminBranding?.favicon) conv.warn(where, "the editor branding's logo and favicon aren't carried over: upload them in the editor (Site Settings → Brand)");
|
|
678
|
+
const data = {
|
|
679
|
+
$schema: '../schemas/site-settings.schema.json',
|
|
680
|
+
title: 'Site Settings',
|
|
681
|
+
editors: rows(raw.adminAccess?.allow, 'entry'),
|
|
682
|
+
brand,
|
|
683
|
+
};
|
|
684
|
+
const seo = seoOf(raw.defaultSeo, conv, `${where}.defaultSeo`);
|
|
685
|
+
if (seo) data.seo = seo;
|
|
686
|
+
if (raw.googleTag) data.googleTag = raw.googleTag;
|
|
687
|
+
data.formRecipients = Object.fromEntries(Object.keys(forms).sort().map((form) => [form, recipients ? [recipients] : rows(forms[form], 'address')]));
|
|
688
|
+
Object.assign(data, conv.object(read.settingsSpecs, Object.fromEntries(Object.entries(raw).filter(([k]) => !OWN_SETTINGS.has(k))), where, { title: 'Site Settings' }));
|
|
689
|
+
const keys = shapesOf(read.settingsSpecs);
|
|
690
|
+
keys.set('seo', SEO_SHAPE);
|
|
691
|
+
keys.set('brand', { kind: 'object', keys: new Map([['logo', INLINE], ['icon', INLINE]]) });
|
|
692
|
+
docs.push({ rel: 'content/site-settings.json', data, shape: { kind: 'object', keys } });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return { read, conv, docs };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The documents' text, once the images have their sizes. */
|
|
699
|
+
export const renderDocs = (docs) => new Map(docs.map((d) => [d.rel, `${layout(d.data, d.shape, 0)}\n`]));
|
|
700
|
+
|
|
701
|
+
// ── Image sizes and file copies (the only network the script does) ─────────────────────
|
|
702
|
+
|
|
703
|
+
function readSizes(file) {
|
|
704
|
+
try {
|
|
705
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
706
|
+
} catch {
|
|
707
|
+
return {};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function fetchSize({ url, key }, tools, creds) {
|
|
712
|
+
const tries = [];
|
|
713
|
+
if (/^https?:\/\//.test(url)) tries.push(() => fetch(url, { signal: AbortSignal.timeout(60_000) }));
|
|
714
|
+
if (creds) {
|
|
715
|
+
tries.push(() => {
|
|
716
|
+
const signed = tools.signS3(creds, { method: 'GET', key });
|
|
717
|
+
return fetch(signed.url, { headers: signed.headers, signal: AbortSignal.timeout(60_000) });
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
for (const go of tries) {
|
|
721
|
+
try {
|
|
722
|
+
const res = await go();
|
|
723
|
+
if (!res.ok) continue;
|
|
724
|
+
const sniffed = tools.sniffImage(new Uint8Array(await res.arrayBuffer()));
|
|
725
|
+
if (sniffed.ok) return { w: sniffed.w, h: sniffed.h };
|
|
726
|
+
} catch {
|
|
727
|
+
// the next way, if any
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Fill every image's `w`/`h`: the cache first, then a fetch for what it lacks. Returns the keys learned and the keys still without a size. */
|
|
734
|
+
export async function fillSizes(images, cacheFile, tools, creds) {
|
|
735
|
+
const cache = readSizes(cacheFile);
|
|
736
|
+
const wanted = new Map();
|
|
737
|
+
for (const { value, url } of images) if (!cache[value.key] && !wanted.has(value.key)) wanted.set(value.key, url);
|
|
738
|
+
const learned = [];
|
|
739
|
+
const missing = [];
|
|
740
|
+
for (const key of [...wanted.keys()].sort()) {
|
|
741
|
+
const size = await fetchSize({ url: wanted.get(key), key }, tools, creds);
|
|
742
|
+
if (size) {
|
|
743
|
+
cache[key] = size;
|
|
744
|
+
learned.push(key);
|
|
745
|
+
} else missing.push(key);
|
|
746
|
+
}
|
|
747
|
+
for (const { value } of images) {
|
|
748
|
+
const size = cache[value.key];
|
|
749
|
+
if (size) Object.assign(value, { w: size.w, h: size.h });
|
|
750
|
+
}
|
|
751
|
+
if (learned.length) {
|
|
752
|
+
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
753
|
+
const sorted = Object.fromEntries(Object.keys(cache).sort().map((k) => [k, { w: cache[k].w, h: cache[k].h }]));
|
|
754
|
+
writeFileSync(cacheFile, `${JSON.stringify(sorted, null, 2)}\n`);
|
|
755
|
+
}
|
|
756
|
+
return { learned, missing };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Copy each file link's object to its caracms key, unless that key exists. Never deletes. */
|
|
760
|
+
async function copyFiles(files, tools, creds) {
|
|
761
|
+
const made = [];
|
|
762
|
+
const kept = [];
|
|
763
|
+
for (const f of [...files.values()].sort((a, b) => a.key.localeCompare(b.key))) {
|
|
764
|
+
const head = tools.signS3(creds, { method: 'HEAD', key: f.key });
|
|
765
|
+
const exists = await fetch(head.url, { method: 'HEAD', headers: head.headers });
|
|
766
|
+
if (exists.ok) {
|
|
767
|
+
kept.push(f.key);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const source = `/${creds.S3_BUCKET}/${f.sourceKey.split('/').map(encodeURIComponent).join('/')}`;
|
|
771
|
+
const put = tools.signS3(creds, { method: 'PUT', key: f.key, headers: { 'x-amz-copy-source': source } });
|
|
772
|
+
const res = await fetch(put.url, { method: 'PUT', headers: put.headers });
|
|
773
|
+
const text = await res.text();
|
|
774
|
+
if (!res.ok || /<Error>/.test(text)) throw new Error(`copying ${f.sourceKey} to ${f.key} failed: ${res.status} ${text.slice(0, 300)}`);
|
|
775
|
+
made.push(f.key);
|
|
776
|
+
}
|
|
777
|
+
return { made, kept };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The installed caracms's plain-JS tools (bin/media.mjs): sniffImage, slugify, signS3, readS3Env. */
|
|
781
|
+
async function caracmsTools(root) {
|
|
782
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
783
|
+
let entry;
|
|
784
|
+
try {
|
|
785
|
+
entry = require.resolve('caracms');
|
|
786
|
+
} catch {
|
|
787
|
+
throw new Error(`caracms isn't installed in ${root}. Run this from the site's root after \`caracms init --write\` and an install.`);
|
|
788
|
+
}
|
|
789
|
+
const pkg = path.resolve(path.dirname(entry), '..');
|
|
790
|
+
const media = await import(pathToFileURL(path.join(pkg, 'bin/media.mjs')).href);
|
|
791
|
+
return { ...media, packageRoot: pkg };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ── The scaffold: a starting caracms.config.ts from the same specs ─────────────────────
|
|
795
|
+
|
|
796
|
+
const q = (s) => JSON.stringify(s);
|
|
797
|
+
const pascal = (key) => key.replace(/(^|-)([a-z0-9])/g, (_, __, c) => c.toUpperCase());
|
|
798
|
+
|
|
799
|
+
function optionsText(options) {
|
|
800
|
+
return `[${options.map((o) => (typeof o === 'string' ? q(o) : `{ value: ${q(o.value)}, label: ${q(o.label ?? o.value)} }`)).join(', ')}]`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** One spec → its caracms field helper call, as TypeScript. */
|
|
804
|
+
export function fieldConfig(spec, read, used, indent) {
|
|
805
|
+
const common = [`label: ${q(spec.label)}`];
|
|
806
|
+
if (spec.help) common.push(`help: ${q(spec.help)}`);
|
|
807
|
+
const opts = (...extra) => `{ ${[...extra, ...common].join(', ')} }`;
|
|
808
|
+
const use = (name) => (used.add(name), name);
|
|
809
|
+
switch (spec.kind) {
|
|
810
|
+
case 'text': {
|
|
811
|
+
const extra = [];
|
|
812
|
+
if (spec.multiline) extra.push('multiline: true');
|
|
813
|
+
if (spec.heading) extra.push('heading: true');
|
|
814
|
+
return `${use('text')}(${opts(...extra)})`;
|
|
815
|
+
}
|
|
816
|
+
case 'select':
|
|
817
|
+
return `${use('select')}(${opts(`options: ${optionsText(spec.options)}`, ...(spec.default !== undefined ? [`default: ${q(spec.default)}`] : []))})`;
|
|
818
|
+
case 'toggle':
|
|
819
|
+
return `${use('toggle')}(${opts(...(spec.default !== undefined ? [`default: ${q(spec.default)}`] : []))})`;
|
|
820
|
+
case 'number':
|
|
821
|
+
return `${use('number')}(${opts()})`;
|
|
822
|
+
case 'date':
|
|
823
|
+
return `${use('date')}(${opts()})`;
|
|
824
|
+
case 'address':
|
|
825
|
+
return `${use('link')}(${opts('addressOnly: true')})`;
|
|
826
|
+
case 'link':
|
|
827
|
+
return `${use('link')}(${opts()})`;
|
|
828
|
+
case 'richText':
|
|
829
|
+
return `${use('richText')}(${opts(`headings: ${q(spec.headings)}`, `links: ${spec.links}`)})`;
|
|
830
|
+
case 'reference':
|
|
831
|
+
return `${use('reference')}(${opts(`to: ${q(read.collections[spec.to]?.key ?? spec.to)}`)})`;
|
|
832
|
+
case 'media':
|
|
833
|
+
return `${use('media')}(${opts(...(spec.only ? [`only: '${spec.only}'`] : []))})`;
|
|
834
|
+
case 'buttons':
|
|
835
|
+
return `${use('buttons')}(${opts(`styles: ${q(spec.styles)}`)})`;
|
|
836
|
+
case 'group':
|
|
837
|
+
return `${use('group')}(${fieldsConfig(spec.fields, read, used, indent)}, ${opts()})`;
|
|
838
|
+
case 'list': {
|
|
839
|
+
if (spec.item) {
|
|
840
|
+
const item =
|
|
841
|
+
spec.item.kind === 'link'
|
|
842
|
+
? `${use('link')}()`
|
|
843
|
+
: spec.item.kind === 'select'
|
|
844
|
+
? `${use('select')}({ options: ${optionsText(spec.item.options)} })`
|
|
845
|
+
: spec.item.kind === 'text' && !spec.item.label
|
|
846
|
+
? `${use('text')}()`
|
|
847
|
+
: fieldConfig(spec.item, read, used, indent);
|
|
848
|
+
return `${use('list')}(${item}, ${opts()})`;
|
|
849
|
+
}
|
|
850
|
+
const label = spec.fields.find((s) => s.kind === 'text')?.name;
|
|
851
|
+
return `${use('list')}(${fieldsConfig(spec.fields, read, used, indent)}, ${opts(...(label ? [`itemLabel: ${q(label)}`] : []))})`;
|
|
852
|
+
}
|
|
853
|
+
default:
|
|
854
|
+
throw new Error(`${spec.at}: no field helper for "${spec.kind}"`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function fieldsConfig(specs, read, used, indent) {
|
|
859
|
+
if (specs.length === 0) return '{}';
|
|
860
|
+
return `{\n${specs.map((s) => `${indent}\t${s.name}: ${fieldConfig(s, read, used, `${indent}\t`)},`).join('\n')}\n${indent}}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** The starting caracms.config.ts. */
|
|
864
|
+
export function scaffoldConfig(read) {
|
|
865
|
+
const used = new Set(['defineSite']);
|
|
866
|
+
const allBlocks = [...read.blocks.keys()];
|
|
867
|
+
const cols = Object.values(read.collections).map((c) => {
|
|
868
|
+
const parts = [`label: ${q(c.label)}`];
|
|
869
|
+
if (c.sections) {
|
|
870
|
+
parts.push(`kind: 'sections'`, `folder: ${q(`content/${c.key}`)}`, `urlBase: ${q(c.key === 'pages' ? '/' : `/${c.key}/`)}`);
|
|
871
|
+
if (c.key === 'pages') parts.push('nested: true');
|
|
872
|
+
if (c.blockKeys.length !== allBlocks.length) parts.push(`blocks: ${q(c.blockKeys)}`);
|
|
873
|
+
if (c.specs.length) parts.push(`fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
|
|
874
|
+
} else {
|
|
875
|
+
parts.push(`kind: 'fields'`, `folder: ${q(`content/${c.key}`)}`, `component: ${q(`src/components/caracms/${pascal(c.key)}.astro`)}`, `fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
|
|
876
|
+
}
|
|
877
|
+
return `\t\t${/^[a-z][a-z0-9]*$/.test(c.key) ? c.key : q(c.key)}: {\n\t\t\t${parts.join(',\n\t\t\t')},\n\t\t},`;
|
|
878
|
+
});
|
|
879
|
+
const blocks = [...read.blocks.entries()].map(([key, b]) => {
|
|
880
|
+
const name = /^[a-z][a-z0-9]*$/.test(key) ? key : q(key);
|
|
881
|
+
const help = b.help ? `\t\t\thelp: ${q(b.help)},\n` : '';
|
|
882
|
+
return `\t\t${name}: {\n\t\t\tlabel: ${q(b.label)},\n${help}\t\t\tcomponent: ${q(`src/blocks/${pascal(key)}.astro`)},\n\t\t\tfields: ${fieldsConfig(b.specs, read, used, '\t\t\t')},\n\t\t},`;
|
|
883
|
+
});
|
|
884
|
+
const settings = read.settingsSpecs.length ? `\tsettings: { fields: ${fieldsConfig(read.settingsSpecs, read, used, '\t')} },\n` : '';
|
|
885
|
+
return `import { ${[...used].sort().join(', ')} } from 'caracms/config';
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* caracms's config for this site, first scaffolded from Tina's schema by
|
|
889
|
+
* scripts/tina-to-caracms.mjs --scaffold-config (Tina's labels and help carried over).
|
|
890
|
+
* From here on it's the site's own: edit it by hand. The conversion never writes it again.
|
|
891
|
+
*
|
|
892
|
+
* Still to do by hand: each block's component (src/blocks/*.astro, drawn through Text,
|
|
893
|
+
* RichText, Media and Block), heading options, page-wide rules in the page component,
|
|
894
|
+
* the forms and the text styles. Trim Tina's longer help as you go.
|
|
895
|
+
*/
|
|
896
|
+
export default defineSite({
|
|
897
|
+
collections: {
|
|
898
|
+
${cols.join('\n')}
|
|
899
|
+
},
|
|
900
|
+
blocks: {
|
|
901
|
+
${blocks.join('\n')}
|
|
902
|
+
},
|
|
903
|
+
${settings} textStyles: {},
|
|
904
|
+
page: 'src/components/caracms/Page.astro',
|
|
905
|
+
});
|
|
906
|
+
`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ── The run ────────────────────────────────────────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
export function parseArgs(argv) {
|
|
912
|
+
const out = { flags: new Set() };
|
|
913
|
+
for (let i = 0; i < argv.length; i++) {
|
|
914
|
+
const a = argv[i];
|
|
915
|
+
if (a === '--scaffold-config' || a === '--force') out.flags.add(a.slice(2));
|
|
916
|
+
else if (a === '--from' || a === '--to' || a === '--recipients') out[a.slice(2)] = argv[++i];
|
|
917
|
+
else throw new Error(`Unknown argument: ${a}`);
|
|
918
|
+
}
|
|
919
|
+
return out;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const USAGE = 'Usage: node scripts/tina-to-caracms.mjs --from <a built Tina checkout> [--to .] [--recipients <address>] [--scaffold-config [--force]]';
|
|
923
|
+
|
|
924
|
+
async function main() {
|
|
925
|
+
let args;
|
|
926
|
+
try {
|
|
927
|
+
args = parseArgs(process.argv.slice(2));
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.error(`${error.message}\n${USAGE}`);
|
|
930
|
+
return 2;
|
|
931
|
+
}
|
|
932
|
+
if (!args.from) {
|
|
933
|
+
console.error(USAGE);
|
|
934
|
+
return 2;
|
|
935
|
+
}
|
|
936
|
+
const FROM = path.resolve(args.from);
|
|
937
|
+
const TO = path.resolve(args.to ?? '.');
|
|
938
|
+
if (FROM === TO) {
|
|
939
|
+
console.error('--from and --to are the same folder: the run would delete the Tina content it reads. Run it from the caracms site against a separate checkout.');
|
|
940
|
+
return 2;
|
|
941
|
+
}
|
|
942
|
+
if (args.recipients !== undefined && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(args.recipients)) {
|
|
943
|
+
console.error(`--recipients takes one email address, not "${args.recipients}".`);
|
|
944
|
+
return 2;
|
|
945
|
+
}
|
|
946
|
+
const schemaFile = path.join(FROM, 'tina/__generated__/_schema.json');
|
|
947
|
+
if (!existsSync(schemaFile)) {
|
|
948
|
+
console.error(`No ${schemaFile}: build the Tina checkout first (its build generates the schema).`);
|
|
949
|
+
return 1;
|
|
950
|
+
}
|
|
951
|
+
const schema = JSON.parse(readFileSync(schemaFile, 'utf8'));
|
|
952
|
+
const tools = await caracmsTools(TO);
|
|
953
|
+
const { values: creds, missing: credsMissing } = tools.readS3Env(process.env, TO);
|
|
954
|
+
const s3 = credsMissing.length === 0 ? creds : null;
|
|
955
|
+
|
|
956
|
+
const { read, conv, docs } = convertAll({ from: FROM, schema, slugify: tools.slugify, recipients: args.recipients });
|
|
957
|
+
|
|
958
|
+
if (args.flags.has('scaffold-config')) {
|
|
959
|
+
const file = path.join(TO, 'caracms.config.ts');
|
|
960
|
+
const starting = readFileSync(path.join(tools.packageRoot, 'bin/templates/caracms.config.ts.txt'), 'utf8');
|
|
961
|
+
if (existsSync(file) && readFileSync(file, 'utf8') !== starting && !args.flags.has('force')) {
|
|
962
|
+
console.error("caracms.config.ts isn't caracms init's starting config, so --scaffold-config leaves it (it's the site's own now). Add --force to replace it.");
|
|
963
|
+
return 1;
|
|
964
|
+
}
|
|
965
|
+
writeFileSync(file, scaffoldConfig(read));
|
|
966
|
+
console.log('Wrote caracms.config.ts from Tina\'s schema.');
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const sizes = await fillSizes(conv.images, path.join(TO, SIZES_FILE), tools, s3);
|
|
970
|
+
const texts = renderDocs(docs);
|
|
971
|
+
|
|
972
|
+
rmSync(path.join(TO, 'content'), { recursive: true, force: true });
|
|
973
|
+
for (const [rel, text] of texts) {
|
|
974
|
+
mkdirSync(path.dirname(path.join(TO, rel)), { recursive: true });
|
|
975
|
+
writeFileSync(path.join(TO, rel), text);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const counts = {};
|
|
979
|
+
for (const rel of texts.keys()) {
|
|
980
|
+
const c = rel.split('/').length > 2 ? rel.split('/')[1] : 'site settings';
|
|
981
|
+
counts[c] = (counts[c] ?? 0) + 1;
|
|
982
|
+
}
|
|
983
|
+
console.log(`Wrote content/: ${Object.entries(counts).map(([k, n]) => `${k} ${n}`).join(', ')}.`);
|
|
984
|
+
console.log(`Images: ${new Set(conv.images.map((i) => i.value.key)).size} keys; ${sizes.learned.length} new size${sizes.learned.length === 1 ? '' : 's'} learned into ${SIZES_FILE}.`);
|
|
985
|
+
if (sizes.missing.length) console.log(`No size found for:\n ${sizes.missing.join('\n ')}`);
|
|
986
|
+
|
|
987
|
+
if (conv.files.size) {
|
|
988
|
+
if (s3) {
|
|
989
|
+
const { made, kept } = await copyFiles(conv.files, tools, s3);
|
|
990
|
+
console.log(`Files: ${made.length} copied, ${kept.length} already there.`);
|
|
991
|
+
} else {
|
|
992
|
+
console.log(`Files: no S3_* settings (${credsMissing.join(', ')}), so these copies weren't made. Run again with them; a key that exists is skipped:`);
|
|
993
|
+
for (const f of [...conv.files.values()].sort((a, b) => a.key.localeCompare(b.key))) console.log(` ${f.sourceKey} → ${f.key}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (args.recipients) console.log(`Form recipients: every form's are ${args.recipients} (--recipients).`);
|
|
997
|
+
if (conv.warnings.length) console.log(`Warnings:\n ${conv.warnings.join('\n ')}`);
|
|
998
|
+
return 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
1002
|
+
main().then(
|
|
1003
|
+
(code) => {
|
|
1004
|
+
process.exitCode = code;
|
|
1005
|
+
},
|
|
1006
|
+
(error) => {
|
|
1007
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1008
|
+
process.exitCode = 1;
|
|
1009
|
+
},
|
|
1010
|
+
);
|
|
1011
|
+
}
|