@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 {
|
|
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.
|
|
8
|
-
* (
|
|
9
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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.
|
|
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.
|
|
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",
|