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
package/src/rich-text.ts
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich text's format (M5a Decision 3): the parser, the serializer and the HTML
|
|
3
|
+
* render, in one browser-safe module the editor (Phase 4) and the render share.
|
|
4
|
+
*
|
|
5
|
+
* ## On disk
|
|
6
|
+
*
|
|
7
|
+
* "body": [
|
|
8
|
+
* { "p": "We drew the letters from the old **ferry signs**, see [the print work](/services/print)." },
|
|
9
|
+
* { "h2": "From the harbour wall\noutward" },
|
|
10
|
+
* { "ul": ["First point", "Second point"] }
|
|
11
|
+
* ]
|
|
12
|
+
*
|
|
13
|
+
* Block kinds `p`, `ul`, `ol`, and `h2`/`h3` where the field allows them; never
|
|
14
|
+
* `h1`. Inline: `**bold**`, `*italic*`, `[text](href)`, and `\n` for a break.
|
|
15
|
+
* Marks open and close within one unit (a paragraph, a heading, one list item);
|
|
16
|
+
* bold and italic nest in each other and in a link's text; a link can't hold a link.
|
|
17
|
+
*
|
|
18
|
+
* - **Reading is forgiving** (parseInline): an unpaired `*`, `[` or `]` is literal
|
|
19
|
+
* text; `\` before `*`, `[`, `]` or `\` makes that character literal.
|
|
20
|
+
* - **Writing is strict** (serializeInline): a literal `*`, `[`, `]` or `\` is
|
|
21
|
+
* escaped with `\`. In an href, `\` and `)` are escaped the same way.
|
|
22
|
+
* - **A plain string is read as rich text** with its characters literal: one
|
|
23
|
+
* paragraph per blank-line-separated chunk, single newlines as breaks.
|
|
24
|
+
* - Empty paragraphs, empty list items and empty lists are dropped, and
|
|
25
|
+
* whitespace (breaks included) at the start or end of a unit is trimmed.
|
|
26
|
+
* - A kind the field doesn't allow reads as `p`, and a link where `links` is off
|
|
27
|
+
* reads as its text, each with an issue for the field's warning. The file keeps
|
|
28
|
+
* them until the field is edited (serialize.ts copies untouched fields as read).
|
|
29
|
+
*
|
|
30
|
+
* ## The value (what the editor and the render hold)
|
|
31
|
+
*
|
|
32
|
+
* [{ kind: 'p', runs: [{ text: 'We drew… ' }, { text: 'ferry signs', bold: true }, …] },
|
|
33
|
+
* { kind: 'ul', items: [[{ text: 'First point' }], [{ text: 'Second point' }]] }]
|
|
34
|
+
*
|
|
35
|
+
* A unit is a list of text runs, each with its marks, as ProseMirror models text:
|
|
36
|
+
* `bold`, `italic` (present only when true) and `href` (the link's href as typed,
|
|
37
|
+
* present only inside a link). A run's text may hold `\n`, a break. Runs are
|
|
38
|
+
* normalised: none empty, neighbours with the same marks merged, so two values
|
|
39
|
+
* that write the same text compare equal.
|
|
40
|
+
*
|
|
41
|
+
* Round trip: serializeInline(parseInline(s)) === s for any s serializeInline
|
|
42
|
+
* wrote. A hand-written value in another spelling (`**a [b](x) c**` for
|
|
43
|
+
* `**a** [**b**](x) **c**`) reads to the same runs and is kept as written until
|
|
44
|
+
* the field is edited.
|
|
45
|
+
*/
|
|
46
|
+
import { fileLinkBehaviour } from './files.ts';
|
|
47
|
+
import { isLinkHref } from './links.ts';
|
|
48
|
+
|
|
49
|
+
export type TextKind = 'p' | 'h2' | 'h3';
|
|
50
|
+
export type ListKind = 'ul' | 'ol';
|
|
51
|
+
export type HeadingKind = 'h2' | 'h3';
|
|
52
|
+
|
|
53
|
+
export interface TextRun {
|
|
54
|
+
text: string;
|
|
55
|
+
bold?: true;
|
|
56
|
+
italic?: true;
|
|
57
|
+
/** The link's href as typed (it may fail the link rule: then it renders as text). */
|
|
58
|
+
href?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type RichTextBlock = { kind: TextKind; runs: TextRun[] } | { kind: ListKind; items: TextRun[][] };
|
|
62
|
+
export type RichTextValue = RichTextBlock[];
|
|
63
|
+
|
|
64
|
+
/** The stored form: one single-key object per block. */
|
|
65
|
+
export type StoredRichTextBlock = { [K in TextKind]?: string } & { [K in ListKind]?: string[] };
|
|
66
|
+
|
|
67
|
+
export interface RichTextOptions {
|
|
68
|
+
/** The heading kinds the field allows: none, `['h2']` or `['h2', 'h3']`. */
|
|
69
|
+
headings: readonly HeadingKind[];
|
|
70
|
+
/** Whether the field allows links. */
|
|
71
|
+
links: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* M9 Decision 11: a heading rich-text field. Paragraphs and bold only: a list's items
|
|
74
|
+
* read as paragraphs (a `disallowed-kind` issue) and italic is dropped (`italic-off`).
|
|
75
|
+
* Such a field also has no headings and no links.
|
|
76
|
+
*/
|
|
77
|
+
heading?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Something the reader changed or couldn't read, for the field's warning. `block` is the stored entry's index. */
|
|
81
|
+
export type RichTextIssue =
|
|
82
|
+
| { code: 'disallowed-kind'; block: number; kind: string }
|
|
83
|
+
| { code: 'link-off'; block: number }
|
|
84
|
+
| { code: 'italic-off'; block: number }
|
|
85
|
+
| { code: 'unreadable'; block: number };
|
|
86
|
+
|
|
87
|
+
// ── Runs ──────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function run(text: string, bold: boolean, italic: boolean, href: string | undefined): TextRun {
|
|
90
|
+
const r: TextRun = { text };
|
|
91
|
+
if (bold) r.bold = true;
|
|
92
|
+
if (italic) r.italic = true;
|
|
93
|
+
if (href !== undefined) r.href = href;
|
|
94
|
+
return r;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sameMarks = (a: TextRun, b: TextRun) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && a.href === b.href;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Runs in canonical form: no empty text, neighbours with the same marks merged,
|
|
101
|
+
* flags only when true, keys in the order text, bold, italic, href. Takes
|
|
102
|
+
* anything (a value from outside) and never throws.
|
|
103
|
+
*/
|
|
104
|
+
export function normaliseRuns(input: unknown, options: { links?: boolean } = {}): TextRun[] {
|
|
105
|
+
if (!Array.isArray(input)) return [];
|
|
106
|
+
const out: TextRun[] = [];
|
|
107
|
+
for (const item of input) {
|
|
108
|
+
if (!item || typeof item !== 'object') continue;
|
|
109
|
+
const o = item as Record<string, unknown>;
|
|
110
|
+
if (typeof o.text !== 'string' || o.text === '') continue;
|
|
111
|
+
const href = options.links === false || typeof o.href !== 'string' ? undefined : o.href;
|
|
112
|
+
const next = run(o.text.replace(/\r\n?/g, '\n'), o.bold === true, o.italic === true, href);
|
|
113
|
+
const last = out.at(-1);
|
|
114
|
+
if (last && sameMarks(last, next)) last.text += next.text;
|
|
115
|
+
else out.push(next);
|
|
116
|
+
}
|
|
117
|
+
return settleMarkEdges(out);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Bold and italic never open just before whitespace or close just after it
|
|
122
|
+
* (breaks included): the reader's flanking rule (a `*` run followed by whitespace
|
|
123
|
+
* can't open, one preceded by whitespace can't close) would refuse that markup.
|
|
124
|
+
* So the mark comes off that whitespace, which moves it outside: `**bold **` is
|
|
125
|
+
* written `**bold** `, and a mark the writer would reopen at a space (to nest
|
|
126
|
+
* under another) starts after it. Worked out on the writer's own plan (planMarks),
|
|
127
|
+
* per link group, until nothing changes; only marks are removed, so it ends.
|
|
128
|
+
* Links keep their whitespace.
|
|
129
|
+
*/
|
|
130
|
+
function settleMarkEdges(input: TextRun[]): TextRun[] {
|
|
131
|
+
let runs = input;
|
|
132
|
+
const ws = (c: string | undefined) => c !== undefined && /\s/.test(c);
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (!runs.some((r) => (r.bold || r.italic) && /\s/.test(r.text))) return runs;
|
|
135
|
+
const chars = runs.flatMap((r) => r.text.split('').map((c) => ({ c, bold: !!r.bold, italic: !!r.italic, href: r.href })));
|
|
136
|
+
const starts: number[] = [];
|
|
137
|
+
let at = 0;
|
|
138
|
+
for (const r of runs) {
|
|
139
|
+
starts.push(at);
|
|
140
|
+
at += r.text.length;
|
|
141
|
+
}
|
|
142
|
+
starts.push(at);
|
|
143
|
+
let changed = false;
|
|
144
|
+
const strip = (from: number, to: number, step: 1 | -1, marks: Mark[]) => {
|
|
145
|
+
for (let a = from; step === 1 ? a < to : a >= to; a += step) {
|
|
146
|
+
if (!ws(chars[a]!.c)) break;
|
|
147
|
+
for (const m of marks) {
|
|
148
|
+
if (chars[a]![m]) {
|
|
149
|
+
chars[a]![m] = false;
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let g = 0;
|
|
156
|
+
while (g < runs.length) {
|
|
157
|
+
let h = g;
|
|
158
|
+
while (h < runs.length && runs[h]!.href === runs[g]!.href) h++;
|
|
159
|
+
const plan = planMarks(runs.slice(g, h));
|
|
160
|
+
for (let k = 0; k < h - g; k++) {
|
|
161
|
+
const idx = g + k;
|
|
162
|
+
if (plan.opens[k]!.length > 0) strip(starts[idx]!, starts[idx + 1]!, 1, plan.opens[k]!);
|
|
163
|
+
if (k > 0 && plan.closes[k]!.length > 0) strip(starts[idx]! - 1, starts[idx - 1]!, -1, plan.closes[k]!);
|
|
164
|
+
}
|
|
165
|
+
if (plan.end.length > 0) strip(starts[h]! - 1, starts[h - 1]!, -1, plan.end);
|
|
166
|
+
g = h;
|
|
167
|
+
}
|
|
168
|
+
if (!changed) return runs;
|
|
169
|
+
const out: TextRun[] = [];
|
|
170
|
+
for (const ch of chars) {
|
|
171
|
+
const next = run(ch.c, ch.bold, ch.italic, ch.href);
|
|
172
|
+
const last = out.at(-1);
|
|
173
|
+
if (last && sameMarks(last, next)) last.text += ch.c;
|
|
174
|
+
else out.push(next);
|
|
175
|
+
}
|
|
176
|
+
runs = out;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Trim whitespace (breaks included) from a unit's start and end, dropping runs that empty. */
|
|
181
|
+
export function trimRuns(runs: TextRun[]): TextRun[] {
|
|
182
|
+
const out = runs.map((r) => ({ ...r }));
|
|
183
|
+
while (out.length > 0) {
|
|
184
|
+
const first = out[0]!;
|
|
185
|
+
first.text = first.text.replace(/^\s+/, '');
|
|
186
|
+
if (first.text !== '') break;
|
|
187
|
+
out.shift();
|
|
188
|
+
}
|
|
189
|
+
while (out.length > 0) {
|
|
190
|
+
const last = out.at(-1)!;
|
|
191
|
+
last.text = last.text.replace(/\s+$/, '');
|
|
192
|
+
if (last.text !== '') break;
|
|
193
|
+
out.pop();
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The unit's plain text, breaks as `\n`. */
|
|
199
|
+
export function runsText(runs: readonly TextRun[]): string {
|
|
200
|
+
return runs.map((r) => r.text).join('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Reading inline markup (forgiving) ─────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const ESCAPABLE = new Set(['*', '[', ']', '\\']);
|
|
206
|
+
const PLAIN = /[^*[\]\\]+/y;
|
|
207
|
+
/** Steps before the reader gives up on structure and reads the unit as literal text. */
|
|
208
|
+
const STEP_LIMIT = 50_000;
|
|
209
|
+
|
|
210
|
+
interface Ctx {
|
|
211
|
+
bold: boolean;
|
|
212
|
+
italic: boolean;
|
|
213
|
+
/** The open link's id (its `[`'s offset), or null outside a link. */
|
|
214
|
+
link: number | null;
|
|
215
|
+
/** What closes this context: `**`, `*`, the link's `](`, or the end (null). */
|
|
216
|
+
closer: 'bold' | 'italic' | 'link' | null;
|
|
217
|
+
/** Where this context's content starts: a mark or link closes only after some content. */
|
|
218
|
+
start: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface RawRun {
|
|
222
|
+
text: string;
|
|
223
|
+
bold: boolean;
|
|
224
|
+
italic: boolean;
|
|
225
|
+
link: number | null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
class Budget extends Error {}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A unit's inline markup as runs. Forgiving: never throws; an unpaired `*`, `[`
|
|
232
|
+
* or `]` is literal text. When several readings fit, it prefers structure (a
|
|
233
|
+
* pairing over a literal) and, inside italic, a bold opening over closing the
|
|
234
|
+
* italic, which is how serializeInline's output reads back to its own runs.
|
|
235
|
+
*/
|
|
236
|
+
export function parseInline(src: string): TextRun[] {
|
|
237
|
+
const text = src.replace(/\r\n?/g, '\n');
|
|
238
|
+
// 1. The strict reading that writes back as itself: what serializeInline wrote
|
|
239
|
+
// reads back to its own runs, even where the delimiters could pair two ways.
|
|
240
|
+
// 2. Any strict reading: every unescaped delimiter paired.
|
|
241
|
+
// 3. The forgiving reading: unpaired delimiters literal.
|
|
242
|
+
const passes: [boolean, ((runs: TextRun[]) => boolean) | null][] = [
|
|
243
|
+
[true, (runs) => serializeInline(runs) === text],
|
|
244
|
+
[true, null],
|
|
245
|
+
[false, null],
|
|
246
|
+
];
|
|
247
|
+
for (const [strict, accept] of passes) {
|
|
248
|
+
try {
|
|
249
|
+
const runs = readStructured(text, strict, accept);
|
|
250
|
+
if (runs) return normaliseRuns(runs);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (!(error instanceof Budget) && !(error instanceof RangeError)) throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return normaliseRuns([{ text: unescapeAll(text) }]);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Every escape resolved, every delimiter literal: the fallback reading. */
|
|
259
|
+
function unescapeAll(src: string): string {
|
|
260
|
+
return src.replace(/\\([*[\]\\])/g, '$1');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readStructured(src: string, strict: boolean, accept: ((runs: TextRun[]) => boolean) | null): TextRun[] | null {
|
|
264
|
+
const out: RawRun[] = [];
|
|
265
|
+
const hrefs = new Map<number, string>();
|
|
266
|
+
let steps = 0;
|
|
267
|
+
const len = src.length;
|
|
268
|
+
|
|
269
|
+
// Delimiter runs (CommonMark's flanking, for `*`): a maximal run of unescaped `*`
|
|
270
|
+
// can open only when followed by non-whitespace, and close only when preceded by
|
|
271
|
+
// non-whitespace. So `A * stray` and `**bold **` keep their asterisks literal.
|
|
272
|
+
const escapedAt = new Set<number>();
|
|
273
|
+
for (let i = 0; i < len; i++) {
|
|
274
|
+
if (src[i] === '\\' && i + 1 < len && ESCAPABLE.has(src[i + 1]!)) {
|
|
275
|
+
escapedAt.add(i + 1);
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const isDelim = (i: number) => src[i] === '*' && !escapedAt.has(i);
|
|
280
|
+
const runStart = new Int32Array(len);
|
|
281
|
+
const runEnd = new Int32Array(len);
|
|
282
|
+
for (let i = 0; i < len; ) {
|
|
283
|
+
if (!isDelim(i)) {
|
|
284
|
+
i++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
let j = i;
|
|
288
|
+
while (j < len && isDelim(j)) j++;
|
|
289
|
+
for (let q = i; q < j; q++) {
|
|
290
|
+
runStart[q] = i;
|
|
291
|
+
runEnd[q] = j;
|
|
292
|
+
}
|
|
293
|
+
i = j;
|
|
294
|
+
}
|
|
295
|
+
const space = (c: string | undefined) => c === undefined || /\s/.test(c);
|
|
296
|
+
const canOpen = (pos: number) => !space(src[runEnd[pos]!]);
|
|
297
|
+
const canClose = (pos: number) => !space(src[runStart[pos]! - 1]);
|
|
298
|
+
|
|
299
|
+
const push = (t: string, ctx: Ctx) => out.push({ text: t, bold: ctx.bold, italic: ctx.italic, link: ctx.link });
|
|
300
|
+
/** Try `attempt`; on failure, put the output back as it was. */
|
|
301
|
+
const tryAlt = (attempt: () => boolean): boolean => {
|
|
302
|
+
const mark = out.length;
|
|
303
|
+
if (attempt()) return true;
|
|
304
|
+
out.length = mark;
|
|
305
|
+
return false;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read from `pos` in `ctx` until its closer, then call `k` with the offset after
|
|
310
|
+
* the closer. True when the whole unit read through (the caller stops there).
|
|
311
|
+
*/
|
|
312
|
+
const seq = (pos: number, ctx: Ctx, k: (pos: number) => boolean): boolean => {
|
|
313
|
+
if (++steps > STEP_LIMIT) throw new Budget();
|
|
314
|
+
if (pos >= len) return ctx.closer === null ? k(pos) : false;
|
|
315
|
+
const c = src[pos]!;
|
|
316
|
+
|
|
317
|
+
if (c === '\\') {
|
|
318
|
+
const next = src[pos + 1];
|
|
319
|
+
if (next !== undefined && ESCAPABLE.has(next)) {
|
|
320
|
+
return tryAlt(() => {
|
|
321
|
+
push(next, ctx);
|
|
322
|
+
return seq(pos + 2, ctx, k);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return tryAlt(() => {
|
|
326
|
+
push('\\', ctx);
|
|
327
|
+
return seq(pos + 1, ctx, k);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (c === '*') {
|
|
332
|
+
const double = src[pos + 1] === '*';
|
|
333
|
+
const opens = canOpen(pos);
|
|
334
|
+
const closes = canClose(pos);
|
|
335
|
+
if (double && closes && ctx.closer === 'bold' && pos > ctx.start && tryAlt(() => k(pos + 2))) return true;
|
|
336
|
+
if (double && opens && !ctx.bold && tryAlt(() => seq(pos + 2, { ...ctx, bold: true, closer: 'bold', start: pos + 2 }, (p) => seq(p, ctx, k)))) return true;
|
|
337
|
+
if (closes && ctx.closer === 'italic' && pos > ctx.start && tryAlt(() => k(pos + 1))) return true;
|
|
338
|
+
if (opens && !ctx.italic && tryAlt(() => seq(pos + 1, { ...ctx, italic: true, closer: 'italic', start: pos + 1 }, (p) => seq(p, ctx, k)))) return true;
|
|
339
|
+
if (strict) return false;
|
|
340
|
+
return tryAlt(() => {
|
|
341
|
+
push('*', ctx);
|
|
342
|
+
return seq(pos + 1, ctx, k);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (c === '[') {
|
|
347
|
+
if (ctx.link === null) {
|
|
348
|
+
const opened = tryAlt(() =>
|
|
349
|
+
seq(pos + 1, { ...ctx, link: pos, closer: 'link', start: pos + 1 }, (p) => {
|
|
350
|
+
// `p` is just after `](`: the href runs to the first unescaped `)`.
|
|
351
|
+
let href = '';
|
|
352
|
+
let i = p;
|
|
353
|
+
while (i < len && src[i] !== ')') {
|
|
354
|
+
if (src[i] === '\\' && i + 1 < len) {
|
|
355
|
+
href += src[i + 1];
|
|
356
|
+
i += 2;
|
|
357
|
+
} else {
|
|
358
|
+
href += src[i];
|
|
359
|
+
i += 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (i >= len) return false;
|
|
363
|
+
hrefs.set(pos, href);
|
|
364
|
+
return seq(i + 1, ctx, k);
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
if (opened) return true;
|
|
368
|
+
}
|
|
369
|
+
if (strict) return false;
|
|
370
|
+
return tryAlt(() => {
|
|
371
|
+
push('[', ctx);
|
|
372
|
+
return seq(pos + 1, ctx, k);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (c === ']') {
|
|
377
|
+
if (ctx.closer === 'link' && src[pos + 1] === '(' && pos > ctx.start && tryAlt(() => k(pos + 2))) return true;
|
|
378
|
+
if (strict) return false;
|
|
379
|
+
return tryAlt(() => {
|
|
380
|
+
push(']', ctx);
|
|
381
|
+
return seq(pos + 1, ctx, k);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
PLAIN.lastIndex = pos;
|
|
386
|
+
const m = PLAIN.exec(src)!;
|
|
387
|
+
return tryAlt(() => {
|
|
388
|
+
push(m[0], ctx);
|
|
389
|
+
return seq(pos + m[0].length, ctx, k);
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const result = () => out.map((r) => run(r.text, r.bold, r.italic, r.link === null ? undefined : (hrefs.get(r.link) ?? '')));
|
|
394
|
+
const ok = seq(0, { bold: false, italic: false, link: null, closer: null, start: 0 }, () => !accept || accept(normaliseRuns(result())));
|
|
395
|
+
// The forgiving reading always reads through (a literal is always possible).
|
|
396
|
+
return ok ? result() : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Writing inline markup (strict) ────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
const escapeText = (t: string) => t.replace(/[\\*[\]]/g, (c) => `\\${c}`);
|
|
402
|
+
const escapeHref = (h: string) => h.replace(/[\\)]/g, (c) => `\\${c}`);
|
|
403
|
+
|
|
404
|
+
type Mark = 'bold' | 'italic';
|
|
405
|
+
|
|
406
|
+
/** Where the writer opens and closes bold and italic over a stretch of runs (one link group). */
|
|
407
|
+
interface MarkPlan {
|
|
408
|
+
/** Marks closed just before run `i` (innermost first). */
|
|
409
|
+
closes: Mark[][];
|
|
410
|
+
/** Marks opened just before run `i` (outermost first). */
|
|
411
|
+
opens: Mark[][];
|
|
412
|
+
/** Marks closed after the last run. */
|
|
413
|
+
end: Mark[];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The writer's nesting: a mark that runs on longer opens first (it's the outer
|
|
418
|
+
* one); bold first on a tie. A mark under one that has to close closes and
|
|
419
|
+
* reopens with it.
|
|
420
|
+
*/
|
|
421
|
+
function planMarks(runs: readonly TextRun[]): MarkPlan {
|
|
422
|
+
const has = (r: TextRun, m: Mark) => !!r[m];
|
|
423
|
+
const stack: Mark[] = [];
|
|
424
|
+
const plan: MarkPlan = { closes: [], opens: [], end: [] };
|
|
425
|
+
runs.forEach((r, idx) => {
|
|
426
|
+
let keep = 0;
|
|
427
|
+
while (keep < stack.length && has(r, stack[keep]!)) keep++;
|
|
428
|
+
const closed: Mark[] = [];
|
|
429
|
+
while (stack.length > keep) closed.push(stack.pop()!);
|
|
430
|
+
const lasting = (m: Mark) => {
|
|
431
|
+
let n = 0;
|
|
432
|
+
for (let j = idx; j < runs.length && has(runs[j]!, m); j++) n++;
|
|
433
|
+
return n;
|
|
434
|
+
};
|
|
435
|
+
const missing = (['bold', 'italic'] as const).filter((m) => has(r, m) && !stack.includes(m));
|
|
436
|
+
missing.sort((a, b) => lasting(b) - lasting(a));
|
|
437
|
+
stack.push(...missing);
|
|
438
|
+
plan.closes.push(closed);
|
|
439
|
+
plan.opens.push(missing);
|
|
440
|
+
});
|
|
441
|
+
while (stack.length > 0) plan.end.push(stack.pop()!);
|
|
442
|
+
return plan;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Bold and italic over a stretch of runs, as nested delimiters (planMarks). */
|
|
446
|
+
function markText(runs: readonly TextRun[], open: { bold: string; italic: string }, close: { bold: string; italic: string }, body: (t: string) => string): string {
|
|
447
|
+
const plan = planMarks(runs);
|
|
448
|
+
let out = '';
|
|
449
|
+
runs.forEach((r, idx) => {
|
|
450
|
+
for (const m of plan.closes[idx]!) out += close[m];
|
|
451
|
+
for (const m of plan.opens[idx]!) out += open[m];
|
|
452
|
+
out += body(r.text);
|
|
453
|
+
});
|
|
454
|
+
for (const m of plan.end) out += close[m];
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Consecutive runs grouped by link: each group is outside any link, or one link's text. */
|
|
459
|
+
function linkGroups(runs: readonly TextRun[]): { href: string | undefined; runs: TextRun[] }[] {
|
|
460
|
+
const groups: { href: string | undefined; runs: TextRun[] }[] = [];
|
|
461
|
+
for (const r of runs) {
|
|
462
|
+
const last = groups.at(-1);
|
|
463
|
+
if (last && last.href === r.href) last.runs.push(r);
|
|
464
|
+
else groups.push({ href: r.href, runs: [r] });
|
|
465
|
+
}
|
|
466
|
+
return groups;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const MARKS = { bold: '**', italic: '*' };
|
|
470
|
+
|
|
471
|
+
/** A unit's runs as strict markup: every literal `*`, `[`, `]` and `\` escaped. */
|
|
472
|
+
export function serializeInline(input: readonly TextRun[]): string {
|
|
473
|
+
const runs = normaliseRuns(input);
|
|
474
|
+
return linkGroups(runs)
|
|
475
|
+
.map((g) => {
|
|
476
|
+
const inner = markText(g.runs, MARKS, MARKS, escapeText);
|
|
477
|
+
return g.href === undefined ? inner : `[${inner}](${escapeHref(g.href)})`;
|
|
478
|
+
})
|
|
479
|
+
.join('');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Blocks ────────────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
const TEXT_KINDS = new Set<string>(['p', 'h2', 'h3']);
|
|
485
|
+
const LIST_KINDS = new Set<string>(['ul', 'ol']);
|
|
486
|
+
|
|
487
|
+
const allowedText = (kind: string, options: RichTextOptions): TextKind =>
|
|
488
|
+
kind === 'p' || ((kind === 'h2' || kind === 'h3') && options.headings.includes(kind)) ? (kind as TextKind) : 'p';
|
|
489
|
+
|
|
490
|
+
/** Read one unit, applying `links` (off: hrefs dropped, with a flag) and a heading field's no italic (M9 Decision 11). */
|
|
491
|
+
function unit(runs: TextRun[], options: RichTextOptions): { runs: TextRun[]; droppedLink: boolean; droppedItalic: boolean } {
|
|
492
|
+
const droppedLink = !options.links && runs.some((r) => r.href !== undefined);
|
|
493
|
+
const droppedItalic = !!options.heading && runs.some((r) => r.italic);
|
|
494
|
+
const plain = droppedItalic ? runs.map(({ italic: _, ...r }) => r) : runs;
|
|
495
|
+
return { runs: trimRuns(normaliseRuns(plain, { links: options.links })), droppedLink, droppedItalic };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** A plain string as rich text: its characters literal, one paragraph per blank-line chunk, single newlines as breaks. */
|
|
499
|
+
export function plainTextToRichText(text: string): RichTextValue {
|
|
500
|
+
return text
|
|
501
|
+
.replace(/\r\n?/g, '\n')
|
|
502
|
+
.split(/\n[ \t]*\n/)
|
|
503
|
+
.map((chunk) => trimRuns([{ text: chunk }]))
|
|
504
|
+
.filter((runs) => runs.length > 0)
|
|
505
|
+
.map((runs) => ({ kind: 'p' as const, runs }));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* A rich-text field's raw value (the file's, or one the editor holds) as its
|
|
510
|
+
* value, with what the reader had to change. Never throws.
|
|
511
|
+
*
|
|
512
|
+
* - A string: plainTextToRichText.
|
|
513
|
+
* - An array: each entry the stored form (`{ "p": "…" }`, `{ "ul": ["…"] }`) or the
|
|
514
|
+
* value's own form (`{ kind, runs }` / `{ kind, items }`). A single-key entry
|
|
515
|
+
* whose value is a string is a text block; its kind reads as `p` unless the
|
|
516
|
+
* field allows it. `ul`/`ol` take an array of strings. Anything else is unreadable
|
|
517
|
+
* and dropped (kept in the file until the field is edited).
|
|
518
|
+
* - Anything else: empty.
|
|
519
|
+
*/
|
|
520
|
+
export function readRichText(raw: unknown, options: RichTextOptions): { value: RichTextValue; issues: RichTextIssue[] } {
|
|
521
|
+
const issues: RichTextIssue[] = [];
|
|
522
|
+
if (typeof raw === 'string') return { value: plainTextToRichText(raw), issues };
|
|
523
|
+
if (!Array.isArray(raw)) return { value: [], issues };
|
|
524
|
+
const value: RichTextValue = [];
|
|
525
|
+
|
|
526
|
+
raw.forEach((entry, index) => {
|
|
527
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
528
|
+
issues.push({ code: 'unreadable', block: index });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const o = entry as Record<string, unknown>;
|
|
532
|
+
let kind: string;
|
|
533
|
+
let units: TextRun[][];
|
|
534
|
+
let list: boolean;
|
|
535
|
+
|
|
536
|
+
if (typeof o.kind === 'string' && (Array.isArray(o.runs) || Array.isArray(o.items))) {
|
|
537
|
+
// The value's own form (from the editor).
|
|
538
|
+
kind = o.kind;
|
|
539
|
+
list = LIST_KINDS.has(kind);
|
|
540
|
+
if (list) units = Array.isArray(o.items) ? o.items.map((item) => normaliseRuns(item)) : [];
|
|
541
|
+
else units = [normaliseRuns(o.runs)];
|
|
542
|
+
} else {
|
|
543
|
+
const keys = Object.keys(o);
|
|
544
|
+
if (keys.length !== 1) {
|
|
545
|
+
issues.push({ code: 'unreadable', block: index });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
kind = keys[0]!;
|
|
549
|
+
const v = o[kind];
|
|
550
|
+
if (LIST_KINDS.has(kind) && Array.isArray(v)) {
|
|
551
|
+
list = true;
|
|
552
|
+
units = v.filter((item): item is string => typeof item === 'string').map(parseInline);
|
|
553
|
+
} else if (!LIST_KINDS.has(kind) && typeof v === 'string') {
|
|
554
|
+
list = false;
|
|
555
|
+
units = [parseInline(v)];
|
|
556
|
+
} else {
|
|
557
|
+
issues.push({ code: 'unreadable', block: index });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if ((!list || options.heading) && !(TEXT_KINDS.has(kind) && allowedText(kind, options) === kind)) {
|
|
563
|
+
issues.push({ code: 'disallowed-kind', block: index, kind });
|
|
564
|
+
}
|
|
565
|
+
const read = units.map((u) => unit(u, options));
|
|
566
|
+
if (read.some((u) => u.droppedLink)) issues.push({ code: 'link-off', block: index });
|
|
567
|
+
if (read.some((u) => u.droppedItalic)) issues.push({ code: 'italic-off', block: index });
|
|
568
|
+
const kept = read.map((u) => u.runs).filter((runs) => runs.length > 0);
|
|
569
|
+
if (kept.length === 0) return;
|
|
570
|
+
// M9 Decision 11: a heading field has no lists; each item is a line of its own.
|
|
571
|
+
if (list && options.heading) for (const runs of kept) value.push({ kind: 'p', runs });
|
|
572
|
+
else if (list) value.push({ kind: kind as ListKind, items: kept });
|
|
573
|
+
else value.push({ kind: allowedText(kind, options), runs: kept[0]! });
|
|
574
|
+
});
|
|
575
|
+
return { value, issues };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** readRichText's value alone. */
|
|
579
|
+
export function parseRichText(raw: unknown, options: RichTextOptions): RichTextValue {
|
|
580
|
+
return readRichText(raw, options).value;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* A value in the stored form, strict: kinds the field doesn't allow written as
|
|
585
|
+
* `p`, links where `links` is off written as their text, empty units dropped.
|
|
586
|
+
*/
|
|
587
|
+
export function serializeRichText(value: unknown, options: RichTextOptions): StoredRichTextBlock[] {
|
|
588
|
+
return parseRichText(value, options).map((block): StoredRichTextBlock => {
|
|
589
|
+
if ('items' in block) return { [block.kind]: block.items.map((item) => serializeInline(item)) };
|
|
590
|
+
return { [block.kind]: serializeInline(block.runs) };
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Every href in a value, in order, for the field's link warnings. */
|
|
595
|
+
export function richTextHrefs(value: RichTextValue): string[] {
|
|
596
|
+
const hrefs: string[] = [];
|
|
597
|
+
const units = value.flatMap((b) => ('runs' in b ? [b.runs] : b.items));
|
|
598
|
+
for (const runs of units) for (const g of linkGroups(runs)) if (g.href !== undefined) hrefs.push(g.href);
|
|
599
|
+
return hrefs;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
export function escapeHtml(text: string): string {
|
|
605
|
+
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const HTML_OPEN = { bold: '<strong>', italic: '<em>' };
|
|
609
|
+
const HTML_CLOSE = { bold: '</strong>', italic: '</em>' };
|
|
610
|
+
const htmlText = (t: string) => escapeHtml(t).replace(/\n/g, '<br>');
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The visually hidden note a new-tab file link carries (M6c Decision 11). The package
|
|
614
|
+
* ships no CSS for public pages, so the standard visually-hidden rules are inline.
|
|
615
|
+
*/
|
|
616
|
+
export const NEW_TAB_NOTE_HTML =
|
|
617
|
+
'<span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"> (opens in a new tab)</span>';
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The attributes a file link gets on the site (M6c Decision 11), from
|
|
621
|
+
* `fileLinkBehaviour`: a PDF without `?download` opens in a new tab (and carries
|
|
622
|
+
* `NEW_TAB_NOTE_HTML` inside), any other file link downloads, and every other
|
|
623
|
+
* address gets nothing. `<Link>`, `<Buttons>` and `richTextHtml(…, { fileLinks: true })` share it.
|
|
624
|
+
*/
|
|
625
|
+
export function fileLinkAttributes(href: string): { attributes: Record<string, string>; newTab: boolean } {
|
|
626
|
+
const behaviour = fileLinkBehaviour(href.trim());
|
|
627
|
+
if (behaviour === 'new-tab') return { attributes: { target: '_blank', rel: 'noopener' }, newTab: true };
|
|
628
|
+
if (behaviour === 'download') return { attributes: { download: '' }, newTab: false };
|
|
629
|
+
return { attributes: {}, newTab: false };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** How `inlineHtml` and `richTextHtml` render. */
|
|
633
|
+
export interface RichTextHtmlOptions {
|
|
634
|
+
/**
|
|
635
|
+
* Mark file links as the site does (Decision 11). Off by default: the editor seeds
|
|
636
|
+
* Tiptap and mirrors the page from this HTML, and the hidden note would read back
|
|
637
|
+
* as link text. RichText.astro turns it on.
|
|
638
|
+
*/
|
|
639
|
+
fileLinks?: boolean;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function anchorHtml(href: string, inner: string, options: RichTextHtmlOptions): string {
|
|
643
|
+
const address = href.trim();
|
|
644
|
+
if (!options.fileLinks) return `<a href="${escapeHtml(address)}">${inner}</a>`;
|
|
645
|
+
const { attributes, newTab } = fileLinkAttributes(address);
|
|
646
|
+
const extra = Object.entries(attributes)
|
|
647
|
+
.map(([name, v]) => (v === '' ? ` ${name}` : ` ${name}="${escapeHtml(v)}"`))
|
|
648
|
+
.join('');
|
|
649
|
+
return `<a href="${escapeHtml(address)}"${extra}>${inner}${newTab ? NEW_TAB_NOTE_HTML : ''}</a>`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** A unit as inline HTML: `<strong>`, `<em>`, `<br>`, and `<a href>` for a link that passes the rule (else its text). */
|
|
653
|
+
export function inlineHtml(runs: readonly TextRun[], options: RichTextHtmlOptions = {}): string {
|
|
654
|
+
return linkGroups(normaliseRuns(runs))
|
|
655
|
+
.map((g) => {
|
|
656
|
+
const inner = markText(g.runs, HTML_OPEN, HTML_CLOSE, htmlText);
|
|
657
|
+
return g.href !== undefined && isLinkHref(g.href) ? anchorHtml(g.href, inner, options) : inner;
|
|
658
|
+
})
|
|
659
|
+
.join('');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* A heading rich-text field's value as the inside of its heading tag (M9 Decision 11):
|
|
664
|
+
* each paragraph's inline runs, the paragraphs joined by `<br>`; never a `<p>`.
|
|
665
|
+
*/
|
|
666
|
+
export function richHeadingHtml(value: RichTextValue, options: RichTextHtmlOptions = {}): string {
|
|
667
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map((runs) => inlineHtml(runs, options)).join('<br>');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A value's plain text, one line per paragraph or item (breaks inside one as `\n`): what reads a rich heading as text. */
|
|
671
|
+
export function richTextPlain(value: RichTextValue): string {
|
|
672
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map(runsText).join('\n');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* A value as semantic HTML with no classes: `<p>`, `<h2>`, `<h3>`, `<ul>`/`<ol>`
|
|
677
|
+
* with `<li>`. A heading with a break renders as Text's multi-line heading does,
|
|
678
|
+
* the lines joined by `<br>`. With `fileLinks`, file links carry the site's
|
|
679
|
+
* attributes (M6c Decision 11); the editor's own calls leave it off.
|
|
680
|
+
*/
|
|
681
|
+
export function richTextHtml(value: RichTextValue, options: RichTextHtmlOptions = {}): string {
|
|
682
|
+
return value
|
|
683
|
+
.map((block) => {
|
|
684
|
+
if ('items' in block) {
|
|
685
|
+
return `<${block.kind}>${block.items.map((item) => `<li>${inlineHtml(item, options)}</li>`).join('')}</${block.kind}>`;
|
|
686
|
+
}
|
|
687
|
+
return `<${block.kind}>${inlineHtml(block.runs, options)}</${block.kind}>`;
|
|
688
|
+
})
|
|
689
|
+
.join('');
|
|
690
|
+
}
|