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
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
|
@@ -70,6 +70,13 @@ export interface DocumentEntry {
|
|
|
70
70
|
label: string;
|
|
71
71
|
/** Set when the file can't be opened. */
|
|
72
72
|
warning: DocumentWarning | null;
|
|
73
|
+
/**
|
|
74
|
+
* M9 Decision 7: hidden from the site, by the page's own `"hidden": true` or (`hiddenBy`, the
|
|
75
|
+
* path of the hidden page above it) a page it's nested under. A site's routes, the sitemap and
|
|
76
|
+
* `contentReader().documents()` leave it out; the editor lists it, marked. Never the home page.
|
|
77
|
+
*/
|
|
78
|
+
hidden?: true;
|
|
79
|
+
hiddenBy?: string;
|
|
73
80
|
/** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
|
|
74
81
|
uses?: string[];
|
|
75
82
|
/** A shared block: its block's type (`''` when it has none). Absent otherwise. */
|
|
@@ -224,6 +231,7 @@ export async function listDocuments(
|
|
|
224
231
|
if (!collection) throw new DocumentError('unknown-collection', `No collection "${name}" is declared.`);
|
|
225
232
|
const paths = await documentPaths(collection, source);
|
|
226
233
|
const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
|
|
234
|
+
if (collection.nested) hideChildren(entries);
|
|
227
235
|
all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
|
|
228
236
|
}
|
|
229
237
|
if (!collectionName) {
|
|
@@ -236,6 +244,22 @@ export async function listDocuments(
|
|
|
236
244
|
return all;
|
|
237
245
|
}
|
|
238
246
|
|
|
247
|
+
/** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
|
|
248
|
+
function hideChildren(entries: DocumentEntry[]): void {
|
|
249
|
+
const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
|
|
250
|
+
if (hidden.size === 0) return;
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
const parts = entry.path.split('/');
|
|
253
|
+
for (let n = 1; n < parts.length; n++) {
|
|
254
|
+
const above = parts.slice(0, n).join('/');
|
|
255
|
+
if (!hidden.has(above)) continue;
|
|
256
|
+
entry.hidden = true;
|
|
257
|
+
entry.hiddenBy = above;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
239
263
|
/**
|
|
240
264
|
* Read one document. Throws DocumentError: 'not-found', 'bad-path',
|
|
241
265
|
* 'unknown-collection', or the listing's warning codes ('invalid-json',
|
|
@@ -308,6 +332,8 @@ function inspect(
|
|
|
308
332
|
return warn('no-title', 'This file has no title, so it can’t be opened. Add a "title".');
|
|
309
333
|
}
|
|
310
334
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
335
|
+
// M9 Decision 7: the home page is never hidden.
|
|
336
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
|
|
311
337
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
312
338
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
313
339
|
} 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
|
}
|
|
@@ -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,
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* a run ↔ text with marks bold, italic, link { href }
|
|
11
11
|
* "\n" ↔ hardBreak (Shift+Enter: a break inside the paragraph, heading or item)
|
|
12
12
|
*
|
|
13
|
+
* A heading field (M9 Decision 11, `options.heading`) has no blocks: its document's
|
|
14
|
+
* content is inline, as its heading tag holds it, each paragraph a line, the lines
|
|
15
|
+
* joined by hardBreaks. Read back, every break starts a paragraph.
|
|
16
|
+
*
|
|
13
17
|
* `fromDoc` reads back through Phase 1's `parseRichText`, so what the store holds
|
|
14
18
|
* is always the field's own value: empty paragraphs, items and lists dropped,
|
|
15
19
|
* breaks at a unit's start or end trimmed, kinds the field doesn't allow as `p`,
|
|
@@ -56,8 +60,13 @@ const textblock = (type: string, runs: readonly TextRun[], attrs?: Record<string
|
|
|
56
60
|
return { type, ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) };
|
|
57
61
|
};
|
|
58
62
|
|
|
59
|
-
/** A value as the editor's document. Never empty: an empty value is one empty paragraph. */
|
|
60
|
-
export function toDoc(value: RichTextValue): PMNode {
|
|
63
|
+
/** A value as the editor's document. Never empty: an empty value is one empty paragraph (a heading's: no content). */
|
|
64
|
+
export function toDoc(value: RichTextValue, options?: Pick<RichTextOptions, 'heading'>): PMNode {
|
|
65
|
+
if (options?.heading) {
|
|
66
|
+
const lines = value.flatMap((block) => ('items' in block ? block.items : [block.runs]));
|
|
67
|
+
const content = lines.flatMap((runs, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...inlineNodes(runs)]);
|
|
68
|
+
return { type: 'doc', ...(content.length ? { content } : {}) };
|
|
69
|
+
}
|
|
61
70
|
const content: PMNode[] = value.map((block) => {
|
|
62
71
|
if ('items' in block) {
|
|
63
72
|
return {
|
|
@@ -91,6 +100,18 @@ export function runsOf(content: readonly PMNode[] | undefined): TextRun[] {
|
|
|
91
100
|
return runs;
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
/** Runs split at each `\n`: a heading field's lines (M9 Decision 11). */
|
|
104
|
+
export function splitLines(runs: readonly TextRun[]): TextRun[][] {
|
|
105
|
+
const lines: TextRun[][] = [[]];
|
|
106
|
+
for (const run of runs) {
|
|
107
|
+
run.text.split('\n').forEach((part, i) => {
|
|
108
|
+
if (i > 0) lines.push([]);
|
|
109
|
+
if (part !== '') lines.at(-1)!.push({ ...run, text: part });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
const isBlock = (node: PMNode) => node.type === 'paragraph' || node.type === 'heading';
|
|
95
116
|
|
|
96
117
|
/**
|
|
@@ -105,6 +126,11 @@ const itemRuns = (item: PMNode): TextRun[] => {
|
|
|
105
126
|
|
|
106
127
|
/** The editor's document as the field's value (normalised by the field's rules). */
|
|
107
128
|
export function fromDoc(doc: PMNode, options: RichTextOptions): RichTextValue {
|
|
129
|
+
if (options.heading) {
|
|
130
|
+
// A heading's document is inline (or, from older content, paragraphs): each line a paragraph.
|
|
131
|
+
const content = (doc.content ?? []).some(isBlock) ? (doc.content ?? []).filter(isBlock).flatMap((child, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...(child.content ?? [])]) : doc.content;
|
|
132
|
+
return parseRichText(splitLines(runsOf(content)).map((runs) => ({ kind: 'p', runs })), options);
|
|
133
|
+
}
|
|
108
134
|
const blocks: unknown[] = [];
|
|
109
135
|
for (const node of doc.content ?? []) {
|
|
110
136
|
if (node.type === 'paragraph') blocks.push({ kind: 'p', runs: runsOf(node.content) });
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* - **Paste keeps only what the field allows:** HTML is read through this schema
|
|
21
21
|
* (anything else is dropped or becomes text); plain text has a blank line start a
|
|
22
22
|
* paragraph and a single newline make a break.
|
|
23
|
+
* - **A heading field** (M9 Decision 11): the document is inline content (no paragraph,
|
|
24
|
+
* list or heading nodes), with bold and hardBreak only; Enter is a line break too.
|
|
23
25
|
* - Undo inside the field is its own (⌘Z / ⇧⌘Z), as every text field's is (Decision 14).
|
|
24
26
|
*/
|
|
25
27
|
import { Extension, Mark, Node } from '@tiptap/core';
|
|
@@ -153,8 +155,41 @@ const Keys = () =>
|
|
|
153
155
|
},
|
|
154
156
|
});
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A heading field's document (M9 Decision 11): inline content straight inside the
|
|
160
|
+
* heading tag, as RichText.astro draws it, so there's no paragraph to put a `<p>` in.
|
|
161
|
+
*/
|
|
162
|
+
const InlineDoc = Node.create({ name: 'doc', topNode: true, content: 'inline*' });
|
|
163
|
+
|
|
164
|
+
/** A heading field's keys: Enter and Shift+Enter both start a new line; bold only. */
|
|
165
|
+
const HeadingKeys = () =>
|
|
166
|
+
Extension.create({
|
|
167
|
+
name: 'caracmsHeadingKeys',
|
|
168
|
+
priority: 1000,
|
|
169
|
+
addProseMirrorPlugins: () => [history()],
|
|
170
|
+
addKeyboardShortcuts() {
|
|
171
|
+
const editor = this.editor;
|
|
172
|
+
const lineBreak = () =>
|
|
173
|
+
editor.commands.command(({ tr, state, dispatch }) => {
|
|
174
|
+
if (dispatch) tr.replaceSelectionWith(state.schema.nodes.hardBreak!.create(), false).scrollIntoView();
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
'Shift-Enter': lineBreak,
|
|
179
|
+
Enter: lineBreak,
|
|
180
|
+
'Mod-b': () => editor.commands.toggleMark('bold'),
|
|
181
|
+
'Mod-i': () => true,
|
|
182
|
+
'Mod-z': () => undo(editor.state, editor.view.dispatch),
|
|
183
|
+
'Shift-Mod-z': () => redo(editor.state, editor.view.dispatch),
|
|
184
|
+
'Mod-y': () => redo(editor.state, editor.view.dispatch),
|
|
185
|
+
Tab: () => false,
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
156
190
|
/** Every extension the field's editor runs with. */
|
|
157
|
-
export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean }) {
|
|
191
|
+
export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean; heading?: boolean }) {
|
|
192
|
+
if (options.heading) return [InlineDoc, Text, HardBreak, Bold, HeadingKeys()];
|
|
158
193
|
const levels = options.headings.map((h) => (h === 'h2' ? 2 : 3));
|
|
159
194
|
return [
|
|
160
195
|
Doc,
|
|
@@ -172,9 +207,13 @@ export function richTextExtensions(options: { headings: readonly HeadingKind[];
|
|
|
172
207
|
];
|
|
173
208
|
}
|
|
174
209
|
|
|
175
|
-
/** Plain text pasted (Spec → Text): a blank line starts a new paragraph, a single newline is a break. */
|
|
210
|
+
/** Plain text pasted (Spec → Text): a blank line starts a new paragraph, a single newline is a break. In a heading (no paragraphs): each a line. */
|
|
176
211
|
export function clipboardTextParser(text: string, _context: unknown, _plain: boolean, view: EditorView): Slice {
|
|
177
212
|
const schema = view.state.schema;
|
|
213
|
+
if (!schema.nodes.paragraph) {
|
|
214
|
+
const inline = pastedParagraphs(text).flatMap((nodes, i) => [...(i > 0 ? [{ type: 'hardBreak' } as PMNode] : []), ...nodes]);
|
|
215
|
+
return new Slice(Fragment.fromArray(inline.map((n) => (n.type === 'hardBreak' ? schema.nodes.hardBreak!.create() : schema.text(n.text!)))), 0, 0);
|
|
216
|
+
}
|
|
178
217
|
const paragraphs = pastedParagraphs(text).map((inline) =>
|
|
179
218
|
schema.nodes.paragraph!.create(
|
|
180
219
|
null,
|
|
@@ -85,6 +85,12 @@ export function DocRow(props: {
|
|
|
85
85
|
<span className="sr">, can't be opened</span>
|
|
86
86
|
</span>
|
|
87
87
|
) : null}
|
|
88
|
+
{/* M9 Decision 7: hidden from the site (or under a hidden page). */}
|
|
89
|
+
{doc.hidden ? (
|
|
90
|
+
<span className="hmark" data-mark="hidden" title={doc.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
|
|
91
|
+
Hidden
|
|
92
|
+
</span>
|
|
93
|
+
) : null}
|
|
88
94
|
{tooLong ? (
|
|
89
95
|
<span className="wmark" title="Too long for mobile" data-mark="too-long">
|
|
90
96
|
<Icon name="phone" size="xs" />
|