caracms 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- 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/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich text's value (rich-text.ts: blocks of runs) and the editor's ProseMirror
|
|
3
|
+
* document (JSON), both ways. Pure, so it's tested in node.
|
|
4
|
+
*
|
|
5
|
+
* p ↔ paragraph
|
|
6
|
+
* h2, h3 ↔ heading { level }
|
|
7
|
+
* ul, ol ↔ bulletList / orderedList of listItem, each a textblock (its text
|
|
8
|
+
* directly, as `<li>` holds it); `fromDoc` also reads an item holding
|
|
9
|
+
* paragraphs, the shape before on-page rich text
|
|
10
|
+
* a run ↔ text with marks bold, italic, link { href }
|
|
11
|
+
* "\n" ↔ hardBreak (Shift+Enter: a break inside the paragraph, heading or item)
|
|
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
|
+
*
|
|
17
|
+
* `fromDoc` reads back through Phase 1's `parseRichText`, so what the store holds
|
|
18
|
+
* is always the field's own value: empty paragraphs, items and lists dropped,
|
|
19
|
+
* breaks at a unit's start or end trimmed, kinds the field doesn't allow as `p`,
|
|
20
|
+
* links dropped where the field has them off (Decision 3).
|
|
21
|
+
*/
|
|
22
|
+
import { parseRichText, type RichTextOptions, type RichTextValue, type TextRun } from '../../../rich-text.ts';
|
|
23
|
+
|
|
24
|
+
export interface PMMark {
|
|
25
|
+
type: 'bold' | 'italic' | 'link';
|
|
26
|
+
attrs?: { href: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PMNode {
|
|
30
|
+
type: string;
|
|
31
|
+
attrs?: Record<string, unknown>;
|
|
32
|
+
content?: PMNode[];
|
|
33
|
+
text?: string;
|
|
34
|
+
marks?: PMMark[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function marksOf(run: TextRun): PMMark[] | undefined {
|
|
38
|
+
const marks: PMMark[] = [];
|
|
39
|
+
if (run.bold) marks.push({ type: 'bold' });
|
|
40
|
+
if (run.italic) marks.push({ type: 'italic' });
|
|
41
|
+
if (run.href !== undefined) marks.push({ type: 'link', attrs: { href: run.href } });
|
|
42
|
+
return marks.length ? marks : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A unit's runs as inline nodes: text with marks, and a hardBreak for each `\n`. */
|
|
46
|
+
export function inlineNodes(runs: readonly TextRun[]): PMNode[] {
|
|
47
|
+
const out: PMNode[] = [];
|
|
48
|
+
for (const run of runs) {
|
|
49
|
+
const marks = marksOf(run);
|
|
50
|
+
run.text.split('\n').forEach((part, i) => {
|
|
51
|
+
if (i > 0) out.push({ type: 'hardBreak' });
|
|
52
|
+
if (part !== '') out.push(marks ? { type: 'text', text: part, marks } : { type: 'text', text: part });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const textblock = (type: string, runs: readonly TextRun[], attrs?: Record<string, unknown>): PMNode => {
|
|
59
|
+
const content = inlineNodes(runs);
|
|
60
|
+
return { type, ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) };
|
|
61
|
+
};
|
|
62
|
+
|
|
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
|
+
}
|
|
70
|
+
const content: PMNode[] = value.map((block) => {
|
|
71
|
+
if ('items' in block) {
|
|
72
|
+
return {
|
|
73
|
+
type: block.kind === 'ol' ? 'orderedList' : 'bulletList',
|
|
74
|
+
content: block.items.map((runs) => textblock('listItem', runs)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (block.kind === 'h2' || block.kind === 'h3') return textblock('heading', block.runs, { level: block.kind === 'h2' ? 2 : 3 });
|
|
78
|
+
return textblock('paragraph', block.runs);
|
|
79
|
+
});
|
|
80
|
+
return { type: 'doc', content: content.length ? content : [{ type: 'paragraph' }] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Inline nodes as runs: a hardBreak is a `\n` (with no marks). */
|
|
84
|
+
export function runsOf(content: readonly PMNode[] | undefined): TextRun[] {
|
|
85
|
+
const runs: TextRun[] = [];
|
|
86
|
+
for (const node of content ?? []) {
|
|
87
|
+
if (node.type === 'hardBreak') {
|
|
88
|
+
runs.push({ text: '\n' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (node.type !== 'text' || !node.text) continue;
|
|
92
|
+
const run: TextRun = { text: node.text };
|
|
93
|
+
for (const mark of node.marks ?? []) {
|
|
94
|
+
if (mark.type === 'bold') run.bold = true;
|
|
95
|
+
else if (mark.type === 'italic') run.italic = true;
|
|
96
|
+
else if (mark.type === 'link' && typeof mark.attrs?.href === 'string') run.href = mark.attrs.href;
|
|
97
|
+
}
|
|
98
|
+
runs.push(run);
|
|
99
|
+
}
|
|
100
|
+
return runs;
|
|
101
|
+
}
|
|
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
|
+
|
|
115
|
+
const isBlock = (node: PMNode) => node.type === 'paragraph' || node.type === 'heading';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A list item's runs: its inline content (a textblock item), or, in the older shape,
|
|
119
|
+
* its paragraphs joined by breaks should it hold more than one.
|
|
120
|
+
*/
|
|
121
|
+
const itemRuns = (item: PMNode): TextRun[] => {
|
|
122
|
+
const content = item.content ?? [];
|
|
123
|
+
if (!content.some(isBlock)) return runsOf(content);
|
|
124
|
+
return content.filter(isBlock).flatMap((child, i) => [...(i > 0 ? [{ text: '\n' }] : []), ...runsOf(child.content)]);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The editor's document as the field's value (normalised by the field's rules). */
|
|
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
|
+
}
|
|
134
|
+
const blocks: unknown[] = [];
|
|
135
|
+
for (const node of doc.content ?? []) {
|
|
136
|
+
if (node.type === 'paragraph') blocks.push({ kind: 'p', runs: runsOf(node.content) });
|
|
137
|
+
else if (node.type === 'heading') blocks.push({ kind: node.attrs?.level === 3 ? 'h3' : 'h2', runs: runsOf(node.content) });
|
|
138
|
+
else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
|
139
|
+
blocks.push({ kind: node.type === 'orderedList' ? 'ol' : 'ul', items: (node.content ?? []).map(itemRuns) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return parseRichText(blocks, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pasted plain text (Spec → Text): a blank line starts a new paragraph, a single
|
|
147
|
+
* newline is a break. The paragraphs' inline content, one array per paragraph.
|
|
148
|
+
*/
|
|
149
|
+
export function pastedParagraphs(text: string): PMNode[][] {
|
|
150
|
+
return text
|
|
151
|
+
.replace(/\r\n?/g, '\n')
|
|
152
|
+
.split(/\n[ \t]*\n/)
|
|
153
|
+
.map((chunk) => chunk.replace(/^\n+|\n+$/g, ''))
|
|
154
|
+
.filter((chunk) => chunk.trim() !== '')
|
|
155
|
+
.map((chunk) => inlineNodes([{ text: chunk }]));
|
|
156
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich-text editor's schema (M5a Decision 3): Tiptap (ProseMirror) with
|
|
3
|
+
* exactly the nodes and marks the field allows, and nothing else. Browser only.
|
|
4
|
+
*
|
|
5
|
+
* - Nodes: paragraph, heading (only when the field allows `h2` or `h2`/`h3`, and
|
|
6
|
+
* only those levels), bulletList / orderedList of listItem, hardBreak. **A list
|
|
7
|
+
* item is a textblock** (on-page rich text, Decision 3): `<li>` holds its text
|
|
8
|
+
* directly, as `richTextHtml` renders it, so the editor's DOM is the render's on
|
|
9
|
+
* the page too. No lists inside lists. Its commands: rich-text-commands.ts.
|
|
10
|
+
* Marks: bold, italic, link (only when the field allows links; it never spans
|
|
11
|
+
* into new text typed at its end).
|
|
12
|
+
* - **No input rules, no paste rules, no autolink, no link-on-paste, no slash
|
|
13
|
+
* menu:** typing `**` gives two asterisks. Formatting is the toolbar, ⌘B, ⌘I and ⌘K.
|
|
14
|
+
* - **The keys follow Spec → Text:** Enter is a new paragraph (in a heading, a new
|
|
15
|
+
* paragraph after it; in a list, a new item); Shift+Enter is a line break in the
|
|
16
|
+
* paragraph, heading or item. Where the table is silent, Google Docs: Enter in an
|
|
17
|
+
* empty item leaves the list; Backspace at the start of an item takes it out of
|
|
18
|
+
* the list; Backspace at the start of a heading joins it to the block above (and
|
|
19
|
+
* an empty heading at the very start becomes a paragraph).
|
|
20
|
+
* - **Paste keeps only what the field allows:** HTML is read through this schema
|
|
21
|
+
* (anything else is dropped or becomes text); plain text has a blank line start a
|
|
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.
|
|
25
|
+
* - Undo inside the field is its own (⌘Z / ⇧⌘Z), as every text field's is (Decision 14).
|
|
26
|
+
*/
|
|
27
|
+
import { Extension, Mark, Node } from '@tiptap/core';
|
|
28
|
+
import { history, redo, undo } from '@tiptap/pm/history';
|
|
29
|
+
import { Fragment, Slice } from '@tiptap/pm/model';
|
|
30
|
+
import type { EditorView } from '@tiptap/pm/view';
|
|
31
|
+
import { isLinkHref } from '../../../links.ts';
|
|
32
|
+
import type { HeadingKind } from '../../../rich-text.ts';
|
|
33
|
+
import { liftListItem, splitListItem } from './rich-text-commands.ts';
|
|
34
|
+
import { pastedParagraphs, type PMNode } from './rich-text-doc.ts';
|
|
35
|
+
|
|
36
|
+
const Doc = Node.create({ name: 'doc', topNode: true, content: 'block+' });
|
|
37
|
+
const Text = Node.create({ name: 'text', group: 'inline' });
|
|
38
|
+
|
|
39
|
+
const Paragraph = Node.create({
|
|
40
|
+
name: 'paragraph',
|
|
41
|
+
group: 'block',
|
|
42
|
+
content: 'inline*',
|
|
43
|
+
priority: 1000,
|
|
44
|
+
parseHTML: () => [{ tag: 'p' }],
|
|
45
|
+
renderHTML: () => ['p', 0],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const heading = (levels: readonly number[]) =>
|
|
49
|
+
Node.create({
|
|
50
|
+
name: 'heading',
|
|
51
|
+
group: 'block',
|
|
52
|
+
content: 'inline*',
|
|
53
|
+
defining: true,
|
|
54
|
+
addAttributes: () => ({ level: { default: levels[0] ?? 2, rendered: false } }),
|
|
55
|
+
parseHTML: () => levels.map((level) => ({ tag: `h${level}`, attrs: { level } })),
|
|
56
|
+
renderHTML: ({ node }) => [`h${node.attrs.level as number}`, 0],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const list = (name: string, tag: string) =>
|
|
60
|
+
Node.create({
|
|
61
|
+
name,
|
|
62
|
+
group: 'block',
|
|
63
|
+
content: 'listItem+',
|
|
64
|
+
parseHTML: () => [{ tag }],
|
|
65
|
+
renderHTML: () => [tag, 0],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const ListItem = Node.create({
|
|
69
|
+
name: 'listItem',
|
|
70
|
+
content: 'inline*',
|
|
71
|
+
defining: true,
|
|
72
|
+
parseHTML: () => [
|
|
73
|
+
{ tag: 'li' },
|
|
74
|
+
// A pasted item holding a paragraph (Google Docs, most web pages: <li><p>…</p></li>): its text is the item's.
|
|
75
|
+
// Above the paragraph's own rule (rules without a priority are 50).
|
|
76
|
+
{ tag: 'li > p', skip: true, priority: 60 },
|
|
77
|
+
],
|
|
78
|
+
renderHTML: () => ['li', 0],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const HardBreak = Node.create({
|
|
82
|
+
name: 'hardBreak',
|
|
83
|
+
group: 'inline',
|
|
84
|
+
inline: true,
|
|
85
|
+
selectable: false,
|
|
86
|
+
linebreakReplacement: true,
|
|
87
|
+
parseHTML: () => [{ tag: 'br' }],
|
|
88
|
+
renderHTML: () => ['br'],
|
|
89
|
+
renderText: () => '\n',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const Bold = Mark.create({
|
|
93
|
+
name: 'bold',
|
|
94
|
+
parseHTML: () => [
|
|
95
|
+
{ tag: 'strong' },
|
|
96
|
+
// Google Docs wraps a paste in <b style="font-weight:normal">.
|
|
97
|
+
{ tag: 'b', getAttrs: (node) => (node as HTMLElement).style.fontWeight !== 'normal' && null },
|
|
98
|
+
{ style: 'font-weight=400', clearMark: (mark) => mark.type.name === 'bold' },
|
|
99
|
+
{ style: 'font-weight', getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null },
|
|
100
|
+
],
|
|
101
|
+
renderHTML: () => ['strong', 0],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const Italic = Mark.create({
|
|
105
|
+
name: 'italic',
|
|
106
|
+
parseHTML: () => [
|
|
107
|
+
{ tag: 'em' },
|
|
108
|
+
{ tag: 'i', getAttrs: (node) => (node as HTMLElement).style.fontStyle !== 'normal' && null },
|
|
109
|
+
{ style: 'font-style=normal', clearMark: (mark) => mark.type.name === 'italic' },
|
|
110
|
+
{ style: 'font-style=italic' },
|
|
111
|
+
],
|
|
112
|
+
renderHTML: () => ['em', 0],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const Link = Mark.create({
|
|
116
|
+
name: 'link',
|
|
117
|
+
// Typing at a link's end doesn't extend it; a link can't hold a link (a mark excludes its own type).
|
|
118
|
+
inclusive: false,
|
|
119
|
+
addAttributes: () => ({ href: { default: '' } }),
|
|
120
|
+
parseHTML: () => [{ tag: 'a[href]', getAttrs: (dom) => ({ href: (dom as HTMLElement).getAttribute('href') ?? '' }) }],
|
|
121
|
+
// The editor shows a link as a link; an href that fails the rule isn't put in the DOM (it's kept in the value).
|
|
122
|
+
renderHTML: ({ mark }) => ['a', { href: isLinkHref(mark.attrs.href) ? (mark.attrs.href as string) : null, 'data-href': mark.attrs.href as string }, 0],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** The keys (Spec → Text) and the field's own undo. ⌘K is the component's (it must not reach the switcher). */
|
|
126
|
+
const Keys = () =>
|
|
127
|
+
Extension.create({
|
|
128
|
+
name: 'caracmsKeys',
|
|
129
|
+
priority: 1000,
|
|
130
|
+
addProseMirrorPlugins: () => [history()],
|
|
131
|
+
addKeyboardShortcuts() {
|
|
132
|
+
const editor = this.editor;
|
|
133
|
+
const inItem = () => editor.state.selection.$from.parent.type.name === 'listItem';
|
|
134
|
+
return {
|
|
135
|
+
'Shift-Enter': () =>
|
|
136
|
+
editor.commands.command(({ tr, state, dispatch }) => {
|
|
137
|
+
const br = state.schema.nodes.hardBreak!;
|
|
138
|
+
if (dispatch) tr.replaceSelectionWith(br.create(), false).scrollIntoView();
|
|
139
|
+
return true;
|
|
140
|
+
}),
|
|
141
|
+
Enter: () => (inItem() ? splitListItem(editor.state, editor.view.dispatch) : false),
|
|
142
|
+
Backspace: () => {
|
|
143
|
+
const { empty, $from } = editor.state.selection;
|
|
144
|
+
if (!empty || $from.parentOffset !== 0 || !inItem()) return false;
|
|
145
|
+
return liftListItem(editor.state, editor.view.dispatch);
|
|
146
|
+
},
|
|
147
|
+
'Mod-b': () => editor.commands.toggleMark('bold'),
|
|
148
|
+
'Mod-i': () => editor.commands.toggleMark('italic'),
|
|
149
|
+
'Mod-z': () => undo(editor.state, editor.view.dispatch),
|
|
150
|
+
'Shift-Mod-z': () => redo(editor.state, editor.view.dispatch),
|
|
151
|
+
'Mod-y': () => redo(editor.state, editor.view.dispatch),
|
|
152
|
+
// No sinking an item into a nested list, and no tab characters: Tab moves focus on.
|
|
153
|
+
Tab: () => false,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
});
|
|
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
|
+
|
|
190
|
+
/** Every extension the field's editor runs with. */
|
|
191
|
+
export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean; heading?: boolean }) {
|
|
192
|
+
if (options.heading) return [InlineDoc, Text, HardBreak, Bold, HeadingKeys()];
|
|
193
|
+
const levels = options.headings.map((h) => (h === 'h2' ? 2 : 3));
|
|
194
|
+
return [
|
|
195
|
+
Doc,
|
|
196
|
+
Text,
|
|
197
|
+
Paragraph,
|
|
198
|
+
...(levels.length ? [heading(levels)] : []),
|
|
199
|
+
list('bulletList', 'ul'),
|
|
200
|
+
list('orderedList', 'ol'),
|
|
201
|
+
ListItem,
|
|
202
|
+
HardBreak,
|
|
203
|
+
Bold,
|
|
204
|
+
Italic,
|
|
205
|
+
...(options.links ? [Link] : []),
|
|
206
|
+
Keys(),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
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. */
|
|
211
|
+
export function clipboardTextParser(text: string, _context: unknown, _plain: boolean, view: EditorView): Slice {
|
|
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
|
+
}
|
|
217
|
+
const paragraphs = pastedParagraphs(text).map((inline) =>
|
|
218
|
+
schema.nodes.paragraph!.create(
|
|
219
|
+
null,
|
|
220
|
+
inline.map((n: PMNode) => (n.type === 'hardBreak' ? schema.nodes.hardBreak!.create() : schema.text(n.text!))),
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
return Slice.maxOpen(Fragment.fromArray(paragraphs));
|
|
224
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fields changed since the preview last rendered (the mockup's #preview-failed: the
|
|
3
|
+
* field says "Not in the preview yet" while the preview is behind). A field is added
|
|
4
|
+
* when it changes and dropped when a render lands; the field frame shows the chip
|
|
5
|
+
* only while the latest render failed (previewStatus.failed).
|
|
6
|
+
*
|
|
7
|
+
* staleFields(editor) Store<ReadonlySet<string>> of `${blockKey} ${field}`
|
|
8
|
+
* staleKey(key, field)
|
|
9
|
+
*
|
|
10
|
+
* One store per editor (a WeakMap); nothing on the server, where there's no preview.
|
|
11
|
+
*/
|
|
12
|
+
import { isIndexPart, splitPath } from '../../../fields.ts';
|
|
13
|
+
import type { Editor } from '../editor.ts';
|
|
14
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
15
|
+
|
|
16
|
+
const stores = new WeakMap<Editor, Store<ReadonlySet<string>>>();
|
|
17
|
+
|
|
18
|
+
export const staleKey = (key: string, field: string) => `${key} ${field}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The field frames a change at `path` marks: a list marks itself (`credits.1.name` →
|
|
22
|
+
* `credits`, M5a Decision 16), and a group is see-through, so its fields mark their own
|
|
23
|
+
* frames (`left.heading` → `left`, `left.heading`; `left.gallery.0.alt` → `left`,
|
|
24
|
+
* `left.gallery`). Every key part up to the first row index.
|
|
25
|
+
*/
|
|
26
|
+
export function staleNames(path: string): string[] {
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
let at = '';
|
|
29
|
+
for (const part of splitPath(path)) {
|
|
30
|
+
if (isIndexPart(part)) break;
|
|
31
|
+
at = at ? `${at}.${part}` : part;
|
|
32
|
+
out.push(at);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function staleFields(editor: Editor): Store<ReadonlySet<string>> {
|
|
38
|
+
let store = stores.get(editor);
|
|
39
|
+
if (store) return store;
|
|
40
|
+
store = createStore<ReadonlySet<string>>(new Set());
|
|
41
|
+
stores.set(editor, store);
|
|
42
|
+
const driver = editor.preview;
|
|
43
|
+
if (!driver) return store;
|
|
44
|
+
const s = store;
|
|
45
|
+
editor.doc.subscribe((_, change) => {
|
|
46
|
+
if (change.kind === 'field') {
|
|
47
|
+
// A change inside a list (`credits.1.name`) marks the list; inside a group, the group's field.
|
|
48
|
+
const keys = staleNames(change.field).map((name) => staleKey(change.key, name));
|
|
49
|
+
if (keys.some((k) => !s.get().has(k))) s.set(new Set([...s.get(), ...keys]));
|
|
50
|
+
} else if (change.kind === 'load') {
|
|
51
|
+
if (s.get().size) s.set(new Set());
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
driver.onMorph(() => {
|
|
55
|
+
if (s.get().size) s.set(new Set());
|
|
56
|
+
});
|
|
57
|
+
return s;
|
|
58
|
+
}
|