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
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Runs of blocks (M9 Decision 9): the page component tells `<Blocks>` which neighbouring
3
+ * blocks go together and what's drawn round them, such as a band holding cards, each card
4
+ * holding several blocks (a Tina template's About page).
5
+ *
6
+ * <Blocks document={document} collection={collection} preview={preview} wrap={cardRuns} />
7
+ *
8
+ * // The page component's own rule, from the blocks' data:
9
+ * const cardRuns: WrapBlocks = (blocks) => …[{ from: 2, to: 6, component: Band, runs: [
10
+ * { from: 2, to: 4, component: Card, props: { id: 'story' } },
11
+ * { from: 5, to: 6, component: Card, props: { id: 'team' } },
12
+ * ] }];
13
+ *
14
+ * - `wrap` gets the blocks that render, in order: each one's document index, type and render
15
+ * data (what its component gets as `block`), so the rule can read a field (`card`, `anchor`).
16
+ * - A run is `from`..`to`, document indices, inclusive: everything between renders inside it,
17
+ * a skipped block's preview marker included. Its `component` is drawn round the run with
18
+ * `props`, the blocks in its default slot. `runs` are runs inside it (the cards in a band),
19
+ * each within it.
20
+ * - Runs are in order and don't overlap. A rule that breaks that is a mistake in the site's
21
+ * code, so the render stops with a sentence saying which runs.
22
+ * - Nothing about each block changes: its index, its context (`index`, `first`, `h1Field`,
23
+ * `eager`), its markers in a preview, so it's selected, typed into and morphed there as
24
+ * anywhere else. A wrapper carries no markers: it's the site's markup.
25
+ */
26
+
27
+ /** A block as `wrap` sees it. */
28
+ export interface WrapBlock {
29
+ /** Its index in the document (the editor's index, `cms.index`). */
30
+ index: number;
31
+ type: string;
32
+ /** Its render data: what its component gets as `block`. */
33
+ data: Record<string, unknown>;
34
+ }
35
+
36
+ /** A run of blocks and what's drawn round it. */
37
+ export interface BlockRun {
38
+ /** The first block's document index. */
39
+ from: number;
40
+ /** The last block's document index (inclusive). */
41
+ to: number;
42
+ /** An Astro component (or any component Astro renders): the run goes in its default slot. */
43
+ component: unknown;
44
+ props?: Record<string, unknown>;
45
+ /** Runs inside this one, each within `from`..`to`. */
46
+ runs?: readonly BlockRun[];
47
+ }
48
+
49
+ /** The page component's rule: the runs, from the blocks that render. */
50
+ export type WrapBlocks = (blocks: readonly WrapBlock[]) => readonly BlockRun[];
51
+
52
+ /** What `<Blocks>` draws, as a tree: an entry (by document index), or a run holding more. */
53
+ export type RunNode = { kind: 'entry'; index: number } | { kind: 'run'; component: unknown; props: Record<string, unknown>; children: RunNode[] };
54
+
55
+ const named = (run: BlockRun) => `the run ${run.from}–${run.to}`;
56
+
57
+ /**
58
+ * The document's entries (`indices`, in order: every block the render has an entry for)
59
+ * grouped by `runs`. Throws on runs out of order, overlapping, outside their parent, or
60
+ * with `from` after `to`.
61
+ */
62
+ export function runTree(indices: readonly number[], runs: readonly BlockRun[]): RunNode[] {
63
+ const place = (within: readonly number[], level: readonly BlockRun[], parent: BlockRun | null): RunNode[] => {
64
+ let previous: BlockRun | null = null;
65
+ for (const run of level) {
66
+ if (!Number.isInteger(run.from) || !Number.isInteger(run.to) || run.from > run.to) throw new Error(`<Blocks wrap>: ${named(run)} needs whole-number indices with from at or before to.`);
67
+ if (!run.component) throw new Error(`<Blocks wrap>: ${named(run)} has no component to draw round it.`);
68
+ if (previous && run.from <= previous.to) throw new Error(`<Blocks wrap>: ${named(run)} starts before ${named(previous)} ends. Runs are in document order and don't overlap.`);
69
+ if (parent && (run.from < parent.from || run.to > parent.to)) throw new Error(`<Blocks wrap>: ${named(run)} isn't inside ${named(parent)}, which holds it.`);
70
+ previous = run;
71
+ }
72
+ const out: RunNode[] = [];
73
+ let i = 0;
74
+ for (const run of level) {
75
+ while (i < within.length && within[i]! < run.from) out.push({ kind: 'entry', index: within[i++]! });
76
+ const inside: number[] = [];
77
+ while (i < within.length && within[i]! <= run.to) inside.push(within[i++]!);
78
+ out.push({ kind: 'run', component: run.component, props: run.props ?? {}, children: place(inside, run.runs ?? [], run) });
79
+ }
80
+ while (i < within.length) out.push({ kind: 'entry', index: within[i++]! });
81
+ return out;
82
+ };
83
+ return place(indices, runs, null);
84
+ }
@@ -28,13 +28,14 @@ import { codeRouteAt, codeRouteMessage, shadowingMessage, shadowingRoutes, type
28
28
  import { pagesRoutes, readCodeRoutes, srcDirOf } from '../code-routes-disk.ts';
29
29
  import { listDocuments, type DocumentEntry } from '../content.ts';
30
30
  import { own } from '../document.ts';
31
- import { fieldWarnings, parseDay, type Field, type Fields, type WarningContext } from '../fields.ts';
31
+ import { fieldWarnings, parseDay, richTextOptions, type Field, type Fields, type WarningContext } from '../fields.ts';
32
32
  import { measureLockup } from '../measure.ts';
33
33
  import { isFileKey } from '../files.ts';
34
34
  import { readRichText } from '../rich-text.ts';
35
35
  import { savedForm } from '../serialize.ts';
36
36
  import { SCHEMA_KEY, schemaRef } from '../schema-key.ts';
37
37
  import { sameAddress } from '../slug.ts';
38
+ import { hiddenDocuments, hiddenPages } from '../links.ts';
38
39
  import { runSchemas, schemaFileChanges, schemasReport } from './schemas.ts';
39
40
  import { jsonFilesUnder, loadSite } from './site.ts';
40
41
 
@@ -133,7 +134,7 @@ function checkValue(c: Context, field: Field, raw: unknown, path: string, inList
133
134
  case 'richText': {
134
135
  if (typeof raw !== 'string' && !Array.isArray(raw)) return wrong('rich text: a list of { "p": "…" } entries, or a string');
135
136
  // The grammar (rich-text.ts): what the reader couldn't read is lost on the next save.
136
- const { issues } = readRichText(raw, { headings: field.headings, links: field.links });
137
+ const { issues } = readRichText(raw, richTextOptions(field));
137
138
  for (const issue of issues) {
138
139
  if (issue.code !== 'unreadable') continue;
139
140
  const allowed = ['p', ...field.headings, 'ul', 'ol'];
@@ -270,7 +271,7 @@ function editorWarnings(c: Context, field: Field, raw: unknown, path: string) {
270
271
  const p = fromDotted(path, w.path);
271
272
  if (w.code === 'unreadable' || w.code === 'missing-reference') continue;
272
273
  // Rich text outside the field's grammar (an h1, a heading or a link the field doesn't take) is an error: Decision 15.
273
- if (w.code === 'disallowed-kind' || w.code === 'link-off') error(c, p, w.message);
274
+ if (w.code === 'disallowed-kind' || w.code === 'link-off' || w.code === 'italic-off') error(c, p, w.message);
274
275
  else warning(c, p, w.message);
275
276
  }
276
277
  }
@@ -307,7 +308,9 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
307
308
  if (raw.title !== undefined && typeof raw.title !== 'string') error(c, 'title', `Expected text (a string), found ${what(raw.title)}.`);
308
309
  switch (collection.kind) {
309
310
  case 'sections': {
310
- checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'blocks', 'seo']);
311
+ checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'blocks', 'seo']);
312
+ // M9 Decision 7.
313
+ if (raw.hidden !== undefined && raw.hidden !== true) error(c, 'hidden', `Expected true (hidden from the site) or no "hidden" at all, found ${what(raw.hidden)}.`);
311
314
  if (!Object.hasOwn(raw, 'blocks')) error(c, 'blocks', 'This page has no "blocks". Every page has a list of blocks, even an empty one ([]).');
312
315
  else if (!Array.isArray(raw.blocks)) error(c, 'blocks', `Expected a list of blocks, found ${what(raw.blocks)}.`);
313
316
  else raw.blocks.forEach((b, i) => checkBlock(c, b, at('blocks', i), collection));
@@ -357,7 +360,7 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
357
360
  const entries = await listDocuments(site);
358
361
  const ids = new Set(entries.map((e) => `${e.collection}\0${e.path}`));
359
362
  const exists = (collection: string, path: string) => ids.has(`${collection}\0${path}`);
360
- const warningContext: WarningContext = { addresses: entries.map((e) => e.url), documentExists: exists, codeRoutes };
363
+ const warningContext: WarningContext = { addresses: entries.map((e) => e.url), documentExists: exists, codeRoutes, hiddenPages: hiddenPages(entries), hiddenDocuments: hiddenDocuments(entries) };
361
364
 
362
365
  // Files in no collection: under content/ and every collection's folder.
363
366
  const listed = new Set(entries.map((e) => e.file));
@@ -391,6 +394,8 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
391
394
  }
392
395
  const raw = JSON.parse(text) as unknown;
393
396
  checkDocument(c, collection, raw);
397
+ // M9 Decision 7: as in Tina, the home page can't be hidden (content.ts reads it as shown).
398
+ if (collection.kind === 'sections' && entry.path === 'index' && isObject(raw) && raw.hidden === true) warning(c, 'hidden', 'The home page can’t be hidden, so it’s shown. Remove "hidden".');
394
399
  if (isObject(raw) && raw[SCHEMA_KEY] === undefined) warning(c, '', `This file has no "$schema" (${JSON.stringify(schemaRef(collection, entry.file))}). \`caracms validate --fix\` adds it.`);
395
400
  const saved = savedForm(text, collection, site);
396
401
  if (saved !== null && saved !== text) warning(c, '', REFORMAT_MESSAGE);
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * `<Blocks>`'s own renderer for runs of blocks (M9 Decision 9; block-runs.ts): each entry
4
+ * as `<Blocks>` draws it (the block's component, or a skipped block's preview marker), and
5
+ * each run as its component round its own entries and runs, recursively. Internal: a site
6
+ * uses `<Blocks wrap={…}>`.
7
+ */
8
+ import type { RunNode } from '../block-runs.ts';
9
+ import type { BlockContext } from '../document.ts';
10
+ import { skippedMarker } from '../skipped.ts';
11
+
12
+ export interface RunItem {
13
+ Component: ((props: any) => any) | undefined;
14
+ data: Record<string, unknown> | null;
15
+ cms: BlockContext | null;
16
+ skipped: { type: string; shared?: string } | null;
17
+ }
18
+
19
+ interface Props {
20
+ nodes: RunNode[];
21
+ items: ReadonlyMap<number, RunItem>;
22
+ preview: boolean;
23
+ }
24
+
25
+ const { nodes, items, preview } = Astro.props;
26
+ ---
27
+
28
+ {
29
+ nodes.map((node) => {
30
+ if (node.kind === 'run') {
31
+ const Wrapper = node.component as (props: any) => any;
32
+ return (
33
+ <Wrapper {...node.props}>
34
+ <Astro.self nodes={node.children} items={items} preview={preview} />
35
+ </Wrapper>
36
+ );
37
+ }
38
+ const item = items.get(node.index);
39
+ if (!item) return null;
40
+ const { Component, data, cms, skipped } = item;
41
+ if (Component && cms) return <Component block={data} cms={cms} />;
42
+ // As in Blocks.astro: a comment, so the site's sibling CSS matches across it.
43
+ return preview && skipped ? <Fragment set:html={skippedMarker(node.index, skipped)} /> : null;
44
+ })
45
+ }
@@ -37,6 +37,12 @@
37
37
  * component passes on the `listPage` it gets, 1 when it doesn't).
38
38
  * - The context is computed here, per render, and passed down as props: no module
39
39
  * state, so concurrent preview renders can't leak into each other.
40
+ * - **Runs of blocks** (M9 Decision 9): `wrap` is the page component's rule for which
41
+ * neighbouring blocks go together and what's drawn round them (a band of cards, each
42
+ * card several blocks). It gets the blocks that render (`{ index, type, data }`) and
43
+ * returns runs, `{ from, to, component, props?, runs? }` (block-runs.ts). Each block in a
44
+ * run keeps its index, context, markers, selection and morph, exactly as outside one.
45
+ * A section builder's (or a shared block's) blocks only.
40
46
  */
