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.
Files changed (58) hide show
  1. package/bin/init.mjs +1 -0
  2. package/bin/templates/catch-all.astro.txt +3 -2
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +12 -0
  5. package/skills/caracms-adopt/look-same.mjs +16 -7
  6. package/skills/caracms-blocks/SKILL.md +49 -2
  7. package/skills/caracms-content/SKILL.md +2 -1
  8. package/skills/caracms-tina/SKILL.md +427 -0
  9. package/skills/caracms-tina/field-kinds.md +88 -0
  10. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  11. package/src/block-runs.ts +84 -0
  12. package/src/cli/validate.ts +10 -5
  13. package/src/components/BlockRunTree.astro +45 -0
  14. package/src/components/Blocks.astro +20 -1
  15. package/src/components/Form.astro +76 -10
  16. package/src/components/Head.astro +1 -0
  17. package/src/components/Media.astro +27 -1
  18. package/src/components/RichText.astro +12 -4
  19. package/src/components/Text.astro +32 -9
  20. package/src/components/form-client.ts +7 -4
  21. package/src/components/sitemap.xml.ts +2 -1
  22. package/src/config.ts +32 -1
  23. package/src/content-schema.ts +7 -4
  24. package/src/content.ts +26 -0
  25. package/src/document.ts +15 -2
  26. package/src/editor/client/block-list/summary.ts +7 -1
  27. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  28. package/src/editor/client/fields/LinkField.tsx +17 -12
  29. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  30. package/src/editor/client/fields/RichTextField.tsx +38 -32
  31. package/src/editor/client/fields/host.tsx +5 -0
  32. package/src/editor/client/fields/parts.tsx +2 -1
  33. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  34. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  35. package/src/editor/client/nav/DocRow.tsx +6 -0
  36. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  37. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  38. package/src/editor/client/preview/driver.ts +5 -5
  39. package/src/editor/client/preview/field-text.ts +40 -2
  40. package/src/editor/client/preview/page-edit.ts +2 -2
  41. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  42. package/src/editor/client/preview/page-editor.ts +3 -3
  43. package/src/editor/client/state/document-store.ts +20 -2
  44. package/src/editor/styles/editor.css +2 -0
  45. package/src/editor/types.ts +2 -0
  46. package/src/fields.ts +63 -15
  47. package/src/forms/rules.ts +9 -1
  48. package/src/head.ts +7 -3
  49. package/src/links.ts +35 -0
  50. package/src/live/README.md +1 -0
  51. package/src/live/backend.ts +41 -23
  52. package/src/live/branches.ts +19 -0
  53. package/src/live/mode.ts +46 -0
  54. package/src/live/runtime.ts +5 -2
  55. package/src/media.ts +14 -6
  56. package/src/read.ts +2 -1
  57. package/src/rich-text.ts +30 -5
  58. 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
- const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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.</p>
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. A code route builds its title the same way
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.