@colixsystems/widget-sdk 0.275.2 → 0.277.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.
@@ -0,0 +1,169 @@
1
+ // GENERATED from source-mask.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change source-mask.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
4
+
5
+ // sc-6086: the shared source masker, extracted verbatim from linter.js so the
6
+ // linter, the packer's import scanner, and the dev-server guard all agree on
7
+ // what is code and what is a comment or string literal (CLAUDE.md §3).
8
+ //
9
+ // Length-preserving: every blanked character becomes a space and newlines are
10
+ // kept, so an index into the mask is the SAME index in the original source.
11
+ // That is what lets a caller match against the mask and splice the original.
12
+
13
+ // Replace the *content* of comments and string / template literals with
14
+ // spaces so the banned-identifier scan only ever sees executable code. A
15
+ // banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
16
+ // is only dangerous as a real identifier reference — never as prose in a
17
+ // `//` comment or as character data inside a string — so matching the bare
18
+ // word there is a false positive that blocks an otherwise-clean widget (a
19
+ // comment that reads "the hour window the grid renders" must not trip
20
+ // `no-window`).
21
+ //
22
+ // Newlines are preserved verbatim so reported line numbers still line up
23
+ // with the original source. Template-literal `${ … }` expression holes are
24
+ // left intact: real code lives there and must still be scanned (`${window}`
25
+ // is a genuine escape). Backslash escapes inside strings/templates are
26
+ // consumed so an escaped quote (`"\""`) doesn't end the literal early.
27
+ function stripNonCode(
28
+ source,
29
+ { keepStrings = false, keepTemplates = keepStrings } = {},
30
+ ) {
31
+ let out = "";
32
+ const n = source.length;
33
+ let mode = "code"; // code | line | block | sq | dq | tmpl
34
+ // Brace depth, plus a stack of the depths at which an enclosing template
35
+ // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
36
+ // strings, or nested templates) be told apart from the literal text.
37
+ let braceDepth = 0;
38
+ const tmplStack = [];
39
+ const keep = (ch) => {
40
+ out += ch;
41
+ };
42
+ const blank = (ch) => {
43
+ out += ch === "\n" || ch === "\r" ? ch : " ";
44
+ };
45
+ // String / template CONTENT: blanked for the banned-identifier scan
46
+ // (prose must not trip `no-window`), kept for the host-API-URL scan,
47
+ // whose whole job is to find a URL literal.
48
+ const str = keepStrings ? keep : blank;
49
+ // An import specifier is always a quoted string, never a template literal —
50
+ // so the import scanner keeps quoted content and blanks templates, which is
51
+ // what stops an `import … from "./x"` line inside a backtick block from
52
+ // reading as a real statement.
53
+ const tmplStr = keepTemplates ? keep : blank;
54
+ let i = 0;
55
+ while (i < n) {
56
+ const ch = source[i];
57
+ const nx = source[i + 1];
58
+ if (mode === "code") {
59
+ if (ch === "/" && nx === "/") {
60
+ mode = "line";
61
+ blank(ch);
62
+ blank(nx);
63
+ i += 2;
64
+ } else if (ch === "/" && nx === "*") {
65
+ mode = "block";
66
+ blank(ch);
67
+ blank(nx);
68
+ i += 2;
69
+ } else if (ch === "'") {
70
+ mode = "sq";
71
+ str(ch);
72
+ i += 1;
73
+ } else if (ch === '"') {
74
+ mode = "dq";
75
+ str(ch);
76
+ i += 1;
77
+ } else if (ch === "`") {
78
+ mode = "tmpl";
79
+ tmplStr(ch);
80
+ i += 1;
81
+ } else if (ch === "{") {
82
+ braceDepth += 1;
83
+ keep(ch);
84
+ i += 1;
85
+ } else if (ch === "}") {
86
+ braceDepth -= 1;
87
+ if (
88
+ tmplStack.length > 0 &&
89
+ tmplStack[tmplStack.length - 1] === braceDepth
90
+ ) {
91
+ tmplStack.pop();
92
+ mode = "tmpl";
93
+ str(ch);
94
+ } else {
95
+ keep(ch);
96
+ }
97
+ i += 1;
98
+ } else {
99
+ keep(ch);
100
+ i += 1;
101
+ }
102
+ } else if (mode === "line") {
103
+ if (ch === "\n") {
104
+ mode = "code";
105
+ keep(ch);
106
+ } else {
107
+ blank(ch);
108
+ }
109
+ i += 1;
110
+ } else if (mode === "block") {
111
+ if (ch === "*" && nx === "/") {
112
+ mode = "code";
113
+ blank(ch);
114
+ blank(nx);
115
+ i += 2;
116
+ } else {
117
+ blank(ch);
118
+ i += 1;
119
+ }
120
+ } else if (mode === "sq" || mode === "dq") {
121
+ const quote = mode === "sq" ? "'" : '"';
122
+ if (ch === "\\") {
123
+ str(ch);
124
+ if (i + 1 < n) str(nx);
125
+ i += 2;
126
+ } else if (ch === quote) {
127
+ mode = "code";
128
+ str(ch);
129
+ i += 1;
130
+ } else if (ch === "\n") {
131
+ // A bare newline terminates an unterminated string in JS; bail back
132
+ // to code so malformed input can't blank the rest of the file.
133
+ mode = "code";
134
+ keep(ch);
135
+ i += 1;
136
+ } else {
137
+ str(ch);
138
+ i += 1;
139
+ }
140
+ } else {
141
+ // mode === "tmpl"
142
+ if (ch === "\\") {
143
+ tmplStr(ch);
144
+ if (i + 1 < n) tmplStr(nx);
145
+ i += 2;
146
+ } else if (ch === "`") {
147
+ mode = "code";
148
+ tmplStr(ch);
149
+ i += 1;
150
+ } else if (ch === "$" && nx === "{") {
151
+ // Enter an expression hole. Remember the brace depth the template
152
+ // resumes at, then count the `{` so its matching `}` is recognised.
153
+ tmplStack.push(braceDepth);
154
+ braceDepth += 1;
155
+ mode = "code";
156
+ keep(ch);
157
+ keep(nx);
158
+ i += 2;
159
+ } else {
160
+ tmplStr(ch);
161
+ i += 1;
162
+ }
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ module.exports = {
168
+ stripNonCode,
169
+ };
@@ -1,9 +1,12 @@
1
- // CommonJS mirror of theme-appearance.js, for the backend's theme coercion and
2
- // the compiler. The BODY is copied VERBATIM; theme-appearance-parity.test.js
3
- // pins identical bodies and identical behaviour.
1
+ // GENERATED from theme-appearance.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change theme-appearance.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
4
4
 
5
5
  const { isHexColor, APPEARANCE_PREFERENCES } = require("./contract.cjs");
6
6
 
7
+ // APPEARANCE_PREFERENCES: what an app opens on and a user may choose.
8
+
9
+
7
10
  // REQ-THEME-APPEARANCE (sc-12335) — the ONE implementation of an app's light and
8
11
  // dark palettes, and of which one is showing. Host surface, not author API.
9
12
 
@@ -181,10 +184,9 @@ function themeInBothModes(theme, mode, twin) {
181
184
  if (mode !== "dark") return withDarkPalette(base, twinPalette);
182
185
  return withDarkPalette(overlayPalette(base, twinPalette), recolourablePalette(base));
183
186
  }
184
-
185
187
  module.exports = {
186
- APPEARANCE_MODES,
187
188
  APPEARANCE_PREFERENCES,
189
+ APPEARANCE_MODES,
188
190
  themePaletteOf,
189
191
  normaliseAppearance,
190
192
  resolveAppearancePreference,
@@ -1,6 +1,6 @@
1
+ // APPEARANCE_PREFERENCES: what an app opens on and a user may choose.
1
2
  import { isHexColor, APPEARANCE_PREFERENCES } from "./contract.js";
2
3
 
3
- // What an app opens on and a user may choose; one list, in the contract.
4
4
  export { APPEARANCE_PREFERENCES };
5
5
 
6
6
  // REQ-THEME-APPEARANCE (sc-12335) — the ONE implementation of an app's light and
@@ -1,10 +1,6 @@
1
- // CommonJS mirror of theme-background.js — the compiler (Expo export) and the
2
- // Mason build runner are CJS and must resolve an app background and a login
3
- // design against the SAME rules the web hosts apply, or the two renders diverge.
4
- //
5
- // The BODY below is copied VERBATIM from theme-background.js; only the module
6
- // syntax differs. theme-background-parity.test.js pins both facts — identical
7
- // bodies AND identical behaviour over a shared case table — so drift fails CI.
1
+ // GENERATED from theme-background.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change theme-background.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
8
4
 
9
5
  // REQ-THEME-08 / REQ-PLR-05-CUSTOM — the ONE implementation of the app's
10
6
  // background gradient and of the sign-in screen's design, shared by both hosts.
@@ -169,7 +165,6 @@ function resolveLoginTextVisibility(login) {
169
165
  subheading: own.hideSubheading !== true,
170
166
  };
171
167
  }
172
-
173
168
  module.exports = {
174
169
  LOGIN_TEXT_MAX_LENGTH,
175
170
  normaliseThemeGradient,
@@ -1,10 +1,8 @@
1
- // CommonJS mirror of theme-components.js — the Mason build runner (CJS)
2
- // validates a `set_theme` components blob against the SAME vocabulary the hosts
3
- // apply, so what a planner may persist and what a widget renders cannot diverge.
4
- //
5
- // The BODY below is copied VERBATIM from theme-components.js; only the module
6
- // syntax differs. theme-components-parity.test.js pins both facts — identical
7
- // bodies AND identical behaviour over a shared case table — so drift fails CI.
1
+ // GENERATED from theme-components.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change theme-components.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
4
+
5
+ const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
8
6
 
9
7
  // REQ-THEME-15 (sc-1497) — the host side of per-component style tokens.
10
8
  //
@@ -23,7 +21,6 @@
23
21
  // widget reads. The Mason build runner validates `set_theme` against the SAME
24
22
  // literal, so what a planner may emit and what a host applies cannot diverge.
25
23
 
26
- const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
27
24
 
28
25
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
29
26
  // must never drop a colour the runner already accepted.
@@ -402,5 +399,11 @@ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
402
399
  const authored = isPlainObject(base.style) ? base.style : null;
403
400
  return { ...base, style: { ...themed, ...authored } };
404
401
  }
405
-
406
- module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle, styleValueBytes, MAX_STYLE_STRING_LENGTH };
402
+ module.exports = {
403
+ normaliseThemeComponents,
404
+ MAX_STYLE_STRING_LENGTH,
405
+ styleValueBytes,
406
+ normaliseWidgetStyleFields,
407
+ normaliseWidgetStyles,
408
+ applyThemeComponentStyle,
409
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.275.2",
3
+ "version": "0.277.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "scripts": {
63
63
  "build": "node scripts/build.js",
64
- "generate:contract": "node scripts/contract-cjs.js",
64
+ "generate:cjs": "node scripts/generate-cjs.js",
65
65
  "test": "node --test \"src/__tests__/*.test.js\""
66
66
  },
67
67
  "engines": {
@@ -75,7 +75,22 @@
75
75
  "react": ">=18.0.0",
76
76
  "react-native": "*",
77
77
  "react-dom": ">=18.0.0",
78
- "react-native-web": ">=0.19.0"
78
+ "react-native-web": ">=0.19.0",
79
+ "@tiptap/core": "^3.32.2",
80
+ "@tiptap/extension-bold": "^3.32.2",
81
+ "@tiptap/extension-code": "^3.32.2",
82
+ "@tiptap/extension-code-block": "^3.32.2",
83
+ "@tiptap/extension-document": "^3.32.2",
84
+ "@tiptap/extension-heading": "^3.32.2",
85
+ "@tiptap/extension-image": "^3.32.2",
86
+ "@tiptap/extension-italic": "^3.32.2",
87
+ "@tiptap/extension-link": "^3.32.2",
88
+ "@tiptap/extension-list": "^3.32.2",
89
+ "@tiptap/extension-paragraph": "^3.32.2",
90
+ "@tiptap/extension-table": "^3.32.2",
91
+ "@tiptap/extension-text": "^3.32.2",
92
+ "@tiptap/extensions": "^3.32.2",
93
+ "react-native-webview": ">=13.7.0"
79
94
  },
80
95
  "peerDependenciesMeta": {
81
96
  "react": {
@@ -89,6 +104,51 @@
89
104
  },
90
105
  "react-dom": {
91
106
  "optional": true
107
+ },
108
+ "@tiptap/core": {
109
+ "optional": true
110
+ },
111
+ "@tiptap/extension-bold": {
112
+ "optional": true
113
+ },
114
+ "@tiptap/extension-code": {
115
+ "optional": true
116
+ },
117
+ "@tiptap/extension-code-block": {
118
+ "optional": true
119
+ },
120
+ "@tiptap/extension-document": {
121
+ "optional": true
122
+ },
123
+ "@tiptap/extension-heading": {
124
+ "optional": true
125
+ },
126
+ "@tiptap/extension-image": {
127
+ "optional": true
128
+ },
129
+ "@tiptap/extension-italic": {
130
+ "optional": true
131
+ },
132
+ "@tiptap/extension-link": {
133
+ "optional": true
134
+ },
135
+ "@tiptap/extension-list": {
136
+ "optional": true
137
+ },
138
+ "@tiptap/extension-paragraph": {
139
+ "optional": true
140
+ },
141
+ "@tiptap/extension-table": {
142
+ "optional": true
143
+ },
144
+ "@tiptap/extension-text": {
145
+ "optional": true
146
+ },
147
+ "@tiptap/extensions": {
148
+ "optional": true
149
+ },
150
+ "react-native-webview": {
151
+ "optional": true
92
152
  }
93
153
  },
94
154
  "optionalDependencies": {
package/src/contract.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The widget SDK contract: the ONE source the linter, the AI agents, the
2
2
  // compiler and both hosts read. `contract.cjs` is GENERATED from `contract.js`
3
- // (`npm run generate:contract`) for CommonJS consumers — never edit it by hand;
4
- // contract-cjs-generated.test.js fails when it is stale.
3
+ // (`npm run generate:cjs`) for CommonJS consumers — never edit it by hand;
4
+ // cjs-generated.test.js fails when it is stale.
5
5
 
6
6
  // REQ-AI-AGENT-DESIGN (sc-3696): the ONE cross-platform elevation vocabulary.
7
7
  // `shadow*` applies on iOS + react-native-web, `elevation` drives Android.
@@ -3464,13 +3464,13 @@ const PRIMITIVES = [
3464
3464
  rnComponent: null,
3465
3465
  docsUrl: null,
3466
3466
  },
3467
- // sc-6970 — formatted content, authored. The authoring half of `<RichText>`:
3468
- // a real selection-aware toolbar plus a live preview, so a generated widget
3469
- // never has to splice HTML tags into a plain TextInput again.
3467
+ // sc-6970 — formatted content, authored. The authoring half of `<RichText>`,
3468
+ // so a generated widget never has to splice HTML tags into a plain TextInput
3469
+ // again. sc-15080: the field edits the formatted result directly.
3470
3470
  {
3471
3471
  name: "MarkdownInput",
3472
3472
  description:
3473
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field that styles the markdown live as the user types (headings sized, bold, italic, code and links drawn in place, the markers kept in the text but dimmed), so the user reads and edits the result in one place; a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list); a Table button that inserts a pipe-table skeleton with the first header cell selected; and a Link button that opens a form for the link text plus its target. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text, exactly as typed. Props: `value`, `onChange(next)`, `placeholder`, `showPreview` (default false; true also draws a rendered `<RichText>` copy underneath, for content the field can only show as markers: images and tables), `previewLabel` (default "Preview", the heading above that copy), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3473
+ "Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a rich-text field where the user edits the FORMATTED RESULT directly — headings, bold, italic, code, links, bulleted and numbered lists and real tables are shown as they will read, with no markdown markers in view — plus a toolbar (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a real table, with buttons to add or remove rows and columns or delete the table while the caret is in one, and a Link button that opens a form for the link text plus its target. Typing `## `, `- `, `1. `, `**bold**` or `*italic*` formats as you type. Use it rather than a bare `TextInput` plus formatting buttons of your own, and do NOT add a `<RichText>` preview under it: the field already shows the result; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup. The stored value is markdown text in the `<RichText>` grammar; loading a value never rewrites it, and an edit writes each construct in its canonical spelling. On iOS and Android the field runs in a WebView bundled into the SDK (no network). Props: `value`, `onChange(next)`, `placeholder`, `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name; without it the form offers only an external address, because a widget cannot know the app's pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) — the field grows with its content between them, then scrolls — `accessibilityLabel`, `style`, `testID`. `showPreview`, `previewLabel` and `renderImage` are still accepted and ignored. Images show inside the field when their source is an http(s) URL and as a labelled placeholder when it is a filestore id. Links in the field are inert, so a tap while editing cannot navigate away from the draft.",
3474
3474
  rnComponent: null,
3475
3475
  docsUrl: null,
3476
3476
  },
@@ -6570,7 +6570,10 @@ const CONTRACT = deepFreeze({
6570
6570
  // ships a hand-written `sv` value beside `en`.
6571
6571
  // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6572
6572
  // live; `showPreview` now defaults to false.
6573
- version: "1.225.0",
6573
+ // 1.226.0: behaviour (sc-15080) — `<MarkdownInput>` is a rich-text editor
6574
+ // showing the formatted result; `showPreview`, `previewLabel` and
6575
+ // `renderImage` are accepted and ignored.
6576
+ version: "1.226.0",
6574
6577
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6575
6578
  hooks: HOOKS,
6576
6579
  primitives: PRIMITIVES,
@@ -1,164 +0,0 @@
1
- // sc-6970 — the selection edits `<MarkdownInput>`'s toolbar performs.
2
- //
3
- // Pure string maths, kept out of the component the way `richtext-tokens.js`
4
- // keeps the theme maths out: a toolbar button is only correct if it wraps the
5
- // author's SELECTION rather than appending at the end, and that is a thing
6
- // tests can pin without a renderer.
7
-
8
- import { escapeMarkdownLabel, formatMarkdownTable } from "./markdown.js";
9
-
10
- const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
11
-
12
- // The literal the inserted table skeleton opens with; the caret lands on it.
13
- const FIRST_HEADING = "Column 1";
14
-
15
- const LINE_KINDS = Object.freeze({
16
- heading2: { marker: "## ", test: /^##\s/ },
17
- heading3: { marker: "### ", test: /^###\s/ },
18
- bullet: { marker: "- ", test: /^[-*]\s/ },
19
- ordered: { marker: null, test: /^\d+[.)]\s/ },
20
- });
21
-
22
- function clamp(index, max) {
23
- const n = Number.isFinite(index) ? Math.trunc(index) : 0;
24
- return Math.min(Math.max(n, 0), max);
25
- }
26
-
27
- function readRange(text, selection) {
28
- const src = typeof text === "string" ? text : "";
29
- const raw = selection && typeof selection === "object" ? selection : {};
30
- const a = clamp(raw.start, src.length);
31
- const b = clamp(raw.end, src.length);
32
- return { src, start: Math.min(a, b), end: Math.max(a, b) };
33
- }
34
-
35
- function wrap(marker) {
36
- return (text, selection) => {
37
- const { src, start, end } = readRange(text, selection);
38
- const selected = src.slice(start, end);
39
- const pair = marker.length * 2;
40
-
41
- // Already wrapped — the button toggles the emphasis back off.
42
- if (selected.length >= pair && selected.startsWith(marker) && selected.endsWith(marker)) {
43
- const inner = selected.slice(marker.length, selected.length - marker.length);
44
- return {
45
- value: src.slice(0, start) + inner + src.slice(end),
46
- selection: { start, end: start + inner.length },
47
- };
48
- }
49
-
50
- return {
51
- value: src.slice(0, start) + marker + selected + marker + src.slice(end),
52
- // Leaves the author's own text selected (or the caret between an empty
53
- // pair), so typing continues inside the emphasis they just asked for.
54
- selection: { start: start + marker.length, end: end + marker.length },
55
- };
56
- };
57
- }
58
-
59
- function linePrefix(kind) {
60
- const spec = LINE_KINDS[kind];
61
- return (text, selection) => {
62
- const { src, start, end } = readRange(text, selection);
63
- const blockStart = src.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
64
- const newlineAfter = src.indexOf("\n", end);
65
- const blockEnd = newlineAfter === -1 ? src.length : newlineAfter;
66
-
67
- const lines = src.slice(blockStart, blockEnd).split("\n");
68
- const filled = lines.filter((line) => line.trim());
69
- // Toggled off only when EVERY line already carries this kind, so a mixed
70
- // selection becomes uniformly marked rather than half-stripped.
71
- const removing =
72
- filled.length > 0 &&
73
- filled.every((line) => spec.test.test(line.replace(/^\s*/, "")));
74
-
75
- let ordinal = 0;
76
- const rebuilt = lines.map((line) => {
77
- const match = LINE_RE.exec(line);
78
- const indent = match[1];
79
- const body = match[3];
80
- if (!line.trim()) return line;
81
- if (removing) return indent + body;
82
- ordinal += 1;
83
- return indent + (spec.marker || `${ordinal}. `) + body;
84
- });
85
-
86
- const block = rebuilt.join("\n");
87
- return {
88
- value: src.slice(0, blockStart) + block + src.slice(blockEnd),
89
- selection: { start: blockStart, end: blockStart + block.length },
90
- };
91
- };
92
- }
93
-
94
- // sc-7349 — a link needs a TARGET, which no selection can supply, so this is
95
- // the one op that takes a third argument. The target is stored verbatim: the
96
- // host's `navigation.openLink` is what decides page / external / refuse, so
97
- // this must not pre-judge it (see markdown.js).
98
- function linkOp() {
99
- return (text, selection, options) => {
100
- const { src, start, end } = readRange(text, selection);
101
- // A bare string is accepted as the target, so the common case stays terse.
102
- const opts =
103
- options && typeof options === "object" ? options : { target: options };
104
- const href = typeof opts.target === "string" ? opts.target.trim() : "";
105
- // Nothing to point at — leave the text exactly as the author left it.
106
- if (!href) return { value: src, selection: { start, end } };
107
-
108
- // An explicit label wins, so a dialog can name the link when the author
109
- // had nothing selected; otherwise the selection IS the label.
110
- const chosen =
111
- typeof opts.label === "string" && opts.label.trim()
112
- ? opts.label
113
- : src.slice(start, end);
114
- const label = escapeMarkdownLabel(chosen) || href;
115
- const markup = `[${label}](${href})`;
116
- return {
117
- value: src.slice(0, start) + markup + src.slice(end),
118
- // Leaves the LABEL selected, so typing replaces the link text rather
119
- // than the markup around it.
120
- selection: { start: start + 1, end: start + 1 + label.length },
121
- };
122
- };
123
- }
124
-
125
- // A table is a block, so it needs its own lines: a blank line before keeps it
126
- // from fusing with the paragraph the caret was in.
127
- function tableOp() {
128
- return (text, selection) => {
129
- const { src, start, end } = readRange(text, selection);
130
- const before = src.slice(0, start);
131
- const after = src.slice(end);
132
- const lead =
133
- before.length === 0 || before.endsWith("\n\n")
134
- ? ""
135
- : before.endsWith("\n")
136
- ? "\n"
137
- : "\n\n";
138
- const trail = after.startsWith("\n") ? "" : "\n";
139
-
140
- const skeleton = formatMarkdownTable();
141
- const offset = before.length + lead.length;
142
- return {
143
- value: before + lead + skeleton + trail + after,
144
- // Selects the first header cell, so the author types over "Column 1"
145
- // instead of hunting for the caret inside the pipes.
146
- selection: {
147
- start: offset + 2,
148
- end: offset + 2 + FIRST_HEADING.length,
149
- },
150
- };
151
- };
152
- }
153
-
154
- export const markdownEditOps = Object.freeze({
155
- bold: wrap("**"),
156
- italic: wrap("*"),
157
- code: wrap("`"),
158
- heading2: linePrefix("heading2"),
159
- heading3: linePrefix("heading3"),
160
- bullet: linePrefix("bullet"),
161
- ordered: linePrefix("ordered"),
162
- link: linkOp(),
163
- table: tableOp(),
164
- });