@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/README.md +5 -3
- package/dist/{document-C4ms2O35.d.ts → document-CR8P25Qh.d.ts} +26 -16
- package/dist/{document-BPDUWV3O.js → document-Cj4uqQEw.js} +22 -1
- package/dist/docx_kernel_bg.wasm +0 -0
- package/dist/index.d.ts +31 -2
- package/dist/index.js +766 -312
- package/dist/model/document.d.ts +2 -2
- package/dist/model/document.js +2 -2
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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())
|
|
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
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const line =
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1029
|
-
heading: "",
|
|
1030
|
-
lines: []
|
|
1298
|
+
paragraphs
|
|
1031
1299
|
};
|
|
1032
|
-
pending.lines.push(line);
|
|
1033
1300
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
|
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
|
|
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
|
|
1384
|
-
const trimmed =
|
|
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 = (
|
|
1418
|
-
const trimmed =
|
|
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 = (
|
|
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:
|
|
1940
|
+
content: inlineMarkdownToRuns(text, {
|
|
1941
|
+
base: runOptions,
|
|
1942
|
+
placeholders: true
|
|
1943
|
+
})
|
|
1620
1944
|
});
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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 = () =>
|
|
1968
|
+
const empty = () => plainParagraph("", "SignatureSpacer");
|
|
1668
1969
|
const buildCell = (party) => {
|
|
1669
1970
|
const cellContent = [
|
|
1670
|
-
|
|
1971
|
+
plainParagraph(party.name, "SignatureParty", { bold: true }),
|
|
1671
1972
|
empty(),
|
|
1672
1973
|
empty(),
|
|
1673
|
-
|
|
1974
|
+
plainParagraph(SIGNATURE_LINE, "SignatureRule")
|
|
1674
1975
|
];
|
|
1675
|
-
if (party.signatory) cellContent.push(
|
|
1676
|
-
if (party.title) cellContent.push(
|
|
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
|
-
|
|
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 };
|