@stll/docx-core 0.17.3 → 0.19.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/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { i as isOoxmlSymbolCharacter, t as DOCX_CONFORMANCE_CLASSES } from "./document-BPDUWV3O.js";
1
+ import { a as isOoxmlSymbolCharacter, t as DOCX_CONFORMANCE_CLASSES } from "./document-Cj4uqQEw.js";
2
2
  import JSZip from "jszip";
3
3
  import { panic } from "better-result";
4
4
  import { XMLParser, XMLValidator } from "fast-xml-parser";
5
+ import { Marked } from "marked";
5
6
  //#region src/serialize/xml.ts
6
7
  const ILLEGAL_XML_CHARS_RE = /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu;
7
8
  const stripIllegalXmlChars = (value) => value.replace(ILLEGAL_XML_CHARS_RE, "");
@@ -703,7 +704,10 @@ const validateHyperlinkChild = (child, path, ctx) => {
703
704
  };
704
705
  const validateTrackedRunChange = (change, path, ctx) => {
705
706
  if (change.info.author.trim() === "") addError(ctx, `${path}.info.author`, "Tracked change author is empty.");
706
- for (const [index, child] of change.content.entries()) validateFieldChild(child, `${path}.content[${index}]`, ctx);
707
+ for (const [index, child] of change.content.entries()) {
708
+ const childPath = `${path}.content[${index}]`;
709
+ validateParagraphContent(child, childPath, ctx);
710
+ }
707
711
  };
