caracms 0.1.0 → 0.3.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 (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
@@ -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:
@@ -105,7 +110,7 @@
105
110
  // Exit codes: 0 when the report is written (whatever the scores, statuses or image caps), 1 for
106
111
  // a usage error, 2 when the report is written but some page couldn't be fetched at all.
107
112
 
108
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
113
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
109
114
  import { createRequire } from 'node:module';
110
115
  import { join, resolve } from 'node:path';
111
116
  import { pathToFileURL } from 'node:url';
@@ -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
  }
@@ -137,7 +142,9 @@ function parseArgs(argv) {
137
142
  }
138
143
 
139
144
  function readPages(spec) {
140
- const lines = existsSync(spec) ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
145
+ // A list file only when it's a file: `--pages /` is the home page, not the disk's root.
146
+ const isFile = existsSync(spec) && statSync(spec).isFile();
147
+ const lines = isFile ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
141
148
  return lines
142
149
  .map((line) => line.replace(/#.*/, '').trim())
143
150
  .filter(Boolean)
@@ -310,7 +317,7 @@ function compare(oldPng, newPng) {
310
317
 
311
318
  // ---------------------------------------------------------------- taking a page
312
319
 
313
- async function shoot(browser, url, width, imageWaitMs) {
320
+ async function shoot(browser, url, width, imageWaitMs, css) {
314
321
  const context = await browser.newContext({
315
322
  viewport: { width, height: width >= 768 ? 900 : 844 },
316
323
  deviceScaleFactor: 1,
@@ -332,11 +339,13 @@ async function shoot(browser, url, width, imageWaitMs) {
332
339
  const bottom = document.documentElement.scrollHeight - window.innerHeight;
333
340
  if (y >= bottom) break;
334
341
  y = Math.min(y + Math.round(window.innerHeight * 0.75), bottom);
335
- window.scrollTo(0, y);
342
+ // Instant, whatever the page's own `scroll-behavior`: a smooth scroll still moving at
343
+ // the shot puts a sticky header somewhere else each run (Urban Return port).
344
+ window.scrollTo({ top: y, behavior: 'instant' });
336
345
  await pause(120);
337
346
  }
338
347
  await pause(300);
339
- window.scrollTo(0, 0);
348
+ window.scrollTo({ top: 0, behavior: 'instant' });
340
349
  });
341
350
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
342
351
  await page.evaluate(() => document.fonts.ready);
@@ -371,6 +380,7 @@ async function shoot(browser, url, width, imageWaitMs) {
371
380
  return { stillLoading: still.length, images: still.slice(0, 3).map((img) => img.currentSrc || img.src) };
372
381
  }, imageWaitMs);
373
382
  await page.waitForTimeout(300);
383
+ if (css) await page.addStyleTag({ content: css });
374
384
  const png = await page.screenshot({ fullPage: true, animations: 'disabled', type: 'png' });
375
385
  return { png, status, imageCap };
376
386
  } finally {
@@ -815,7 +825,9 @@ function renderPage(report, rows) {
815
825
  ? ` 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
826
  : ''
817
827
  } 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>
828
+ <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.${
829
+ report.options.css ? ` <strong>CSS</strong> (<code>--css</code>) added to both pages before each shot: <code>${escape(report.options.css)}</code>.` : ''
830
+ }</p>
819
831
  </header>
820
832
  ${rows}
821
833
  </body>
@@ -843,8 +855,9 @@ async function main() {
843
855
  usage(`--mask-ids isn't a regular expression: ${error instanceof Error ? error.message : error}`);
844
856
  }
845
857
  }
858
+ const css = args.css ?? '';
846
859
  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']);
860
+ const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids', 'css']);
848
861
  for (const key of Object.keys(args)) if (!known.has(key)) usage(`unknown option --${key}`);
849
862
 
850
863
  const chromium = await loadPlaywright(args.playwright ?? process.env.LOOK_SAME_PLAYWRIGHT);
@@ -885,8 +898,8 @@ async function main() {
885
898
  const newUrl = joinUrl(args.new, path);
886
899
  const result = { path, width, oldUrl, newUrl, files };
887
900
  try {
888
- const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000);
889
- const newShot = await shoot(browser, newUrl, width, imageWait * 1000);
901
+ const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000, css);
902
+ const newShot = await shoot(browser, newUrl, width, imageWait * 1000, css);
890
903
  writeFileSync(join(out, files.old), oldShot.png);
891
904
  writeFileSync(join(out, files.new), newShot.png);
892
905
  const { diff, ...score } = compare(oldShot.png, newShot.png);
@@ -929,7 +942,7 @@ async function main() {
929
942
  generated: new Date().toISOString(),
930
943
  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
944
  tolerance: TOLERANCE,
932
- options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null },
945
+ 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
946
  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
947
  results,
935
948
  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,13 +265,33 @@ 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
254
274
  header and footer.
255
- - `<Media>` needs a block's `cms`, so images in the page's frame (a logo) stay the site's
256
- own files.
275
+ - **An image in the page's frame** (a header logo, a mega-menu photo, a footer badge) is a
276
+ `media()` field in Site Settings, drawn with `settingsImageUrl` from `caracms/content`
277
+ (`<Media>` needs a block's `cms`, which the frame doesn't have):
278
+
279
+ ```astro
280
+ ---
281
+ import { settingsImageUrl, siteSettings } from 'caracms/content';
282
+ import { site } from 'virtual:caracms/config';
283
+ const settings = await siteSettings(site, Astro.locals);
284
+ const photo = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
285
+ ---
286
+ <div class="mega" data-image={photo ?? '/images/placeholder.svg'}></div>
287
+ ```
288
+
289
+ It gives one URL by `<Media>`'s rules: one side is a `fit`, both are a `fill` cropped to
290
+ the focal point, a GIF or an SVG passes through unresized, no size is a `fit` at 1920. Ask
291
+ for the size it's shown at (twice it for a sharp 2× screen); there's no `srcset`. It's
292
+ null when the field is empty or the media isn't set up, so give the frame a fallback. It
293
+ works in the build, in the editor's preview (the draft's image) and under
294
+ `CARACMS_MEDIA=memory`.
257
295
 
258
296
  ## Forms
259
297
 
@@ -274,6 +312,33 @@ Draw it in a block with `<Form form="contact" preview={cms.preview} submit="Send
274
312
  `select({ options: [{ value: 'contact', label: 'Contact' }] })`. Submissions land in the
275
313
  editor's inbox and are emailed to Site Settings' `formRecipients` for that form.
276
314
 
315
+ A field can also take `help`, `placeholder` (text, email, tel, textarea), `prefix` (text,
316
+ tel), `error` (its own error line), `autocomplete`, and on a textarea `rows` (its height in
317
+ lines, 1 to 50; default 6).
318
+
319
+ The markup's classes are `.cms-form`, `.cms-form-field`, `.cms-form-label`,
320
+ `.cms-form-help`, `.cms-form-error`, `.cms-form-submit` and `.cms-form-status`. For a
321
+ site that draws its own look, `<Form>` takes, each optional (left out, it renders as
322
+ without them):
323
+
324
+ - `class`, and any other attribute (`data-reveal`, `id`, `aria-*`), on the `<form>`. The
325
+ form's own attributes (`action`, `method`, its `data-caracms-*`…) can't be replaced.
326
+ - `submitClass`: classes on the submit button, beside `cms-form-submit`
327
+ (`submitClass="btn btn-primary"`).
328
+ - `thanks`: the line after a send, in place of "Thanks, we'll be in touch shortly."
329
+ `failed`: the line when a send fails, in place of "Something went wrong. Please email
330
+ us directly." (when the endpoint gives its own sentence, that shows instead).
331
+ - `errors`: each field type's error line, in place of the package's wording:
332
+ `errors={{ email: 'Please check your email address.' }}`. A field's own `error` still wins.
333
+ - `actions`: wraps the button and the status line in `<div class="cms-form-actions">`, so
334
+ the site's CSS can set them in a row.
335
+ - `labelPlaceholders`: a field that can take a placeholder and sets none uses its label.
336
+
337
+ ```astro
338
+ <Form form="contact" preview={cms.preview} class="contact-form" data-reveal
339
+ submitClass="btn btn-primary" thanks="Thanks — we'll be in touch." actions labelPlaceholders />
340
+ ```
341
+
277
342
  ## Scripts and the live preview
278
343
 
279
344
  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.