41
47
  import { blocks } from 'virtual:caracms/blocks';
42
48
  import { components } from 'virtual:caracms/collections';
@@ -45,6 +51,8 @@ import { blockContexts, documentKind, fieldsHeading, own, type BlockContext, typ
45
51
  import { firstImagePath } from '../fields.ts';
46
52
  import { documentReader, renderFields, renderSourceOf, resolveShared } from '../read.ts';
47
53
  import { skippedMarker } from '../skipped.ts';
54
+ import { runTree, type RunNode, type WrapBlocks } from '../block-runs.ts';
55
+ import BlockRunTree from './BlockRunTree.astro';
48
56
 
49
57
  interface Props {
50
58
  document: PageDocument;
@@ -53,9 +61,11 @@ interface Props {
53
61
  preview?: boolean;
54
62
  /** Posts and tags, Decision 12: the page of a paged listing (the page component's `listPage`); 1 by default. */
55
63
  listPage?: number;
64
+ /** M9 Decision 9: which runs of blocks go together, and what's drawn round each (block-runs.ts). */
65
+ wrap?: WrapBlocks;
56
66
  }
57
67
 
58
- const { document, collection, preview = false } = Astro.props;
68
+ const { document, collection, preview = false, wrap } = Astro.props;
59
69
  // A preview is always page 1; anything that isn't a whole number from 1 reads as 1.
60
70
  const listPage = !preview && Number.isInteger(Astro.props.listPage) && Astro.props.listPage! >= 1 ? Astro.props.listPage! : 1;
61
71
  const read = documentReader(site, renderSourceOf(Astro.locals));
@@ -71,6 +81,7 @@ interface Item {
71
81
  let items: Item[] = [];
72
82
  let empty = false;
73
83
  let body: { Component: (props: any) => any; props: Record<string, unknown> } | null = null;
84
+ let tree: RunNode[] | null = null;
74
85
 
75
86
  if (kind === 'fields') {
76
87
  const c = collection ? own(site.collections, collection) : undefined;
@@ -106,11 +117,19 @@ if (kind === 'fields') {
106
117
  }),
107
118
  );
108
119
  empty = page.blocks.length === 0;
120
+ if (wrap) {
121
+ const rendered = items.filter((it) => it.Component && it.cms).map((it) => ({ index: it.index, type: it.cms!.type, data: it.data! }));
122
+ const runs = wrap(rendered);
123
+ if (runs.length) tree = runTree(items.map((it) => it.index), runs);
124
+ }
109
125
  }
126
+ const byIndex = new Map(items.map((it) => [it.index, it]));
110
127
  ---
111
128
 
112
129
  {body && <body.Component {...body.props} />}
130
+ {tree && <BlockRunTree nodes={tree} items={byIndex} preview={preview} />}
113
131
  {
132
+ !tree &&
114
133
  items.map(({ index, Component, data, cms, skipped }) => {
115
134
  if (Component && cms) return <Component block={data} cms={cms} />;
116
135
  // A comment, not an element: the site's sibling CSS (`+`, `:has(+ …)`) matches across it as on the public page.
@@ -24,10 +24,30 @@
24
24
  * editor can send it, and a click falls through to the block.
25
25
  *
26
26
  * An id that isn't one of the site's forms renders nothing (and says so in a preview).
27
+ *
28
+ * **A site that draws its own look** (M9 port friction 10) has these hooks, each optional;
29
+ * left out, the form renders exactly as above:
30
+ *
31
+ * - `class` and any other attribute (`data-*`, `id`, `aria-*`…) go on the `<form>`;
32
+ * the ones the form sets itself (`action`, `method`, `enctype`, `inert`, its own
33
+ * `data-caracms-*`, `data-max-*`, `data-file-kinds`, `data-thanks`, `data-failed`) can't be replaced;
34
+ * - `submitClass`: classes on the submit button, beside `cms-form-submit`;
35
+ * - `thanks`: the line after a send (default "Thanks, we'll be in touch shortly.");
36
+ * `failed`: the line when a send fails without the endpoint's own sentence
37
+ * (default "Something went wrong. Please email us directly.");
38
+ * - `errors`: the error line by field type (`{ email: 'Please check your email.' }`),
39
+ * in place of the package's wording; a field's own `error` in the config still wins;
40
+ * - `actions`: wrap the submit button and the status line in `<div class="cms-form-actions">`,
41
+ * so site CSS can lay them out in a row;
42
+ * - `labelPlaceholders`: a text, email, tel or textarea field with no `placeholder` of its
43
+ * own uses its label as one (the `<label>` stays in the markup).
44
+ *
45
+ * A textarea's height is its field's `rows` in the config (default 6).
27
46
  */
28
47
  import { site } from 'virtual:caracms/config';
29
48
  import { build } from 'virtual:caracms/public';
30
- import { cleanReturn, FILE_ACCEPT, FILE_KINDS, HONEYPOT, MAX_FILE_BYTES, MAX_FILES, type ResolvedFormField } from '../forms/rules.ts';
49
+ import { cleanReturn, FILE_ACCEPT, FILE_KINDS, HONEYPOT, MAX_FILE_BYTES, MAX_FILES, PLACEHOLDER_TYPES, type FormFieldType, type ResolvedFormField } from '../forms/rules.ts';
50
+ import { passThrough } from './attributes.ts';
31
51
 
32
52
  interface Props {
33
53
  /** The form's id in the site's config. */
@@ -36,10 +56,40 @@ interface Props {
36
56
  preview?: boolean;
37
57
  /** The submit button's text. Default "Send". */
38
58
  submit?: string;
59
+ /** Classes on the `<form>`, beside `cms-form`. */
39
60
  class?: string;
61
+ /** Classes on the submit button, beside `cms-form-submit`. */
62
+ submitClass?: string;
63
+ /** The line after a send. Default "Thanks, we'll be in touch shortly." */
64
+ thanks?: string;
65
+ /** The line when a send fails and the endpoint gave no sentence of its own. Default "Something went wrong. Please email us directly." */
66
+ failed?: string;
67
+ /** The error line by field type, in place of the package's wording. A field's own `error` (config) still wins. */
68
+ errors?: Partial<Record<FormFieldType, string>>;
69
+ /** Wrap the submit button and the status line in `<div class="cms-form-actions">`. */
70
+ actions?: boolean;
71
+ /** A field that takes a placeholder and sets none uses its label as one. */
72
+ labelPlaceholders?: boolean;
73
+ /** Anything else (`data-*`, `id`, `aria-*`…) goes on the `<form>`. */
74
+ [attribute: string]: unknown;
40
75
  }
41
76
 
42
- const { form: id, preview = false, submit = 'Send', class: className } = Astro.props;
77
+ const {
78
+ form: id,
79
+ preview = false,
80
+ submit = 'Send',
81
+ class: className,
82
+ submitClass,
83
+ thanks,
84
+ failed,
85
+ errors,
86
+ actions = false,
87
+ labelPlaceholders = false,
88
+ ...given
89
+ } = Astro.props;
90
+ // The form's own attributes stay its own: a pass-through can't replace them.
91
+ const OWN = new Set(['action', 'method', 'enctype', 'inert', 'data-max-files', 'data-max-bytes', 'data-file-kinds', 'data-thanks', 'data-failed']);
92
+ const rest = passThrough(Object.fromEntries(Object.entries(given).filter(([name]) => !OWN.has(name.toLowerCase()) && !name.toLowerCase().startsWith('data-caracms-'))));
43
93
  const form = Object.hasOwn(site.forms, id) ? site.forms[id]! : null;
44
94
  const files = form?.fields.some((f) => f.type === 'files') ?? false;
45
95
  const back = cleanReturn(preview ? '/' : Astro.url.pathname);
@@ -54,6 +104,8 @@ const DEFAULT_ERROR: Record<ResolvedFormField['type'], string> = {
54
104
  select: 'Please choose one.',
55
105
  files: 'Please attach a file.',
56
106
  };
107
+ const placeholderOf = (f: ResolvedFormField) => f.placeholder ?? (labelPlaceholders && PLACEHOLDER_TYPES.includes(f.type) ? f.label : undefined);
108
+ const errorOf = (f: ResolvedFormField) => f.error || errors?.[f.type] || DEFAULT_ERROR[f.type];
57
109
  const described = (f: ResolvedFormField) =>
58
110
  [f.prefix ? `${fieldId(f.name)}-prefix` : '', f.help ? `${fieldId(f.name)}-help` : '', `${fieldId(f.name)}-error`].filter(Boolean).join(' ');
59
111
  ---
@@ -61,6 +113,7 @@ const described = (f: ResolvedFormField) =>
61
113
  {
62
114
  form ? (
63
115
  <form
116
+ {...rest}
64
117
  class:list={['cms-form', className]}
65
118
  action="/_caracms/form"
66
119
  method="post"
@@ -69,6 +122,8 @@ const described = (f: ResolvedFormField) =>
69
122
  data-max-files={files ? MAX_FILES : undefined}
70
123
  data-max-bytes={files ? MAX_FILE_BYTES : undefined}
71
124
  data-file-kinds={files ? FILE_KINDS : undefined}
125
+ data-thanks={thanks || undefined}
126
+ data-failed={failed || undefined}
72
127
  inert={preview || undefined}
73
128
  >
74
129
  <input type="hidden" name="form" value={id} />
@@ -86,7 +141,7 @@ const described = (f: ResolvedFormField) =>
86
141
  {f.optional && <span class="cms-form-optional"> (optional)</span>}
87
142
  </label>
88
143
  {f.type === 'textarea' ? (
89
- <textarea id={fieldId(f.name)} name={f.name} rows="6" required={!f.optional} autocomplete={f.autocomplete} placeholder={f.placeholder} aria-describedby={described(f)} />
144
+ <textarea id={fieldId(f.name)} name={f.name} rows={String(f.rows ?? 6)} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
90
145
  ) : f.type === 'select' ? (
91
146
  <select id={fieldId(f.name)} name={f.name} required={!f.optional} autocomplete={f.autocomplete} aria-describedby={described(f)}>
92
147
  <option value="">Choose…</option>
@@ -104,10 +159,10 @@ const described = (f: ResolvedFormField) =>
104
159
  <span class="cms-form-prefix" id={`${fieldId(f.name)}-prefix`}>
105
160
  {f.prefix}
106
161
  </span>
107
- <input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={f.placeholder} aria-describedby={described(f)} />
162
+ <input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
108
163
  </div>
109
164
  ) : (
110
- <input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={f.placeholder} aria-describedby={described(f)} />
165
+ <input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
111
166
  )}
112
167
  {f.help && (
113
168
  <p class="cms-form-help" id={`${fieldId(f.name)}-help`}>
@@ -115,15 +170,26 @@ const described = (f: ResolvedFormField) =>
115
170
  </p>
116
171
  )}
117
172
  <p class="cms-form-error" id={`${fieldId(f.name)}-error`}>
118
- {f.error || DEFAULT_ERROR[f.type]}
173
+ {errorOf(f)}
119
174
  </p>
120
175
  </div>
121
176
  ))}
122
177
  {turnstileKey && <div class="cms-form-turnstile" data-caracms-turnstile data-sitekey={turnstileKey} />}
123
- <button type="submit" class="cms-form-submit">
124
- {submit}
125
- </button>
126
- <p class="cms-form-status" role="status" aria-live="polite" data-caracms-form-status />
178
+ {actions ? (
179
+ <div class="cms-form-actions">
180
+ <button type="submit" class:list={['cms-form-submit', submitClass]}>
181
+ {submit}
182
+ </button>
183
+ <p class="cms-form-status" role="status" aria-live="polite" data-caracms-form-status />
184
+ </div>
185
+ ) : (
186
+ <>
187
+ <button type="submit" class:list={['cms-form-submit', submitClass]}>
188
+ {submit}
189
+ </button>
190
+ <p class="cms-form-status" role="status" aria-live="polite" data-caracms-form-status />
191
+ </>
192
+ )}
127
193
  </form>
128
194
  ) : preview ? (
129
195
  <p class="cms-form-missing">There's no form "{id}" in the site's config.</p>
@@ -45,6 +45,7 @@ const head = headOf({
45
45
  tagAllowed: build.production && !preview,
46
46
  listPage,
47
47
  titleSeparator: site.titleSeparator,
48
+ titleFallback: site.titleFallback,
48
49
  });
49
50
  ---
50
51
 
@@ -19,12 +19,21 @@
19
19
  * source matches and only the poster shows.
20
20
  * - **A player**: MediaPlayer.astro (its poster and a "Play video: {name}" button);
21
21
  * only it carries a script, so a page with no player has none.
22
+ * - **PNG** (M9 Decision 8): a field declared `media({ format: 'png' })` gets `@png`
23
+ * URLs at every width; every other image is WebP.
22
24
  * - **Loading** (Decision 4): eager in the first two rendered blocks (`cms.eager`),
23
25
  * `fetchpriority="high"` only on the image at `cms.priority`; lazy otherwise. Props
24
26
  * override both.
25
27
  * - **Preview markers**: `data-cms-field="<path>" data-cms-kind="media"` on the element,
26
28
  * preview renders only, when `field` is given (a dotted path into a list works).
27
29
  * Without `field` (a card showing another document's cover) there's no marker.
30
+ * - **An empty slot** (M9 port friction, item 12): nothing for visitors. In a preview, with
31
+ * `field`, a placeholder carrying the markers, so the slot can be clicked on the page
32
+ * (it selects the field, as a placed image does): a `<span>` (valid inside a `<p>` or a
33
+ * link) with the caller's `class`, at `ratio` when given (else the fixed `width`/`height`,
34
+ * else at least 6rem square), drawn as a dashed box in the text's own colour. So a block
35
+ * that wants its empty slot clickable calls `<Media>` even when the value is empty
36
+ * (`{(block.media || cms.preview) && <Media … />}`); the visitors' page is unchanged.
28
37
  * - **Without the media variables** (Decision 5): a preview (and `astro dev`) renders an
29
38
  * image slot as nothing; a build throws the one sentence naming them.
30
39
  *
@@ -51,6 +60,8 @@ const markers = cms.preview && field ? { 'data-cms-field': field, 'data-cms-kind
51
60
  const load = loading ?? (cms.eager ? 'eager' : 'lazy');
52
61
  const priority = fetchpriority ?? (field && cms.priority === field ? 'high' : undefined);
53
62
  const fixed = width !== undefined || height !== undefined;
63
+ // M9 Decision 8: the field's own format (PNG for flat artwork), WebP otherwise.
64
+ const format = descriptor?.format;
54
65
 
55
66
  let img: (ReturnType<typeof fluidImage> & { alt: string; style?: string }) | null = null;
56
67
  if (media?.kind === 'image') {
@@ -61,14 +72,29 @@ if (media?.kind === 'image') {
61
72
  if (!cms.preview && isProductionBuild() && !failedOnce()) throw new Error(`caracms: ${mediaUnsetMessage(config)}`);
62
73
  } else {
63
74
  if (!fixed && !sizes) throw new Error(`<Media>: a fluid image needs \`sizes\` (${cms.collection ? `collection "${cms.collection}"` : `block "${cms.type}"`}${field ? `, field "${field}"` : ''}).`);
64
- const built = fixed ? fixedImage(config.settings, media, { width, height, fit }) : fluidImage(config.settings, media, { sizes: sizes!, ratio, minWidth, maxWidth });
75
+ const built = fixed ? fixedImage(config.settings, media, { width, height, fit, format }) : fluidImage(config.settings, media, { sizes: sizes!, ratio, minWidth, maxWidth, format });
65
76
  const styles = media.position ? [`object-position: ${media.position}`, style].filter(Boolean).join('; ') : style;
66
77
  img = { ...built, alt: media.alt, style: styles };
67
78
  }
68
79
  }
69
80
  const clip = media?.kind === 'video' ? media : null;
81
+ // An empty slot in a preview: something to click (see above). Never in the public render.
82
+ const placeholderStyle = cms.preview && field && !media ? emptySlotStyle({ ratio, width, height, style }) : null;
83
+
84
+ function emptySlotStyle(size: { ratio?: number; width?: number; height?: number; style?: string }): string {
85
+ const box =
86
+ size.ratio !== undefined
87
+ ? [`aspect-ratio: ${size.ratio}`, 'min-width: 6rem']
88
+ : size.width !== undefined || size.height !== undefined
89
+ ? [size.width !== undefined ? `width: ${size.width}px` : 'min-width: 6rem', size.height !== undefined ? `height: ${size.height}px` : 'min-height: 6rem']
90
+ : ['min-width: 6rem', 'min-height: 6rem'];
91
+ const look = ['background: color-mix(in srgb, currentColor 6%, transparent)', 'outline: 1.5px dashed color-mix(in srgb, currentColor 40%, transparent)', 'outline-offset: -1.5px', 'cursor: pointer'];
92
+ return ['display: block', 'box-sizing: border-box', ...box, size.style, ...look].filter(Boolean).join('; ');
93
+ }
70
94
  ---
71
95
 
96
+ {placeholderStyle !== null && <span class={className} style={placeholderStyle} aria-hidden="true" data-cms-placeholder="" {...rest} {...markers} />}
97
+
72
98
  {
73
99
  img && (
74
100
  <img
@@ -10,7 +10,7 @@
10
10
  * joined by `<br>`.
11
11
  * - A kind the field doesn't allow renders as `<p>`, and a link where the field
12
12
  * has links off as its text (the field warns; rich-text.ts reads them so).
13
- * - The blocks sit in one wrapper element, `as` (default `div`), which takes
13
+ * - The blocks sit in one wrapper element, `as` (default `div`; a heading field's below), which takes
14
14
  * `class` and any other attribute. Public render: nothing at all for an empty value.
15
15
  * - Preview render only (M3 Decision 17): the wrapper is always there, with
16
16
  * `data-cms-field="<path>" data-cms-kind="richText"`. A top-level field is typed
@@ -21,12 +21,18 @@
21
21
  * (`items.0.body`) is edited in the sidebar only, so it has no tabindex. The public
22
22
  * render has none of these.
23
23
  * - `field` is the field's key, or a dotted path into a list (Decision 16).
24
+ * - **A heading field** (`richText({ heading: true })`, M9 Decision 11): the element is
25
+ * `as`, or `h1` when it holds the page's first heading and `h2` otherwise (as Text's
26
+ * heading), and inside it each paragraph's runs, the paragraphs joined by `<br>`: no
27
+ * `<p>`. Bold is `<strong>`, so the site styles it (`h1 strong { … }`).
28
+ *
29
+ * <RichText cms={cms} field="statement" value={block.statement} class="board-statement" />
24
30
  */
25
31
  import { site } from 'virtual:caracms/config';
26
32
  import type { HTMLTag } from 'astro/types';
27
33
  import type { BlockContext } from '../document.ts';
28
34
  import { parseField } from '../fields.ts';
29
- import { richTextHtml, type RichTextValue } from '../rich-text.ts';
35
+ import { richHeadingHtml, richTextHtml, type RichTextValue } from '../rich-text.ts';
30
36
  import { isTypedOnPage, requireField } from './descriptor.ts';
31
37
  import { passThrough } from './attributes.ts';
32
38
 
@@ -39,12 +45,14 @@ interface Props {
39
45
  [attribute: string]: unknown;
40
46
  }
41
47
 
42
- const { cms, field, value, as: Tag = 'div', class: className, ...given } = Astro.props;
48
+ const { cms, field, value, as, class: className, ...given } = Astro.props;
43
49
  // M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
44
50
  const rest = passThrough(given);
45
51
  const descriptor = requireField(site, cms, field, 'richText', 'RichText');
52
+ const Tag = as ?? (descriptor.heading ? (cms.h1Field === field ? 'h1' : 'h2') : 'div');
46
53
  // Re-read through the field, so a value from anywhere renders by the field's rules.
47
- const html = richTextHtml(parseField(descriptor, value) as RichTextValue, { fileLinks: true });
54
+ const parsed = parseField(descriptor, value) as RichTextValue;
55
+ const html = descriptor.heading ? richHeadingHtml(parsed) : richTextHtml(parsed, { fileLinks: true });
48
56
  const markers = cms.preview ? { 'data-cms-field': field, 'data-cms-kind': 'richText', ...(isTypedOnPage(field) ? { tabindex: '0' } : {}) } : {};
49
57
  ---
50
58
 
@@ -16,6 +16,14 @@
16
16
  * `<Text cms={cms} field={`credits.${i}.name`} value={credit.name} />` in the
17
17
  * block's own loop. The marker carries the path. Text inside a list is edited in
18
18
  * the sidebar (Decision 13), so a nested path is never `contenteditable`.
19
+ * - **The lines rendering** (M9 Decision 10), for a field declared `text({ lines: true })`:
20
+ * each line is drawn trimmed and blank lines draw nothing; with one line left it's drawn unwrapped; with more, each
21
+ * line is a `<span>` with `lineClass` (Share's `block text-balance`), and no `<br>`.
22
+ * In a preview each span also carries `data-cms-line`, so the text read back from the
23
+ * page (preview/field-text.ts) starts a new line at each, and the element carries
24
+ * `data-cms-lines` (and `data-cms-line-class`), so a sidebar edit is drawn the same way.
25
+ *
26
+ * <Text cms={cms} field="heading" value={block.heading} lineClass="block text-balance" />
19
27
  */
20
28
  import { site } from 'virtual:caracms/config';
21
29
  import type { HTMLTag } from 'astro/types';
@@ -29,16 +37,25 @@ interface Props {
29
37
  value: string;
30
38
  as?: HTMLTag;
31
39
  class?: string;
40
+ /** The lines rendering's class on each line's `<span>` (M9 Decision 10). */
41
+ lineClass?: string;
32
42
  /** Anything else (such as the caller's scoped-style attribute) goes on the element. */
33
43
  [attribute: string]: unknown;
34
44
  }
35
45
 
36
- const { cms, field, value, as, class: className, ...given } = Astro.props;
46
+ const { cms, field, value, as, class: className, lineClass, ...given } = Astro.props;
37
47
  // M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
38
48
  const rest = passThrough(given);
39
49
  const descriptor = requireField(site, cms, field, 'text', 'Text');
40
50
  const Tag = as ?? (descriptor.heading ? (cms.h1Field === field ? 'h1' : 'h2') : 'p');
41
- const lines = value ? (descriptor.multiline ? value.split('\n') : [value]) : [];
51
+ const typed = value ? (descriptor.multiline ? value.split('\n') : [value]) : [];
52
+ // M9 Decision 10: blank lines draw nothing, and two or more lines are a span each.
53
+ // Each line is drawn trimmed, as Tina's toLines() did (port friction 7); the stored value keeps its spaces.
54
+ const lines = descriptor.lines ? typed.map((line) => line.trim()).filter((line) => line !== '') : typed;
55
+ const spans = descriptor.lines && lines.length > 1;
56
+ const lineMarker = cms.preview ? { 'data-cms-line': '' } : {};
57
+ // The preview writes a sidebar edit back as these lines (preview/field-text.ts → writeFieldText).
58
+ const linesMarkers = cms.preview && descriptor.lines ? { 'data-cms-lines': '', ...(lineClass ? { 'data-cms-line-class': lineClass } : {}) } : {};
42
59
  const markers = cms.preview
43
60
  ? { 'data-cms-field': field, 'data-cms-kind': 'text', ...(isTypedOnPage(field) ? { contenteditable: 'plaintext-only' } : {}) }
44
61
  : {};
@@ -46,13 +63,19 @@ const markers = cms.preview
46
63
 
47
64
  {
48
65
  (lines.length > 0 || cms.preview) && (
49
- <Tag class={className} {...rest} {...markers}>
50
- {lines.map((line, i) => (
51
- <>
52
- {i > 0 && <br />}
53
- {line}
54
- </>
55
- ))}
66
+ <Tag class={className} {...rest} {...markers} {...linesMarkers}>
67
+ {spans
68
+ ? lines.map((line) => (
69
+ <span class={lineClass} {...lineMarker}>
70
+ {line}
71
+ </span>
72
+ ))
73
+ : lines.map((line, i) => (
74
+ <>
75
+ {i > 0 && <br />}
76
+ {line}
77
+ </>
78
+ ))}
56
79
  </Tag>
57
80
  )
58
81
  }
@@ -3,7 +3,8 @@
3
3
  * (Form.astro carries it). It enhances a form that already works without it:
4
4
  *
5
5
  * - submits with `fetch` (`Accept: application/json`), shows "Sending…", then the
6
- * thanks line, or the endpoint's own sentence when it gave one;
6
+ * thanks line, or the endpoint's own sentence when it gave one (the thanks and the
7
+ * failure lines are the form's `data-thanks` / `data-failed` when Form.astro was given them);
7
8
  * - makes a files field's picks add up: each pick is added to the files already chosen
8
9
  * (the real list kept in a `DataTransfer` and given back to `input.files`, so the fetch
9
10
  * and a plain submit both send every one), the same name and size never twice, listed
@@ -126,6 +127,8 @@ function enhance(form: HTMLFormElement): void {
126
127
  if (!status) return;
127
128
  const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
128
129
  const widget = form.querySelector<HTMLElement>('[data-caracms-turnstile]');
130
+ const thanks = form.dataset.thanks || THANKS;
131
+ const failedLine = form.dataset.failed || FAILED;
129
132
  const say = (text: string, failed: boolean) => {
130
133
  status.textContent = text;
131
134
  status.classList.toggle('cms-form-failed', failed);
@@ -145,7 +148,7 @@ function enhance(form: HTMLFormElement): void {
145
148
  if (tooMuch) return say(tooMuch, true);
146
149
  submit?.setAttribute('disabled', '');
147
150
  say('Sending…', false);
148
- let failure = FAILED;
151
+ let failure = failedLine;
149
152
  try {
150
153
  const res = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, body: new FormData(form) });
151
154
  if (!res.ok) {
@@ -154,7 +157,7 @@ function enhance(form: HTMLFormElement): void {
154
157
  throw new Error(String(res.status));
155
158
  }
156
159
  form.reset();
157
- say(THANKS, false);
160
+ say(thanks, false);
158
161
  } catch {
159
162
  say(failure, true);
160
163
  } finally {
@@ -167,7 +170,7 @@ function enhance(form: HTMLFormElement): void {
167
170
  // The no-JS path lands back here with ?sent=1.
168
171
  const params = new URLSearchParams(location.search);
169
172
  if (params.get('sent') === '1') {
170
- say(THANKS, false);
173
+ say(thanks, false);
171
174
  params.delete('sent');
172
175
  const rest = params.toString();
173
176
  history.replaceState(null, '', `${location.pathname}${rest ? `?${rest}` : ''}${location.hash}`);
@@ -21,7 +21,8 @@ export async function sitemapUrls(siteUrl: string): Promise<string[]> {
21
21
  for (const collection of siteCollections(site)) {
22
22
  if (collection.urlBase === null) continue;
23
23
  for (const entry of await listDocuments(site, collection.name)) {
24
- if (entry.warning || entry.url === null) continue;
24
+ // M9 Decision 7: a hidden page (or one under it) isn't on the site.
25
+ if (entry.warning || entry.url === null || entry.hidden) continue;
25
26
  urls.push(`${siteUrl}${entry.url.split('/').map((p) => encodeURIComponent(p)).join('/')}`);
26
27
  }
27
28
  }