@ansonlai/docx-redline-js 0.1.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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Table Ingestion
3
+ *
4
+ * Builds virtual-grid table models with merged-cell awareness.
5
+ */
6
+
7
+ import { NS_W } from '../core/types.js';
8
+ import { serializeXml } from '../adapters/xml-adapter.js';
9
+ import {
10
+ getElementsByTagNS,
11
+ getElementsByTagNSOrTag,
12
+ getFirstElementByTag,
13
+ getFirstElementByTagNS
14
+ } from '../core/xml-query.js';
15
+ import { ingestParagraphElement } from './ingestion-paragraph.js';
16
+
17
+ /**
18
+ * Ingests a table into a virtual grid model to handle merged cells.
19
+ *
20
+ * @param {Element} tableNode - w:tbl element
21
+ * @returns {Object}
22
+ */
23
+ export function ingestTableToVirtualGrid(tableNode) {
24
+ const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
25
+ const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
26
+ const colCount = gridCols.length;
27
+
28
+ const trElements = getElementsByTagNS(tableNode, NS_W, 'tr');
29
+ const rowCount = trElements.length;
30
+
31
+ const grid = Array.from({ length: rowCount }, () =>
32
+ Array.from({ length: colCount }, () => null)
33
+ );
34
+ const cellMap = new Map();
35
+ const vMergeOrigins = new Map();
36
+
37
+ for (let rowIdx = 0; rowIdx < trElements.length; rowIdx++) {
38
+ const tr = trElements[rowIdx];
39
+ const tcElements = getElementsByTagNS(tr, NS_W, 'tc');
40
+ let gridCol = 0;
41
+
42
+ for (let tcIdx = 0; tcIdx < tcElements.length; tcIdx++) {
43
+ const tc = tcElements[tcIdx];
44
+ const tcPr = getFirstElementByTagNS(tc, NS_W, 'tcPr');
45
+
46
+ while (gridCol < colCount && grid[rowIdx][gridCol] !== null) {
47
+ gridCol++;
48
+ }
49
+ if (gridCol >= colCount) break;
50
+
51
+ const gridSpanEl = tcPr ? (getFirstElementByTagNS(tcPr, NS_W, 'gridSpan') || getFirstElementByTag(tcPr, 'w:gridSpan')) : null;
52
+ const colSpan = parseInt(gridSpanEl?.getAttribute('w:val') || '1', 10);
53
+
54
+ const vMergeEl = tcPr ? (getFirstElementByTagNS(tcPr, NS_W, 'vMerge') || getFirstElementByTag(tcPr, 'w:vMerge')) : null;
55
+ const vMergeVal = vMergeEl?.getAttribute('w:val');
56
+ const hasVMerge = vMergeEl !== null;
57
+
58
+ let cell;
59
+ if (hasVMerge && vMergeVal !== 'restart') {
60
+ const origin = vMergeOrigins.get(gridCol);
61
+ if (origin) {
62
+ origin.cell.rowSpan++;
63
+ cell = {
64
+ gridRow: rowIdx,
65
+ gridCol,
66
+ rowSpan: 0,
67
+ colSpan,
68
+ tcNode: tc,
69
+ blocks: [],
70
+ tcPrXml: serializeTcPr(tcPr),
71
+ isMergeOrigin: false,
72
+ isMergeContinuation: true,
73
+ mergeOrigin: origin.cell
74
+ };
75
+ } else {
76
+ cell = createRegularCell(rowIdx, gridCol, colSpan, tc, tcPr);
77
+ }
78
+ } else {
79
+ const blocks = parseCellBlocks(tc);
80
+ cell = {
81
+ gridRow: rowIdx,
82
+ gridCol,
83
+ rowSpan: 1,
84
+ colSpan,
85
+ tcNode: tc,
86
+ blocks,
87
+ tcPrXml: serializeTcPr(tcPr),
88
+ isMergeOrigin: hasVMerge && vMergeVal === 'restart',
89
+ isMergeContinuation: false,
90
+ getText: () => blocks.map(block => block.acceptedText).join('\n')
91
+ };
92
+
93
+ if (hasVMerge && vMergeVal === 'restart') {
94
+ for (let span = 0; span < colSpan; span++) {
95
+ vMergeOrigins.set(gridCol + span, { originRow: rowIdx, cell });
96
+ }
97
+ } else {
98
+ for (let span = 0; span < colSpan; span++) {
99
+ vMergeOrigins.delete(gridCol + span);
100
+ }
101
+ }
102
+ }
103
+
104
+ for (let spanOffset = 0; spanOffset < colSpan; spanOffset++) {
105
+ const targetCol = gridCol + spanOffset;
106
+ if (targetCol < colCount) {
107
+ grid[rowIdx][targetCol] = cell;
108
+ cellMap.set(`${rowIdx},${targetCol}`, cell);
109
+ }
110
+ }
111
+
112
+ gridCol += colSpan;
113
+ }
114
+ }
115
+
116
+ return {
117
+ rowCount,
118
+ colCount,
119
+ grid,
120
+ cellMap,
121
+ tblPrXml: extractTblPr(tableNode),
122
+ tblGridXml: extractTblGrid(tableNode),
123
+ trPrList: Array.from(trElements).map(tr => extractTrPr(tr))
124
+ };
125
+ }
126
+
127
+ function createRegularCell(rowIdx, gridCol, colSpan, tc, tcPr) {
128
+ const blocks = parseCellBlocks(tc);
129
+ return {
130
+ gridRow: rowIdx,
131
+ gridCol,
132
+ rowSpan: 1,
133
+ colSpan,
134
+ tcNode: tc,
135
+ blocks,
136
+ tcPrXml: serializeTcPr(tcPr),
137
+ isMergeOrigin: false,
138
+ isMergeContinuation: false,
139
+ getText: () => blocks.map(block => block.acceptedText).join('\n')
140
+ };
141
+ }
142
+
143
+ function parseCellBlocks(tcNode) {
144
+ const paragraphs = getElementsByTagNSOrTag(tcNode, NS_W, 'p');
145
+ return paragraphs.map(paragraph => {
146
+ const { runModel, acceptedText, pPr } = ingestParagraphElement(paragraph);
147
+ return { runModel, acceptedText, pPr };
148
+ });
149
+ }
150
+
151
+ function serializeTcPr(tcPrNode) {
152
+ if (!tcPrNode) return '<w:tcPr/>';
153
+ return serializeXml(tcPrNode);
154
+ }
155
+
156
+ function extractTblPr(tableNode) {
157
+ const tblPr = getFirstElementByTagNS(tableNode, NS_W, 'tblPr');
158
+ return tblPr ? serializeXml(tblPr) : '<w:tblPr/>';
159
+ }
160
+
161
+ function extractTblGrid(tableNode) {
162
+ const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
163
+ return tblGrid ? serializeXml(tblGrid) : '<w:tblGrid/>';
164
+ }
165
+
166
+ function extractTrPr(trNode) {
167
+ const trPr = getFirstElementByTagNS(trNode, NS_W, 'trPr');
168
+ return trPr ? serializeXml(trPr) : '<w:trPr/>';
169
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared XML helpers for ingestion flows.
3
+ */
4
+
5
+ /**
6
+ * Returns all children as an array.
7
+ *
8
+ * @param {Node} node - Parent node
9
+ * @returns {Node[]}
10
+ */
11
+ export function childNodesToArray(node) {
12
+ return Array.from(node?.childNodes || []);
13
+ }
14
+
15
+ /**
16
+ * Serializes all attributes into a plain string.
17
+ *
18
+ * @param {Element} element - Element with attributes
19
+ * @returns {string}
20
+ */
21
+ export function serializeAttributes(element) {
22
+ return Array.from(element.attributes)
23
+ .map(attr => `${attr.name}="${attr.value}"`)
24
+ .join(' ');
25
+ }
26
+
27
+ /**
28
+ * Returns true when a node belongs to the target namespace and optional local name.
29
+ *
30
+ * @param {Node} node - Candidate node
31
+ * @param {string} namespaceUri - Namespace URI
32
+ * @param {string} [localName] - Optional local name
33
+ * @returns {boolean}
34
+ */
35
+ export function isNamespacedNode(node, namespaceUri, localName = '') {
36
+ if (!node || node.namespaceURI !== namespaceUri) return false;
37
+ if (!localName) return true;
38
+ return node.localName === localName;
39
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Ingestion Facade
3
+ *
4
+ * Backward-compatible exports for paragraph and table ingestion.
5
+ */
6
+
7
+ export { ingestOoxml, ingestParagraphElement, detectNumberingContext } from './ingestion-paragraph.js';
8
+ export { ingestTableToVirtualGrid } from './ingestion-table.js';
@@ -0,0 +1,280 @@
1
+ /**
2
+ * List generation flow extracted from ReconciliationPipeline.
3
+ */
4
+
5
+ import { preprocessMarkdown } from './markdown-processor.js';
6
+ import { matchListMarker, stripListMarker } from './list-markers.js';
7
+ import { serializeToOoxml } from './serialization.js';
8
+ import { generateTableOoxml } from '../services/table-reconciliation.js';
9
+ import { parseTable } from './content-analysis.js';
10
+ import { log } from '../adapters/logger.js';
11
+
12
+ /**
13
+ * Executes list generation when paragraph content expands into list/table blocks.
14
+ *
15
+ * @param {Object} options - Generation options
16
+ * @param {string} options.cleanText - Preprocessed target text
17
+ * @param {Object|null} options.numberingContext - Existing numbering context
18
+ * @param {Array} [options.originalRunModel=[]] - Original run model
19
+ * @param {string} [options.originalText=''] - Original text fallback
20
+ * @param {boolean} [options.generateRedlines=true] - Track-change toggle
21
+ * @param {string} [options.author='AI'] - Author metadata
22
+ * @param {string|null} [options.font=null] - Optional font
23
+ * @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
24
+ * @returns {Promise<import('../core/types.js').ReconciliationResult>}
25
+ */
26
+ export async function executeListGeneration(options) {
27
+ const {
28
+ cleanText,
29
+ numberingContext,
30
+ originalRunModel = [],
31
+ originalText = '',
32
+ generateRedlines = true,
33
+ author = 'AI',
34
+ font = null,
35
+ numberingService
36
+ } = options;
37
+
38
+ const normalizedListText = normalizeCompositeListMarkers(cleanText);
39
+ const lineMetadata = buildLineMetadata(normalizedListText);
40
+ const rawLines = lineMetadata.map(line => line.raw);
41
+ const results = [];
42
+
43
+ let deletionRuns = [];
44
+ if (generateRedlines) {
45
+ if (originalRunModel && originalRunModel.length > 0) {
46
+ deletionRuns = originalRunModel
47
+ .filter(run => run.kind === 'text' || run.kind === 'run')
48
+ .map(run => ({ ...run, kind: 'deletion', author }));
49
+ } else if (originalText && originalText.trim().length > 0) {
50
+ const trimmed = originalText.trim();
51
+ deletionRuns = [{
52
+ kind: 'deletion',
53
+ text: trimmed,
54
+ author,
55
+ startOffset: 0,
56
+ endOffset: trimmed.length
57
+ }];
58
+ }
59
+ }
60
+
61
+ const indentStep = detectIndentationStep(rawLines);
62
+ log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
63
+
64
+ const firstMarker = lineMetadata.find(line => line.marker)?.marker || '';
65
+ const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
66
+ log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
67
+
68
+ for (let i = 0; i < lineMetadata.length; i++) {
69
+ const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
70
+ if (tableBlock) {
71
+ const tableData = parseTable(tableBlock.tableText);
72
+ if (tableData.headers.length > 0 || tableData.rows.length > 0) {
73
+ if (generateRedlines && results.length === 0 && deletionRuns.length > 0) {
74
+ results.push(serializeToOoxml(deletionRuns, null, [], {
75
+ author,
76
+ generateRedlines,
77
+ font
78
+ }));
79
+ }
80
+
81
+ results.push(generateTableOoxml(tableData, { generateRedlines, author }));
82
+ i = tableBlock.endIndex;
83
+ continue;
84
+ }
85
+ }
86
+
87
+ const line = lineMetadata[i];
88
+ const entry = buildListEntry(
89
+ line,
90
+ i,
91
+ indentStep,
92
+ numberingContext,
93
+ numberingService,
94
+ generateRedlines,
95
+ author,
96
+ font,
97
+ deletionRuns
98
+ );
99
+ results.push(entry.ooxml);
100
+ }
101
+
102
+ const numberingXml = numberingService.generateNumberingXml();
103
+ const finalOoxml = results.join('');
104
+ const blankParagraph = '<w:p><w:pPr></w:pPr></w:p>';
105
+ const oxmlWithSpacing = finalOoxml + blankParagraph;
106
+
107
+ log(`[ListGen] ✅ Generated OOXML for ${results.length} list items, total length: ${oxmlWithSpacing.length}`);
108
+ log(`[ListGen] First 200 chars: ${oxmlWithSpacing.substring(0, 200)}...`);
109
+
110
+ return {
111
+ ooxml: oxmlWithSpacing,
112
+ isValid: true,
113
+ warnings: ['Paragraph expanded to list fragment'],
114
+ type: 'fragment',
115
+ includeNumbering: true,
116
+ numberingXml
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Heuristically detects indentation step (spaces/tabs per level).
122
+ *
123
+ * @param {string[]} lines - Raw lines
124
+ * @returns {number}
125
+ */
126
+ export function detectIndentationStep(lines) {
127
+ const indentations = lines
128
+ .map(line => line.match(/^(\s*)/)[0].length)
129
+ .filter(length => length > 0)
130
+ .sort((a, b) => a - b);
131
+
132
+ if (indentations.length === 0) return 2;
133
+
134
+ let minJump = indentations[0];
135
+ for (let i = 1; i < indentations.length; i++) {
136
+ const jump = indentations[i] - indentations[i - 1];
137
+ if (jump > 0 && jump < minJump) {
138
+ minJump = jump;
139
+ }
140
+ }
141
+
142
+ return minJump || 2;
143
+ }
144
+
145
+ function buildLineMetadata(cleanText) {
146
+ return cleanText
147
+ .split('\n')
148
+ .filter(line => line.trim().length > 0)
149
+ .map(raw => {
150
+ const markerMatch = matchListMarker(raw);
151
+ const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
152
+ return {
153
+ raw,
154
+ marker: markerMatch ? markerMatch[2].trim() : '',
155
+ headerMatch,
156
+ indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
157
+ isTableLine: /^\s*\|/.test(raw),
158
+ isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
159
+ };
160
+ });
161
+ }
162
+
163
+ function normalizeCompositeListMarkers(text) {
164
+ const lines = String(text || '').split('\n');
165
+ const nonEmptyIndexes = lines
166
+ .map((line, index) => ({ line, index }))
167
+ .filter(entry => entry.line.trim().length > 0);
168
+ if (nonEmptyIndexes.length < 2) return text;
169
+
170
+ const rewrites = [];
171
+ let rewrittenCount = 0;
172
+
173
+ for (const { line, index } of nonEmptyIndexes) {
174
+ const outerMarkerMatch = matchListMarker(line);
175
+ if (!outerMarkerMatch) return text;
176
+
177
+ const stripped = stripListMarker(line);
178
+ const innerMarkerMatch = matchListMarker(stripped);
179
+ if (!innerMarkerMatch) return text;
180
+
181
+ const outerMarker = (outerMarkerMatch[2] || '').trim();
182
+ const innerMarker = (innerMarkerMatch[2] || '').trim();
183
+ if (!outerMarker || !innerMarker || outerMarker === innerMarker) return text;
184
+
185
+ const indent = outerMarkerMatch[1] || '';
186
+ const rewritten = `${indent}${stripped.trimStart()}`;
187
+ rewrites.push({ index, rewritten });
188
+ rewrittenCount++;
189
+ }
190
+
191
+ if (rewrittenCount < 2) return text;
192
+
193
+ const updated = lines.slice();
194
+ for (const rewrite of rewrites) {
195
+ updated[rewrite.index] = rewrite.rewritten;
196
+ }
197
+ log(`[ListGen] Normalized ${rewrittenCount} composite list markers (e.g., "- A." -> "A.").`);
198
+ return updated.join('\n');
199
+ }
200
+
201
+ function collectMarkdownTableBlock(lineMetadata, index) {
202
+ const current = lineMetadata[index];
203
+ const next = lineMetadata[index + 1];
204
+
205
+ if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
206
+ return null;
207
+ }
208
+
209
+ const tableLines = [];
210
+ let cursor = index;
211
+ while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
212
+ tableLines.push(lineMetadata[cursor].raw);
213
+ cursor++;
214
+ }
215
+
216
+ return {
217
+ tableText: tableLines.join('\n'),
218
+ endIndex: cursor - 1
219
+ };
220
+ }
221
+
222
+ function buildListEntry(
223
+ line,
224
+ lineIndex,
225
+ indentStep,
226
+ numberingContext,
227
+ numberingService,
228
+ generateRedlines,
229
+ author,
230
+ font,
231
+ deletionRuns
232
+ ) {
233
+ let pPrXml = '';
234
+ let segmentText = '';
235
+
236
+ if (line.headerMatch) {
237
+ const level = Math.min(line.headerMatch[1].length, 9);
238
+ const outlineLevel = Math.min(level - 1, 8);
239
+ const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
240
+ const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
241
+ segmentText = line.headerMatch[2].trim();
242
+ pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
243
+ } else if (line.marker) {
244
+ const lineFormat = numberingService.detectNumberingFormat(line.marker);
245
+ const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
246
+ const contextLevel = numberingContext?.ilvl || 0;
247
+ const ilvl = lineFormat.format === 'outline'
248
+ ? Math.min(8, lineFormat.depth)
249
+ : Math.min(8, indentLevel + contextLevel);
250
+
251
+ segmentText = stripListMarker(line.raw);
252
+ const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
253
+ pPrXml = numberingService.buildListPPr(numId, ilvl);
254
+ } else {
255
+ segmentText = line.raw;
256
+ }
257
+
258
+ const { cleanText, formatHints } = preprocessMarkdown(segmentText);
259
+ const runModel = [];
260
+
261
+ if (lineIndex === 0 && deletionRuns.length > 0) {
262
+ runModel.push(...deletionRuns);
263
+ }
264
+
265
+ runModel.push({
266
+ kind: generateRedlines ? 'insertion' : 'run',
267
+ text: cleanText,
268
+ author,
269
+ startOffset: 0,
270
+ endOffset: cleanText.length
271
+ });
272
+
273
+ return {
274
+ ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
275
+ author,
276
+ generateRedlines,
277
+ font
278
+ })
279
+ };
280
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared list-marker detection/parsing helpers.
3
+ *
4
+ * Keeps marker parsing consistent across router, pipeline, and patching flows.
5
+ */
6
+
7
+ const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*\u2022])`;
8
+
9
+ const LINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`);
10
+ const LINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`);
11
+ const MULTILINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`, 'm');
12
+ const MULTILINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`, 'm');
13
+
14
+ /**
15
+ * Determines whether text should be treated as list-target content.
16
+ * Strict mode requires at least one whitespace after the marker.
17
+ *
18
+ * @param {string} text - Candidate text
19
+ * @returns {boolean}
20
+ */
21
+ export function isListTargetStrict(text) {
22
+ if (typeof text !== 'string') return false;
23
+ return text.includes('\n') && MULTILINE_REGEX_STRICT.test(text);
24
+ }
25
+
26
+ /**
27
+ * Determines whether text should be treated as list-target content.
28
+ * Loose mode allows markers with optional trailing whitespace.
29
+ *
30
+ * @param {string} text - Candidate text
31
+ * @returns {boolean}
32
+ */
33
+ export function isListTargetLoose(text) {
34
+ if (typeof text !== 'string') return false;
35
+ return text.includes('\n') && MULTILINE_REGEX_LOOSE.test(text.trim());
36
+ }
37
+
38
+ /**
39
+ * Matches a list marker at the start of a line.
40
+ *
41
+ * @param {string} line - Input line
42
+ * @param {Object} [options={}] - Match options
43
+ * @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
44
+ * @returns {RegExpMatchArray|null}
45
+ */
46
+ export function matchListMarker(line, options = {}) {
47
+ const { allowZeroSpaceAfterMarker = false } = options;
48
+ const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
49
+ return line.match(regex);
50
+ }
51
+
52
+ /**
53
+ * Extracts the marker text from a line.
54
+ *
55
+ * @param {string} line - Input line
56
+ * @param {Object} [options={}] - Match options
57
+ * @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
58
+ * @returns {string}
59
+ */
60
+ export function extractListMarker(line, options = {}) {
61
+ const match = matchListMarker(line, options);
62
+ return match ? match[2].trim() : '';
63
+ }
64
+
65
+ /**
66
+ * Strips the marker (and its immediate trailing spacing) from a line.
67
+ *
68
+ * @param {string} line - Input line
69
+ * @param {Object} [options={}] - Strip options
70
+ * @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
71
+ * @returns {string}
72
+ */
73
+ export function stripListMarker(line, options = {}) {
74
+ const { allowZeroSpaceAfterMarker = false } = options;
75
+ const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
76
+ return line.replace(regex, '');
77
+ }