708
712
  const validateImage = (image, path, ctx, options = {}) => {
709
713
  if (!image.rId && !image.src?.startsWith("data:") && !options.hasPreservedRawDrawing) addError(ctx, `${path}.rId`, "Image must have a relationship id.");
@@ -828,12 +832,251 @@ const validateCounterPairs = (starts, ends, { label, startName, endName, ctx, se
828
832
  }
829
833
  };
830
834
  //#endregion
835
+ //#region src/markdown/href.ts
836
+ const ALLOWED_URL_PROTOCOLS = /* @__PURE__ */ new Set([
837
+ "http:",
838
+ "https:",
839
+ "mailto:",
840
+ "tel:"
841
+ ]);
842
+ /**
843
+ * Keep only http(s), mailto, and tel URLs, normalised through the URL parser.
844
+ * Anything else (javascript:, data:, relative paths, malformed input) drops to
845
+ * `undefined` so a markdown link degrades to its text instead of carrying an
846
+ * executable target into the document.
847
+ */
848
+ const sanitizeExternalUrl = (rawUrl) => {
849
+ if (!rawUrl) return;
850
+ const trimmed = rawUrl.trim();
851
+ if (!trimmed) return;
852
+ const parsed = parseUrl(trimmed);
853
+ if (!parsed || !ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) return;
854
+ if ((parsed.protocol === "mailto:" || parsed.protocol === "tel:") && parsed.pathname.trim() === "") return;
855
+ return parsed.href;
856
+ };
857
+ const parseUrl = (value) => {
858
+ try {
859
+ return new URL(value);
860
+ } catch {
861
+ return null;
862
+ }
863
+ };
864
+ const hasUnsafeAnchorCharacter = (anchor) => {
865
+ for (const char of anchor) {
866
+ const codePoint = char.codePointAt(0) ?? 0;
867
+ if (codePoint <= 32 || codePoint === 127 || char.trim() === "") return true;
868
+ }
869
+ return false;
870
+ };
871
+ /** A `#anchor` stays a document-internal target; anything else must pass {@link sanitizeExternalUrl}. */
872
+ const sanitizeMarkdownHref = (rawHref) => {
873
+ const trimmed = rawHref.trim();
874
+ if (!trimmed) return;
875
+ if (trimmed.startsWith("#")) {
876
+ const anchor = trimmed.slice(1);
877
+ if (!anchor || hasUnsafeAnchorCharacter(anchor)) return;
878
+ return `#${anchor}`;
879
+ }
880
+ return sanitizeExternalUrl(trimmed);
881
+ };
882
+ //#endregion
883
+ //#region src/markdown/lexer.ts
884
+ /**
885
+ * One `marked` configuration for every markdown surface in docx-core. Plain
886
+ * markdown (`compileMarkdownToContent`) and the legal-source compiler read the
887
+ * same GFM token stream; the legal profile adds a block-level extension that
888
+ * turns an `@directive` line into its own token so the line never merges into
889
+ * a neighbouring paragraph.
890
+ */
891
+ const LEGAL_DIRECTIVE_TOKEN_TYPE = "legalDirective";
892
+ const DIRECTIVE_LINE_PATTERN = /^[ \t]*@(?<name>[A-Za-z][A-Za-z0-9_-]*)(?<argument>[^\n]*)(?:\n|$)/u;
893
+ const DIRECTIVE_LINE_START_PATTERN = /^[ \t]*@[A-Za-z]/mu;
894
+ const legalDirectiveExtension = {
895
+ name: LEGAL_DIRECTIVE_TOKEN_TYPE,
896
+ level: "block",
897
+ start: (src) => {
898
+ const index = src.search(DIRECTIVE_LINE_START_PATTERN);
899
+ return index === -1 ? void 0 : index;
900
+ },
901
+ tokenizer: (src) => {
902
+ const match = DIRECTIVE_LINE_PATTERN.exec(src);
903
+ const name = match?.groups?.["name"];
904
+ if (!match || name === void 0) return;
905
+ return {
906
+ type: LEGAL_DIRECTIVE_TOKEN_TYPE,
907
+ raw: match[0],
908
+ directive: `@${name.toLowerCase()}`,
909
+ argument: (match.groups?.["argument"] ?? "").trim()
910
+ };
911
+ }
912
+ };
913
+ const plainMarkdown = new Marked({ gfm: true });
914
+ const legalMarkdown = new Marked({
915
+ gfm: true,
916
+ extensions: [legalDirectiveExtension]
917
+ });
918
+ /** GFM block tokens of a markdown document. */
919
+ const lexMarkdown = (source) => plainMarkdown.lexer(source);
920
+ /**
921
+ * A directive line always starts a block. marked's paragraph tokenizer asks
922
+ * block extensions where the next block starts, but its list and blockquote
923
+ * tokenizers treat any following non-blank line as lazy continuation, so
924
+ * `- item\n@clause Next` would swallow the directive. Inserting one blank
925
+ * line before every directive that lacks one gives every tokenizer the same
926
+ * boundary; the returned map keeps diagnostics on the author's line numbers.
927
+ */
928
+ const separateDirectiveLines = (source) => {
929
+ const lines = source.split("\n");
930
+ const output = [];
931
+ const insertedBefore = [];
932
+ let inserted = 0;
933
+ for (const [index, line] of lines.entries()) {
934
+ const previous = output.at(-1);
935
+ if (index > 0 && DIRECTIVE_LINE_START_PATTERN.test(line) && previous !== void 0 && previous.trim() !== "") {
936
+ output.push("");
937
+ insertedBefore.push(inserted);
938
+ inserted += 1;
939
+ }
940
+ output.push(line);
941
+ insertedBefore.push(inserted);
942
+ }
943
+ return {
944
+ text: output.join("\n"),
945
+ originalLineOf: (line) => line - (insertedBefore.at(line - 1) ?? inserted)
946
+ };
947
+ };
948
+ /** GFM block tokens of a legal draft, with `@directive` lines as {@link LegalDirectiveToken}s. */
949
+ const lexLegalSource = (source) => {
950
+ const prepared = separateDirectiveLines(source);
951
+ return {
952
+ tokens: legalMarkdown.lexer(prepared.text),
953
+ originalLineOf: prepared.originalLineOf
954
+ };
955
+ };
956
+ /** Inline tokens (emphasis, code spans, links, breaks) of one paragraph's text. */
957
+ const lexInlineMarkdown = (text) => plainMarkdown.Lexer.lexInline(text, plainMarkdown.defaults);
958
+ const isLegalDirectiveToken = (token) => token.type === LEGAL_DIRECTIVE_TOKEN_TYPE;
959
+ const isTokenType = (token, type) => token.type === type;
960
+ //#endregion
961
+ //#region src/markdown/inline.ts
962
+ const MONO_FONT = {
963
+ ascii: "Courier New",
964
+ hAnsi: "Courier New"
965
+ };
966
+ /**
967
+ * One run. A Word run cannot carry a raw newline (the layout engine renders
968
+ * such lines on top of each other), so "\n" becomes an explicit break node.
969
+ */
970
+ const textRun = (text, format = {}) => {
971
+ const formatting = {
972
+ ...format.bold ? { bold: true } : {},
973
+ ...format.italic ? { italic: true } : {},
974
+ ...format.strike ? { strike: true } : {},
975
+ ...format.mono ? { fontFamily: MONO_FONT } : {},
976
+ ...format.highlight ? { highlight: format.highlight } : {}
977
+ };
978
+ const content = [];
979
+ for (const [index, segment] of text.split("\n").entries()) {
980
+ if (index > 0) content.push({ type: "break" });
981
+ if (segment.length > 0) content.push({
982
+ type: "text",
983
+ text: segment,
984
+ preserveSpace: true
985
+ });
986
+ }
987
+ if (content.length === 0) content.push({
988
+ type: "text",
989
+ text: "",
990
+ preserveSpace: true
991
+ });
992
+ return {
993
+ type: "run",
994
+ formatting,
995
+ content
996
+ };
997
+ };
998
+ const PLACEHOLDER_PATTERN = /\[\[(?<inner>[^\][]+?)\]\]/gu;
999
+ /**
1000
+ * Literal text (no markdown reading) with `[[…]]` placeholders highlighted:
1001
+ * for fields that are data rather than prose, such as signature parties.
1002
+ */
1003
+ const plainTextRuns = (text, format = {}) => placeholderRuns(text, format);
1004
+ const placeholderRuns = (text, format) => {
1005
+ if (!text.includes("[[")) return [textRun(text, format)];
1006
+ const runs = [];
1007
+ let cursor = 0;
1008
+ for (const match of text.matchAll(PLACEHOLDER_PATTERN)) {
1009
+ const start = match.index;
1010
+ if (start > cursor) runs.push(textRun(text.slice(cursor, start), format));
1011
+ runs.push(textRun(match.groups?.["inner"] ?? "", {
1012
+ ...format,
1013
+ highlight: "yellow"
1014
+ }));
1015
+ cursor = start + match[0].length;
1016
+ }
1017
+ if (cursor < text.length) runs.push(textRun(text.slice(cursor), format));
1018
+ return runs.length > 0 ? runs : [textRun(text, format)];
1019
+ };
1020
+ const plainRuns = (text, format, placeholders) => placeholders ? placeholderRuns(text, format) : [textRun(text, format)];
1021
+ const tokensToRuns = (tokens, fallback, format, context) => {
1022
+ if (!tokens || tokens.length === 0) return plainRuns(fallback, format, context.placeholders);
1023
+ const runs = [];
1024
+ for (const token of tokens) if (isTokenType(token, "strong")) runs.push(...tokensToRuns(token.tokens, token.text, {
1025
+ ...format,
1026
+ bold: true
1027
+ }, context));
1028
+ else if (isTokenType(token, "em")) runs.push(...tokensToRuns(token.tokens, token.text, {
1029
+ ...format,
1030
+ italic: true
1031
+ }, context));
1032
+ else if (isTokenType(token, "del")) runs.push(...tokensToRuns(token.tokens, token.text, {
1033
+ ...format,
1034
+ strike: true
1035
+ }, context));
1036
+ else if (isTokenType(token, "codespan")) runs.push(textRun(token.text, {
1037
+ ...format,
1038
+ mono: true
1039
+ }));
1040
+ else if (isTokenType(token, "link")) runs.push(...linkRuns(token.tokens, token.text, token.href, format, context));
1041
+ else if (isTokenType(token, "paragraph")) runs.push(...tokensToRuns(token.tokens, token.text, format, context));
1042
+ else if (token.type === "br") runs.push({
1043
+ type: "run",
1044
+ content: [{ type: "break" }]
1045
+ });
1046
+ else if (token.type === "space") {
1047
+ if (runs.length > 0 && token.raw.includes("\n")) runs.push(textRun("\n", format));
1048
+ } else if (isTokenType(token, "text")) {
1049
+ const nested = token.tokens;
1050
+ if (nested && nested.length > 0) runs.push(...tokensToRuns(nested, token.text, format, context));
1051
+ else runs.push(...plainRuns(token.text, format, context.placeholders));
1052
+ } else if ("text" in token && typeof token.text === "string") runs.push(...plainRuns(token.text, format, context.placeholders));
1053
+ return runs.length > 0 ? runs : plainRuns(fallback, format, context.placeholders);
1054
+ };
1055
+ const linkRuns = (tokens, text, rawHref, format, context) => {
1056
+ const children = tokensToRuns(tokens, text, format, context).filter((child) => child.type === "run");
1057
+ const linkChildren = children.length > 0 ? children : [textRun(text, format)];
1058
+ const href = sanitizeMarkdownHref(rawHref);
1059
+ if (!href) return linkChildren;
1060
+ const anchor = href.startsWith("#") ? href.slice(1) : void 0;
1061
+ return [{
1062
+ type: "hyperlink",
1063
+ href,
1064
+ ...anchor ? { anchor } : {},
1065
+ children: linkChildren
1066
+ }];
1067
+ };
1068
+ /** Render already-lexed inline tokens; `fallback` is the source text when the tokens are empty. */
1069
+ const inlineTokensToRuns = (tokens, fallback, options = {}) => tokensToRuns(tokens, fallback, options.base ?? {}, { placeholders: options.placeholders ?? false });
1070
+ /** Lex and render one paragraph's inline markdown. */
1071
+ const inlineMarkdownToRuns = (text, options = {}) => inlineTokensToRuns(lexInlineMarkdown(text), text, options);
1072
+ //#endregion
831
1073
  //#region src/legal-source/parser.ts
832
1074
  const DEFAULT_KIND = "agreement";
833
1075
  const DEFAULT_LOCALE = "en-GB";
834
1076
  const DEFAULT_NUMBERING = "legal";
835
1077
  const DEFAULT_PAGE_SIZE = "A4";
836
1078
  const DEFAULT_ORIENTATION = "portrait";
1079
+ const MAX_CLAUSE_LEVEL = 6;
837
1080
  const DIRECTIVE_ALIASES = {
838
1081
  "@annex": "@schedule",
839
1082
  "@appendix": "@schedule",
@@ -844,6 +1087,7 @@ const DIRECTIVE_ALIASES = {
844
1087
  "@signature": "@signatures",
845
1088
  "@subsection": "@subclause"
846
1089
  };
1090
+ const CLOSING_DIRECTIVE_PATTERN = /^@end[a-z]*$/u;
847
1091
  const DIRECTIVES = /* @__PURE__ */ new Set([
848
1092
  "@doc",
849
1093
  "@title",
@@ -857,194 +1101,337 @@ const DIRECTIVES = /* @__PURE__ */ new Set([
857
1101
  "@signatures",
858
1102
  "@pagebreak"
859
1103
  ]);
1104
+ /**
1105
+ * Walks the top-level token stream. Line numbers come from the newlines in
1106
+ * the consumed tokens' `raw` text, which marked keeps contiguous with the
1107
+ * source, so diagnostics point at the directive line that produced them.
1108
+ */
1109
+ var TokenCursor = class {
1110
+ tokens;
1111
+ originalLineOf;
1112
+ index = 0;
1113
+ lineNumber = 1;
1114
+ constructor({ tokens, originalLineOf }) {
1115
+ this.tokens = tokens;
1116
+ this.originalLineOf = originalLineOf;
1117
+ }
1118
+ get current() {
1119
+ return this.tokens.at(this.index);
1120
+ }
1121
+ /** The author's line number for the current token. */
1122
+ get line() {
1123
+ return this.originalLineOf(this.lineNumber);
1124
+ }
1125
+ /** Index of the current token; lets a caller tell whether a helper consumed anything. */
1126
+ get position() {
1127
+ return this.index;
1128
+ }
1129
+ advance() {
1130
+ const token = this.current;
1131
+ if (token === void 0) return;
1132
+ this.index += 1;
1133
+ this.lineNumber += countNewlines(token.raw);
1134
+ return token;
1135
+ }
1136
+ };
1137
+ const countNewlines = (text) => {
1138
+ let count = 0;
1139
+ for (const char of text) if (char === "\n") count += 1;
1140
+ return count;
1141
+ };
1142
+ /** A token that starts a new structural block, so no body may run past it. */
1143
+ const startsStructure = (token) => isLegalDirectiveToken(token) || isTokenType(token, "heading");
860
1144
  const parseLegalSource = (source, options = {}) => {
861
- const fixes = [];
862
- const diagnostics = [];
863
- const blocks = [];
864
- const meta = {
865
- kind: DEFAULT_KIND,
866
- locale: DEFAULT_LOCALE,
867
- numbering: DEFAULT_NUMBERING,
868
- page: {
869
- size: DEFAULT_PAGE_SIZE,
870
- orientation: DEFAULT_ORIENTATION
1145
+ const state = {
1146
+ blocks: [],
1147
+ diagnostics: [],
1148
+ fixes: [],
1149
+ meta: {
1150
+ kind: DEFAULT_KIND,
1151
+ locale: DEFAULT_LOCALE,
1152
+ numbering: DEFAULT_NUMBERING,
1153
+ page: {
1154
+ size: DEFAULT_PAGE_SIZE,
1155
+ orientation: DEFAULT_ORIENTATION
1156
+ },
1157
+ title: null
871
1158
  },
872
- title: null
873
- };
874
- let pending = null;
875
- const pushPending = () => {
876
- if (!pending) return;
877
- const block = pendingToBlock(pending, diagnostics, fixes);
878
- if (block) {
879
- blocks.push(block);
880
- if (block.type === "title") meta.title = block.text;
881
- }
882
- pending = null;
1159
+ cursor: new TokenCursor(lexLegalSource(source))
883
1160
  };
884
- const lines = source.replace(/\r\n?/gu, "\n").split("\n");
885
- for (const [index, rawLine] of lines.entries()) {
886
- const lineNumber = index + 1;
887
- const line = rawLine.trimEnd();
888
- const trimmed = line.trim();
889
- if (!trimmed) {
890
- pending?.lines.push("");
1161
+ for (;;) {
1162
+ const token = state.cursor.current;
1163
+ if (token === void 0) break;
1164
+ const line = state.cursor.line;
1165
+ if (isLegalDirectiveToken(token)) {
1166
+ state.cursor.advance();
1167
+ parseDirective(token, line, state);
891
1168
  continue;
892
1169
  }
893
- const markdownHeading = parseMarkdownHeading(trimmed);
894
- if (markdownHeading) {
895
- pushPending();
896
- const { depth, heading } = markdownHeading;
897
- if (depth === 1) {
898
- pending = {
899
- type: "title",
900
- line: lineNumber,
901
- heading,
902
- lines: []
903
- };
904
- pushPending();
905
- } else pending = {
906
- type: "clause",
907
- line: lineNumber,
908
- level: Math.min(depth - 1, 6),
909
- heading,
910
- lines: []
911
- };
912
- fixes.push({
913
- code: "markdown-heading-normalized",
914
- message: "Converted a Markdown heading into a legal directive.",
915
- line: lineNumber
916
- });
1170
+ if (isTokenType(token, "heading")) {
1171
+ state.cursor.advance();
1172
+ parseMarkdownHeading(token, line, state);
917
1173
  continue;
918
1174
  }
919
- if (trimmed.startsWith("@")) {
920
- const [rawDirective = "", ...rest] = trimmed.split(/\s+/u);
921
- const canonicalDirective = DIRECTIVE_ALIASES[rawDirective.toLowerCase()] ?? rawDirective.toLowerCase();
922
- const argument = rest.join(" ").trim();
923
- if (!DIRECTIVES.has(canonicalDirective)) {
924
- diagnostics.push({
925
- code: "unknown-directive",
926
- message: `Unknown legal directive "${rawDirective}".`,
927
- severity: "error",
928
- line: lineNumber
929
- });
930
- pending?.lines.push(line);
931
- continue;
932
- }
933
- if (canonicalDirective !== rawDirective.toLowerCase()) fixes.push({
934
- code: "directive-alias-normalized",
935
- message: `Normalized ${rawDirective} to ${canonicalDirective}.`,
936
- line: lineNumber
1175
+ parseBareMarkdown(state);
1176
+ }
1177
+ if (!state.meta.title) {
1178
+ const firstTitle = state.blocks.find((block) => block.type === "title");
1179
+ state.meta.title = firstTitle?.type === "title" ? firstTitle.text : options.titleFallback ?? "Untitled document";
1180
+ }
1181
+ const draft = {
1182
+ meta: state.meta,
1183
+ blocks: state.blocks
1184
+ };
1185
+ return applyDocumentAutofixes({
1186
+ diagnostics: state.diagnostics,
1187
+ draft,
1188
+ fixes: state.fixes
1189
+ });
1190
+ };
1191
+ const pushBlock = (state, block) => {
1192
+ if (!block) return;
1193
+ state.blocks.push(block);
1194
+ if (block.type === "title") state.meta.title = block.text;
1195
+ };
1196
+ const parseDirective = (token, line, state) => {
1197
+ const { diagnostics, fixes, meta } = state;
1198
+ const rawDirective = token.directive;
1199
+ const directive = DIRECTIVE_ALIASES[rawDirective] ?? rawDirective;
1200
+ const { argument } = token;
1201
+ if (CLOSING_DIRECTIVE_PATTERN.test(directive)) {
1202
+ fixes.push({
1203
+ code: "closing-directive-ignored",
1204
+ message: `Ignored ${rawDirective}: blocks end where the next directive starts.`,
1205
+ line
1206
+ });
1207
+ return;
1208
+ }
1209
+ if (!DIRECTIVES.has(directive)) {
1210
+ const spelled = token.raw.trim().split(/\s+/u).at(0) ?? rawDirective;
1211
+ diagnostics.push({
1212
+ code: "unknown-directive",
1213
+ message: `Unknown legal directive "${spelled}".`,
1214
+ severity: "error",
1215
+ line
1216
+ });
1217
+ return;
1218
+ }
1219
+ if (directive !== rawDirective) fixes.push({
1220
+ code: "directive-alias-normalized",
1221
+ message: `Normalized ${rawDirective} to ${directive}.`,
1222
+ line
1223
+ });
1224
+ switch (directive) {
1225
+ case "@doc":
1226
+ parseDocDirective(argument, meta, diagnostics, line);
1227
+ return;
1228
+ case "@title":
1229
+ pushBlock(state, argument ? {
1230
+ type: "title",
1231
+ text: argument
1232
+ } : null);
1233
+ return;
1234
+ case "@recital":
1235
+ pushBlock(state, {
1236
+ type: "recital",
1237
+ paragraphs: takeParagraphs(state)
937
1238
  });
938
- pushPending();
939
- switch (canonicalDirective) {
940
- case "@doc":
941
- parseDocDirective(argument, meta, diagnostics, lineNumber);
942
- break;
943
- case "@title":
944
- pending = {
945
- type: "title",
946
- line: lineNumber,
947
- heading: argument,
948
- lines: []
949
- };
950
- pushPending();
951
- break;
952
- case "@recital":
953
- pending = {
954
- type: "recital",
955
- line: lineNumber,
956
- heading: argument,
957
- lines: []
958
- };
959
- break;
960
- case "@clause":
961
- pending = {
962
- type: "clause",
963
- line: lineNumber,
964
- level: 1,
965
- heading: argument,
966
- lines: []
967
- };
968
- break;
969
- case "@subclause":
970
- pending = {
971
- type: "clause",
972
- line: lineNumber,
973
- level: 2,
974
- heading: argument,
975
- lines: []
976
- };
977
- break;
978
- case "@paragraph":
979
- pending = {
980
- type: "paragraph",
981
- line: lineNumber,
982
- heading: argument,
983
- lines: []
984
- };
985
- break;
986
- case "@list":
987
- pending = {
988
- type: "list",
989
- line: lineNumber,
990
- ordered: /\bordered\b/iu.test(argument),
991
- heading: argument,
992
- lines: []
993
- };
994
- break;
995
- case "@table":
996
- pending = {
997
- type: "table",
998
- line: lineNumber,
999
- heading: argument,
1000
- lines: []
1001
- };
1002
- break;
1003
- case "@schedule":
1004
- pending = {
1005
- type: "schedule",
1006
- line: lineNumber,
1007
- heading: argument,
1008
- lines: []
1009
- };
1010
- break;
1011
- case "@signatures":
1012
- pending = {
1013
- type: "signatures",
1014
- line: lineNumber,
1015
- heading: argument,
1016
- lines: []
1017
- };
1018
- break;
1019
- case "@pagebreak":
1020
- blocks.push({ type: "pageBreak" });
1021
- break;
1022
- default: break;
1023
- }
1024
- continue;
1239
+ return;
1240
+ case "@clause":
1241
+ pushBlock(state, clauseBlock(1, argument, line, state));
1242
+ return;
1243
+ case "@subclause":
1244
+ pushBlock(state, clauseBlock(2, argument, line, state));
1245
+ return;
1246
+ case "@paragraph":
1247
+ pushBlock(state, {
1248
+ type: "paragraph",
1249
+ paragraphs: takeParagraphs(state)
1250
+ });
1251
+ return;
1252
+ case "@list": {
1253
+ const ordered = /\bordered\b/iu.test(argument);
1254
+ const items = takeRawLines(state).flatMap((rawLine) => {
1255
+ const stripped = stripListMarker(rawLine, ordered);
1256
+ return stripped ? [stripped] : [];
1257
+ });
1258
+ pushBlock(state, {
1259
+ type: "list",
1260
+ ordered,
1261
+ items
1262
+ });
1263
+ return;
1025
1264
  }
1026
- pending ??= {
1265
+ case "@table":
1266
+ pushBlock(state, parseTableBlock(takeRawLines(state), line, diagnostics, fixes));
1267
+ return;
1268
+ case "@schedule":
1269
+ pushBlock(state, {
1270
+ type: "schedule",
1271
+ heading: stripManualNumbering(argument, line, fixes),
1272
+ paragraphs: takeParagraphs(state)
1273
+ });
1274
+ return;
1275
+ case "@signatures":
1276
+ pushBlock(state, {
1277
+ type: "signatures",
1278
+ parties: parseSignatureParties(takeRawLines(state), argument)
1279
+ });
1280
+ return;
1281
+ case "@pagebreak":
1282
+ pushBlock(state, { type: "pageBreak" });
1283
+ return;
1284
+ default: return;
1285
+ }
1286
+ };
1287
+ const clauseBlock = (level, rawHeading, line, state) => {
1288
+ const heading = stripManualNumbering(rawHeading, line, state.fixes);
1289
+ const paragraphs = takeParagraphs(state);
1290
+ if (!heading) {
1291
+ state.fixes.push({
1292
+ code: "headingless-clause-downgraded",
1293
+ message: "Converted a headingless @clause into a paragraph block.",
1294
+ line
1295
+ });
1296
+ return {
1027
1297
  type: "paragraph",
1028
- line: lineNumber,
1029
- heading: "",
1030
- lines: []
1298
+ paragraphs
1031
1299
  };
1032
- pending.lines.push(line);
1033
1300
  }
1034
- pushPending();
1035
- if (!meta.title) {
1036
- const firstTitle = blocks.find((block) => block.type === "title");
1037
- meta.title = firstTitle?.type === "title" ? firstTitle.text : options.titleFallback ?? "Untitled document";
1301
+ return {
1302
+ type: "clause",
1303
+ level,
1304
+ heading,
1305
+ paragraphs
1306
+ };
1307
+ };
1308
+ const parseMarkdownHeading = (token, line, state) => {
1309
+ const heading = token.text.trim();
1310
+ state.fixes.push({
1311
+ code: "markdown-heading-normalized",
1312
+ message: "Converted a Markdown heading into a legal directive.",
1313
+ line
1314
+ });
1315
+ if (token.depth === 1) {
1316
+ pushBlock(state, heading ? {
1317
+ type: "title",
1318
+ text: heading
1319
+ } : null);
1320
+ return;
1038
1321
  }
1039
- return applyDocumentAutofixes({
1040
- diagnostics,
1041
- draft: {
1042
- meta,
1043
- blocks
1044
- },
1045
- fixes
1322
+ pushBlock(state, clauseBlock(Math.min(token.depth - 1, MAX_CLAUSE_LEVEL), heading, line, state));
1323
+ };
1324
+ /**
1325
+ * Markdown outside any directive. Consecutive prose stays one paragraph
1326
+ * block (several paragraphs), while a list or table is its own block so
1327
+ * markdown lists get real numbering instead of literal `-` markers.
1328
+ */
1329
+ const parseBareMarkdown = (state) => {
1330
+ const token = state.cursor.current;
1331
+ if (token === void 0) return;
1332
+ if (isTokenType(token, "list")) {
1333
+ state.cursor.advance();
1334
+ pushBlock(state, {
1335
+ type: "list",
1336
+ ordered: token.ordered,
1337
+ items: flattenListItems(token)
1338
+ });
1339
+ return;
1340
+ }
1341
+ if (isTokenType(token, "table")) {
1342
+ state.cursor.advance();
1343
+ pushBlock(state, {
1344
+ type: "table",
1345
+ table: {
1346
+ headers: token.header.map((cell) => cellText(cell)),
1347
+ rows: token.rows.map((row) => row.map((cell) => cellText(cell)))
1348
+ }
1349
+ });
1350
+ return;
1351
+ }
1352
+ const before = state.cursor.position;
1353
+ const paragraphs = takeParagraphs(state);
1354
+ if (paragraphs.length > 0) {
1355
+ pushBlock(state, {
1356
+ type: "paragraph",
1357
+ paragraphs
1358
+ });
1359
+ return;
1360
+ }
1361
+ if (state.cursor.position === before) state.cursor.advance();
1362
+ };
1363
+ /**
1364
+ * Consecutive prose tokens as paragraph strings: paragraphs (soft line
1365
+ * breaks collapsed to spaces), blockquotes, code blocks, and raw HTML. Stops
1366
+ * at the next directive, heading, list, or table so those keep their order.
1367
+ */
1368
+ const takeParagraphs = (state) => {
1369
+ const paragraphs = [];
1370
+ for (;;) {
1371
+ const token = state.cursor.current;
1372
+ if (token === void 0 || startsStructure(token)) return paragraphs;
1373
+ if (isTokenType(token, "list") || isTokenType(token, "table")) return paragraphs;
1374
+ const prose = proseParagraphs(token);
1375
+ if (prose === null) return paragraphs;
1376
+ state.cursor.advance();
1377
+ paragraphs.push(...prose);
1378
+ }
1379
+ };
1380
+ /** The paragraph strings of one prose token, or `null` when the token is not prose. */
1381
+ const proseParagraphs = (token) => {
1382
+ if (token.type === "space" || token.type === "hr") return [];
1383
+ if (isTokenType(token, "paragraph") || isTokenType(token, "text")) {
1384
+ const text = collapseSoftBreaks(token.text);
1385
+ return text ? [text] : [];
1386
+ }
1387
+ if (isTokenType(token, "blockquote")) return token.tokens.flatMap((inner) => proseParagraphs(inner) ?? []);
1388
+ if (isTokenType(token, "code")) return token.text.split("\n").flatMap((codeLine) => {
1389
+ const trimmed = codeLine.trim();
1390
+ return trimmed ? [escapeInlineMarkdown(trimmed)] : [];
1046
1391
  });
1392
+ if (isTokenType(token, "html")) {
1393
+ const text = collapseSoftBreaks(token.text);
1394
+ return text ? [text] : [];
1395
+ }
1396
+ return null;
1047
1397
  };
1398
+ /**
1399
+ * Raw source lines of everything up to the next directive or heading, for
1400
+ * the line-oriented directive bodies (`@list`, `@table`, `@signatures`).
1401
+ */
1402
+ const takeRawLines = (state) => {
1403
+ let raw = "";
1404
+ for (;;) {
1405
+ const token = state.cursor.current;
1406
+ if (token === void 0 || startsStructure(token)) break;
1407
+ state.cursor.advance();
1408
+ raw += token.raw;
1409
+ }
1410
+ return raw.split("\n").map((rawLine) => rawLine.trimEnd());
1411
+ };
1412
+ const flattenListItems = (list) => {
1413
+ const items = [];
1414
+ for (const item of list.items) {
1415
+ const parts = [];
1416
+ const nested = [];
1417
+ for (const child of item.tokens) {
1418
+ if (isTokenType(child, "list")) {
1419
+ nested.push(child);
1420
+ continue;
1421
+ }
1422
+ parts.push(...proseParagraphs(child) ?? []);
1423
+ }
1424
+ const text = parts.join(" ").trim();
1425
+ if (text) items.push(text);
1426
+ for (const nestedList of nested) items.push(...flattenListItems(nestedList));
1427
+ }
1428
+ return items;
1429
+ };
1430
+ const cellText = (cell) => collapseSoftBreaks(cell.text);
1431
+ const INLINE_MARKDOWN_SPECIALS = /[\\`*_[\]<>~!]/gu;
1432
+ /** Backslash-escape the inline markdown syntax so the text renders verbatim. */
1433
+ const escapeInlineMarkdown = (text) => text.replaceAll(INLINE_MARKDOWN_SPECIALS, (char) => `\\${char}`);
1434
+ const collapseSoftBreaks = (text) => text.split("\n").map((textLine) => textLine.trim()).filter((textLine) => textLine.length > 0).join(" ");
1048
1435
  const parseDocDirective = (argument, meta, diagnostics, line) => {
1049
1436
  const attrs = parseAttributes(argument);
1050
1437
  const kind = attrs.get("kind");
@@ -1164,84 +1551,12 @@ const isAsciiAlphaNumeric = (char) => {
1164
1551
  if (code === void 0) return false;
1165
1552
  return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
1166
1553
  };
1167
- const parseMarkdownHeading = (line) => {
1168
- let depth = 0;
1169
- for (const char of line) {
1170
- if (char !== "#") break;
1171
- depth++;
1172
- }
1173
- if (depth < 1 || depth > 6 || line.at(depth) !== " ") return null;
1174
- const heading = line.slice(depth + 1).trim();
1175
- return heading ? {
1176
- depth,
1177
- heading
1178
- } : null;
1179
- };
1180
- const pendingToBlock = (pending, diagnostics, fixes) => {
1181
- switch (pending.type) {
1182
- case "title": {
1183
- const text = pending.heading || paragraphText(pending.lines);
1184
- if (!text) return null;
1185
- return {
1186
- type: "title",
1187
- text
1188
- };
1189
- }
1190
- case "recital": return {
1191
- type: "recital",
1192
- paragraphs: compactParagraphs(pending.lines)
1193
- };
1194
- case "clause": {
1195
- const heading = stripManualNumbering(pending.heading, pending.line, fixes);
1196
- if (!heading) {
1197
- fixes.push({
1198
- code: "headingless-clause-downgraded",
1199
- message: "Converted a headingless @clause into a paragraph block.",
1200
- line: pending.line
1201
- });
1202
- return {
1203
- type: "paragraph",
1204
- paragraphs: compactParagraphs(pending.lines)
1205
- };
1206
- }
1207
- return {
1208
- type: "clause",
1209
- level: pending.level,
1210
- heading,
1211
- paragraphs: compactParagraphs(pending.lines)
1212
- };
1213
- }
1214
- case "paragraph": return {
1215
- type: "paragraph",
1216
- paragraphs: compactParagraphs(pending.lines)
1217
- };
1218
- case "list": return {
1219
- type: "list",
1220
- ordered: pending.ordered,
1221
- items: pending.lines.flatMap((line) => {
1222
- const stripped = stripListMarker(line, pending.ordered);
1223
- return stripped ? [stripped] : [];
1224
- })
1225
- };
1226
- case "table": return parseTableBlock(pending, diagnostics, fixes);
1227
- case "schedule": return {
1228
- type: "schedule",
1229
- heading: stripManualNumbering(pending.heading, pending.line, fixes),
1230
- paragraphs: compactParagraphs(pending.lines)
1231
- };
1232
- case "signatures": return {
1233
- type: "signatures",
1234
- parties: parseSignatureParties(pending.lines, pending.heading)
1235
- };
1236
- default: return null;
1237
- }
1238
- };
1239
- const parseTableBlock = (pending, diagnostics, fixes) => {
1240
- const rows = pending.lines.flatMap((rawLine) => {
1241
- const line = rawLine.trim();
1242
- return line.startsWith("|") ? [line] : [];
1243
- }).flatMap((line) => {
1244
- const row = parsePipeRow(line);
1554
+ const parseTableBlock = (lines, line, diagnostics, fixes) => {
1555
+ const rows = lines.flatMap((rawLine) => {
1556
+ const trimmed = rawLine.trim();
1557
+ return trimmed.startsWith("|") ? [trimmed] : [];
1558
+ }).flatMap((tableLine) => {
1559
+ const row = parsePipeRow(tableLine);
1245
1560
  return row.length > 0 ? [row] : [];
1246
1561
  });
1247
1562
  const header = rows.at(0) ?? [];
@@ -1250,7 +1565,7 @@ const parseTableBlock = (pending, diagnostics, fixes) => {
1250
1565
  fixes.push({
1251
1566
  code: "table-row-width-normalized",
1252
1567
  message: "Normalized a table row to match the header width.",
1253
- line: pending.line
1568
+ line
1254
1569
  });
1255
1570
  return header.map((_, index) => row.at(index) ?? "");
1256
1571
  });
@@ -1258,7 +1573,7 @@ const parseTableBlock = (pending, diagnostics, fixes) => {
1258
1573
  code: "missing-table-header",
1259
1574
  message: "Table directives must include a pipe-table header row.",
1260
1575
  severity: "error",
1261
- line: pending.line
1576
+ line
1262
1577
  });
1263
1578
  return {
1264
1579
  type: "table",
@@ -1380,8 +1695,8 @@ const parseSignatureParties = (lines, heading) => {
1380
1695
  current = { name };
1381
1696
  };
1382
1697
  if (heading.trim()) startParty(heading.trim().replace(/^party:\s*/iu, ""));
1383
- for (const line of lines) {
1384
- const trimmed = line.trim();
1698
+ for (const rawLine of lines) {
1699
+ const trimmed = rawLine.trim();
1385
1700
  if (!trimmed) continue;
1386
1701
  const separatorIndex = trimmed.indexOf(":");
1387
1702
  if (separatorIndex === -1) {
@@ -1414,31 +1729,13 @@ const parseSignatureParties = (lines, heading) => {
1414
1729
  };
1415
1730
  const ORDERED_LIST_MARKER_RE = /^(?:\d+(?:\.\d+)+|\d+[.)])\s+/u;
1416
1731
  const MANUAL_NUMBERING_PREFIX_RE = /^(?:\d+(?:\.\d+)+|\d+[.)]|[A-Za-z][.)]|\([a-zivx]+\))\s+/u;
1417
- const stripListMarker = (line, ordered) => {
1418
- const trimmed = line.trim();
1732
+ const stripListMarker = (rawLine, ordered) => {
1733
+ const trimmed = rawLine.trim();
1419
1734
  if (ordered) return trimmed.replace(ORDERED_LIST_MARKER_RE, "");
1420
1735
  return trimmed.replace(/^[-*•]\s+/u, "");
1421
1736
  };
1422
- const parsePipeRow = (line) => line.replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
1737
+ const parsePipeRow = (tableLine) => tableLine.replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
1423
1738
  const isMarkdownDividerRow = (row) => row.every((cell) => /^:?-{3,}:?$/u.test(cell));
1424
- const compactParagraphs = (lines) => {
1425
- const paragraphs = [];
1426
- let current = [];
1427
- for (const line of lines) {
1428
- const trimmed = line.trim();
1429
- if (!trimmed) {
1430
- if (current.length > 0) {
1431
- paragraphs.push(current.join(" "));
1432
- current = [];
1433
- }
1434
- continue;
1435
- }
1436
- current.push(trimmed);
1437
- }
1438
- if (current.length > 0) paragraphs.push(current.join(" "));
1439
- return paragraphs;
1440
- };
1441
- const paragraphText = (lines) => compactParagraphs(lines).join(" ");
1442
1739
  const stripManualNumbering = (value, line, fixes) => {
1443
1740
  const stripped = value.trim().replace(MANUAL_NUMBERING_PREFIX_RE, "");
1444
1741
  if (stripped !== value.trim()) fixes.push({
@@ -1455,8 +1752,32 @@ const isPageSize = (value) => value === "A4" || value === "Letter";
1455
1752
  const isPageOrientation = (value) => value === "portrait" || value === "landscape";
1456
1753
  //#endregion
1457
1754
  //#region src/legal-source/validate.ts
1755
+ const WHOLE_TEXT_EMPHASIS = /^(?:\*\*[^*]+\*\*|__[^_]+__)$/u;
1756
+ /** Body strings of a block that a reader would expect to be prose, not a heading. */
1757
+ const bodyStrings = (block) => {
1758
+ switch (block.type) {
1759
+ case "paragraph":
1760
+ case "recital":
1761
+ case "clause":
1762
+ case "schedule": return block.paragraphs;
1763
+ case "list": return block.items;
1764
+ case "table": return block.table.rows.flat();
1765
+ case "title":
1766
+ case "signatures":
1767
+ case "pageBreak": return [];
1768
+ default: return [];
1769
+ }
1770
+ };
1458
1771
  const validateLegalDraft = (draft) => {
1459
1772
  const diagnostics = [];
1773
+ for (const block of draft.blocks) if (bodyStrings(block).some((text) => WHOLE_TEXT_EMPHASIS.test(text.trim()))) {
1774
+ diagnostics.push({
1775
+ code: "whole-paragraph-emphasis",
1776
+ message: "A body paragraph, list item, or table cell is bold from end to end; use a clause heading for headings and keep bold for short labels.",
1777
+ severity: "warning"
1778
+ });
1779
+ break;
1780
+ }
1460
1781
  if (!draft.meta.title?.trim()) diagnostics.push({
1461
1782
  code: "missing-title",
1462
1783
  message: "The draft must have a title.",
@@ -1616,35 +1937,15 @@ const paragraph = (text, styleId, runOptions = {}, numPr, pageBreakBefore = fals
1616
1937
  ...numPr ? { numPr } : {},
1617
1938
  ...pageBreakBefore ? { pageBreakBefore: true } : {}
1618
1939
  },
1619
- content: textRunsWithPlaceholders(text, runOptions)
1940
+ content: inlineMarkdownToRuns(text, {
1941
+ base: runOptions,
1942
+ placeholders: true
1943
+ })
1620
1944
  });
1621
- const PLACEHOLDER_PATTERN = /\[\[(?<inner>[^\][]+?)\]\]/gu;
1622
- const textRunsWithPlaceholders = (text, options = {}) => {
1623
- if (!text.includes("[[")) return [textRun(text, options)];
1624
- const runs = [];
1625
- let cursor = 0;
1626
- for (const match of text.matchAll(PLACEHOLDER_PATTERN)) {
1627
- const start = match.index;
1628
- if (start > cursor) runs.push(textRun(text.slice(cursor, start), options));
1629
- const inner = match.groups?.["inner"] ?? "";
1630
- runs.push(textRun(inner, options, { highlight: "yellow" }));
1631
- cursor = start + match[0].length;
1632
- }
1633
- if (cursor < text.length) runs.push(textRun(text.slice(cursor), options));
1634
- return runs.length > 0 ? runs : [textRun(text, options)];
1635
- };
1636
- const textRun = (text, options = {}, extra = {}) => ({
1637
- type: "run",
1638
- formatting: {
1639
- ...options.bold ? { bold: true } : {},
1640
- ...options.italic ? { italic: true } : {},
1641
- ...extra.highlight ? { highlight: extra.highlight } : {}
1642
- },
1643
- content: [{
1644
- type: "text",
1645
- text,
1646
- preserveSpace: true
1647
- }]
1945
+ const plainParagraph = (text, styleId, runOptions = {}) => ({
1946
+ type: "paragraph",
1947
+ formatting: { styleId },
1948
+ content: plainTextRuns(text, runOptions)
1648
1949
  });
1649
1950
  const table = (headers, rows) => ({
1650
1951
  type: "table",
@@ -1664,16 +1965,16 @@ const signatureTable = (parties) => {
1664
1965
  signatory: "",
1665
1966
  title: ""
1666
1967
  }];
1667
- const empty = () => paragraph("", "SignatureSpacer");
1968
+ const empty = () => plainParagraph("", "SignatureSpacer");
1668
1969
  const buildCell = (party) => {
1669
1970
  const cellContent = [
1670
- paragraph(party.name, "SignatureParty", { bold: true }),
1971
+ plainParagraph(party.name, "SignatureParty", { bold: true }),
1671
1972
  empty(),
1672
1973
  empty(),
1673
- paragraph(SIGNATURE_LINE, "SignatureRule")
1974
+ plainParagraph(SIGNATURE_LINE, "SignatureRule")
1674
1975
  ];
1675
- if (party.signatory) cellContent.push(paragraph(party.signatory, "SignatureField"));
1676
- if (party.title) cellContent.push(paragraph(party.title, "SignatureField", { italic: true }));
1976
+ if (party.signatory) cellContent.push(plainParagraph(party.signatory, "SignatureField"));
1977
+ if (party.title) cellContent.push(plainParagraph(party.title, "SignatureField", { italic: true }));
1677
1978
  return {
1678
1979
  type: "tableCell",
1679
1980
  content: cellContent
@@ -2059,4 +2360,157 @@ const compileLegalSourceToDocx = async (source, options = {}) => {
2059
2360
  };
2060
2361
  };
2061
2362
  //#endregion
2062
- export { DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, parseLegalSource, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };
2363
+ //#region src/markdown/content.ts
2364
+ const para = (runs, styleId) => ({
2365
+ type: "paragraph",
2366
+ formatting: styleId ? { styleId } : {},
2367
+ content: runs.length > 0 ? runs : [textRun("")]
2368
+ });
2369
+ const listPara = (runs, rendering) => ({
2370
+ type: "paragraph",
2371
+ formatting: { numPr: {
2372
+ numId: rendering.numId,
2373
+ ilvl: rendering.level
2374
+ } },
2375
+ listRendering: rendering,
2376
+ content: runs.length > 0 ? runs : [textRun("")]
2377
+ });
2378
+ const cellOf = (cell) => ({
2379
+ type: "tableCell",
2380
+ content: [para(inlineTokensToRuns(cell.tokens, cell.text))]
2381
+ });
2382
+ const tableFromToken = (token) => ({
2383
+ type: "table",
2384
+ rows: [{
2385
+ type: "tableRow",
2386
+ cells: token.header.map((cell) => cellOf(cell))
2387
+ }, ...token.rows.map((row) => ({
2388
+ type: "tableRow",
2389
+ cells: row.map((cell) => cellOf(cell))
2390
+ }))]
2391
+ });
2392
+ const LIST_INDENT_STEP_TWIPS = 720;
2393
+ const buildListLevel = (ilvl, isBullet, start) => ({
2394
+ ilvl,
2395
+ ...!isBullet && { start },
2396
+ numFmt: isBullet ? "bullet" : "decimal",
2397
+ lvlText: isBullet ? "•" : `%${ilvl + 1}.`,
2398
+ suffix: "tab",
2399
+ pPr: {
2400
+ indentLeft: LIST_INDENT_STEP_TWIPS * (ilvl + 1),
2401
+ indentFirstLine: -360,
2402
+ hangingIndent: true
2403
+ }
2404
+ });
2405
+ /**
2406
+ * The numId a list renders under. The first list to reach a (numId, ilvl)
2407
+ * pair defines that level; a later list at the same depth under the same
2408
+ * parent shares it when it is the same kind (so sibling nested bullets share
2409
+ * one counter), and gets a numId of its own when it is not (a nested ordered
2410
+ * list must not inherit a sibling's bullet definition).
2411
+ */
2412
+ const resolveListNumId = (numIds, parentNumId, level) => {
2413
+ const levels = numIds.levels.get(parentNumId);
2414
+ const existing = levels?.get(level.ilvl);
2415
+ if (levels !== void 0 && existing === void 0) {
2416
+ levels.set(level.ilvl, level);
2417
+ return parentNumId;
2418
+ }
2419
+ if (levels !== void 0 && existing !== void 0 && existing.numFmt === level.numFmt && existing.start === level.start) return parentNumId;
2420
+ const numId = numIds.next++;
2421
+ numIds.levels.set(numId, /* @__PURE__ */ new Map([[level.ilvl, level]]));
2422
+ return numId;
2423
+ };
2424
+ const listBlocks = (list, level, parentNumId, numIds) => {
2425
+ const out = [];
2426
+ const start = Number(list.start) || 1;
2427
+ const decimalLevels = Array.from({ length: level + 1 }, () => "decimal");
2428
+ const numId = resolveListNumId(numIds, parentNumId, buildListLevel(level, !list.ordered, start));
2429
+ for (const item of list.items) {
2430
+ const rendering = list.ordered ? {
2431
+ marker: `%${level + 1}.`,
2432
+ level,
2433
+ numId,
2434
+ isBullet: false,
2435
+ numFmt: "decimal",
2436
+ levelNumFmts: decimalLevels,
2437
+ ...start !== 1 && { startOverride: start }
2438
+ } : {
2439
+ marker: "•",
2440
+ level,
2441
+ numId,
2442
+ isBullet: true
2443
+ };
2444
+ const inlineTokens = [];
2445
+ const nestedLists = [];
2446
+ for (const child of item.tokens) if (isTokenType(child, "list")) nestedLists.push(child);
2447
+ else inlineTokens.push(child);
2448
+ out.push(listPara(inlineTokensToRuns(inlineTokens, item.text), rendering));
2449
+ for (const nested of nestedLists) out.push(...listBlocks(nested, level + 1, numId, numIds));
2450
+ }
2451
+ return out;
2452
+ };
2453
+ const MAX_HEADING_LEVEL = 4;
2454
+ const blocksFromTokens = (tokens, numIds) => {
2455
+ const blocks = [];
2456
+ for (const token of tokens ?? []) if (isTokenType(token, "heading")) {
2457
+ const level = Math.min(Math.max(token.depth, 1), MAX_HEADING_LEVEL);
2458
+ blocks.push(para(inlineTokensToRuns(token.tokens, token.text), `Heading${level}`));
2459
+ } else if (isTokenType(token, "paragraph")) blocks.push(para(inlineTokensToRuns(token.tokens, token.text)));
2460
+ else if (isTokenType(token, "list")) {
2461
+ const numId = numIds.next++;
2462
+ numIds.levels.set(numId, /* @__PURE__ */ new Map());
2463
+ blocks.push(...listBlocks(token, 0, numId, numIds));
2464
+ } else if (isTokenType(token, "table")) blocks.push(tableFromToken(token));
2465
+ else if (isTokenType(token, "code")) for (const line of token.text.split("\n")) blocks.push(para([textRun(line.length > 0 ? line : " ", { mono: true })]));
2466
+ else if (isTokenType(token, "blockquote")) for (const inner of blocksFromTokens(token.tokens, numIds)) {
2467
+ const styled = inner.type === "paragraph" ? {
2468
+ ...inner,
2469
+ formatting: {
2470
+ ...inner.formatting,
2471
+ styleId: "Quote"
2472
+ }
2473
+ } : inner;
2474
+ blocks.push(styled);
2475
+ }
2476
+ else if (token.type === "hr") blocks.push(para([textRun("———")]));
2477
+ else if (token.type !== "space" && "text" in token && typeof token.text === "string" && token.text.trim().length > 0) blocks.push(para([textRun(token.text)]));
2478
+ return blocks;
2479
+ };
2480
+ const buildNumbering = (numIdLevels) => {
2481
+ const abstractNums = [];
2482
+ const nums = [];
2483
+ for (const [numId, levels] of numIdLevels) {
2484
+ const sortedLevels = [...levels.entries()].sort(([a], [b]) => a - b).map(([, lvl]) => lvl);
2485
+ abstractNums.push({
2486
+ abstractNumId: numId,
2487
+ multiLevelType: sortedLevels.length > 1 ? "multilevel" : "singleLevel",
2488
+ levels: sortedLevels
2489
+ });
2490
+ nums.push({
2491
+ numId,
2492
+ abstractNumId: numId
2493
+ });
2494
+ }
2495
+ return {
2496
+ abstractNums,
2497
+ nums
2498
+ };
2499
+ };
2500
+ /**
2501
+ * Parse GFM markdown into document blocks plus the numbering its lists need.
2502
+ * Synchronous. The caller places the blocks into a `Document` of its own
2503
+ * (page geometry, styles, and presets are the host's decision).
2504
+ */
2505
+ const compileMarkdownToContent = (markdown) => {
2506
+ const numIds = {
2507
+ next: 1,
2508
+ levels: /* @__PURE__ */ new Map()
2509
+ };
2510
+ return {
2511
+ content: blocksFromTokens(lexMarkdown(markdown), numIds),
2512
+ ...numIds.levels.size > 0 && { numbering: buildNumbering(numIds.levels) }
2513
+ };
2514
+ };
2515
+ //#endregion
2516
+ export { DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, compileMarkdownToContent, parseLegalSource, sanitizeExternalUrl, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };