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
package/bin/init.mjs
CHANGED
|
@@ -43,6 +43,7 @@ const SKILL_BLURBS = {
|
|
|
43
43
|
'caracms-content': 'changing content: the JSON files in content/, rich text, images, references, Site Settings, and working on a branch',
|
|
44
44
|
'caracms-blocks': 'building the site: blocks and their fields, collections, text styles, the page component, forms, and scripts beside the editor',
|
|
45
45
|
'caracms-adopt': 'bringing the site\'s existing pages onto caracms, one page at a time, and checking they look the same',
|
|
46
|
+
'caracms-tina': 'porting a site built on Tina (a Tina template site) onto caracms with the reference conversion script, re-runnable on newer content',
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const posix = (p) => p.split(sep).join('/');
|
|
@@ -12,8 +12,9 @@ import { site } from 'virtual:caracms/config';
|
|
|
12
12
|
export const prerender = true;
|
|
13
13
|
|
|
14
14
|
export async function getStaticPaths() {
|
|
15
|
-
// A file that can't be opened (invalid JSON, no title, a nested index.json) has no page
|
|
16
|
-
|
|
15
|
+
// A file that can't be opened (invalid JSON, no title, a nested index.json) has no page,
|
|
16
|
+
// and nor has a hidden one (or one under it): visitors get the 404 page.
|
|
17
|
+
const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning && !e.hidden);
|
|
17
18
|
return entries.map((e) => ({ params: { path: e.path === 'index' ? undefined : e.path }, props: { path: e.path } }));
|
|
18
19
|
}
|
|
19
20
|
|
package/package.json
CHANGED
|
@@ -142,6 +142,11 @@ form, such as `<brand> | <tagline>`, goes in the home page's own `seo.title`
|
|
|
142
142
|
the home page, where an editor changing it will look, and it wins whatever Site Settings
|
|
143
143
|
holds.
|
|
144
144
|
|
|
145
|
+
If the old site showed one site-wide title on every page without its own (a Tina template
|
|
146
|
+
does), set the config's `titleFallback: 'site'` beside `titleSeparator`: step 2 then holds
|
|
147
|
+
on every page, not only the home page (the paged-listing ` — page N` still follows). The
|
|
148
|
+
default, `'page-and-brand'`, is the list above.
|
|
149
|
+
|
|
145
150
|
**Per-page layout props.** The old routes often passed the layout something by hand: the
|
|
146
151
|
header's colour or variant, `<main>`'s classes, a script in `<head>` for one page, the
|
|
147
152
|
current page for the nav's highlight. A document has no route to do that, so the page
|
|
@@ -461,6 +466,13 @@ node .claude/skills/caracms-adopt/look-same.mjs --playwright /tmp/look-same \
|
|
|
461
466
|
--pages pages.txt --out /tmp/look-same/report \
|
|
462
467
|
--ignore-attr 'data-tina-*' --mask-ids auto
|
|
463
468
|
```
|
|
469
|
+
- **An element that varies run to run on the old site itself** (a wordmark fitted to its box
|
|
470
|
+
while the display font loads, so two shots of the same build differ) can't be fixed by
|
|
471
|
+
either site. `--css <rules>` adds the same CSS to both pages just before each shot: hide
|
|
472
|
+
the element or pin its size, e.g.
|
|
473
|
+
`--css '[data-wordmark-wrap]{visibility:hidden!important}'`. It changes the shots only,
|
|
474
|
+
not the HTML diff, and the report names the rules; keep them to the one element, since
|
|
475
|
+
whatever they cover is no longer compared.
|
|
464
476
|
- **Spaces**: a space between inline content shows as `␣` at the start of the next text,
|
|
465
477
|
wherever it sat (`<span>sub </span><em>` and `<span>sub</span> <em>` are the same), and a
|
|
466
478
|
space beside a block tag is dropped, since neither shows. So a `␣` line in the diff is a
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
// --ignore-attr <list> optional: attributes the HTML diff drops, a comma list of names. A name
|
|
28
28
|
// ending in * matches by prefix. A Tina port, for example:
|
|
29
29
|
// --ignore-attr data-tina-field,data-tina-island or --ignore-attr 'data-tina-*'
|
|
30
|
+
// --css <rules> optional: CSS added to both pages just before each shot, for an element
|
|
31
|
+
// that varies from run to run on the old site itself (a wordmark fitted to its
|
|
32
|
+
// box while the display font loads). Hide it or pin its size on both sides, e.g.
|
|
33
|
+
// --css '[data-wordmark]{visibility:hidden!important}'. It changes the shots
|
|
34
|
+
// only, not the HTML diff.
|
|
30
35
|
// --mask-ids <regex|auto> optional: ids drawn at random per render (MEC's st-a8f3k2, React's
|
|
31
36
|
// :r1:) differ on every build, and so does every attribute that points at
|
|
32
37
|
// them. Ids whose value matches the regex (JavaScript syntax, unanchored:
|
|
@@ -118,7 +123,7 @@ const TOLERANCE = 16;
|
|
|
118
123
|
function usage(message) {
|
|
119
124
|
if (message) console.error(`look-same: ${message}`);
|
|
120
125
|
console.error(
|
|
121
|
-
'usage: node look-same.mjs --playwright <dir> --old <url> --new <url> --pages <file|a,b,c> --out <dir> [--widths 1440,390] [--image-wait 5] [--ignore-attr a,data-b-*] [--mask-ids <regex>|auto]',
|
|
126
|
+
'usage: node look-same.mjs --playwright <dir> --old <url> --new <url> --pages <file|a,b,c> --out <dir> [--widths 1440,390] [--image-wait 5] [--ignore-attr a,data-b-*] [--mask-ids <regex>|auto] [--css <rules>]',
|
|
122
127
|
);
|
|
123
128
|
process.exit(1);
|
|
124
129
|
}
|
|
@@ -310,7 +315,7 @@ function compare(oldPng, newPng) {
|
|
|
310
315
|
|
|
311
316
|
// ---------------------------------------------------------------- taking a page
|
|
312
317
|
|
|
313
|
-
async function shoot(browser, url, width, imageWaitMs) {
|
|
318
|
+
async function shoot(browser, url, width, imageWaitMs, css) {
|
|
314
319
|
const context = await browser.newContext({
|
|
315
320
|
viewport: { width, height: width >= 768 ? 900 : 844 },
|
|
316
321
|
deviceScaleFactor: 1,
|
|
@@ -371,6 +376,7 @@ async function shoot(browser, url, width, imageWaitMs) {
|
|
|
371
376
|
return { stillLoading: still.length, images: still.slice(0, 3).map((img) => img.currentSrc || img.src) };
|
|
372
377
|
}, imageWaitMs);
|
|
373
378
|
await page.waitForTimeout(300);
|
|
379
|
+
if (css) await page.addStyleTag({ content: css });
|
|
374
380
|
const png = await page.screenshot({ fullPage: true, animations: 'disabled', type: 'png' });
|
|
375
381
|
return { png, status, imageCap };
|
|
376
382
|
} finally {
|
|
@@ -815,7 +821,9 @@ function renderPage(report, rows) {
|
|
|
815
821
|
? ` Ids matching <code>${escape(report.options.maskIds)}</code> (<code>--mask-ids</code>), and the references to them, are written <code>[id1]</code>, <code>[id2]</code>, … in the order the ids come on the page (a reference to an id the page doesn't have is <code>[missing1]</code>, …), so a reference that no longer points at its element still shows.`
|
|
816
822
|
: ''
|
|
817
823
|
} It catches what a screenshot can't: an attribute, an order under a cover, a lost space.</p>
|
|
818
|
-
<p><strong>Status:</strong> every page is compared whatever it answers (a 404 page too), and its HTTP status is shown beside it; two different statuses are a difference. <strong>Images:</strong> each shot waits up to ${report.options.imageWait} s for the images that have started loading; a shot that gave up says so
|
|
824
|
+
<p><strong>Status:</strong> every page is compared whatever it answers (a 404 page too), and its HTTP status is shown beside it; two different statuses are a difference. <strong>Images:</strong> each shot waits up to ${report.options.imageWait} s for the images that have started loading; a shot that gave up says so.${
|
|
825
|
+
report.options.css ? ` <strong>CSS</strong> (<code>--css</code>) added to both pages before each shot: <code>${escape(report.options.css)}</code>.` : ''
|
|
826
|
+
}</p>
|
|
819
827
|
</header>
|
|
820
828
|
${rows}
|
|
821
829
|
</body>
|
|
@@ -843,8 +851,9 @@ async function main() {
|
|
|
843
851
|
usage(`--mask-ids isn't a regular expression: ${error instanceof Error ? error.message : error}`);
|
|
844
852
|
}
|
|
845
853
|
}
|
|
854
|
+
const css = args.css ?? '';
|
|
846
855
|
const htmlOptions = { ignore: ignoredAttributes(args['ignore-attr']), maskIds };
|
|
847
|
-
const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids']);
|
|
856
|
+
const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids', 'css']);
|
|
848
857
|
for (const key of Object.keys(args)) if (!known.has(key)) usage(`unknown option --${key}`);
|
|
849
858
|
|
|
850
859
|
const chromium = await loadPlaywright(args.playwright ?? process.env.LOOK_SAME_PLAYWRIGHT);
|
|
@@ -885,8 +894,8 @@ async function main() {
|
|
|
885
894
|
const newUrl = joinUrl(args.new, path);
|
|
886
895
|
const result = { path, width, oldUrl, newUrl, files };
|
|
887
896
|
try {
|
|
888
|
-
const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000);
|
|
889
|
-
const newShot = await shoot(browser, newUrl, width, imageWait * 1000);
|
|
897
|
+
const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000, css);
|
|
898
|
+
const newShot = await shoot(browser, newUrl, width, imageWait * 1000, css);
|
|
890
899
|
writeFileSync(join(out, files.old), oldShot.png);
|
|
891
900
|
writeFileSync(join(out, files.new), newShot.png);
|
|
892
901
|
const { diff, ...score } = compare(oldShot.png, newShot.png);
|
|
@@ -929,7 +938,7 @@ async function main() {
|
|
|
929
938
|
generated: new Date().toISOString(),
|
|
930
939
|
score: `differingShare: of the pixels both screenshots cover, the share differing in any colour channel by more than ${TOLERANCE}/255. heightDelta: new height minus old, in CSS pixels.`,
|
|
931
940
|
tolerance: TOLERANCE,
|
|
932
|
-
options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null },
|
|
941
|
+
options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null, css: css || null },
|
|
933
942
|
status: 'status: the HTTP status each side answered, { old, new }. statusDiffers: true when they differ, which is a difference, not an error. imageCap (results only, when present): the shot gave up waiting for images after imageWait seconds; per side, how many were still loading and the first few addresses.',
|
|
934
943
|
results,
|
|
935
944
|
htmlScore:
|
|
@@ -124,7 +124,18 @@ const { block, cms } = Astro.props;
|
|
|
124
124
|
width / height) for a fluid image, or a CSS `width` and/or `height` for a fixed one;
|
|
125
125
|
`fit: 'contain'` to fit instead of crop. The block's CSS sizes it through `class`.
|
|
126
126
|
It takes the element's own attributes (`id`, `title`, `draggable`, `aria-*`,
|
|
127
|
-
`data-*`) too.
|
|
127
|
+
`data-*`) too. With an empty value it draws nothing for visitors and, in a preview,
|
|
128
|
+
a dashed placeholder the editor can click to pick the image. So call it as
|
|
129
|
+
`(block.media || cms.preview) && <Media …/>`, not `block.media && …`, wherever an
|
|
130
|
+
empty slot should still be reachable on the page.
|
|
131
|
+
- **A media value reaches the block resolved, not as stored.** Where the file has
|
|
132
|
+
`{ key, alt, focal, w, h }` or `{ video, play, poster: 12.5 }`, the block gets
|
|
133
|
+
`{ kind: 'image', key, alt, decorative, focal, position, w, h }` or
|
|
134
|
+
`{ kind: 'video', playbackId, play, name, mp4, poster: <url>, duration, w, h }`
|
|
135
|
+
(`poster` is Mux's still as a URL, `w`, `h` and `duration` are numbers or `null`;
|
|
136
|
+
`block.media` is `null` when the slot is empty). A site helper that reads a media
|
|
137
|
+
value (a hero's poster, a video's `playbackId`) reads this shape; use `kind` to tell
|
|
138
|
+
the two apart.
|
|
128
139
|
- `cms` is the render context: `cms.preview` (a preview render), `cms.first`,
|
|
129
140
|
`cms.index`, `cms.read` (other documents, below). For "the first block on the page"
|
|
130
141
|
(an eager hero, a heading that opens the page) use `cms.first`, true for the first
|
|
@@ -165,6 +176,8 @@ journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fi
|
|
|
165
176
|
static part is that `urlBase` (`src/pages/work/[slug].astro`, rendering with
|
|
166
177
|
`RenderDocument` from `caracms/components/RenderDocument.astro` and `listDocuments` /
|
|
167
178
|
`readDocument` from `caracms/content`, as `src/pages/[...path].astro` does for pages).
|
|
179
|
+
Its `getStaticPaths` leaves out entries with a `warning` (a file that can't be opened) and
|
|
180
|
+
`hidden` ones (a page hidden from the site, or one under it): those answer the 404 page.
|
|
168
181
|
Every other route in `src/pages/` is the site's own code, and its address can't hold a
|
|
169
182
|
document.
|
|
170
183
|
- Shared blocks (`content/shared/`) and Site Settings are built in; no config.
|
|
@@ -192,6 +205,11 @@ const settings = await siteSettings(site, Astro.locals);
|
|
|
192
205
|
---
|
|
193
206
|
```
|
|
194
207
|
|
|
208
|
+
**A block that needs Site Settings** (a form block showing the site's phone number) reads
|
|
209
|
+
them the same way, with `siteSettings(site, Astro.locals)`: in a preview it reads the
|
|
210
|
+
editor's draft, so an edit to Site Settings shows in the block too. `cms.read` is for
|
|
211
|
+
collections only; it doesn't reach Site Settings.
|
|
212
|
+
|
|
195
213
|
## Text styles and lockups
|
|
196
214
|
|
|
197
215
|
A lockup is a heading whose lines are set by the editor and fitted to their box in a
|
|
@@ -247,7 +265,9 @@ editor's preview alike. It gets `PageProps` (`caracms/document`): `document`,
|
|
|
247
265
|
home page's own `seo.title`.) The ` · ` is the
|
|
248
266
|
config's `titleSeparator`, spaces included: `titleSeparator: ' | '` beside `page` in
|
|
249
267
|
`defineSite({ … })` keeps a site's old `About | Fernhill`. It's code, not a Site
|
|
250
|
-
Settings field: the title's format is design.
|
|
268
|
+
Settings field: the title's format is design. `titleFallback: 'site'` beside it makes every
|
|
269
|
+
page without its own `seo.title` fall back as the home page does (Site Settings'
|
|
270
|
+
`seo.title`, then the brand name); the default is `'page-and-brand'`. A code route builds its title the same way
|
|
251
271
|
from `siteSettings`: `` `${title}${settings.seo.titleSeparator}${settings.brand.name}` ``. `<Blocks>` renders the blocks (or a fields document through its
|
|
252
272
|
collection's component).
|
|
253
273
|
- `document.kind === 'shared'` is a shared block's own preview: render it without the
|
|
@@ -274,6 +294,33 @@ Draw it in a block with `<Form form="contact" preview={cms.preview} submit="Send
|
|
|
274
294
|
`select({ options: [{ value: 'contact', label: 'Contact' }] })`. Submissions land in the
|
|
275
295
|
editor's inbox and are emailed to Site Settings' `formRecipients` for that form.
|
|
276
296
|
|
|
297
|
+
A field can also take `help`, `placeholder` (text, email, tel, textarea), `prefix` (text,
|
|
298
|
+
tel), `error` (its own error line), `autocomplete`, and on a textarea `rows` (its height in
|
|
299
|
+
lines, 1 to 50; default 6).
|
|
300
|
+
|
|
301
|
+
The markup's classes are `.cms-form`, `.cms-form-field`, `.cms-form-label`,
|
|
302
|
+
`.cms-form-help`, `.cms-form-error`, `.cms-form-submit` and `.cms-form-status`. For a
|
|
303
|
+
site that draws its own look, `<Form>` takes, each optional (left out, it renders as
|
|
304
|
+
without them):
|
|
305
|
+
|
|
306
|
+
- `class`, and any other attribute (`data-reveal`, `id`, `aria-*`), on the `<form>`. The
|
|
307
|
+
form's own attributes (`action`, `method`, its `data-caracms-*`…) can't be replaced.
|
|
308
|
+
- `submitClass`: classes on the submit button, beside `cms-form-submit`
|
|
309
|
+
(`submitClass="btn btn-primary"`).
|
|
310
|
+
- `thanks`: the line after a send, in place of "Thanks, we'll be in touch shortly."
|
|
311
|
+
`failed`: the line when a send fails, in place of "Something went wrong. Please email
|
|
312
|
+
us directly." (when the endpoint gives its own sentence, that shows instead).
|
|
313
|
+
- `errors`: each field type's error line, in place of the package's wording:
|
|
314
|
+
`errors={{ email: 'Please check your email address.' }}`. A field's own `error` still wins.
|
|
315
|
+
- `actions`: wraps the button and the status line in `<div class="cms-form-actions">`, so
|
|
316
|
+
the site's CSS can set them in a row.
|
|
317
|
+
- `labelPlaceholders`: a field that can take a placeholder and sets none uses its label.
|
|
318
|
+
|
|
319
|
+
```astro
|
|
320
|
+
<Form form="contact" preview={cms.preview} class="contact-form" data-reveal
|
|
321
|
+
submitClass="btn btn-primary" thanks="Thanks — we'll be in touch." actions labelPlaceholders />
|
|
322
|
+
```
|
|
323
|
+
|
|
277
324
|
## Scripts and the live preview
|
|
278
325
|
|
|
279
326
|
The editor shows the page in iframes, through the same page component with
|
|
@@ -58,7 +58,8 @@ A page (`sections`):
|
|
|
58
58
|
(`<title> · <brand name>`, unless `seo.title` is set; the config's `titleSeparator` can
|
|
59
59
|
make the ` · ` another separator); it isn't necessarily drawn on the page. The home page
|
|
60
60
|
is the exception: its title is its own `seo.title`, else Site Settings' `seo.title`, else
|
|
61
|
-
the brand name (the document's `title` only when all of those are empty).
|
|
61
|
+
the brand name (the document's `title` only when all of those are empty). With the
|
|
62
|
+
config's `titleFallback: 'site'`, every page falls back that way, not only the home page.
|
|
62
63
|
- Each block is `{ "type": <a key of the config's blocks>, ...its fields }`. A field left
|
|
63
64
|
out reads as empty.
|
|
64
65
|
- `{ "shared": "<name>" }` places the shared block `content/shared/<name>.json` there.
|