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.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -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 +4 -2
- 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 +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- 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/live/publish.tsx +20 -10
- 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/pane/parts.tsx +34 -2
- 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/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +54 -24
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -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
|
}
|
package/src/config.ts
CHANGED
|
@@ -227,8 +227,19 @@ export interface SiteConfig {
|
|
|
227
227
|
* hands it to the site's own routes as `seo.titleSeparator`, so their titles match.
|
|
228
228
|
*/
|
|
229
229
|
titleSeparator?: string;
|
|
230
|
+
/**
|
|
231
|
+
* What a page without its own `seo.title` falls back to (M9, head.ts). `'page-and-brand'`
|
|
232
|
+
* (the default): `<document title><titleSeparator><brand name>`, the home page excepted.
|
|
233
|
+
* `'site'`: every page falls back as the home page does, to Site Settings' `seo.title`,
|
|
234
|
+
* then the brand name, then the document title. For a site (a Tina template's, say)
|
|
235
|
+
* whose pages all show the site's own title.
|
|
236
|
+
*/
|
|
237
|
+
titleFallback?: TitleFallback;
|
|
230
238
|
}
|
|
231
239
|
|
|
240
|
+
/** The config's `titleFallback` values (head.ts). */
|
|
241
|
+
export type TitleFallback = 'page-and-brand' | 'site';
|
|
242
|
+
|
|
232
243
|
/** The fallback title's separator when the config sets none (head.ts). */
|
|
233
244
|
export const DEFAULT_TITLE_SEPARATOR = ' · ';
|
|
234
245
|
|
|
@@ -329,6 +340,8 @@ export interface ResolvedSite {
|
|
|
329
340
|
forms: Record<string, ResolvedForm>;
|
|
330
341
|
/** The fallback title's separator, spaces included (`DEFAULT_TITLE_SEPARATOR` when the config sets none). */
|
|
331
342
|
titleSeparator: string;
|
|
343
|
+
/** What a page without its own `seo.title` falls back to (`'page-and-brand'` when the config sets none). */
|
|
344
|
+
titleFallback: TitleFallback;
|
|
332
345
|
/**
|
|
333
346
|
* M8 Decision 10: the URLs the site's own `src/pages/` serves (code-routes.ts), read by the
|
|
334
347
|
* integration at setup (and by the commands). Absent before then: nothing is reserved.
|
|
@@ -563,7 +576,13 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
|
|
|
563
576
|
fail(`titleSeparator must be the text between a page's title and the site's name, spaces included, like ' | ' (got ${JSON.stringify(config.titleSeparator)}).`);
|
|
564
577
|
}
|
|
565
578
|
|
|
566
|
-
|
|
579
|
+
// What a page without its own title falls back to (M9): the page and the brand, or the site's title.
|
|
580
|
+
const titleFallback = config.titleFallback ?? 'page-and-brand';
|
|
581
|
+
if (titleFallback !== 'page-and-brand' && titleFallback !== 'site') {
|
|
582
|
+
fail(`titleFallback must be 'page-and-brand' (the page's title and the site's name) or 'site' (Site Settings' title on every page) (got ${JSON.stringify(config.titleFallback)}).`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback };
|
|
567
586
|
}
|
|
568
587
|
|
|
569
588
|
interface FieldCheck {
|
|
@@ -617,6 +636,11 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
|
|
|
617
636
|
// M6c (G8): help is plain text, said in the sidebar.
|
|
618
637
|
if (field.help !== undefined && (typeof field.help !== 'string' || field.help.trim() === '')) fail(`${where}: help is a sentence of plain text, shown under the field in the editor (got ${JSON.stringify(field.help)}).`);
|
|
619
638
|
switch (field.kind) {
|
|
639
|
+
case 'text':
|
|
640
|
+
// M9 Decision 10.
|
|
641
|
+
if (field.lines !== undefined && field.lines !== true) fail(`${where}: lines is true or left out (got ${JSON.stringify(field.lines)}).`);
|
|
642
|
+
if (field.lines && !field.multiline) fail(`${where}: lines draws each typed line as its own element, so the field takes several lines. Write text({ lines: true }) without multiline: false.`);
|
|
643
|
+
break;
|
|
620
644
|
case 'lockup':
|
|
621
645
|
if (!Object.hasOwn(check.textStyles, field.style)) fail(`${where} uses the text style "${field.style}", which isn't in textStyles.`);
|
|
622
646
|
break;
|
|
@@ -635,6 +659,10 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
|
|
|
635
659
|
"An h3 with no h2 above it breaks the page's outline, and the page's h1 is a block's heading field.",
|
|
636
660
|
);
|
|
637
661
|
}
|
|
662
|
+
// M9 Decision 11: a heading is bold and line breaks only.
|
|
663
|
+
if (field.heading !== undefined && field.heading !== true) fail(`${where}: heading is true or left out (got ${JSON.stringify(field.heading)}).`);
|
|
664
|
+
if (field.heading && headings.length > 0) fail(`${where}: a heading rich-text field can't hold headings of its own; it is the block's heading. Leave headings out.`);
|
|
665
|
+
if (field.heading && field.links) fail(`${where}: a heading rich-text field takes bold and line breaks only, not links. Leave links out.`);
|
|
638
666
|
break;
|
|
639
667
|
}
|
|
640
668
|
case 'buttons':
|
|
@@ -701,6 +729,9 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
|
|
|
701
729
|
if (field.only !== undefined && field.only !== 'image' && field.only !== 'video') fail(`${where}: media's only must be 'image' or 'video' (got ${JSON.stringify(field.only)}).`);
|
|
702
730
|
if (field.play !== undefined && field.play !== 'loop') fail(`${where}: media's play can only be 'loop' (a loop-only slot, like a hero's); leave it out to let each clip choose (got ${JSON.stringify(field.play)}).`);
|
|
703
731
|
if (field.play === 'loop' && field.only === 'image') fail(`${where}: play: 'loop' is for a slot that takes videos, but this one takes images only.`);
|
|
732
|
+
// M9 Decision 8.
|
|
733
|
+
if (field.format !== undefined && field.format !== 'webp' && field.format !== 'png') fail(`${where}: media's format must be 'png' or 'webp' (got ${JSON.stringify(field.format)}).`);
|
|
734
|
+
if (field.format !== undefined && field.only === 'video') fail(`${where}: format is for images, but this slot takes videos only.`);
|
|
704
735
|
break;
|
|
705
736
|
default:
|
|
706
737
|
break;
|
package/src/content-schema.ts
CHANGED
|
@@ -47,11 +47,12 @@ const clip: Schema = closed(
|
|
|
47
47
|
const plainImage: Schema = closed({ key: { type: 'string', minLength: 1 }, w: size, h: size, focal }, ['key']);
|
|
48
48
|
const seo: Schema = closed({ title: string, description: string, image: plainImage });
|
|
49
49
|
|
|
50
|
-
function richText(headings: readonly string[]): Schema {
|
|
50
|
+
function richText(headings: readonly string[], lists = true): Schema {
|
|
51
51
|
const kinds = ['p', ...headings];
|
|
52
52
|
const unit: Schema[] = [
|
|
53
53
|
...kinds.map((k) => closed({ [k]: string }, [k])),
|
|
54
|
-
|
|
54
|
+
// M9 Decision 11: a heading rich-text field is paragraphs only.
|
|
55
|
+
...(lists ? ['ul', 'ol'] : []).map((k) => closed({ [k]: strings }, [k])),
|
|
55
56
|
];
|
|
56
57
|
// A plain string reads as rich text too (rich-text.ts → "A plain string is read as rich text").
|
|
57
58
|
return { anyOf: [string, { type: 'array', items: { oneOf: unit } }] };
|
|
@@ -68,7 +69,7 @@ export function fieldSchema(field: Field): Schema {
|
|
|
68
69
|
case 'select':
|
|
69
70
|
return described({ enum: field.options.map((o) => o.value) }, field);
|
|
70
71
|
case 'richText':
|
|
71
|
-
return described(richText(field.headings), field);
|
|
72
|
+
return described(richText(field.headings, !field.heading), field);
|
|
72
73
|
case 'link':
|
|
73
74
|
return described(field.addressOnly ? closed({ href: string }) : closed({ label: string, href: string }), field);
|
|
74
75
|
case 'buttons':
|
|
@@ -129,7 +130,9 @@ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection:
|
|
|
129
130
|
const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
|
|
130
131
|
switch (collection.kind) {
|
|
131
132
|
case 'sections':
|
|
132
|
-
|
|
133
|
+
// M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
|
|
134
|
+
const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
|
|
135
|
+
return document(collection, { hidden, ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
|
|
133
136
|
case 'fields':
|
|
134
137
|
return document(collection, { ...own, seo }, ['title']);
|
|
135
138
|
case 'shared':
|
package/src/content.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { parseSource } from './json-source.ts';
|
|
|
41
41
|
export { contentReader } from './read.ts';
|
|
42
42
|
// M8 trial (item 1): Site Settings for a site's own routes, as the page component gets them.
|
|
43
43
|
export { siteSettings } from './read.ts';
|
|
44
|
+
// Urban Return port, Decision 20: a Site Settings image's URL for the page's frame (media.ts).
|
|
45
|
+
export { settingsImageUrl } from './media.ts';
|
|
44
46
|
|
|
45
47
|
export type WarningCode = 'invalid-json' | 'no-title' | 'nested-index';
|
|
46
48
|
|
|
@@ -70,6 +72,13 @@ export interface DocumentEntry {
|
|
|
70
72
|
label: string;
|
|
71
73
|
/** Set when the file can't be opened. */
|
|
72
74
|
warning: DocumentWarning | null;
|
|
75
|
+
/**
|
|
76
|
+
* M9 Decision 7: hidden from the site, by the page's own `"hidden": true` or (`hiddenBy`, the
|
|
77
|
+
* path of the hidden page above it) a page it's nested under. A site's routes, the sitemap and
|
|
78
|
+
* `contentReader().documents()` leave it out; the editor lists it, marked. Never the home page.
|
|
79
|
+
*/
|
|
80
|
+
hidden?: true;
|
|
81
|
+
hiddenBy?: string;
|
|
73
82
|
/** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
|
|
74
83
|
uses?: string[];
|
|
75
84
|
/** A shared block: its block's type (`''` when it has none). Absent otherwise. */
|
|
@@ -224,6 +233,7 @@ export async function listDocuments(
|
|
|
224
233
|
if (!collection) throw new DocumentError('unknown-collection', `No collection "${name}" is declared.`);
|
|
225
234
|
const paths = await documentPaths(collection, source);
|
|
226
235
|
const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
|
|
236
|
+
if (collection.nested) hideChildren(entries);
|
|
227
237
|
all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
|
|
228
238
|
}
|
|
229
239
|
if (!collectionName) {
|
|
@@ -236,6 +246,22 @@ export async function listDocuments(
|
|
|
236
246
|
return all;
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
|
|
250
|
+
function hideChildren(entries: DocumentEntry[]): void {
|
|
251
|
+
const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
|
|
252
|
+
if (hidden.size === 0) return;
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const parts = entry.path.split('/');
|
|
255
|
+
for (let n = 1; n < parts.length; n++) {
|
|
256
|
+
const above = parts.slice(0, n).join('/');
|
|
257
|
+
if (!hidden.has(above)) continue;
|
|
258
|
+
entry.hidden = true;
|
|
259
|
+
entry.hiddenBy = above;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
/**
|
|
240
266
|
* Read one document. Throws DocumentError: 'not-found', 'bad-path',
|
|
241
267
|
* 'unknown-collection', or the listing's warning codes ('invalid-json',
|
|
@@ -308,6 +334,8 @@ function inspect(
|
|
|
308
334
|
return warn('no-title', 'This file has no title, so it can’t be opened. Add a "title".');
|
|
309
335
|
}
|
|
310
336
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
337
|
+
// M9 Decision 7: the home page is never hidden.
|
|
338
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
|
|
311
339
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
312
340
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
313
341
|
} else if (document.kind === 'shared') {
|
package/src/document.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface SiteDocument {
|
|
|
60
60
|
* own `seo` as it is.
|
|
61
61
|
*/
|
|
62
62
|
seo?: Seo;
|
|
63
|
+
/**
|
|
64
|
+
* M9 Decision 7: the page is hidden from the site (the file's top-level `"hidden": true`;
|
|
65
|
+
* absent means shown). Not served, not in the sitemap, not offered by the link picker; still
|
|
66
|
+
* edited, previewed and published in /admin. The editor sends `false` to unhide, which takes
|
|
67
|
+
* the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
|
|
68
|
+
*/
|
|
69
|
+
hidden?: boolean;
|
|
63
70
|
/**
|
|
64
71
|
* M6c (G2): the page fields' parsed values, keyed by field, every field of the
|
|
65
72
|
* collection's `fields` present (the file's top-level keys after `title`). Present
|
|
@@ -217,6 +224,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
|
|
|
217
224
|
title: typeof doc.title === 'string' ? doc.title : '',
|
|
218
225
|
blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
|
|
219
226
|
...seoOf(doc),
|
|
227
|
+
...(doc.hidden === true ? { hidden: true } : {}),
|
|
220
228
|
};
|
|
221
229
|
}
|
|
222
230
|
|
|
@@ -279,6 +287,8 @@ export function normaliseDocument(input: unknown, site: Pick<ResolvedSite, 'bloc
|
|
|
279
287
|
return { source, type, known: true, data: parsed };
|
|
280
288
|
}),
|
|
281
289
|
...seoOf(asObject(input)),
|
|
290
|
+
// M9 Decision 7: true hides, false unhides, absent leaves the file's as it is.
|
|
291
|
+
...(typeof doc.hidden === 'boolean' ? { hidden: doc.hidden } : {}),
|
|
282
292
|
};
|
|
283
293
|
}
|
|
284
294
|
|
|
@@ -366,6 +376,9 @@ export interface DocumentReader {
|
|
|
366
376
|
* render from the document (never module state, so concurrent preview renders
|
|
367
377
|
* can't leak into each other: M3 → Watch-outs).
|
|
368
378
|
*/
|
|
379
|
+
/** M9 Decision 9: `<Blocks wrap>`'s rule and its runs, for a site's page component (`import type { WrapBlocks } from 'caracms/document'`). */
|
|
380
|
+
export type { BlockRun, WrapBlock, WrapBlocks } from './block-runs.ts';
|
|
381
|
+
|
|
369
382
|
export interface BlockContext {
|
|
370
383
|
/**
|
|
371
384
|
* The block's position in the document being rendered: the editor's block index (0 for a
|
|
@@ -497,8 +510,8 @@ export function headingTag(cms: BlockContext, field: string): 'h1' | 'h2' {
|
|
|
497
510
|
return cms.h1Field === field ? 'h1' : 'h2';
|
|
498
511
|
}
|
|
499
512
|
|
|
500
|
-
/** Is a field one that can hold the page's heading (Decision 16): a lockup or a text field marked `heading`? */
|
|
501
|
-
export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text') && field.heading;
|
|
513
|
+
/** Is a field one that can hold the page's heading (Decision 16): a lockup, or a text or rich-text field (M9 Decision 11) marked `heading`? */
|
|
514
|
+
export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
|
|
502
515
|
|
|
503
516
|
/**
|
|
504
517
|
* Decision 16: the index of the first block, in render order (skipping unknown
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* fieldsSummary(block) the picker's line under a type: its fields' labels
|
|
10
10
|
*/
|
|
11
11
|
import { own, sharedEntryOf } from '../../../document.ts';
|
|
12
|
-
import { titleCase, type Field } from '../../../fields.ts';
|
|
12
|
+
import { parseField, titleCase, type Field } from '../../../fields.ts';
|
|
13
|
+
import { richTextPlain, type RichTextValue } from '../../../rich-text.ts';
|
|
13
14
|
import type { EditorBlock, EditorSite, ListedDocument } from '../../types.ts';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -94,6 +95,11 @@ function firstText(fields: Record<string, Field>, block: Pick<EditorBlock, 'data
|
|
|
94
95
|
if (lines.length) return lines.join(' / ');
|
|
95
96
|
}
|
|
96
97
|
if (field.kind === 'text' && typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim();
|
|
98
|
+
// M9 Decision 11: a rich heading's words, without its bold.
|
|
99
|
+
if (field.kind === 'richText' && field.heading) {
|
|
100
|
+
const words = richTextPlain(parseField(field, value) as RichTextValue).replace(/\s+/g, ' ').trim();
|
|
101
|
+
if (words) return words;
|
|
102
|
+
}
|
|
97
103
|
}
|
|
98
104
|
return null;
|
|
99
105
|
}
|
|
@@ -336,6 +336,25 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
336
336
|
/** A lock answer (a 409, or Edit here) shown at once, until a poll started after it answers. */
|
|
337
337
|
const lockOverride = createStore<{ file: string; state: LockState; lock: LockView | null; after: number } | null>(null);
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Redis diet Decision 10: files a write from this tab was Kept for, with the poll count
|
|
341
|
+
* then. The write took (or kept) the lock, so the tab holds it until a poll started
|
|
342
|
+
* after that write answers: the poll is the word from then on.
|
|
343
|
+
*/
|
|
344
|
+
const keptAfter = new Map<string, number>();
|
|
345
|
+
/** Files this tab holds the lock on: the last poll's, a lock answer's (Edit here), and those a write just took. */
|
|
346
|
+
const lockedFiles = (): string[] => {
|
|
347
|
+
const files = new Set((liveState.get().status?.locks ?? []).filter((l) => l.mine === 'this-tab').map((l) => l.file));
|
|
348
|
+
const o = lockOverride.get();
|
|
349
|
+
if (o?.state === 'yours') files.add(o.file);
|
|
350
|
+
const seq = liveState.get().seq;
|
|
351
|
+
for (const [file, after] of keptAfter) {
|
|
352
|
+
if (seq > after) keptAfter.delete(file);
|
|
353
|
+
else files.add(file);
|
|
354
|
+
}
|
|
355
|
+
return [...files];
|
|
356
|
+
};
|
|
357
|
+
|
|
339
358
|
// Unsent edits held in this browser (the cookie says so): the bar starts at Keeping.
|
|
340
359
|
const initialKeep = props.unsent ? 'keeping' : 'kept';
|
|
341
360
|
const keeper = options.client
|
|
@@ -351,12 +370,11 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
351
370
|
tab,
|
|
352
371
|
retryDelays: RETRY_DELAYS,
|
|
353
372
|
you,
|
|
354
|
-
lockedFiles
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
},
|
|
373
|
+
lockedFiles,
|
|
374
|
+
holds: (file: string) => lockedFiles().includes(file),
|
|
375
|
+
onKept: (file: string) => keptAfter.set(file, liveSession.started()),
|
|
376
|
+
...(liveProps?.keepDelayMs !== undefined ? { keepDelay: liveProps.keepDelayMs } : {}),
|
|
377
|
+
...(liveProps?.keepCapMs !== undefined ? { keepCap: liveProps.keepCapMs } : {}),
|
|
360
378
|
onLocked: (file, refusal) => lockOverride.set({ file, state: refusal.state, lock: refusal.lock, after: liveSession.started() }),
|
|
361
379
|
onSignedOut: (body) => liveState.update({ signedOut: body }),
|
|
362
380
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M9 Decision 7: the page's "Hidden from the site" switch, at the document level under its
|
|
3
|
+
* page fields and above Search and sharing. It's the toggle field's switch (SimpleFields.tsx →
|
|
4
|
+
* ToggleField), set through `editor.doc.setHidden`, so it saves and publishes like any edit.
|
|
5
|
+
*
|
|
6
|
+
* - Only on a section builder's page with an address, and never on the home page (`index`),
|
|
7
|
+
* which can't be hidden.
|
|
8
|
+
* - A page under a hidden page is hidden with it (content.ts → `hiddenBy`): the switch shows
|
|
9
|
+
* on and can't be turned off here, and its help names the page to show again.
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
13
|
+
import { FieldFrame } from './FieldFrame.tsx';
|
|
14
|
+
import { FieldHostProvider, type FieldHost } from './host.tsx';
|
|
15
|
+
|
|
16
|
+
const SCOPE = 'page-hidden';
|
|
17
|
+
const NAME = 'hidden';
|
|
18
|
+
|
|
19
|
+
export function HiddenToggle() {
|
|
20
|
+
const editor = useEditor();
|
|
21
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
22
|
+
const kind = useStore(editor.doc.state, (s) => s.document.kind);
|
|
23
|
+
const hidden = useStore(editor.doc.state, (s) => !!s.document.hidden);
|
|
24
|
+
const documents = useStore(editor.documents);
|
|
25
|
+
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
26
|
+
const host = useMemo<FieldHost>(
|
|
27
|
+
() => ({ scope: SCOPE, picked: () => null, pick() {}, set: (_path, value) => editor.doc.setHidden(value === true), removeItem() {}, raw: () => undefined, readOnly }),
|
|
28
|
+
[editor, readOnly],
|
|
29
|
+
);
|
|
30
|
+
const collection = editor.site.collections.find((c) => c.name === ref.collection);
|
|
31
|
+
if (kind !== 'sections' || !collection?.urlBase || ref.path === 'index') return null;
|
|
32
|
+
const listed = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
|
|
33
|
+
const by = listed?.hiddenBy ? documents.find((d) => d.collection === ref.collection && d.path === listed.hiddenBy) : undefined;
|
|
34
|
+
const on = hidden || !!by;
|
|
35
|
+
const id = `f-${SCOPE}-${NAME}`;
|
|
36
|
+
const help = by
|
|
37
|
+
? `Hidden with ${by.label}, the page it's under. Show that page again to show this one.`
|
|
38
|
+
: 'Visitors get the 404 page, and it’s left out of the sitemap and the link picker. You can still edit, preview and publish it.';
|
|
39
|
+
return (
|
|
40
|
+
<div className="page-hidden" data-page-hidden={on ? 'on' : 'off'}>
|
|
41
|
+
<FieldHostProvider host={host}>
|
|
42
|
+
<FieldFrame blockKey={SCOPE} name={NAME} label="Hidden from the site" help={help} htmlFor={id}>
|
|
43
|
+
<button type="button" className="switch" role="switch" id={id} data-path={NAME} aria-checked={on} disabled={readOnly || !!by} onClick={() => host.set(NAME, !hidden)}>
|
|
44
|
+
<span className="track" />
|
|
45
|
+
<span>{on ? 'On' : 'Off'}</span>
|
|
46
|
+
</button>
|
|
47
|
+
</FieldFrame>
|
|
48
|
+
</FieldHostProvider>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -88,6 +88,7 @@ export function LinkControl(props: { path: string; href: string; onChange: (href
|
|
|
88
88
|
const file = fileKeyOf(href.trim());
|
|
89
89
|
const doc = href && !file ? documentAt(href, linkable) : undefined;
|
|
90
90
|
const missing = props.warnings.find((w) => w.code === 'link-missing');
|
|
91
|
+
const hiddenTarget = props.warnings.find((w) => w.code === 'link-hidden');
|
|
91
92
|
const rule = props.warnings.find((w) => w.code === 'link-rule');
|
|
92
93
|
const files = useLibraryFiles();
|
|
93
94
|
const chooseFile = () => {
|
|
@@ -181,20 +182,24 @@ export function LinkControl(props: { path: string; href: string; onChange: (href
|
|
|
181
182
|
if (doc && !rule) {
|
|
182
183
|
const collection = editor.site.collections.find((c) => c.name === doc.collection);
|
|
183
184
|
const crumb = collection?.nested ? parentLabel(doc, documents) : '';
|
|
185
|
+
// M9 Decision 7: a link to a hidden page keeps its chip, with the warning under it.
|
|
184
186
|
return (
|
|
185
|
-
|
|
186
|
-
<div className="lnk
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
187
|
+
<>
|
|
188
|
+
<div className="lnk" data-link-path={props.path} data-link-hidden={hiddenTarget ? '' : undefined}>
|
|
189
|
+
<div className="lnk-box" data-path={props.path} tabIndex={-1}>
|
|
190
|
+
<Icon name={docIcon(collection)} />
|
|
191
|
+
<span className="lnk-doc">
|
|
192
|
+
<b>
|
|
193
|
+
{crumb ? `${crumb} / ` : ''}
|
|
194
|
+
{doc.label || doc.title}
|
|
195
|
+
</b>
|
|
196
|
+
<span className="mono">{doc.url}</span>
|
|
197
|
+
</span>
|
|
198
|
+
{clear}
|
|
199
|
+
</div>
|
|
196
200
|
</div>
|
|
197
|
-
|
|
201
|
+
{hiddenTarget ? <Warnings warnings={[hiddenTarget]} /> : null}
|
|
202
|
+
</>
|
|
198
203
|
);
|
|
199
204
|
}
|
|
200
205
|
if (missing && !rule) {
|
|
@@ -148,6 +148,12 @@ export function RefPicker(props: { to: string; taken?: readonly string[]; onPick
|
|
|
148
148
|
<Icon name={icon} />
|
|
149
149
|
<span className="nm">{d.label || d.title}</span>
|
|
150
150
|
<span className="meta">
|
|
151
|
+
{/* M9 Decision 7: a hidden page is still offered (the reference is legitimate), marked as the pages list marks it. */}
|
|
152
|
+
{d.hidden ? (
|
|
153
|
+
<span className="hmark" data-mark="hidden" title={d.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
|
|
154
|
+
Hidden
|
|
155
|
+
</span>
|
|
156
|
+
) : null}
|
|
151
157
|
<span>{on ? 'Added' : <span className="mono">{d.url ?? d.path}</span>}</span>
|
|
152
158
|
</span>
|
|
153
159
|
</button>
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - **The toolbar:** the paragraph style (only when the field allows headings:
|
|
7
7
|
* Paragraph, Heading 2, and Heading 3 where allowed; never Heading 1), Bold,
|
|
8
|
-
* Italic, Bulleted list, Numbered list, and Link (when the field allows links).
|
|
8
|
+
* Italic, Bulleted list, Numbered list, and Link (when the field allows links). A heading
|
|
9
|
+
* field (M9 Decision 11) has Bold only.
|
|
9
10
|
* ⌘B, ⌘I and ⌘K do the same.
|
|
10
11
|
* - **The link popover** (⌘K or the toolbar): find a document by title or address,
|
|
11
12
|
* or type an address; picking a document stores its URL (Decision 4). An address
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
*/
|
|
24
25
|
import { Editor } from '@tiptap/core';
|
|
25
26
|
import { useEffect, useRef, useState, type FocusEvent } from 'react';
|
|
26
|
-
import type
|
|
27
|
+
import { richTextOptions, type RichTextField as RichTextDescriptor } from '../../../fields.ts';
|
|
27
28
|
import { fileAddress, fileKeyOf } from '../../../files.ts';
|
|
28
29
|
import type { LibraryFile } from '../../types.ts';
|
|
29
30
|
import { richTextHtml, type RichTextValue } from '../../../rich-text.ts';
|
|
@@ -48,8 +49,8 @@ export function RichTextField(props: { name: string; label: string; field: RichT
|
|
|
48
49
|
const { name, field } = props;
|
|
49
50
|
const id = `f-${host.scope}-${name}`.replace(/\./g, '-');
|
|
50
51
|
const value = (Array.isArray(props.value) ? props.value : []) as RichTextValue;
|
|
51
|
-
const options =
|
|
52
|
-
const optionsKey = `${field.headings.join(',')}|${field.links}`;
|
|
52
|
+
const options = richTextOptions(field);
|
|
53
|
+
const optionsKey = `${field.headings.join(',')}|${field.links}|${!!field.heading}`;
|
|
53
54
|
const linkable = useLinkable();
|
|
54
55
|
const warnings = useFieldWarnings(name, field, props.value);
|
|
55
56
|
|
|
@@ -79,7 +80,7 @@ export function RichTextField(props: { name: string; label: string; field: RichT
|
|
|
79
80
|
const ed = new Editor({
|
|
80
81
|
element: el,
|
|
81
82
|
extensions: richTextExtensions(latest.current.options),
|
|
82
|
-
content: toDoc(JSON.parse(lastValue.current) as RichTextValue),
|
|
83
|
+
content: toDoc(JSON.parse(lastValue.current) as RichTextValue, latest.current.options),
|
|
83
84
|
injectCSS: false,
|
|
84
85
|
enableInputRules: false,
|
|
85
86
|
enablePasteRules: false,
|
|
@@ -126,7 +127,7 @@ export function RichTextField(props: { name: string; label: string; field: RichT
|
|
|
126
127
|
const json = JSON.stringify(value);
|
|
127
128
|
if (!ed || json === lastValue.current) return;
|
|
128
129
|
lastValue.current = json;
|
|
129
|
-
if (JSON.stringify(fromDoc(ed.getJSON() as PMNode, options)) !== json) ed.commands.setContent(toDoc(value), { emitUpdate: false });
|
|
130
|
+
if (JSON.stringify(fromDoc(ed.getJSON() as PMNode, options)) !== json) ed.commands.setContent(toDoc(value, options), { emitUpdate: false });
|
|
130
131
|
}, [props.value]);
|
|
131
132
|
|
|
132
133
|
useEffect(() => {
|
|
@@ -172,8 +173,8 @@ export function RichTextField(props: { name: string; label: string; field: RichT
|
|
|
172
173
|
};
|
|
173
174
|
|
|
174
175
|
const shownAsText = warnings.filter((w) => w.code === 'disallowed-kind').length;
|
|
175
|
-
const quiet = warnings.filter((w) => w.code === 'disallowed-kind' || w.code === 'link-off');
|
|
176
|
-
const loud = warnings.filter((w) => w.code !== 'disallowed-kind' && w.code !== 'link-off');
|
|
176
|
+
const quiet = warnings.filter((w) => w.code === 'disallowed-kind' || w.code === 'link-off' || w.code === 'italic-off');
|
|
177
|
+
const loud = warnings.filter((w) => w.code !== 'disallowed-kind' && w.code !== 'link-off' && w.code !== 'italic-off');
|
|
177
178
|
const disabled = host.readOnly;
|
|
178
179
|
|
|
179
180
|
return (
|
|
@@ -215,30 +216,35 @@ export function RichTextField(props: { name: string; label: string; field: RichT
|
|
|
215
216
|
<button type="button" aria-label="Bold" aria-pressed={active('bold')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('bold').run())}>
|
|
216
217
|
<b>B</b>
|
|
217
218
|
</button>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
219
|
+
{/* M9 Decision 11: a heading field is bold and line breaks only. */}
|
|
220
|
+
{field.heading ? null : (
|
|
221
|
+
<>
|
|
222
|
+
<button type="button" aria-label="Italic" aria-pressed={active('italic')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('italic').run())}>
|
|
223
|
+
<i className="tb-i">I</i>
|
|
224
|
+
</button>
|
|
225
|
+
<span className="sep" aria-hidden="true" />
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
aria-label="Bulleted list"
|
|
229
|
+
aria-pressed={active('bulletList')}
|
|
230
|
+
disabled={disabled}
|
|
231
|
+
onMouseDown={keep}
|
|
232
|
+
onClick={() => run((e) => toggleList(e, 'bulletList'))}
|
|
233
|
+
>
|
|
234
|
+
<FieldIcon name="ul" size="sm" />
|
|
235
|
+
</button>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-label="Numbered list"
|
|
239
|
+
aria-pressed={active('orderedList')}
|
|
240
|
+
disabled={disabled}
|
|
241
|
+
onMouseDown={keep}
|
|
242
|
+
onClick={() => run((e) => toggleList(e, 'orderedList'))}
|
|
243
|
+
>
|
|
244
|
+
<FieldIcon name="ol" size="sm" />
|
|
245
|
+
</button>
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
242
248
|
{field.links ? (
|
|
243
249
|
<>
|
|
244
250
|
<span className="sep" aria-hidden="true" />
|
|
@@ -20,6 +20,7 @@ import { fieldWarnings, getAtPath, parseField, splitPath, type Field, type Field
|
|
|
20
20
|
import { mediaFor } from '../media/store.ts';
|
|
21
21
|
import { useEditor, useStore } from '../context.tsx';
|
|
22
22
|
import type { Editor, View } from '../editor.ts';
|
|
23
|
+
import { hiddenDocuments, hiddenPages } from '../../../links.ts';
|
|
23
24
|
|
|
24
25
|
export interface FieldHost {
|
|
25
26
|
/**
|
|
@@ -121,6 +122,10 @@ export function useWarningContext(): WarningContext {
|
|
|
121
122
|
const keys = files ? new Set(files.map((f) => f.key)) : null;
|
|
122
123
|
return {
|
|
123
124
|
addresses: documents.map((d) => d.url),
|
|
125
|
+
// M9 Decision 7: a link to a hidden page warns.
|
|
126
|
+
hiddenPages: hiddenPages(documents),
|
|
127
|
+
// ...and so does a reference to one.
|
|
128
|
+
hiddenDocuments: hiddenDocuments(documents),
|
|
124
129
|
// M8 trial (item 3): an address the site's own code serves exists too.
|
|
125
130
|
codeRoutes: editor.site.codeRoutes ?? [],
|
|
126
131
|
documentExists: (collection: string, path: string) => ids.has(`${collection}/${path}`),
|
|
@@ -113,7 +113,8 @@ export interface LinkHits {
|
|
|
113
113
|
export function linkHits(query: string, documents: readonly ListedDocument[], collections: Editor['site']['collections']): LinkHits {
|
|
114
114
|
const q = query.trim();
|
|
115
115
|
const address: LinkHit | null = q && looksLikeAddress(q) ? { key: `addr:${q}`, href: q, doc: null, crumb: '' } : null;
|
|
116
|
-
|
|
116
|
+
// M9 Decision 7: a hidden page isn't offered (a link to one only warns).
|
|
117
|
+
const linkable = documents.filter((d) => typeof d.url === 'string' && !d.hidden);
|
|
117
118
|
const groups = q
|
|
118
119
|
? searchDocuments(q, linkable, collections).map((g) => ({
|
|
119
120
|
label: g.collection.label,
|