caracms 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/init.mjs +1 -0
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +12 -0
- package/skills/caracms-adopt/look-same.mjs +16 -7
- package/skills/caracms-blocks/SKILL.md +49 -2
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +1 -0
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +26 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/types.ts +2 -0
- package/src/fields.ts +63 -15
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +7 -3
- package/src/links.ts +35 -0
- package/src/live/README.md +1 -0
- package/src/live/backend.ts +41 -23
- package/src/live/branches.ts +19 -0
- package/src/live/mode.ts +46 -0
- package/src/live/runtime.ts +5 -2
- package/src/media.ts +14 -6
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -0,0 +1,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
|
+
}
|
package/src/cli/validate.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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>
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|