@stll/docx-core 0.17.2 → 0.18.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/README.md CHANGED
@@ -4,8 +4,9 @@ A typed OOXML/DOCX document model with parsing, validation, and serialization.
4
4
 
5
5
  The package exposes a structured document model (paragraphs, runs, tables,
6
6
  styles, section properties) together with the tools to produce and check DOCX
7
- packages, plus a legal-source compiler that turns a plain legal draft into that
8
- model or a finished DOCX file.
7
+ packages, plus a legal-source compiler that turns a legal draft (GFM markdown
8
+ plus `@` directives) into that model or a finished DOCX file, and a plain
9
+ markdown reader (`compileMarkdownToContent`) that shares its parser.
9
10
 
10
11
  ```ts
11
12
  import { compileLegalSourceToDocx, validateDocxPackage } from "@stll/docx-core";
@@ -30,7 +31,8 @@ bun add @stll/docx-core
30
31
 
31
32
  - `.` — the document model types, the legal-source compiler
32
33
  (`parseLegalSource`, `compileLegalSourceToDocument`,
33
- `compileLegalSourceToDocx`, `validateLegalDraft`), DOCX serialization
34
+ `compileLegalSourceToDocx`, `validateLegalDraft`), the markdown reader
35
+ (`compileMarkdownToContent`, `sanitizeExternalUrl`), DOCX serialization
34
36
  (`serializeDocumentToDocx`), and validation (`validateDocxPackage`,
35
37
  `validateDocumentModel`, `assertValidDocumentModel`).
36
38
  - `./model` — the document model types only.
@@ -1153,6 +1153,8 @@ type TextBox = {
1153
1153
  autoFit?: ShapeTextBody["autoFit"];
1154
1154
  /** Horizontal text wrapping inside the box */
1155
1155
  textWrap?: ShapeTextBody["textWrap"];
1156
+ /** Vertical text alignment inside the box */
1157
+ verticalAlign?: ShapeTextBody["anchor"];
1156
1158
  /** Internal margins */
1157
1159
  margins?: {
1158
1160
  top?: number;
Binary file
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Dt as RunContent, Et as Run, Kt as Table, Mt as SectionProperties, N as DocumentBody, Xt as TableRow, a as DocxPackage, b as BlockContent, bt as ParagraphContent, en as TextContent, i as DocxConformanceClass, n as Document, p as Style, qt as TableCell, t as DOCX_CONFORMANCE_CLASSES, w as BreakContent, wt as PositionalTab, yt as Paragraph } from "./document-P4LhYoK3.js";
1
+ import { Dt as RunContent, Et as Run, Kt as Table, Mt as SectionProperties, N as DocumentBody, Xt as TableRow, a as DocxPackage, b as BlockContent, bt as ParagraphContent, en as TextContent, i as DocxConformanceClass, mn as NumberingDefinitions, n as Document, p as Style, qt as TableCell, t as DOCX_CONFORMANCE_CLASSES, w as BreakContent, wt as PositionalTab, yt as Paragraph } from "./document-C4ms2O35.js";
2
2
  import { Buffer } from "node:buffer";
3
3
  //#region src/legal-source/types.d.ts
4
4
  type LegalDocumentKind = "agreement" | "letter" | "memo" | "checklist" | "pleading" | "other";
@@ -24,6 +24,12 @@ type LegalSignatureParty = {
24
24
  signatory?: string;
25
25
  title?: string;
26
26
  };
27
+ /**
28
+ * A parsed draft block. Every `text`, `heading`, paragraph, list item, and
29
+ * table cell string keeps its inline GFM markdown (`**bold**`, `*italic*`,
30
+ * `[link](https://…)`, `` `code` ``) and `[[placeholder]]` markers; the
31
+ * compiler renders them into runs.
32
+ */
27
33
  type LegalDraftBlock = {
28
34
  type: "title";
29
35
  text: string;
@@ -114,6 +120,29 @@ declare const validateLegalDraft: (draft: LegalDraft) => LegalDraftDiagnostic[];
114
120
  //#region src/legal-source/index.d.ts
115
121
  declare const compileLegalSourceToDocx: (source: string, options?: LegalSourceCompileOptions) => Promise<LegalSourceDocxCompileResult>;
116
122
  //#endregion
123
+ //#region src/markdown/content.d.ts
124
+ /** Block content and the numbering definitions its list paragraphs reference. */
125
+ type MarkdownContent = {
126
+ content: BlockContent[];
127
+ /** Present only when the markdown contained at least one list. */
128
+ numbering?: NumberingDefinitions;
129
+ };
130
+ /**
131
+ * Parse GFM markdown into document blocks plus the numbering its lists need.
132
+ * Synchronous. The caller places the blocks into a `Document` of its own
133
+ * (page geometry, styles, and presets are the host's decision).
134
+ */
135
+ declare const compileMarkdownToContent: (markdown: string) => MarkdownContent;
136
+ //#endregion
137
+ //#region src/markdown/href.d.ts
138
+ /**
139
+ * Keep only http(s), mailto, and tel URLs, normalised through the URL parser.
140
+ * Anything else (javascript:, data:, relative paths, malformed input) drops to
141
+ * `undefined` so a markdown link degrades to its text instead of carrying an
142
+ * executable target into the document.
143
+ */
144
+ declare const sanitizeExternalUrl: (rawUrl: string | undefined) => string | undefined;
145
+ //#endregion
117
146
  //#region src/serialize/docx.d.ts
118
147
  type SerializeDocumentOptions = {
119
148
  /** BCP-47 language tag (e.g. "en", "cs", "cs-CZ"); used for footer labels. */
@@ -150,4 +179,4 @@ declare const validateDocxPackage: (buffer: ArrayBuffer | Uint8Array) => Promise
150
179
  declare const validateDocumentModel: (document: Document) => ValidateDocumentModelResult;
151
180
  declare const assertValidDocumentModel: (document: Document) => void;
152
181
  //#endregion
153
- export { type Autofix, type BlockContent, type BreakContent, type CompiledLegalDocument, DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, type Document, type DocumentBody, type DocxConformanceClass, type DocxPackage, type DocxPackageIssueCode, type LegalDraft, type LegalDraftBlock, type LegalDraftDiagnostic, type LegalSourceCompileOptions, type LegalSourceCompileResult, type LegalSourceDocxCompileResult, type LegalSourceParseResult, type Paragraph, type ParagraphContent, type PositionalTab, type Run, type RunContent, type SectionProperties, type Style, type Table, type TableCell, type TableRow, type TextContent, type ValidateDocumentModelIssue, type ValidateDocumentModelResult, type ValidateDocxPackageResult, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, parseLegalSource, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };
182
+ export { type Autofix, type BlockContent, type BreakContent, type CompiledLegalDocument, DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, type Document, type DocumentBody, type DocxConformanceClass, type DocxPackage, type DocxPackageIssueCode, type LegalDraft, type LegalDraftBlock, type LegalDraftDiagnostic, type LegalSourceCompileOptions, type LegalSourceCompileResult, type LegalSourceDocxCompileResult, type LegalSourceParseResult, type MarkdownContent, type Paragraph, type ParagraphContent, type PositionalTab, type Run, type RunContent, type SectionProperties, type Style, type Table, type TableCell, type TableRow, type TextContent, type ValidateDocumentModelIssue, type ValidateDocumentModelResult, type ValidateDocxPackageResult, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, compileMarkdownToContent, parseLegalSource, sanitizeExternalUrl, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { i as isOoxmlSymbolCharacter, t as DOCX_CONFORMANCE_CLASSES } from "./do
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, "");
@@ -828,12 +829,251 @@ const validateCounterPairs = (starts, ends, { label, startName, endName, ctx, se
828
829
  }
829
830
  };
830
831
  //#endregion
832
+ //#region src/markdown/href.ts
833
+ const ALLOWED_URL_PROTOCOLS = /* @__PURE__ */ new Set([
834
+ "http:",
835
+ "https:",
836
+ "mailto:",
837
+ "tel:"
838
+ ]);
839
+ /**
840
+ * Keep only http(s), mailto, and tel URLs, normalised through the URL parser.
841
+ * Anything else (javascript:, data:, relative paths, malformed input) drops to
842
+ * `undefined` so a markdown link degrades to its text instead of carrying an
843
+ * executable target into the document.
844
+ */
845
+ const sanitizeExternalUrl = (rawUrl) => {
846
+ if (!rawUrl) return;
847
+ const trimmed = rawUrl.trim();
848
+ if (!trimmed) return;
849
+ const parsed = parseUrl(trimmed);
850
+ if (!parsed || !ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) return;
851
+ if ((parsed.protocol === "mailto:" || parsed.protocol === "tel:") && parsed.pathname.trim() === "") return;
852
+ return parsed.href;
853
+ };
854
+ const parseUrl = (value) => {
855
+ try {
856
+ return new URL(value);
857
+ } catch {
858
+ return null;
859
+ }
860
+ };
861
+ const hasUnsafeAnchorCharacter = (anchor) => {
862
+ for (const char of anchor) {
863
+ const codePoint = char.codePointAt(0) ?? 0;
864
+ if (codePoint <= 32 || codePoint === 127 || char.trim() === "") return true;
865
+ }
866
+ return false;
867
+ };
868
+ /** A `#anchor` stays a document-internal target; anything else must pass {@link sanitizeExternalUrl}. */
869
+ const sanitizeMarkdownHref = (rawHref) => {
870
+ const trimmed = rawHref.trim();
871
+ if (!trimmed) return;
872
+ if (trimmed.startsWith("#")) {
873
+ const anchor = trimmed.slice(1);
874
+ if (!anchor || hasUnsafeAnchorCharacter(anchor)) return;
875
+ return `#${anchor}`;
876
+ }
877
+ return sanitizeExternalUrl(trimmed);
878
+ };
879
+ //#endregion
880
+ //#region src/markdown/lexer.ts
881
+ /**
882
+ * One `marked` configuration for every markdown surface in docx-core. Plain
883
+ * markdown (`compileMarkdownToContent`) and the legal-source compiler read the
884
+ * same GFM token stream; the legal profile adds a block-level extension that
885
+ * turns an `@directive` line into its own token so the line never merges into
886
+ * a neighbouring paragraph.
887
+ */
888
+ const LEGAL_DIRECTIVE_TOKEN_TYPE = "legalDirective";
889
+ const DIRECTIVE_LINE_PATTERN = /^[ \t]*@(?<name>[A-Za-z][A-Za-z0-9_-]*)(?<argument>[^\n]*)(?:\n|$)/u;
890
+ const DIRECTIVE_LINE_START_PATTERN = /^[ \t]*@[A-Za-z]/mu;
891
+ const legalDirectiveExtension = {
892
+ name: LEGAL_DIRECTIVE_TOKEN_TYPE,
893
+ level: "block",
894
+ start: (src) => {
895
+ const index = src.search(DIRECTIVE_LINE_START_PATTERN);
896
+ return index === -1 ? void 0 : index;
897
+ },
898
+ tokenizer: (src) => {
899
+ const match = DIRECTIVE_LINE_PATTERN.exec(src);
900
+ const name = match?.groups?.["name"];
901
+ if (!match || name === void 0) return;
902
+ return {
903
+ type: LEGAL_DIRECTIVE_TOKEN_TYPE,
904
+ raw: match[0],
905
+ directive: `@${name.toLowerCase()}`,
906
+ argument: (match.groups?.["argument"] ?? "").trim()
907
+ };
908
+ }
909
+ };
910
+ const plainMarkdown = new Marked({ gfm: true });
911
+ const legalMarkdown = new Marked({
912
+ gfm: true,
913
+ extensions: [legalDirectiveExtension]
914
+ });
915
+ /** GFM block tokens of a markdown document. */
916
+ const lexMarkdown = (source) => plainMarkdown.lexer(source);
917
+ /**
918
+ * A directive line always starts a block. marked's paragraph tokenizer asks
919
+ * block extensions where the next block starts, but its list and blockquote
920
+ * tokenizers treat any following non-blank line as lazy continuation, so
921
+ * `- item\n@clause Next` would swallow the directive. Inserting one blank
922
+ * line before every directive that lacks one gives every tokenizer the same
923
+ * boundary; the returned map keeps diagnostics on the author's line numbers.
924
+ */
925
+ const separateDirectiveLines = (source) => {
926
+ const lines = source.split("\n");
927
+ const output = [];
928
+ const insertedBefore = [];
929
+ let inserted = 0;
930
+ for (const [index, line] of lines.entries()) {
931
+ const previous = output.at(-1);
932
+ if (index > 0 && DIRECTIVE_LINE_START_PATTERN.test(line) && previous !== void 0 && previous.trim() !== "") {
933
+ output.push("");
934
+ insertedBefore.push(inserted);
935
+ inserted += 1;
936
+ }
937
+ output.push(line);
938
+ insertedBefore.push(inserted);
939
+ }
940
+ return {
941
+ text: output.join("\n"),
942
+ originalLineOf: (line) => line - (insertedBefore.at(line - 1) ?? inserted)
943
+ };
944
+ };
945
+ /** GFM block tokens of a legal draft, with `@directive` lines as {@link LegalDirectiveToken}s. */
946
+ const lexLegalSource = (source) => {
947
+ const prepared = separateDirectiveLines(source);
948
+ return {
949
+ tokens: legalMarkdown.lexer(prepared.text),
950
+ originalLineOf: prepared.originalLineOf
951
+ };
952
+ };
953
+ /** Inline tokens (emphasis, code spans, links, breaks) of one paragraph's text. */
954
+ const lexInlineMarkdown = (text) => plainMarkdown.Lexer.lexInline(text, plainMarkdown.defaults);
955
+ const isLegalDirectiveToken = (token) => token.type === LEGAL_DIRECTIVE_TOKEN_TYPE;
956
+ const isTokenType = (token, type) => token.type === type;
957
+ //#endregion
958
+ //#region src/markdown/inline.ts
959
+ const MONO_FONT = {
960
+ ascii: "Courier New",
961
+ hAnsi: "Courier New"
962
+ };
963
+ /**
964
+ * One run. A Word run cannot carry a raw newline (the layout engine renders
965
+ * such lines on top of each other), so "\n" becomes an explicit break node.
966
+ */
967
+ const textRun = (text, format = {}) => {
968
+ const formatting = {
969
+ ...format.bold ? { bold: true } : {},
970
+ ...format.italic ? { italic: true } : {},
971
+ ...format.strike ? { strike: true } : {},
972
+ ...format.mono ? { fontFamily: MONO_FONT } : {},
973
+ ...format.highlight ? { highlight: format.highlight } : {}
974
+ };
975
+ const content = [];
976
+ for (const [index, segment] of text.split("\n").entries()) {
977
+ if (index > 0) content.push({ type: "break" });
978
+ if (segment.length > 0) content.push({
979
+ type: "text",
980
+ text: segment,
981
+ preserveSpace: true
982
+ });
983
+ }
984
+ if (content.length === 0) content.push({
985
+ type: "text",
986
+ text: "",
987
+ preserveSpace: true
988
+ });
989
+ return {
990
+ type: "run",
991
+ formatting,
992
+ content
993
+ };
994
+ };
995
+ const PLACEHOLDER_PATTERN = /\[\[(?<inner>[^\][]+?)\]\]/gu;
996
+ /**
997
+ * Literal text (no markdown reading) with `[[…]]` placeholders highlighted:
998
+ * for fields that are data rather than prose, such as signature parties.
999
+ */
1000
+ const plainTextRuns = (text, format = {}) => placeholderRuns(text, format);
1001
+ const placeholderRuns = (text, format) => {
1002
+ if (!text.includes("[[")) return [textRun(text, format)];
1003
+ const runs = [];
1004
+ let cursor = 0;
1005
+ for (const match of text.matchAll(PLACEHOLDER_PATTERN)) {
1006
+ const start = match.index;
1007
+ if (start > cursor) runs.push(textRun(text.slice(cursor, start), format));
1008
+ runs.push(textRun(match.groups?.["inner"] ?? "", {
1009
+ ...format,
1010
+ highlight: "yellow"
1011
+ }));
1012
+ cursor = start + match[0].length;
1013
+ }
1014
+ if (cursor < text.length) runs.push(textRun(text.slice(cursor), format));
1015
+ return runs.length > 0 ? runs : [textRun(text, format)];
1016
+ };
1017
+ const plainRuns = (text, format, placeholders) => placeholders ? placeholderRuns(text, format) : [textRun(text, format)];
1018
+ const tokensToRuns = (tokens, fallback, format, context) => {
1019
+ if (!tokens || tokens.length === 0) return plainRuns(fallback, format, context.placeholders);
1020
+ const runs = [];
1021
+ for (const token of tokens) if (isTokenType(token, "strong")) runs.push(...tokensToRuns(token.tokens, token.text, {
1022
+ ...format,
1023
+ bold: true
1024
+ }, context));
1025
+ else if (isTokenType(token, "em")) runs.push(...tokensToRuns(token.tokens, token.text, {
1026
+ ...format,
1027
+ italic: true
1028
+ }, context));
1029
+ else if (isTokenType(token, "del")) runs.push(...tokensToRuns(token.tokens, token.text, {
1030
+ ...format,
1031
+ strike: true
1032
+ }, context));
1033
+ else if (isTokenType(token, "codespan")) runs.push(textRun(token.text, {
1034
+ ...format,
1035
+ mono: true
1036
+ }));
1037
+ else if (isTokenType(token, "link")) runs.push(...linkRuns(token.tokens, token.text, token.href, format, context));
1038
+ else if (isTokenType(token, "paragraph")) runs.push(...tokensToRuns(token.tokens, token.text, format, context));
1039
+ else if (token.type === "br") runs.push({
1040
+ type: "run",
1041
+ content: [{ type: "break" }]
1042
+ });
1043
+ else if (token.type === "space") {
1044
+ if (runs.length > 0 && token.raw.includes("\n")) runs.push(textRun("\n", format));
1045
+ } else if (isTokenType(token, "text")) {
1046
+ const nested = token.tokens;
1047
+ if (nested && nested.length > 0) runs.push(...tokensToRuns(nested, token.text, format, context));
1048
+ else runs.push(...plainRuns(token.text, format, context.placeholders));
1049
+ } else if ("text" in token && typeof token.text === "string") runs.push(...plainRuns(token.text, format, context.placeholders));
1050
+ return runs.length > 0 ? runs : plainRuns(fallback, format, context.placeholders);
1051
+ };
1052
+ const linkRuns = (tokens, text, rawHref, format, context) => {
1053
+ const children = tokensToRuns(tokens, text, format, context).filter((child) => child.type === "run");
1054
+ const linkChildren = children.length > 0 ? children : [textRun(text, format)];
1055
+ const href = sanitizeMarkdownHref(rawHref);
1056
+ if (!href) return linkChildren;
1057
+ const anchor = href.startsWith("#") ? href.slice(1) : void 0;
1058
+ return [{
1059
+ type: "hyperlink",
1060
+ href,
1061
+ ...anchor ? { anchor } : {},
1062
+ children: linkChildren
1063
+ }];
1064
+ };
1065
+ /** Render already-lexed inline tokens; `fallback` is the source text when the tokens are empty. */
1066
+ const inlineTokensToRuns = (tokens, fallback, options = {}) => tokensToRuns(tokens, fallback, options.base ?? {}, { placeholders: options.placeholders ?? false });
1067
+ /** Lex and render one paragraph's inline markdown. */
1068
+ const inlineMarkdownToRuns = (text, options = {}) => inlineTokensToRuns(lexInlineMarkdown(text), text, options);
1069
+ //#endregion
831
1070
  //#region src/legal-source/parser.ts
832
1071
  const DEFAULT_KIND = "agreement";
833
1072
  const DEFAULT_LOCALE = "en-GB";
834
1073
  const DEFAULT_NUMBERING = "legal";
835
1074
  const DEFAULT_PAGE_SIZE = "A4";
836
1075
  const DEFAULT_ORIENTATION = "portrait";
1076
+ const MAX_CLAUSE_LEVEL = 6;
837
1077
  const DIRECTIVE_ALIASES = {
838
1078
  "@annex": "@schedule",
839
1079
  "@appendix": "@schedule",
@@ -844,6 +1084,7 @@ const DIRECTIVE_ALIASES = {
844
1084
  "@signature": "@signatures",
845
1085
  "@subsection": "@subclause"
846
1086
  };
1087
+ const CLOSING_DIRECTIVE_PATTERN = /^@end[a-z]*$/u;
847
1088
  const DIRECTIVES = /* @__PURE__ */ new Set([
848
1089
  "@doc",
849
1090
  "@title",
@@ -857,194 +1098,337 @@ const DIRECTIVES = /* @__PURE__ */ new Set([
857
1098
  "@signatures",
858
1099
  "@pagebreak"
859
1100
  ]);
1101
+ /**
1102
+ * Walks the top-level token stream. Line numbers come from the newlines in
1103
+ * the consumed tokens' `raw` text, which marked keeps contiguous with the
1104
+ * source, so diagnostics point at the directive line that produced them.
1105
+ */
1106
+ var TokenCursor = class {
1107
+ tokens;
1108
+ originalLineOf;
1109
+ index = 0;
1110
+ lineNumber = 1;
1111
+ constructor({ tokens, originalLineOf }) {
1112
+ this.tokens = tokens;
1113
+ this.originalLineOf = originalLineOf;
1114
+ }
1115
+ get current() {
1116
+ return this.tokens.at(this.index);
1117
+ }
1118
+ /** The author's line number for the current token. */
1119
+ get line() {
1120
+ return this.originalLineOf(this.lineNumber);
1121
+ }
1122
+ /** Index of the current token; lets a caller tell whether a helper consumed anything. */
1123
+ get position() {
1124
+ return this.index;
1125
+ }
1126
+ advance() {
1127
+ const token = this.current;
1128
+ if (token === void 0) return;
1129
+ this.index += 1;
1130
+ this.lineNumber += countNewlines(token.raw);
1131
+ return token;
1132
+ }
1133
+ };
1134
+ const countNewlines = (text) => {
1135
+ let count = 0;
1136
+ for (const char of text) if (char === "\n") count += 1;
1137
+ return count;
1138
+ };
1139
+ /** A token that starts a new structural block, so no body may run past it. */
1140
+ const startsStructure = (token) => isLegalDirectiveToken(token) || isTokenType(token, "heading");
860
1141
  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
1142
+ const state = {
1143
+ blocks: [],
1144
+ diagnostics: [],
1145
+ fixes: [],
1146
+ meta: {
1147
+ kind: DEFAULT_KIND,
1148
+ locale: DEFAULT_LOCALE,
1149
+ numbering: DEFAULT_NUMBERING,
1150
+ page: {
1151
+ size: DEFAULT_PAGE_SIZE,
1152
+ orientation: DEFAULT_ORIENTATION
1153
+ },
1154
+ title: null
871
1155
  },
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;
1156
+ cursor: new TokenCursor(lexLegalSource(source))
883
1157
  };
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("");
1158
+ for (;;) {
1159
+ const token = state.cursor.current;
1160
+ if (token === void 0) break;
1161
+ const line = state.cursor.line;
1162
+ if (isLegalDirectiveToken(token)) {
1163
+ state.cursor.advance();
1164
+ parseDirective(token, line, state);
891
1165
  continue;
892
1166
  }
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
- });
1167
+ if (isTokenType(token, "heading")) {
1168
+ state.cursor.advance();
1169
+ parseMarkdownHeading(token, line, state);
917
1170
  continue;
918
1171
  }
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
1172
+ parseBareMarkdown(state);
1173
+ }
1174
+ if (!state.meta.title) {
1175
+ const firstTitle = state.blocks.find((block) => block.type === "title");
1176
+ state.meta.title = firstTitle?.type === "title" ? firstTitle.text : options.titleFallback ?? "Untitled document";
1177
+ }
1178
+ const draft = {
1179
+ meta: state.meta,
1180
+ blocks: state.blocks
1181
+ };
1182
+ return applyDocumentAutofixes({
1183
+ diagnostics: state.diagnostics,
1184
+ draft,
1185
+ fixes: state.fixes
1186
+ });
1187
+ };
1188
+ const pushBlock = (state, block) => {
1189
+ if (!block) return;
1190
+ state.blocks.push(block);
1191
+ if (block.type === "title") state.meta.title = block.text;
1192
+ };
1193
+ const parseDirective = (token, line, state) => {
1194
+ const { diagnostics, fixes, meta } = state;
1195
+ const rawDirective = token.directive;
1196
+ const directive = DIRECTIVE_ALIASES[rawDirective] ?? rawDirective;
1197
+ const { argument } = token;
1198
+ if (CLOSING_DIRECTIVE_PATTERN.test(directive)) {
1199
+ fixes.push({
1200
+ code: "closing-directive-ignored",
1201
+ message: `Ignored ${rawDirective}: blocks end where the next directive starts.`,
1202
+ line
1203
+ });
1204
+ return;
1205
+ }
1206
+ if (!DIRECTIVES.has(directive)) {
1207
+ const spelled = token.raw.trim().split(/\s+/u).at(0) ?? rawDirective;
1208
+ diagnostics.push({
1209
+ code: "unknown-directive",
1210
+ message: `Unknown legal directive "${spelled}".`,
1211
+ severity: "error",
1212
+ line
1213
+ });
1214
+ return;
1215
+ }
1216
+ if (directive !== rawDirective) fixes.push({
1217
+ code: "directive-alias-normalized",
1218
+ message: `Normalized ${rawDirective} to ${directive}.`,
1219
+ line
1220
+ });
1221
+ switch (directive) {
1222
+ case "@doc":
1223
+ parseDocDirective(argument, meta, diagnostics, line);
1224
+ return;
1225
+ case "@title":
1226
+ pushBlock(state, argument ? {
1227
+ type: "title",
1228
+ text: argument
1229
+ } : null);
1230
+ return;
1231
+ case "@recital":
1232
+ pushBlock(state, {
1233
+ type: "recital",
1234
+ paragraphs: takeParagraphs(state)
937
1235
  });
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;
1236
+ return;
1237
+ case "@clause":
1238
+ pushBlock(state, clauseBlock(1, argument, line, state));
1239
+ return;
1240
+ case "@subclause":
1241
+ pushBlock(state, clauseBlock(2, argument, line, state));
1242
+ return;
1243
+ case "@paragraph":
1244
+ pushBlock(state, {
1245
+ type: "paragraph",
1246
+ paragraphs: takeParagraphs(state)
1247
+ });
1248
+ return;
1249
+ case "@list": {
1250
+ const ordered = /\bordered\b/iu.test(argument);
1251
+ const items = takeRawLines(state).flatMap((rawLine) => {
1252
+ const stripped = stripListMarker(rawLine, ordered);
1253
+ return stripped ? [stripped] : [];
1254
+ });
1255
+ pushBlock(state, {
1256
+ type: "list",
1257
+ ordered,
1258
+ items
1259
+ });
1260
+ return;
1025
1261
  }
1026
- pending ??= {
1262
+ case "@table":
1263
+ pushBlock(state, parseTableBlock(takeRawLines(state), line, diagnostics, fixes));
1264
+ return;
1265
+ case "@schedule":
1266
+ pushBlock(state, {
1267
+ type: "schedule",
1268
+ heading: stripManualNumbering(argument, line, fixes),
1269
+ paragraphs: takeParagraphs(state)
1270
+ });
1271
+ return;
1272
+ case "@signatures":
1273
+ pushBlock(state, {
1274
+ type: "signatures",
1275
+ parties: parseSignatureParties(takeRawLines(state), argument)
1276
+ });
1277
+ return;
1278
+ case "@pagebreak":
1279
+ pushBlock(state, { type: "pageBreak" });
1280
+ return;
1281
+ default: return;
1282
+ }
1283
+ };
1284
+ const clauseBlock = (level, rawHeading, line, state) => {
1285
+ const heading = stripManualNumbering(rawHeading, line, state.fixes);
1286
+ const paragraphs = takeParagraphs(state);
1287
+ if (!heading) {
1288
+ state.fixes.push({
1289
+ code: "headingless-clause-downgraded",
1290
+ message: "Converted a headingless @clause into a paragraph block.",
1291
+ line
1292
+ });
1293
+ return {
1027
1294
  type: "paragraph",
1028
- line: lineNumber,
1029
- heading: "",
1030
- lines: []
1295
+ paragraphs
1031
1296
  };
1032
- pending.lines.push(line);
1033
1297
  }
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";
1298
+ return {
1299
+ type: "clause",
1300
+ level,
1301
+ heading,
1302
+ paragraphs
1303
+ };
1304
+ };
1305
+ const parseMarkdownHeading = (token, line, state) => {
1306
+ const heading = token.text.trim();
1307
+ state.fixes.push({
1308
+ code: "markdown-heading-normalized",
1309
+ message: "Converted a Markdown heading into a legal directive.",
1310
+ line
1311
+ });
1312
+ if (token.depth === 1) {
1313
+ pushBlock(state, heading ? {
1314
+ type: "title",
1315
+ text: heading
1316
+ } : null);
1317
+ return;
1318
+ }
1319
+ pushBlock(state, clauseBlock(Math.min(token.depth - 1, MAX_CLAUSE_LEVEL), heading, line, state));
1320
+ };
1321
+ /**
1322
+ * Markdown outside any directive. Consecutive prose stays one paragraph
1323
+ * block (several paragraphs), while a list or table is its own block so
1324
+ * markdown lists get real numbering instead of literal `-` markers.
1325
+ */
1326
+ const parseBareMarkdown = (state) => {
1327
+ const token = state.cursor.current;
1328
+ if (token === void 0) return;
1329
+ if (isTokenType(token, "list")) {
1330
+ state.cursor.advance();
1331
+ pushBlock(state, {
1332
+ type: "list",
1333
+ ordered: token.ordered,
1334
+ items: flattenListItems(token)
1335
+ });
1336
+ return;
1038
1337
  }
1039
- return applyDocumentAutofixes({
1040
- diagnostics,
1041
- draft: {
1042
- meta,
1043
- blocks
1044
- },
1045
- fixes
1338
+ if (isTokenType(token, "table")) {
1339
+ state.cursor.advance();
1340
+ pushBlock(state, {
1341
+ type: "table",
1342
+ table: {
1343
+ headers: token.header.map((cell) => cellText(cell)),
1344
+ rows: token.rows.map((row) => row.map((cell) => cellText(cell)))
1345
+ }
1346
+ });
1347
+ return;
1348
+ }
1349
+ const before = state.cursor.position;
1350
+ const paragraphs = takeParagraphs(state);
1351
+ if (paragraphs.length > 0) {
1352
+ pushBlock(state, {
1353
+ type: "paragraph",
1354
+ paragraphs
1355
+ });
1356
+ return;
1357
+ }
1358
+ if (state.cursor.position === before) state.cursor.advance();
1359
+ };
1360
+ /**
1361
+ * Consecutive prose tokens as paragraph strings: paragraphs (soft line
1362
+ * breaks collapsed to spaces), blockquotes, code blocks, and raw HTML. Stops
1363
+ * at the next directive, heading, list, or table so those keep their order.
1364
+ */
1365
+ const takeParagraphs = (state) => {
1366
+ const paragraphs = [];
1367
+ for (;;) {
1368
+ const token = state.cursor.current;
1369
+ if (token === void 0 || startsStructure(token)) return paragraphs;
1370
+ if (isTokenType(token, "list") || isTokenType(token, "table")) return paragraphs;
1371
+ const prose = proseParagraphs(token);
1372
+ if (prose === null) return paragraphs;
1373
+ state.cursor.advance();
1374
+ paragraphs.push(...prose);
1375
+ }
1376
+ };
1377
+ /** The paragraph strings of one prose token, or `null` when the token is not prose. */
1378
+ const proseParagraphs = (token) => {
1379
+ if (token.type === "space" || token.type === "hr") return [];
1380
+ if (isTokenType(token, "paragraph") || isTokenType(token, "text")) {
1381
+ const text = collapseSoftBreaks(token.text);
1382
+ return text ? [text] : [];
1383
+ }
1384
+ if (isTokenType(token, "blockquote")) return token.tokens.flatMap((inner) => proseParagraphs(inner) ?? []);
1385
+ if (isTokenType(token, "code")) return token.text.split("\n").flatMap((codeLine) => {
1386
+ const trimmed = codeLine.trim();
1387
+ return trimmed ? [escapeInlineMarkdown(trimmed)] : [];
1046
1388
  });
1389
+ if (isTokenType(token, "html")) {
1390
+ const text = collapseSoftBreaks(token.text);
1391
+ return text ? [text] : [];
1392
+ }
1393
+ return null;
1047
1394
  };
1395
+ /**
1396
+ * Raw source lines of everything up to the next directive or heading, for
1397
+ * the line-oriented directive bodies (`@list`, `@table`, `@signatures`).
1398
+ */
1399
+ const takeRawLines = (state) => {
1400
+ let raw = "";
1401
+ for (;;) {
1402
+ const token = state.cursor.current;
1403
+ if (token === void 0 || startsStructure(token)) break;
1404
+ state.cursor.advance();
1405
+ raw += token.raw;
1406
+ }
1407
+ return raw.split("\n").map((rawLine) => rawLine.trimEnd());
1408
+ };
1409
+ const flattenListItems = (list) => {
1410
+ const items = [];
1411
+ for (const item of list.items) {
1412
+ const parts = [];
1413
+ const nested = [];
1414
+ for (const child of item.tokens) {
1415
+ if (isTokenType(child, "list")) {
1416
+ nested.push(child);
1417
+ continue;
1418
+ }
1419
+ parts.push(...proseParagraphs(child) ?? []);
1420
+ }
1421
+ const text = parts.join(" ").trim();
1422
+ if (text) items.push(text);
1423
+ for (const nestedList of nested) items.push(...flattenListItems(nestedList));
1424
+ }
1425
+ return items;
1426
+ };
1427
+ const cellText = (cell) => collapseSoftBreaks(cell.text);
1428
+ const INLINE_MARKDOWN_SPECIALS = /[\\`*_[\]<>~!]/gu;
1429
+ /** Backslash-escape the inline markdown syntax so the text renders verbatim. */
1430
+ const escapeInlineMarkdown = (text) => text.replaceAll(INLINE_MARKDOWN_SPECIALS, (char) => `\\${char}`);
1431
+ const collapseSoftBreaks = (text) => text.split("\n").map((textLine) => textLine.trim()).filter((textLine) => textLine.length > 0).join(" ");
1048
1432
  const parseDocDirective = (argument, meta, diagnostics, line) => {
1049
1433
  const attrs = parseAttributes(argument);
1050
1434
  const kind = attrs.get("kind");
@@ -1164,84 +1548,12 @@ const isAsciiAlphaNumeric = (char) => {
1164
1548
  if (code === void 0) return false;
1165
1549
  return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
1166
1550
  };
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);
1551
+ const parseTableBlock = (lines, line, diagnostics, fixes) => {
1552
+ const rows = lines.flatMap((rawLine) => {
1553
+ const trimmed = rawLine.trim();
1554
+ return trimmed.startsWith("|") ? [trimmed] : [];
1555
+ }).flatMap((tableLine) => {
1556
+ const row = parsePipeRow(tableLine);
1245
1557
  return row.length > 0 ? [row] : [];
1246
1558
  });
1247
1559
  const header = rows.at(0) ?? [];
@@ -1250,7 +1562,7 @@ const parseTableBlock = (pending, diagnostics, fixes) => {
1250
1562
  fixes.push({
1251
1563
  code: "table-row-width-normalized",
1252
1564
  message: "Normalized a table row to match the header width.",
1253
- line: pending.line
1565
+ line
1254
1566
  });
1255
1567
  return header.map((_, index) => row.at(index) ?? "");
1256
1568
  });
@@ -1258,7 +1570,7 @@ const parseTableBlock = (pending, diagnostics, fixes) => {
1258
1570
  code: "missing-table-header",
1259
1571
  message: "Table directives must include a pipe-table header row.",
1260
1572
  severity: "error",
1261
- line: pending.line
1573
+ line
1262
1574
  });
1263
1575
  return {
1264
1576
  type: "table",
@@ -1380,8 +1692,8 @@ const parseSignatureParties = (lines, heading) => {
1380
1692
  current = { name };
1381
1693
  };
1382
1694
  if (heading.trim()) startParty(heading.trim().replace(/^party:\s*/iu, ""));
1383
- for (const line of lines) {
1384
- const trimmed = line.trim();
1695
+ for (const rawLine of lines) {
1696
+ const trimmed = rawLine.trim();
1385
1697
  if (!trimmed) continue;
1386
1698
  const separatorIndex = trimmed.indexOf(":");
1387
1699
  if (separatorIndex === -1) {
@@ -1414,31 +1726,13 @@ const parseSignatureParties = (lines, heading) => {
1414
1726
  };
1415
1727
  const ORDERED_LIST_MARKER_RE = /^(?:\d+(?:\.\d+)+|\d+[.)])\s+/u;
1416
1728
  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();
1729
+ const stripListMarker = (rawLine, ordered) => {
1730
+ const trimmed = rawLine.trim();
1419
1731
  if (ordered) return trimmed.replace(ORDERED_LIST_MARKER_RE, "");
1420
1732
  return trimmed.replace(/^[-*•]\s+/u, "");
1421
1733
  };
1422
- const parsePipeRow = (line) => line.replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
1734
+ const parsePipeRow = (tableLine) => tableLine.replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
1423
1735
  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
1736
  const stripManualNumbering = (value, line, fixes) => {
1443
1737
  const stripped = value.trim().replace(MANUAL_NUMBERING_PREFIX_RE, "");
1444
1738
  if (stripped !== value.trim()) fixes.push({
@@ -1455,8 +1749,32 @@ const isPageSize = (value) => value === "A4" || value === "Letter";
1455
1749
  const isPageOrientation = (value) => value === "portrait" || value === "landscape";
1456
1750
  //#endregion
1457
1751
  //#region src/legal-source/validate.ts
1752
+ const WHOLE_TEXT_EMPHASIS = /^(?:\*\*[^*]+\*\*|__[^_]+__)$/u;
1753
+ /** Body strings of a block that a reader would expect to be prose, not a heading. */
1754
+ const bodyStrings = (block) => {
1755
+ switch (block.type) {
1756
+ case "paragraph":
1757
+ case "recital":
1758
+ case "clause":
1759
+ case "schedule": return block.paragraphs;
1760
+ case "list": return block.items;
1761
+ case "table": return block.table.rows.flat();
1762
+ case "title":
1763
+ case "signatures":
1764
+ case "pageBreak": return [];
1765
+ default: return [];
1766
+ }
1767
+ };
1458
1768
  const validateLegalDraft = (draft) => {
1459
1769
  const diagnostics = [];
1770
+ for (const block of draft.blocks) if (bodyStrings(block).some((text) => WHOLE_TEXT_EMPHASIS.test(text.trim()))) {
1771
+ diagnostics.push({
1772
+ code: "whole-paragraph-emphasis",
1773
+ 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.",
1774
+ severity: "warning"
1775
+ });
1776
+ break;
1777
+ }
1460
1778
  if (!draft.meta.title?.trim()) diagnostics.push({
1461
1779
  code: "missing-title",
1462
1780
  message: "The draft must have a title.",
@@ -1616,35 +1934,15 @@ const paragraph = (text, styleId, runOptions = {}, numPr, pageBreakBefore = fals
1616
1934
  ...numPr ? { numPr } : {},
1617
1935
  ...pageBreakBefore ? { pageBreakBefore: true } : {}
1618
1936
  },
1619
- content: textRunsWithPlaceholders(text, runOptions)
1937
+ content: inlineMarkdownToRuns(text, {
1938
+ base: runOptions,
1939
+ placeholders: true
1940
+ })
1620
1941
  });
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
- }]
1942
+ const plainParagraph = (text, styleId, runOptions = {}) => ({
1943
+ type: "paragraph",
1944
+ formatting: { styleId },
1945
+ content: plainTextRuns(text, runOptions)
1648
1946
  });
1649
1947
  const table = (headers, rows) => ({
1650
1948
  type: "table",
@@ -1664,16 +1962,16 @@ const signatureTable = (parties) => {
1664
1962
  signatory: "",
1665
1963
  title: ""
1666
1964
  }];
1667
- const empty = () => paragraph("", "SignatureSpacer");
1965
+ const empty = () => plainParagraph("", "SignatureSpacer");
1668
1966
  const buildCell = (party) => {
1669
1967
  const cellContent = [
1670
- paragraph(party.name, "SignatureParty", { bold: true }),
1968
+ plainParagraph(party.name, "SignatureParty", { bold: true }),
1671
1969
  empty(),
1672
1970
  empty(),
1673
- paragraph(SIGNATURE_LINE, "SignatureRule")
1971
+ plainParagraph(SIGNATURE_LINE, "SignatureRule")
1674
1972
  ];
1675
- if (party.signatory) cellContent.push(paragraph(party.signatory, "SignatureField"));
1676
- if (party.title) cellContent.push(paragraph(party.title, "SignatureField", { italic: true }));
1973
+ if (party.signatory) cellContent.push(plainParagraph(party.signatory, "SignatureField"));
1974
+ if (party.title) cellContent.push(plainParagraph(party.title, "SignatureField", { italic: true }));
1677
1975
  return {
1678
1976
  type: "tableCell",
1679
1977
  content: cellContent
@@ -2059,4 +2357,157 @@ const compileLegalSourceToDocx = async (source, options = {}) => {
2059
2357
  };
2060
2358
  };
2061
2359
  //#endregion
2062
- export { DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, parseLegalSource, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };
2360
+ //#region src/markdown/content.ts
2361
+ const para = (runs, styleId) => ({
2362
+ type: "paragraph",
2363
+ formatting: styleId ? { styleId } : {},
2364
+ content: runs.length > 0 ? runs : [textRun("")]
2365
+ });
2366
+ const listPara = (runs, rendering) => ({
2367
+ type: "paragraph",
2368
+ formatting: { numPr: {
2369
+ numId: rendering.numId,
2370
+ ilvl: rendering.level
2371
+ } },
2372
+ listRendering: rendering,
2373
+ content: runs.length > 0 ? runs : [textRun("")]
2374
+ });
2375
+ const cellOf = (cell) => ({
2376
+ type: "tableCell",
2377
+ content: [para(inlineTokensToRuns(cell.tokens, cell.text))]
2378
+ });
2379
+ const tableFromToken = (token) => ({
2380
+ type: "table",
2381
+ rows: [{
2382
+ type: "tableRow",
2383
+ cells: token.header.map((cell) => cellOf(cell))
2384
+ }, ...token.rows.map((row) => ({
2385
+ type: "tableRow",
2386
+ cells: row.map((cell) => cellOf(cell))
2387
+ }))]
2388
+ });
2389
+ const LIST_INDENT_STEP_TWIPS = 720;
2390
+ const buildListLevel = (ilvl, isBullet, start) => ({
2391
+ ilvl,
2392
+ ...!isBullet && { start },
2393
+ numFmt: isBullet ? "bullet" : "decimal",
2394
+ lvlText: isBullet ? "•" : `%${ilvl + 1}.`,
2395
+ suffix: "tab",
2396
+ pPr: {
2397
+ indentLeft: LIST_INDENT_STEP_TWIPS * (ilvl + 1),
2398
+ indentFirstLine: -360,
2399
+ hangingIndent: true
2400
+ }
2401
+ });
2402
+ /**
2403
+ * The numId a list renders under. The first list to reach a (numId, ilvl)
2404
+ * pair defines that level; a later list at the same depth under the same
2405
+ * parent shares it when it is the same kind (so sibling nested bullets share
2406
+ * one counter), and gets a numId of its own when it is not (a nested ordered
2407
+ * list must not inherit a sibling's bullet definition).
2408
+ */
2409
+ const resolveListNumId = (numIds, parentNumId, level) => {
2410
+ const levels = numIds.levels.get(parentNumId);
2411
+ const existing = levels?.get(level.ilvl);
2412
+ if (levels !== void 0 && existing === void 0) {
2413
+ levels.set(level.ilvl, level);
2414
+ return parentNumId;
2415
+ }
2416
+ if (levels !== void 0 && existing !== void 0 && existing.numFmt === level.numFmt && existing.start === level.start) return parentNumId;
2417
+ const numId = numIds.next++;
2418
+ numIds.levels.set(numId, /* @__PURE__ */ new Map([[level.ilvl, level]]));
2419
+ return numId;
2420
+ };
2421
+ const listBlocks = (list, level, parentNumId, numIds) => {
2422
+ const out = [];
2423
+ const start = Number(list.start) || 1;
2424
+ const decimalLevels = Array.from({ length: level + 1 }, () => "decimal");
2425
+ const numId = resolveListNumId(numIds, parentNumId, buildListLevel(level, !list.ordered, start));
2426
+ for (const item of list.items) {
2427
+ const rendering = list.ordered ? {
2428
+ marker: `%${level + 1}.`,
2429
+ level,
2430
+ numId,
2431
+ isBullet: false,
2432
+ numFmt: "decimal",
2433
+ levelNumFmts: decimalLevels,
2434
+ ...start !== 1 && { startOverride: start }
2435
+ } : {
2436
+ marker: "•",
2437
+ level,
2438
+ numId,
2439
+ isBullet: true
2440
+ };
2441
+ const inlineTokens = [];
2442
+ const nestedLists = [];
2443
+ for (const child of item.tokens) if (isTokenType(child, "list")) nestedLists.push(child);
2444
+ else inlineTokens.push(child);
2445
+ out.push(listPara(inlineTokensToRuns(inlineTokens, item.text), rendering));
2446
+ for (const nested of nestedLists) out.push(...listBlocks(nested, level + 1, numId, numIds));
2447
+ }
2448
+ return out;
2449
+ };
2450
+ const MAX_HEADING_LEVEL = 4;
2451
+ const blocksFromTokens = (tokens, numIds) => {
2452
+ const blocks = [];
2453
+ for (const token of tokens ?? []) if (isTokenType(token, "heading")) {
2454
+ const level = Math.min(Math.max(token.depth, 1), MAX_HEADING_LEVEL);
2455
+ blocks.push(para(inlineTokensToRuns(token.tokens, token.text), `Heading${level}`));
2456
+ } else if (isTokenType(token, "paragraph")) blocks.push(para(inlineTokensToRuns(token.tokens, token.text)));
2457
+ else if (isTokenType(token, "list")) {
2458
+ const numId = numIds.next++;
2459
+ numIds.levels.set(numId, /* @__PURE__ */ new Map());
2460
+ blocks.push(...listBlocks(token, 0, numId, numIds));
2461
+ } else if (isTokenType(token, "table")) blocks.push(tableFromToken(token));
2462
+ else if (isTokenType(token, "code")) for (const line of token.text.split("\n")) blocks.push(para([textRun(line.length > 0 ? line : " ", { mono: true })]));
2463
+ else if (isTokenType(token, "blockquote")) for (const inner of blocksFromTokens(token.tokens, numIds)) {
2464
+ const styled = inner.type === "paragraph" ? {
2465
+ ...inner,
2466
+ formatting: {
2467
+ ...inner.formatting,
2468
+ styleId: "Quote"
2469
+ }
2470
+ } : inner;
2471
+ blocks.push(styled);
2472
+ }
2473
+ else if (token.type === "hr") blocks.push(para([textRun("———")]));
2474
+ else if (token.type !== "space" && "text" in token && typeof token.text === "string" && token.text.trim().length > 0) blocks.push(para([textRun(token.text)]));
2475
+ return blocks;
2476
+ };
2477
+ const buildNumbering = (numIdLevels) => {
2478
+ const abstractNums = [];
2479
+ const nums = [];
2480
+ for (const [numId, levels] of numIdLevels) {
2481
+ const sortedLevels = [...levels.entries()].sort(([a], [b]) => a - b).map(([, lvl]) => lvl);
2482
+ abstractNums.push({
2483
+ abstractNumId: numId,
2484
+ multiLevelType: sortedLevels.length > 1 ? "multilevel" : "singleLevel",
2485
+ levels: sortedLevels
2486
+ });
2487
+ nums.push({
2488
+ numId,
2489
+ abstractNumId: numId
2490
+ });
2491
+ }
2492
+ return {
2493
+ abstractNums,
2494
+ nums
2495
+ };
2496
+ };
2497
+ /**
2498
+ * Parse GFM markdown into document blocks plus the numbering its lists need.
2499
+ * Synchronous. The caller places the blocks into a `Document` of its own
2500
+ * (page geometry, styles, and presets are the host's decision).
2501
+ */
2502
+ const compileMarkdownToContent = (markdown) => {
2503
+ const numIds = {
2504
+ next: 1,
2505
+ levels: /* @__PURE__ */ new Map()
2506
+ };
2507
+ return {
2508
+ content: blocksFromTokens(lexMarkdown(markdown), numIds),
2509
+ ...numIds.levels.size > 0 && { numbering: buildNumbering(numIds.levels) }
2510
+ };
2511
+ };
2512
+ //#endregion
2513
+ export { DOCX_CONFORMANCE_CLASSES, DOCX_PACKAGE_ISSUE_CODES, assertValidDocumentModel, compileLegalSourceToDocument, compileLegalSourceToDocx, compileMarkdownToContent, parseLegalSource, sanitizeExternalUrl, serializeDocumentToDocx, validateDocumentModel, validateDocxPackage, validateLegalDraft };
@@ -1,2 +1,2 @@
1
- import { $ as ImagePosition, $t as TextBox, A as ComplexField, An as TableFormatting, At as SdtType, B as FieldCharContent, Bn as KnownBorderStyle, Bt as ShapeTextBody, C as BookmarkStart, Cn as SpacingExplicit, Ct as PictureWatermark, D as CommentRangeEnd, Dn as TableBorders, Dt as RunContent, E as Comment, En as TabStopAlignment, Et as Run, F as DrawingRawXmlMode, Fn as TextEffect, Ft as Shape, G as FootnoteProperties, Gt as TabContent, H as FooterReference, Hn as ThemeColorSlot, Ht as SimpleField, I as Endnote, In as TextFormatting, It as ShapeContent, J as HeaderReference, Jt as TableCellPropertyChange, K as HeaderFooter, Kt as Table, L as EndnotePosition, Ln as UnderlineStyle, Lt as ShapeFill, M as Deletion, Mn as TableMeasurement, Mt as SectionProperties, N as DocumentBody, Nn as TableRowFormatting, Nt as SectionPropertyChange, O as CommentRangeStart, On as TableCellBorders, Ot as RunPropertyChange, P as DrawingContent, Pn as TableWidthType, Pt as SectionStart, Q as ImagePadding, Qt as TableStructuralChangeInfo, R as EndnoteProperties, Rn as BorderSpec, Rt as ShapeGeometryAdjustment, S as BookmarkEnd, Sn as ParagraphFormatting, St as ParagraphPropertyChange, T as Column, Tn as TabStop, Tt as PropertyChangeInfo, U as Footnote, Ut as SoftHyphenContent, V as FieldType, Vn as ShadingProperties, Vt as ShapeType, W as FootnotePosition, Wt as SymbolContent, X as Image, Xt as TableRow, Y as Hyperlink, Yt as TablePropertyChange, Z as ImageCrop, Zt as TableRowPropertyChange, _ as ThemeColorScheme, _n as ConditionalFormatStyle, _t as NoteReferenceContent, a as DocxPackage, an as Watermark, at as InstrTextContent, b as BlockContent, bn as LineSpacingRule, bt as ParagraphContent, c as FontTable, cn as AbstractNumbering, ct as MathEquation, d as RelationshipMap, dn as ListMarkerFormatting, dt as MoveFromRangeStart, en as TextContent, et as ImageSize, f as RelationshipType, fn as ListRendering, ft as MoveTo, g as Theme, gn as CellMargins, gt as NoteNumberRestart, h as StyleType, hn as NumberingInstance, ht as NoBreakHyphenContent, i as DocxConformanceClass, in as VerticalAlign, it as Insertion, j as DRAWING_RAW_XML_MODES, jn as TableLook, jt as Section, k as CommentReference, kn as TableCellFormatting, kt as SdtProperties, l as MediaFile, ln as LevelSuffix, lt as MoveFrom, m as StyleDefinitions, mn as NumberingDefinitions, mt as MoveToRangeStart, n as Document, nn as TrackedChangeInfo, nt as ImageWrap, o as DocDefaults, on as isOoxmlSymbolCharacter, ot as LineNumberRestart, p as Style, pn as NumberFormat, pt as MoveToRangeEnd, q as HeaderFooterType, qt as TableCell, r as DocumentSettings, rn as TrackedRunChange, rt as InlineSdt, s as FontInfo, sn as normalizeRevisionId, st as MAX_REVISION_ID, t as DOCX_CONFORMANCE_CLASSES, tn as TextWatermark, tt as ImageTransform, u as Relationship, un as ListLevel, ut as MoveFromRangeEnd, v as ThemeFont, vn as EmphasisMark, vt as PageOrientation, w as BreakContent, wn as TabLeader, wt as PositionalTab, x as BlockSdt, xn as ParagraphAlignment, xt as ParagraphMarkChange, y as ThemeFontScheme, yn as FloatingTableProperties, yt as Paragraph, z as Field, zn as ColorValue, zt as ShapeOutline } from "../document-P4LhYoK3.js";
1
+ import { $ as ImagePosition, $t as TextBox, A as ComplexField, An as TableFormatting, At as SdtType, B as FieldCharContent, Bn as KnownBorderStyle, Bt as ShapeTextBody, C as BookmarkStart, Cn as SpacingExplicit, Ct as PictureWatermark, D as CommentRangeEnd, Dn as TableBorders, Dt as RunContent, E as Comment, En as TabStopAlignment, Et as Run, F as DrawingRawXmlMode, Fn as TextEffect, Ft as Shape, G as FootnoteProperties, Gt as TabContent, H as FooterReference, Hn as ThemeColorSlot, Ht as SimpleField, I as Endnote, In as TextFormatting, It as ShapeContent, J as HeaderReference, Jt as TableCellPropertyChange, K as HeaderFooter, Kt as Table, L as EndnotePosition, Ln as UnderlineStyle, Lt as ShapeFill, M as Deletion, Mn as TableMeasurement, Mt as SectionProperties, N as DocumentBody, Nn as TableRowFormatting, Nt as SectionPropertyChange, O as CommentRangeStart, On as TableCellBorders, Ot as RunPropertyChange, P as DrawingContent, Pn as TableWidthType, Pt as SectionStart, Q as ImagePadding, Qt as TableStructuralChangeInfo, R as EndnoteProperties, Rn as BorderSpec, Rt as ShapeGeometryAdjustment, S as BookmarkEnd, Sn as ParagraphFormatting, St as ParagraphPropertyChange, T as Column, Tn as TabStop, Tt as PropertyChangeInfo, U as Footnote, Ut as SoftHyphenContent, V as FieldType, Vn as ShadingProperties, Vt as ShapeType, W as FootnotePosition, Wt as SymbolContent, X as Image, Xt as TableRow, Y as Hyperlink, Yt as TablePropertyChange, Z as ImageCrop, Zt as TableRowPropertyChange, _ as ThemeColorScheme, _n as ConditionalFormatStyle, _t as NoteReferenceContent, a as DocxPackage, an as Watermark, at as InstrTextContent, b as BlockContent, bn as LineSpacingRule, bt as ParagraphContent, c as FontTable, cn as AbstractNumbering, ct as MathEquation, d as RelationshipMap, dn as ListMarkerFormatting, dt as MoveFromRangeStart, en as TextContent, et as ImageSize, f as RelationshipType, fn as ListRendering, ft as MoveTo, g as Theme, gn as CellMargins, gt as NoteNumberRestart, h as StyleType, hn as NumberingInstance, ht as NoBreakHyphenContent, i as DocxConformanceClass, in as VerticalAlign, it as Insertion, j as DRAWING_RAW_XML_MODES, jn as TableLook, jt as Section, k as CommentReference, kn as TableCellFormatting, kt as SdtProperties, l as MediaFile, ln as LevelSuffix, lt as MoveFrom, m as StyleDefinitions, mn as NumberingDefinitions, mt as MoveToRangeStart, n as Document, nn as TrackedChangeInfo, nt as ImageWrap, o as DocDefaults, on as isOoxmlSymbolCharacter, ot as LineNumberRestart, p as Style, pn as NumberFormat, pt as MoveToRangeEnd, q as HeaderFooterType, qt as TableCell, r as DocumentSettings, rn as TrackedRunChange, rt as InlineSdt, s as FontInfo, sn as normalizeRevisionId, st as MAX_REVISION_ID, t as DOCX_CONFORMANCE_CLASSES, tn as TextWatermark, tt as ImageTransform, u as Relationship, un as ListLevel, ut as MoveFromRangeEnd, v as ThemeFont, vn as EmphasisMark, vt as PageOrientation, w as BreakContent, wn as TabLeader, wt as PositionalTab, x as BlockSdt, xn as ParagraphAlignment, xt as ParagraphMarkChange, y as ThemeFontScheme, yn as FloatingTableProperties, yt as Paragraph, z as Field, zn as ColorValue, zt as ShapeOutline } from "../document-C4ms2O35.js";
2
2
  export { type AbstractNumbering, type BlockContent, type BlockSdt, type BookmarkEnd, type BookmarkStart, type BorderSpec, type BreakContent, type CellMargins, type ColorValue, type Column, type Comment, type CommentRangeEnd, type CommentRangeStart, type CommentReference, type ComplexField, type ConditionalFormatStyle, DOCX_CONFORMANCE_CLASSES, DRAWING_RAW_XML_MODES, type Deletion, type DocDefaults, Document, type DocumentBody, DocumentSettings, DocxConformanceClass, DocxPackage, type DrawingContent, type DrawingRawXmlMode, type EmphasisMark, type Endnote, type EndnotePosition, type EndnoteProperties, type Field, type FieldCharContent, type FieldType, type FloatingTableProperties, type FontInfo, type FontTable, type FooterReference, type Footnote, type FootnotePosition, type FootnoteProperties, type HeaderFooter, type HeaderFooterType, type HeaderReference, type Hyperlink, type Image, type ImageCrop, type ImagePadding, type ImagePosition, type ImageSize, type ImageTransform, type ImageWrap, type InlineSdt, type Insertion, type InstrTextContent, type KnownBorderStyle, type LevelSuffix, type LineNumberRestart, type LineSpacingRule, type ListLevel, type ListMarkerFormatting, type ListRendering, MAX_REVISION_ID, type MathEquation, type MediaFile, type MoveFrom, type MoveFromRangeEnd, type MoveFromRangeStart, type MoveTo, type MoveToRangeEnd, type MoveToRangeStart, type NoBreakHyphenContent, type NoteNumberRestart, type NoteReferenceContent, type NumberFormat, type NumberingDefinitions, type NumberingInstance, type PageOrientation, type Paragraph, type ParagraphAlignment, type ParagraphContent, type ParagraphFormatting, type ParagraphMarkChange, type ParagraphPropertyChange, type PictureWatermark, type PositionalTab, type PropertyChangeInfo, type Relationship, type RelationshipMap, type RelationshipType, type Run, type RunContent, type RunPropertyChange, type SdtProperties, type SdtType, type Section, type SectionProperties, type SectionPropertyChange, type SectionStart, type ShadingProperties, type Shape, type ShapeContent, type ShapeFill, type ShapeGeometryAdjustment, type ShapeOutline, type ShapeTextBody, type ShapeType, type SimpleField, type SoftHyphenContent, type SpacingExplicit, type Style, type StyleDefinitions, type StyleType, type SymbolContent, type TabContent, type TabLeader, type TabStop, type TabStopAlignment, type Table, type TableBorders, type TableCell, type TableCellBorders, type TableCellFormatting, type TableCellPropertyChange, type TableFormatting, type TableLook, type TableMeasurement, type TablePropertyChange, type TableRow, type TableRowFormatting, type TableRowPropertyChange, type TableStructuralChangeInfo, type TableWidthType, type TextBox, type TextContent, type TextEffect, type TextFormatting, type TextWatermark, type Theme, type ThemeColorScheme, type ThemeColorSlot, type ThemeFont, type ThemeFontScheme, type TrackedChangeInfo, type TrackedRunChange, type UnderlineStyle, type VerticalAlign, type Watermark, isOoxmlSymbolCharacter, normalizeRevisionId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/docx-core",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Typed OOXML/DOCX model, validation, serialization, legal-source compilation, and browser-native package projection.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -62,7 +62,8 @@
62
62
  "dependencies": {
63
63
  "better-result": "3.0.1",
64
64
  "fast-xml-parser": "^5.10.1",
65
- "jszip": "3.10.1"
65
+ "jszip": "3.10.1",
66
+ "marked": "^18.0.5"
66
67
  },
67
68
  "devDependencies": {
68
69
  "bun-types": "1.4.0",