@stll/folio-core 0.32.0 → 0.32.1

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.
@@ -1,227 +1,24 @@
1
1
  import { createEmptyDocument } from "../utils/createDocument.js";
2
- import { sanitizeExternalUrl } from "../utils/urlSecurity.js";
3
- import { marked } from "marked";
2
+ import { compileMarkdownToContent } from "@stll/docx-core";
4
3
  //#region src/markdown/fromMarkdown.ts
5
4
  /**
6
5
  * Markdown → DOCX-document import — the inverse of {@link toMarkdown} and the
7
- * second half of the skills bridge. Parses the GFM subset skill bodies use
8
- * (headings, paragraphs, bold/italic/strike, inline code, bullet + ordered
9
- * lists incl. nesting, pipe tables, blockquotes, links) into the docx
10
- * `Document` model so a skill's markdown can be edited in the Folio editor and
11
- * re-exported with {@link toMarkdown} without drift.
6
+ * second half of the skills bridge. The parsing lives in `@stll/docx-core`
7
+ * (`compileMarkdownToContent`), the same GFM reader the legal-source compiler
8
+ * uses; this wrapper only places the blocks into an editor-ready `Document`.
12
9
  *
13
10
  * Round-trip notes:
14
- * - Lists are emitted as real list paragraphs (`listRendering`), so the editor
15
- * shows a marker and {@link toMarkdown} re-derives `- ` / `1. ` rather than
16
- * leaking a literal bullet glyph into the text.
17
- * - Inline code uses `Courier New` a whitelisted monospace family that
18
- * {@link toMarkdown} infers back to a backtick span (Folio renders it via its
19
- * bundled Cousine substitute).
11
+ * - Lists arrive as real list paragraphs (`listRendering` plus `numPr`) with a
12
+ * matching `document.package.numbering`, so the editor shows a marker and
13
+ * {@link toMarkdown} re-derives `- ` / `1. ` rather than leaking a literal
14
+ * bullet glyph. Merging this content onto another document that has its own
15
+ * numbering (e.g. a styled preset) needs `mergeDocumentContent` to renumber
16
+ * the two numbering namespaces apart.
20
17
  * - Markdown carries no page geometry, so the section is flattened to a
21
18
  * continuous, header/footer-free band (a skill body is a document, not a
22
19
  * Word page). Headers/footers live outside `document.content` and are never
23
20
  * produced here.
24
- * - Every markdown list also gets a matching `w:abstractNum`/`w:num` pair in
25
- * `document.package.numbering` (see {@link buildNumbering}), so the result
26
- * is self-consistent and `createDocx` never has to fail with a missing
27
- * numbering definition. Merging this content onto another document that
28
- * has its own numbering (e.g. a styled preset) needs `mergeDocumentContent`
29
- * to renumber the two numbering namespaces apart — appending
30
- * `document.package.document.content` directly can collide.
31
21
  */
32
- const MONO_FONT = {
33
- ascii: "Courier New",
34
- hAnsi: "Courier New"
35
- };
36
- const isTokenType = (token, type) => token.type === type;
37
- const textRun = (text, fmt = {}) => {
38
- const formatting = {
39
- ...fmt.bold ? { bold: true } : {},
40
- ...fmt.italic ? { italic: true } : {},
41
- ...fmt.strike ? { strike: true } : {},
42
- ...fmt.mono ? { fontFamily: MONO_FONT } : {}
43
- };
44
- const segments = text.split("\n");
45
- const content = [];
46
- for (const [index, segment] of segments.entries()) {
47
- if (index > 0) content.push({ type: "break" });
48
- if (segment.length > 0) content.push({
49
- type: "text",
50
- text: segment
51
- });
52
- }
53
- if (content.length === 0) content.push({
54
- type: "text",
55
- text: ""
56
- });
57
- return {
58
- type: "run",
59
- formatting,
60
- content
61
- };
62
- };
63
- const sanitizeMarkdownHref = (rawHref) => {
64
- const trimmed = rawHref.trim();
65
- if (!trimmed) return;
66
- if (trimmed.startsWith("#")) {
67
- const anchor = trimmed.slice(1);
68
- if (!anchor || hasUnsafeAnchorCharacter(anchor)) return;
69
- return `#${anchor}`;
70
- }
71
- return sanitizeExternalUrl(trimmed);
72
- };
73
- const hasUnsafeAnchorCharacter = (anchor) => {
74
- for (const char of anchor) {
75
- const codePoint = char.codePointAt(0) ?? 0;
76
- if (codePoint <= 32 || codePoint === 127 || char.trim() === "") return true;
77
- }
78
- return false;
79
- };
80
- const inlineToRuns = (tokens, fallback, base) => {
81
- if (!tokens || tokens.length === 0) return [textRun(fallback, base)];
82
- const runs = [];
83
- for (const token of tokens) if (isTokenType(token, "strong")) runs.push(...inlineToRuns(token.tokens, token.text, {
84
- ...base,
85
- bold: true
86
- }));
87
- else if (isTokenType(token, "em")) runs.push(...inlineToRuns(token.tokens, token.text, {
88
- ...base,
89
- italic: true
90
- }));
91
- else if (isTokenType(token, "del")) runs.push(...inlineToRuns(token.tokens, token.text, {
92
- ...base,
93
- strike: true
94
- }));
95
- else if (isTokenType(token, "codespan")) runs.push(textRun(token.text, {
96
- ...base,
97
- mono: true
98
- }));
99
- else if (isTokenType(token, "link")) {
100
- const children = inlineToRuns(token.tokens, token.text, base).filter((child) => child.type === "run");
101
- const href = sanitizeMarkdownHref(token.href);
102
- const linkChildren = children.length > 0 ? children : [textRun(token.text, base)];
103
- if (!href) {
104
- runs.push(...linkChildren);
105
- continue;
106
- }
107
- const anchor = href.startsWith("#") ? href.slice(1) : void 0;
108
- runs.push({
109
- type: "hyperlink",
110
- href,
111
- ...anchor ? { anchor } : {},
112
- children: linkChildren
113
- });
114
- } else if (isTokenType(token, "paragraph")) runs.push(...inlineToRuns(token.tokens, token.text, base));
115
- else if (token.type === "br") runs.push({
116
- type: "run",
117
- content: [{ type: "break" }]
118
- });
119
- else if (token.type === "space") {
120
- if (runs.length > 0 && token.raw.includes("\n")) runs.push(textRun("\n", base));
121
- } else if (isTokenType(token, "text")) {
122
- const nested = token.tokens;
123
- if (nested && nested.length > 0) runs.push(...inlineToRuns(nested, token.text, base));
124
- else runs.push(textRun(token.text, base));
125
- } else if ("text" in token && typeof token.text === "string") runs.push(textRun(token.text, base));
126
- return runs.length > 0 ? runs : [textRun(fallback, base)];
127
- };
128
- const para = (runs, styleId) => ({
129
- type: "paragraph",
130
- formatting: styleId ? { styleId } : {},
131
- content: runs.length > 0 ? runs : [textRun("")]
132
- });
133
- const listPara = (runs, rendering) => ({
134
- type: "paragraph",
135
- formatting: { numPr: {
136
- numId: rendering.numId,
137
- ilvl: rendering.level
138
- } },
139
- listRendering: rendering,
140
- content: runs.length > 0 ? runs : [textRun("")]
141
- });
142
- const cellOf = (cell) => ({
143
- type: "tableCell",
144
- content: [para(inlineToRuns(cell.tokens, cell.text, {}))]
145
- });
146
- const tableFromToken = (token) => ({
147
- type: "table",
148
- rows: [{
149
- type: "tableRow",
150
- cells: token.header.map((c) => cellOf(c))
151
- }, ...token.rows.map((row) => ({
152
- type: "tableRow",
153
- cells: row.map((c) => cellOf(c))
154
- }))]
155
- });
156
- const LIST_INDENT_STEP_TWIPS = 720;
157
- const buildListLevel = (ilvl, isBullet, start) => ({
158
- ilvl,
159
- ...!isBullet && { start },
160
- numFmt: isBullet ? "bullet" : "decimal",
161
- lvlText: isBullet ? "•" : `%${ilvl + 1}.`,
162
- suffix: "tab",
163
- pPr: {
164
- indentLeft: LIST_INDENT_STEP_TWIPS * (ilvl + 1),
165
- indentFirstLine: -360,
166
- hangingIndent: true
167
- }
168
- });
169
- const listBlocks = (list, level, numId, levels) => {
170
- const out = [];
171
- const start = Number(list.start) || 1;
172
- const decimalLevels = Array.from({ length: level + 1 }, () => "decimal");
173
- if (!levels.has(level)) levels.set(level, buildListLevel(level, !list.ordered, start));
174
- for (const item of list.items) {
175
- const rendering = list.ordered ? {
176
- marker: `%${level + 1}.`,
177
- level,
178
- numId,
179
- isBullet: false,
180
- numFmt: "decimal",
181
- levelNumFmts: decimalLevels,
182
- ...start !== 1 && { startOverride: start }
183
- } : {
184
- marker: "•",
185
- level,
186
- numId,
187
- isBullet: true
188
- };
189
- const inlineTokens = [];
190
- const nestedLists = [];
191
- for (const child of item.tokens) if (isTokenType(child, "list")) nestedLists.push(child);
192
- else inlineTokens.push(child);
193
- out.push(listPara(inlineToRuns(inlineTokens, item.text, {}), rendering));
194
- for (const nested of nestedLists) out.push(...listBlocks(nested, level + 1, numId, levels));
195
- }
196
- return out;
197
- };
198
- const blocksFromTokens = (tokens, numIds) => {
199
- const blocks = [];
200
- for (const token of tokens ?? []) if (isTokenType(token, "heading")) {
201
- const level = Math.min(Math.max(token.depth, 1), 4);
202
- blocks.push(para(inlineToRuns(token.tokens, token.text, {}), `Heading${level}`));
203
- } else if (isTokenType(token, "paragraph")) blocks.push(para(inlineToRuns(token.tokens, token.text, {})));
204
- else if (isTokenType(token, "list")) {
205
- const numId = numIds.next++;
206
- const levels = /* @__PURE__ */ new Map();
207
- numIds.levels.set(numId, levels);
208
- blocks.push(...listBlocks(token, 0, numId, levels));
209
- } else if (isTokenType(token, "table")) blocks.push(tableFromToken(token));
210
- else if (isTokenType(token, "code")) for (const line of token.text.split("\n")) blocks.push(para([textRun(line.length > 0 ? line : " ", { mono: true })]));
211
- else if (isTokenType(token, "blockquote")) for (const inner of blocksFromTokens(token.tokens, numIds)) {
212
- const styled = inner.type === "paragraph" ? {
213
- ...inner,
214
- formatting: {
215
- ...inner.formatting,
216
- styleId: "Quote"
217
- }
218
- } : inner;
219
- blocks.push(styled);
220
- }
221
- else if (token.type === "hr") blocks.push(para([textRun("———")]));
222
- else if (token.type !== "space" && "text" in token && typeof token.text === "string" && token.text.trim().length > 0) blocks.push(para([textRun(token.text)]));
223
- return blocks;
224
- };
225
22
  const applyMarkdownPageGeometry = (document) => {
226
23
  const section = document.package.document.finalSectionProperties;
227
24
  if (!section) return;
@@ -232,26 +29,6 @@ const applyMarkdownPageGeometry = (document) => {
232
29
  section.headerDistance = 0;
233
30
  section.footerDistance = 0;
234
31
  };
235
- const buildNumbering = (numIdLevels) => {
236
- const abstractNums = [];
237
- const nums = [];
238
- for (const [numId, levels] of numIdLevels) {
239
- const sortedLevels = [...levels.entries()].sort(([a], [b]) => a - b).map(([, lvl]) => lvl);
240
- abstractNums.push({
241
- abstractNumId: numId,
242
- multiLevelType: sortedLevels.length > 1 ? "multilevel" : "singleLevel",
243
- levels: sortedLevels
244
- });
245
- nums.push({
246
- numId,
247
- abstractNumId: numId
248
- });
249
- }
250
- return {
251
- abstractNums,
252
- nums
253
- };
254
- };
255
32
  /**
256
33
  * Convert a markdown string to a parsed `Document`. Synchronous. The result is
257
34
  * ready to hand to the editor (`<DocxEditor document={…} />`) and to re-export
@@ -259,13 +36,9 @@ const buildNumbering = (numIdLevels) => {
259
36
  */
260
37
  function fromMarkdown(markdown) {
261
38
  const document = createEmptyDocument();
262
- const numIds = {
263
- next: 1,
264
- levels: /* @__PURE__ */ new Map()
265
- };
266
- const blocks = blocksFromTokens(marked.lexer(markdown), numIds);
267
- if (blocks.length > 0) document.package.document.content = blocks;
268
- if (numIds.levels.size > 0) document.package.numbering = buildNumbering(numIds.levels);
39
+ const { content, numbering } = compileMarkdownToContent(markdown);
40
+ if (content.length > 0) document.package.document.content = content;
41
+ if (numbering) document.package.numbering = numbering;
269
42
  applyMarkdownPageGeometry(document);
270
43
  return document;
271
44
  }
@@ -1,5 +1,5 @@
1
+ import { sanitizeExternalUrl } from "@stll/docx-core";
1
2
  //#region src/utils/urlSecurity.d.ts
2
- declare function sanitizeExternalUrl(rawUrl: string | undefined): string | undefined;
3
3
  declare function normalizeUserUrl(rawUrl: string): string;
4
4
  declare function isAllowedUserUrl(rawUrl: string): boolean;
5
5
  declare function sanitizeLinkTarget(target: string | undefined): string;
@@ -1,29 +1,11 @@
1
+ import { sanitizeExternalUrl } from "@stll/docx-core";
1
2
  //#region src/utils/urlSecurity.ts
2
- const ALLOWED_URL_PROTOCOLS = /* @__PURE__ */ new Set([
3
- "http:",
4
- "https:",
5
- "mailto:",
6
- "tel:"
7
- ]);
8
3
  const ALLOWED_TARGETS = /* @__PURE__ */ new Set([
9
4
  "_blank",
10
5
  "_self",
11
6
  "_parent",
12
7
  "_top"
13
8
  ]);
14
- function sanitizeExternalUrl(rawUrl) {
15
- if (!rawUrl) return;
16
- const trimmed = rawUrl.trim();
17
- if (!trimmed) return;
18
- try {
19
- const parsed = new URL(trimmed);
20
- if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) return;
21
- if ((parsed.protocol === "mailto:" || parsed.protocol === "tel:") && parsed.pathname.trim() === "") return;
22
- return parsed.href;
23
- } catch {
24
- return;
25
- }
26
- }
27
9
  function normalizeUserUrl(rawUrl) {
28
10
  const trimmed = rawUrl.trim();
29
11
  if (!trimmed) return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -113,7 +113,7 @@
113
113
  "perf": "bun scripts/profile-editor.ts"
114
114
  },
115
115
  "dependencies": {
116
- "@stll/docx-core": "^0.17.3",
116
+ "@stll/docx-core": "^0.18.0",
117
117
  "@stll/docx-utils": "^0.1.0",
118
118
  "@stll/template-conditions": "^0.1.0",
119
119
  "better-result": "3.0.1",
@@ -122,7 +122,6 @@
122
122
  "fast-xml-parser": "^5.10.1",
123
123
  "hyphen": "1.14.1",
124
124
  "jszip": "3.10.1",
125
- "marked": "^18.0.5",
126
125
  "prosemirror-commands": "^1.7.1",
127
126
  "prosemirror-dropcursor": "^1.8.2",
128
127
  "prosemirror-gapcursor": "^1.4.1",