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
|
@@ -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,
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* #live #failed #blocked #history). Live mode only.
|
|
5
5
|
*
|
|
6
6
|
* - **The bar's button**: "3 waiting" with who, and Publish; "Publishing…" with the
|
|
7
|
-
* running bar; "Live · 14:12"; "Publish failed" with Try again; "
|
|
7
|
+
* running bar; "Live · 14:12"; "Publish failed" with Try again; "Taking longer than
|
|
8
|
+
* usual" (runs-anywhere Decision 10: not live 15 minutes on, no failure known), with
|
|
9
|
+
* Try again when a redeployer is set; "Nothing waiting".
|
|
8
10
|
* Off while the server can't be reached or you're signed out.
|
|
9
11
|
* - **The popover is the summary**: each waiting document, who changed it and when,
|
|
10
12
|
* "Editing now", uncommitted changes included (the status merges them), the merge
|
|
11
13
|
* notice where the draft was kept, then the final Publish button. Then publishing →
|
|
12
|
-
* live, or failed with Try again, or
|
|
13
|
-
* redeploy
|
|
14
|
+
* live, or failed (or taking longer than usual) with Try again, or with the line that
|
|
15
|
+
* a developer can redeploy it when no redeployer is set (`hasRedeployer: false`;
|
|
16
|
+
* runs-anywhere Decision 11: no host's name).
|
|
14
17
|
* - **History**: who published, when, which documents, and whether each went live,
|
|
15
18
|
* in the site's time zone, named once. Read when the waiting popover opens (on
|
|
16
19
|
* intent), so it's there when History is pressed, and the last read shows while
|
|
@@ -129,12 +132,12 @@ export function PublishButton() {
|
|
|
129
132
|
</button>
|
|
130
133
|
);
|
|
131
134
|
}
|
|
132
|
-
if (pub.state === 'failed' && !n) {
|
|
135
|
+
if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
|
|
133
136
|
return (
|
|
134
|
-
<button {...common} className="pub fail" data-pub=
|
|
137
|
+
<button {...common} className="pub fail" data-pub={pub.state}>
|
|
135
138
|
<span className="wt">
|
|
136
139
|
<Icon name="warn" size="sm" />
|
|
137
|
-
Publish failed
|
|
140
|
+
{pub.state === 'slow' ? 'Taking longer than usual' : 'Publish failed'}
|
|
138
141
|
</span>
|
|
139
142
|
<span className={`pb${pub.canTryAgain ? '' : ' off'}`}>{pub.canTryAgain ? 'Try again' : 'Publish'}</span>
|
|
140
143
|
</button>
|
|
@@ -280,14 +283,16 @@ export function WaitingPop() {
|
|
|
280
283
|
</>,
|
|
281
284
|
);
|
|
282
285
|
}
|
|
283
|
-
if (pub.state === 'failed' && !n) {
|
|
286
|
+
if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
|
|
287
|
+
const slow = pub.state === 'slow';
|
|
288
|
+
const published = record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}` : null;
|
|
284
289
|
return wrap(
|
|
285
290
|
<>
|
|
286
291
|
<div className="pop-h">
|
|
287
292
|
<div>
|
|
288
|
-
<h2 style={{ color: 'var(--status-danger)' }}>The publish didn't go live</h2>
|
|
293
|
+
<h2 style={{ color: 'var(--status-danger)' }}>{slow ? 'This publish is taking longer than usual' : "The publish didn't go live"}</h2>
|
|
289
294
|
<p className="sub">
|
|
290
|
-
{
|
|
295
|
+
{slow ? `${published ? `${published}. ` : ''}It may have failed.` : `${published ? `${published}, but` : 'The'} the live site couldn't be updated.`}
|
|
291
296
|
</p>
|
|
292
297
|
</div>
|
|
293
298
|
</div>
|
|
@@ -305,7 +310,7 @@ export function WaitingPop() {
|
|
|
305
310
|
<div className="note danger" data-note="no-token">
|
|
306
311
|
<Icon name="warn" />
|
|
307
312
|
<div>
|
|
308
|
-
<b>A developer can redeploy it
|
|
313
|
+
<b>A developer can redeploy it</b>
|
|
309
314
|
</div>
|
|
310
315
|
</div>
|
|
311
316
|
)}
|
|
@@ -597,6 +602,11 @@ export function HistoryPop() {
|
|
|
597
602
|
<Icon name="warn" size="xs" />
|
|
598
603
|
Failed
|
|
599
604
|
</span>
|
|
605
|
+
) : h.state === 'slow' ? (
|
|
606
|
+
<span className="chip danger" data-chip="slow">
|
|
607
|
+
<Icon name="warn" size="xs" />
|
|
608
|
+
Taking longer than usual
|
|
609
|
+
</span>
|
|
600
610
|
) : (
|
|
601
611
|
<span className="chip waiting">Publishing</span>
|
|
602
612
|
)}
|
|
@@ -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" />
|
|
@@ -211,8 +211,8 @@ export function startRichTextBar(editor: Editor, overlays: Overlays, pageEdit: P
|
|
|
211
211
|
|
|
212
212
|
/** The bar's buttons, for the field's options (built once per set of options). */
|
|
213
213
|
function build(s: Shown) {
|
|
214
|
-
const { headings, links } = s.state.options;
|
|
215
|
-
const key = `${headings.join(',')}|${links}`;
|
|
214
|
+
const { headings, links, heading } = s.state.options;
|
|
215
|
+
const key = `${headings.join(',')}|${links}|${!!heading}`;
|
|
216
216
|
if (s.built === key) return;
|
|
217
217
|
s.built = key;
|
|
218
218
|
const sep = '<span class="cms-rtsep" aria-hidden="true"></span>';
|
|
@@ -223,10 +223,10 @@ export function startRichTextBar(editor: Editor, overlays: Overlays, pageEdit: P
|
|
|
223
223
|
? `<button type="button" class="cms-st" data-rtc="style" aria-haspopup="menu" aria-expanded="false" aria-label="Paragraph style: Paragraph">Paragraph${icon('chev-d')}</button>${sep}`
|
|
224
224
|
: '') +
|
|
225
225
|
button('bold', 'Bold', 'Bold (⌘B)', '<b>B</b>') +
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
// M9 Decision 11: a heading field is bold and line breaks only.
|
|
227
|
+
(heading
|
|
228
|
+
? ''
|
|
229
|
+
: button('italic', 'Italic', 'Italic (⌘I)', '<i>I</i>') + sep + button('ul', 'Bulleted list', 'Bulleted list', icon('ul')) + button('ol', 'Numbered list', 'Numbered list', icon('ol'))) +
|
|
230
230
|
(links ? sep + button('link', 'Link', 'Link (⌘K)', icon('link'), ' aria-haspopup="dialog"') : '');
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -20,6 +20,7 @@ import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
|
|
|
20
20
|
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
21
21
|
import { FieldsDocumentLevel, SettingsLevel, SharedDocumentLevel } from './FormLevel.tsx';
|
|
22
22
|
import { SeoGroup } from '../../fields/SeoGroup.tsx';
|
|
23
|
+
import { HiddenToggle } from '../../fields/HiddenToggle.tsx';
|
|
23
24
|
import { PageFields } from '../../fields/PageFields.tsx';
|
|
24
25
|
|
|
25
26
|
/** The open document's level, by its kind (M5a): a section builder's blocks, or a form. */
|
|
@@ -81,6 +82,8 @@ function SectionsLevel() {
|
|
|
81
82
|
)}
|
|
82
83
|
{/* M6c (G2): the page's own fields, when its collection has some, under its blocks. */}
|
|
83
84
|
<PageFields />
|
|
85
|
+
{/* M9 Decision 7: hidden from the site, or not. */}
|
|
86
|
+
<HiddenToggle />
|
|
84
87
|
{/* M6 Decision 13: the page's own Search and sharing, under its blocks. */}
|
|
85
88
|
<SeoGroup />
|
|
86
89
|
</div>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pieces every pane level is built from (the mockup's sidebar components): the
|
|
3
3
|
* trail with the stacked level tabs, the current level's header, the notes every
|
|
4
|
-
* level shows
|
|
4
|
+
* level shows (the GitHub access note among them, runs-anywhere Decision 5), and the
|
|
5
|
+
* Recent strip at the foot. A level is
|
|
5
6
|
*
|
|
6
7
|
* <>
|
|
7
8
|
* <Trail>{level tabs}<CurrentHead … /></Trail>
|
|
@@ -76,13 +77,44 @@ export function CurrentHead(props: {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* Notes every level shows above its content:
|
|
80
|
+
* Notes every level shows above its content: GitHub access (live mode, below), then
|
|
81
|
+
* "Can't reach the server" (Decision 6),
|
|
80
82
|
* or, when the server answered a write with a refusal (a 4xx: the edits don't fit
|
|
81
83
|
* their base, the file was renamed or deleted while open), its own message. M3
|
|
82
84
|
* retries with the next change or a reload (no timer), so M4's countdown, "Try now"
|
|
83
85
|
* and unsent count are slots without data.
|
|
84
86
|
*/
|
|
85
87
|
export function GlobalNotes() {
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<GitHubAccessNote />
|
|
91
|
+
<KeepNotes />
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs-anywhere Decision 5: the site's GitHub access is wrong (a refused credential, a
|
|
98
|
+
* missing permission, a token expiring soon), for everyone, on every level, while it
|
|
99
|
+
* holds. Not dismissable. The sentence is the server's (`status.githubAccess`).
|
|
100
|
+
*/
|
|
101
|
+
function GitHubAccessNote() {
|
|
102
|
+
const editor = useEditor();
|
|
103
|
+
const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
|
|
104
|
+
if (editor.mode !== 'live' || !access) return null;
|
|
105
|
+
const expiring = access.kind === 'expiring';
|
|
106
|
+
return (
|
|
107
|
+
<div className={`note ${expiring ? 'warn' : 'danger'}`} role={expiring ? 'status' : 'alert'} data-note="github-access" data-kind={access.kind}>
|
|
108
|
+
<Icon name="warn" />
|
|
109
|
+
<div>
|
|
110
|
+
<b>{access.title}</b>
|
|
111
|
+
<p>{access.message}</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function KeepNotes() {
|
|
86
118
|
const editor = useEditor();
|
|
87
119
|
const state = useStore(editor.keep, (k) => k.state);
|
|
88
120
|
const error = useStore(editor.keep, (k) => k.error);
|
|
@@ -83,13 +83,13 @@ import type { DocumentStore } from '../state/document-store.ts';
|
|
|
83
83
|
import { createStore, type Store } from '../state/store.ts';
|
|
84
84
|
import { ENDPOINTS, PREVIEW_MARK, TRUE_WIDTH, WIDTHS, sameDoc, type DocRef, type EditorSite, type Width } from '../../types.ts';
|
|
85
85
|
import { createSequencer, type Sequencer } from './render-sequencer.ts';
|
|
86
|
-
import { fieldText } from './field-text.ts';
|
|
86
|
+
import { fieldText, shownText, writeFieldText } from './field-text.ts';
|
|
87
87
|
import { restoreSelection, saveSelection } from './selection.ts';
|
|
88
88
|
import { readLockups, type LockupWarning } from './warnings.ts';
|
|
89
89
|
import { ACTIVITY_EVENTS } from '../state/live.ts';
|
|
90
90
|
import { fieldElement } from './markers.ts';
|
|
91
91
|
import { mountedIn, writeRichText } from './page-editor-registry.ts';
|
|
92
|
-
import { fieldAt, getAtPath, isKeyPath, type Fields, type RichTextField } from '../../../fields.ts';
|
|
92
|
+
import { fieldAt, getAtPath, isKeyPath, richTextOptions, type Fields, type RichTextField } from '../../../fields.ts';
|
|
93
93
|
import { fieldsOfBlock } from '../state/schema.ts';
|
|
94
94
|
|
|
95
95
|
export interface PreviewFrame {
|
|
@@ -501,7 +501,7 @@ export function createPreviewDriver(options: {
|
|
|
501
501
|
function setRichText(doc: Document, block: number, field: string, value: unknown, descriptor: RichTextField) {
|
|
502
502
|
const el = fieldElement(doc, block, field, 'richText');
|
|
503
503
|
if (!el || mountedIn(doc)?.el === el) return;
|
|
504
|
-
writeRichText(el, value,
|
|
504
|
+
writeRichText(el, value, richTextOptions(descriptor));
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
/** The field's descriptor in a block, when it's a rich-text field typed on the page (top level, or in a group). */
|
|
@@ -515,14 +515,14 @@ export function createPreviewDriver(options: {
|
|
|
515
515
|
|
|
516
516
|
/**
|
|
517
517
|
* Put a plain text field's text into a frame. `\n` becomes <br> (innerText's setter;
|
|
518
|
-
* the line-break rule); the text is compared as typed (field-text.ts), not as the
|
|
518
|
+
* the line-break rule), or a line each in the lines rendering (field-text.ts); the text is compared as typed (field-text.ts), not as the
|
|
519
519
|
* site's CSS shows it. `skipActive`: leave the field being typed in alone (it's the
|
|
520
520
|
* source of the text, and rewriting it would lose the caret).
|
|
521
521
|
*/
|
|
522
522
|
function setText(doc: Document, block: number, field: string, text: string, skipActive = false) {
|
|
523
523
|
const el = fieldElement(doc, block, field, 'text');
|
|
524
524
|
if (!el || (skipActive && doc.activeElement === el)) return;
|
|
525
|
-
if (fieldText(el) !== text) el
|
|
525
|
+
if (fieldText(el) !== shownText(el, text)) writeFieldText(el, text);
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
store.subscribe((session, change) => {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* capitals), and a contenteditable's spaces come back as no-break spaces.
|
|
6
6
|
*
|
|
7
7
|
* - a text node is its text (no-break spaces as spaces); a `<br>` is `\n`
|
|
8
|
-
* - a block element the browser inserted (a `<div>` on Enter) starts a new line
|
|
8
|
+
* - a block element the browser inserted (a `<div>` on Enter) starts a new line, and so
|
|
9
|
+
* does a line of the lines rendering (`[data-cms-line]`, M9 Decision 10)
|
|
9
10
|
* - the trailing `<br>` a browser adds so an empty last line shows is dropped
|
|
10
11
|
* - one line only: breaks become spaces
|
|
11
12
|
*/
|
|
@@ -20,7 +21,7 @@ export function fieldText(el: Element, multiline = true): string {
|
|
|
20
21
|
const tag = (child as Element).tagName;
|
|
21
22
|
if (tag === 'BR') out += '\n';
|
|
22
23
|
else {
|
|
23
|
-
if (BLOCKS.has(tag) && out && !out.endsWith('\n')) out += '\n';
|
|
24
|
+
if ((BLOCKS.has(tag) || (child as Element).hasAttribute('data-cms-line')) && out && !out.endsWith('\n')) out += '\n';
|
|
24
25
|
walk(child);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -31,3 +32,40 @@ export function fieldText(el: Element, multiline = true): string {
|
|
|
31
32
|
if (out.endsWith('\n')) out = out.slice(0, -1);
|
|
32
33
|
return multiline ? out : out.replace(/\n+/g, ' ');
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The text a field element shows for `text`: a lines-rendering field (`[data-cms-lines]`,
|
|
38
|
+
* M9 Decision 10) trims each line and drops blank ones, as Text.astro does; any other field shows it all.
|
|
39
|
+
*/
|
|
40
|
+
export function shownText(el: Element, text: string): string {
|
|
41
|
+
return el.hasAttribute('data-cms-lines') ? text.split('\n').map((line) => line.trim()).filter((line) => line !== '').join('\n') : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Put a plain text field's text into its element as the render draws it. `\n` becomes
|
|
46
|
+
* `<br>` (innerText's setter; the line-break rule), except in the lines rendering (M9
|
|
47
|
+
* Decision 10): blank lines drop, one line is bare text, and two or more are a `<span>`
|
|
48
|
+
* each with the component's `lineClass` and the `data-cms-line` marker.
|
|
49
|
+
*/
|
|
50
|
+
export function writeFieldText(el: HTMLElement, text: string): void {
|
|
51
|
+
if (!el.hasAttribute('data-cms-lines')) {
|
|
52
|
+
el.innerText = text;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const lines = shownText(el, text).split('\n').filter((line) => line !== '');
|
|
56
|
+
if (lines.length <= 1) {
|
|
57
|
+
el.textContent = lines[0] ?? '';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const doc = el.ownerDocument;
|
|
61
|
+
const cls = el.getAttribute('data-cms-line-class');
|
|
62
|
+
el.replaceChildren(
|
|
63
|
+
...lines.map((line) => {
|
|
64
|
+
const span = doc.createElement('span');
|
|
65
|
+
if (cls) span.className = cls;
|
|
66
|
+
span.setAttribute('data-cms-line', '');
|
|
67
|
+
span.textContent = line;
|
|
68
|
+
return span;
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -46,7 +46,7 @@ import { blockElement, blockIndexOf, blockRootOf, fieldElement, skippedAnchor, s
|
|
|
46
46
|
import { mountPageEditor, pointInField, positionOf, type MountedPageEditor } from './page-editor.ts';
|
|
47
47
|
import { mountedIn, setMounted } from './page-editor-registry.ts';
|
|
48
48
|
import { fieldsOfBlock } from '../state/schema.ts';
|
|
49
|
-
import { fieldAt, getAtPath, isKeyPath } from '../../../fields.ts';
|
|
49
|
+
import { fieldAt, getAtPath, isKeyPath, richTextOptions } from '../../../fields.ts';
|
|
50
50
|
import type { RichTextOptions } from '../../../rich-text.ts';
|
|
51
51
|
|
|
52
52
|
export interface Typing {
|
|
@@ -227,7 +227,7 @@ export function pageEditFor(editor: Editor): PageEdit | null {
|
|
|
227
227
|
const block = editor.doc.block(key);
|
|
228
228
|
const session = editor.doc.get();
|
|
229
229
|
const descriptor = block?.known ? fieldAt(fieldsOfBlock(editor.site, session.document, session.ref.collection, block), field) : undefined;
|
|
230
|
-
return descriptor?.kind === 'richText' ?
|
|
230
|
+
return descriptor?.kind === 'richText' ? richTextOptions(descriptor) : null;
|
|
231
231
|
};
|
|
232
232
|
|
|
233
233
|
/** Mount the page editor on a field (tearing down any other). Null where it isn't typed on the page. */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Light on purpose (no Tiptap here): page-edit.ts mounts and registers; the driver
|
|
9
9
|
* only asks.
|
|
10
10
|
*/
|
|
11
|
-
import { parseRichText, richTextHtml, type RichTextOptions } from '../../../rich-text.ts';
|
|
11
|
+
import { parseRichText, richHeadingHtml, richTextHtml, type RichTextOptions } from '../../../rich-text.ts';
|
|
12
12
|
|
|
13
13
|
export interface MountedField {
|
|
14
14
|
/** The field's element: the editor's root while mounted. */
|
|
@@ -42,9 +42,10 @@ export function setMounted(doc: Document, mounted: MountedField | null): void {
|
|
|
42
42
|
else byDoc.delete(doc);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** A field's value as the render gives it: the same HTML RichText.astro writes. */
|
|
45
|
+
/** A field's value as the render gives it: the same HTML RichText.astro writes (a heading's: its lines, M9 Decision 11). */
|
|
46
46
|
export function renderedHtml(raw: unknown, options: RichTextOptions): string {
|
|
47
|
-
|
|
47
|
+
const value = parseRichText(raw, options);
|
|
48
|
+
return options.heading ? richHeadingHtml(value) : richTextHtml(value);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -141,7 +141,7 @@ export function mountPageEditor(
|
|
|
141
141
|
onKeyDown?: (event: KeyboardEvent) => boolean;
|
|
142
142
|
},
|
|
143
143
|
): MountedPageEditor {
|
|
144
|
-
const options = { headings: [...init.options.headings], links: init.options.links };
|
|
144
|
+
const options: RichTextOptions = { headings: [...init.options.headings], links: init.options.links, ...(init.options.heading ? { heading: true } : {}) };
|
|
145
145
|
let value = parseRichText(init.value, options);
|
|
146
146
|
let last = JSON.stringify(value);
|
|
147
147
|
// Decision 5's recipe: the attributes exactly as the render left them.
|
|
@@ -152,7 +152,7 @@ export function mountPageEditor(
|
|
|
152
152
|
// Decision 3: ProseMirror uses the element itself only when given `{ mount }`.
|
|
153
153
|
element: { mount: el } as unknown as HTMLElement,
|
|
154
154
|
extensions: richTextExtensions(options),
|
|
155
|
-
content: toDoc(value),
|
|
155
|
+
content: toDoc(value, options),
|
|
156
156
|
injectCSS: false,
|
|
157
157
|
enableInputRules: false,
|
|
158
158
|
enablePasteRules: false,
|
|
@@ -189,7 +189,7 @@ export function mountPageEditor(
|
|
|
189
189
|
// Only the part that differs is replaced, so the selection maps through it (a
|
|
190
190
|
// whole-document setContent would put it at the end), without an update and
|
|
191
191
|
// outside the page's own undo (Decision 11).
|
|
192
|
-
const target = editor.schema.nodeFromJSON(toDoc(next));
|
|
192
|
+
const target = editor.schema.nodeFromJSON(toDoc(next, options));
|
|
193
193
|
const current = editor.state.doc;
|
|
194
194
|
const start = current.content.findDiffStart(target.content);
|
|
195
195
|
if (start === null) return;
|
|
@@ -62,6 +62,8 @@ export type DocumentChange =
|
|
|
62
62
|
/** `field` is the path that was set: a field's key, or a dotted path into it (`credits.1.name`). */
|
|
63
63
|
| { kind: 'field'; key: string; field: string; origin: ChangeOrigin }
|
|
64
64
|
| { kind: 'title' }
|
|
65
|
+
/** M9 Decision 7: the page hidden from the site, or shown again. */
|
|
66
|
+
| { kind: 'hidden' }
|
|
65
67
|
| { kind: 'add'; key: string }
|
|
66
68
|
| { kind: 'remove'; key: string }
|
|
67
69
|
| { kind: 'move'; key: string }
|
|
@@ -114,6 +116,8 @@ export interface DocumentStore {
|
|
|
114
116
|
*/
|
|
115
117
|
setField(key: string, field: string, value: unknown, options?: { origin?: ChangeOrigin; undoLabel?: string }): void;
|
|
116
118
|
setTitle(title: string): void;
|
|
119
|
+
/** M9 Decision 7: hide a section builder's page from the site, or show it again (never the home page's). */
|
|
120
|
+
setHidden(hidden: boolean): void;
|
|
117
121
|
/** Add a new, empty block of a known type at `index` (default: the end). Returns its key. */
|
|
118
122
|
addBlock(type: string, index?: number): string;
|
|
119
123
|
/**
|
|
@@ -196,11 +200,12 @@ export function createDocumentStore(
|
|
|
196
200
|
: doc.blocks.map(keyed);
|
|
197
201
|
const seo = (doc.kind === undefined || doc.kind === 'sections' || doc.kind === 'fields') && doc.seo !== undefined ? { seo: doc.seo as Record<string, unknown> } : {};
|
|
198
202
|
const data = (doc.kind === undefined || doc.kind === 'sections') && doc.data !== undefined ? { data: doc.data } : {};
|
|
203
|
+
const hidden = (doc.kind === undefined || doc.kind === 'sections') && doc.hidden ? { hidden: true } : {};
|
|
199
204
|
return {
|
|
200
205
|
ref: { collection: input.ref.collection, path: input.ref.path },
|
|
201
206
|
entry: input.entry,
|
|
202
207
|
base: input.base,
|
|
203
|
-
document: { kind, title: doc.title, blocks, ...seo, ...data },
|
|
208
|
+
document: { kind, title: doc.title, blocks, ...seo, ...data, ...hidden },
|
|
204
209
|
revision: 0,
|
|
205
210
|
undo: [],
|
|
206
211
|
};
|
|
@@ -268,6 +273,13 @@ export function createDocumentStore(
|
|
|
268
273
|
commit({ ...current, document: { ...current.document, title }, revision: current.revision + 1 }, { kind: 'title' });
|
|
269
274
|
},
|
|
270
275
|
|
|
276
|
+
setHidden(hidden) {
|
|
277
|
+
if (!editable() || !builder()) return;
|
|
278
|
+
const current = state.get();
|
|
279
|
+
if (!!current.document.hidden === hidden) return;
|
|
280
|
+
commit({ ...current, document: { ...current.document, hidden }, revision: current.revision + 1 }, { kind: 'hidden' });
|
|
281
|
+
},
|
|
282
|
+
|
|
271
283
|
addBlock(type, index) {
|
|
272
284
|
if (!editable() || !builder()) return '';
|
|
273
285
|
const created = emptyBlock(type, site as Parameters<typeof emptyBlock>[1]);
|
|
@@ -418,7 +430,13 @@ export function createDocumentStore(
|
|
|
418
430
|
case 'shared':
|
|
419
431
|
return { kind: 'shared', title: document.title, block: first ? plain(first) : { source: 0, type: '', known: false, data: {} } };
|
|
420
432
|
default:
|
|
421
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
title: document.title,
|
|
435
|
+
blocks: document.blocks.map(plain),
|
|
436
|
+
...seoWire(),
|
|
437
|
+
...(document.data !== undefined ? { data: document.data } : {}),
|
|
438
|
+
...(document.hidden !== undefined ? { hidden: document.hidden } : {}),
|
|
439
|
+
};
|
|
422
440
|
}
|
|
423
441
|
},
|
|
424
442
|
};
|