flex-md 1.1.0 → 3.0.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 +75 -29
- package/SPEC.md +559 -0
- package/dist/__tests__/validate.test.d.ts +1 -0
- package/dist/__tests__/validate.test.js +108 -0
- package/dist/detect/json/detectIntent.d.ts +2 -0
- package/dist/detect/json/detectIntent.js +79 -0
- package/dist/detect/json/detectPresence.d.ts +6 -0
- package/dist/detect/json/detectPresence.js +191 -0
- package/dist/detect/json/index.d.ts +7 -0
- package/dist/detect/json/index.js +12 -0
- package/dist/detect/json/types.d.ts +43 -0
- package/dist/detect/json/types.js +1 -0
- package/dist/detection/detector.d.ts +6 -0
- package/dist/detection/detector.js +104 -0
- package/dist/detection/extractor.d.ts +10 -0
- package/dist/detection/extractor.js +54 -0
- package/dist/extract/extract.d.ts +5 -0
- package/dist/extract/extract.js +50 -0
- package/dist/extract/types.d.ts +11 -0
- package/dist/extract/types.js +1 -0
- package/dist/index.d.ts +13 -3
- package/dist/index.js +20 -3
- package/dist/issues/build.d.ts +26 -0
- package/dist/issues/build.js +62 -0
- package/dist/md/lists.d.ts +14 -0
- package/dist/md/lists.js +33 -0
- package/dist/md/match.d.ts +12 -0
- package/dist/md/match.js +44 -0
- package/dist/md/outline.d.ts +6 -0
- package/dist/md/outline.js +67 -0
- package/dist/md/parse.d.ts +29 -0
- package/dist/md/parse.js +105 -0
- package/dist/md/tables.d.ts +25 -0
- package/dist/md/tables.js +72 -0
- package/dist/ofs/enricher.d.ts +16 -0
- package/dist/ofs/enricher.js +77 -0
- package/dist/ofs/extractor.d.ts +9 -0
- package/dist/ofs/extractor.js +75 -0
- package/dist/ofs/issues.d.ts +14 -0
- package/dist/ofs/issues.js +92 -0
- package/dist/ofs/issuesEnvelope.d.ts +15 -0
- package/dist/ofs/issuesEnvelope.js +71 -0
- package/dist/ofs/parser.d.ts +9 -0
- package/dist/ofs/parser.js +133 -0
- package/dist/ofs/stringify.d.ts +5 -0
- package/dist/ofs/stringify.js +32 -0
- package/dist/ofs/validator.d.ts +10 -0
- package/dist/ofs/validator.js +91 -0
- package/dist/outline/builder.d.ts +10 -0
- package/dist/outline/builder.js +85 -0
- package/dist/outline/renderer.d.ts +6 -0
- package/dist/outline/renderer.js +23 -0
- package/dist/parser.js +58 -10
- package/dist/parsers/lists.d.ts +6 -0
- package/dist/parsers/lists.js +36 -0
- package/dist/parsers/tables.d.ts +10 -0
- package/dist/parsers/tables.js +58 -0
- package/dist/pipeline/enforce.d.ts +10 -0
- package/dist/pipeline/enforce.js +46 -0
- package/dist/pipeline/kind.d.ts +16 -0
- package/dist/pipeline/kind.js +24 -0
- package/dist/pipeline/repair.d.ts +14 -0
- package/dist/pipeline/repair.js +112 -0
- package/dist/strictness/container.d.ts +14 -0
- package/dist/strictness/container.js +46 -0
- package/dist/strictness/processor.d.ts +5 -0
- package/dist/strictness/processor.js +29 -0
- package/dist/strictness/types.d.ts +77 -0
- package/dist/strictness/types.js +106 -0
- package/dist/test-pipeline.d.ts +1 -0
- package/dist/test-pipeline.js +53 -0
- package/dist/test-runner.d.ts +1 -0
- package/dist/test-runner.js +331 -0
- package/dist/test-strictness.d.ts +1 -0
- package/dist/test-strictness.js +213 -0
- package/dist/types.d.ts +140 -22
- package/dist/validate/policy.d.ts +10 -0
- package/dist/validate/policy.js +17 -0
- package/dist/validate/types.d.ts +11 -0
- package/dist/validate/types.js +1 -0
- package/dist/validate/validate.d.ts +2 -0
- package/dist/validate/validate.js +308 -0
- package/docs/mdflex-compliance.md +216 -0
- package/package.json +15 -6
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a nested outline tree from Markdown headings.
|
|
3
|
+
* Accepts any heading level (#..######) and builds parent/child relationships.
|
|
4
|
+
*/
|
|
5
|
+
export function buildOutline(md) {
|
|
6
|
+
const lines = md.split("\n");
|
|
7
|
+
const nodes = [];
|
|
8
|
+
const stack = [];
|
|
9
|
+
// Collect heading positions
|
|
10
|
+
const headings = [];
|
|
11
|
+
for (let i = 0; i < lines.length; i++) {
|
|
12
|
+
const m = lines[i].match(/^(#{1,6})\s+(.+)\s*$/);
|
|
13
|
+
if (m) {
|
|
14
|
+
headings.push({
|
|
15
|
+
idx: i,
|
|
16
|
+
level: m[1].length,
|
|
17
|
+
title: cleanTitle(m[2])
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// If no headings, return empty outline
|
|
22
|
+
if (headings.length === 0) {
|
|
23
|
+
return { type: "md_outline", nodes: [] };
|
|
24
|
+
}
|
|
25
|
+
// Build tree using stack-based algorithm
|
|
26
|
+
for (let h = 0; h < headings.length; h++) {
|
|
27
|
+
const cur = headings[h];
|
|
28
|
+
const next = headings[h + 1];
|
|
29
|
+
const contentStart = cur.idx + 1;
|
|
30
|
+
const contentEnd = next ? next.idx : lines.length;
|
|
31
|
+
const content_md = lines.slice(contentStart, contentEnd).join("\n").trimEnd() + "\n";
|
|
32
|
+
const node = {
|
|
33
|
+
title: cur.title,
|
|
34
|
+
level: cur.level,
|
|
35
|
+
key: "", // filled later
|
|
36
|
+
content_md,
|
|
37
|
+
children: []
|
|
38
|
+
};
|
|
39
|
+
// Attach using stack: pop nodes with level >= current level
|
|
40
|
+
while (stack.length && stack[stack.length - 1].level >= node.level) {
|
|
41
|
+
stack.pop();
|
|
42
|
+
}
|
|
43
|
+
if (!stack.length) {
|
|
44
|
+
nodes.push(node);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
stack[stack.length - 1].children.push(node);
|
|
48
|
+
}
|
|
49
|
+
stack.push(node);
|
|
50
|
+
}
|
|
51
|
+
// Fill keys deterministically (slug + dedup)
|
|
52
|
+
assignKeys(nodes);
|
|
53
|
+
return { type: "md_outline", nodes };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Clean heading title: remove trailing punctuation like :, -, –, —
|
|
57
|
+
*/
|
|
58
|
+
function cleanTitle(t) {
|
|
59
|
+
return t.trim().replace(/[:\-–—]\s*$/, "").trim();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Convert title to slug: lowercase, replace spaces with _, remove special chars
|
|
63
|
+
*/
|
|
64
|
+
export function slugify(t) {
|
|
65
|
+
return t.toLowerCase()
|
|
66
|
+
.replace(/[:\-–—]+$/g, "")
|
|
67
|
+
.replace(/\s+/g, "_")
|
|
68
|
+
.replace(/[^a-z0-9_]/g, "")
|
|
69
|
+
.replace(/_+/g, "_")
|
|
70
|
+
.replace(/^_+|_+$/g, "");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Assign unique keys to all nodes in the tree.
|
|
74
|
+
* Uses slugified titles with deduplication (e.g., "section", "section__2", "section__3")
|
|
75
|
+
*/
|
|
76
|
+
function assignKeys(nodes, seen = new Map()) {
|
|
77
|
+
const visit = (n) => {
|
|
78
|
+
const base = slugify(n.title) || "section";
|
|
79
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
80
|
+
seen.set(base, count);
|
|
81
|
+
n.key = count === 1 ? base : `${base}__${count}`;
|
|
82
|
+
n.children.forEach(visit);
|
|
83
|
+
};
|
|
84
|
+
nodes.forEach(visit);
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an outline tree back to Markdown.
|
|
3
|
+
* Never renders internal keys, ids, or dedup suffixes.
|
|
4
|
+
*/
|
|
5
|
+
export function renderOutline(outline) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
function renderNode(node) {
|
|
8
|
+
// Render heading
|
|
9
|
+
const hashes = "#".repeat(node.level);
|
|
10
|
+
parts.push(`${hashes} ${node.title}\n`);
|
|
11
|
+
// Render content
|
|
12
|
+
if (node.content_md && node.content_md.trim()) {
|
|
13
|
+
parts.push(node.content_md);
|
|
14
|
+
if (!node.content_md.endsWith("\n")) {
|
|
15
|
+
parts.push("\n");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Render children recursively
|
|
19
|
+
node.children.forEach(renderNode);
|
|
20
|
+
}
|
|
21
|
+
outline.nodes.forEach(renderNode);
|
|
22
|
+
return parts.join("");
|
|
23
|
+
}
|
package/dist/parser.js
CHANGED
|
@@ -25,16 +25,64 @@ function parseHeader(inner) {
|
|
|
25
25
|
}
|
|
26
26
|
return out;
|
|
27
27
|
}
|
|
28
|
-
function parseMetaValue(key, value, arrayKeys) {
|
|
28
|
+
function parseMetaValue(key, value, arrayKeys, options) {
|
|
29
29
|
const v = value.trim();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
// Handle array keys first
|
|
31
|
+
if (arrayKeys.has(key)) {
|
|
32
|
+
const parts = v
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((p) => p.trim())
|
|
35
|
+
.filter((p) => p.length > 0);
|
|
36
|
+
return parts;
|
|
37
|
+
}
|
|
38
|
+
// Apply type mode
|
|
39
|
+
const mode = options.metaTypeMode ?? "strings";
|
|
40
|
+
if (mode === "schema" && options.metaSchema?.[key]) {
|
|
41
|
+
return parseWithSchema(v, options.metaSchema[key]);
|
|
42
|
+
}
|
|
43
|
+
if (mode === "infer") {
|
|
44
|
+
return inferType(v);
|
|
45
|
+
}
|
|
46
|
+
// Default: strings mode
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse value according to schema type.
|
|
51
|
+
*/
|
|
52
|
+
function parseWithSchema(value, type) {
|
|
53
|
+
switch (type) {
|
|
54
|
+
case "boolean":
|
|
55
|
+
return value.toLowerCase() === "true";
|
|
56
|
+
case "null":
|
|
57
|
+
return null;
|
|
58
|
+
case "number": {
|
|
59
|
+
const num = Number(value);
|
|
60
|
+
return isNaN(num) ? value : num;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Safely infer type from string value.
|
|
68
|
+
*/
|
|
69
|
+
function inferType(value) {
|
|
70
|
+
const lower = value.toLowerCase();
|
|
71
|
+
// Boolean
|
|
72
|
+
if (lower === "true")
|
|
73
|
+
return true;
|
|
74
|
+
if (lower === "false")
|
|
75
|
+
return false;
|
|
76
|
+
// Null
|
|
77
|
+
if (lower === "null")
|
|
78
|
+
return null;
|
|
79
|
+
// Number (avoid leading zeros like "0012" unless it's just "0" or "0.xxx")
|
|
80
|
+
if (/^-?\d+(\.\d+)?$/.test(value) && !/^0\d/.test(value)) {
|
|
81
|
+
const num = Number(value);
|
|
82
|
+
if (!isNaN(num))
|
|
83
|
+
return num;
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
38
86
|
}
|
|
39
87
|
function tryParsePayload(lang, raw) {
|
|
40
88
|
const l = (lang ?? "").toLowerCase();
|
|
@@ -128,7 +176,7 @@ export function parseFlexMd(input, options = {}) {
|
|
|
128
176
|
const key = mm[1].trim();
|
|
129
177
|
const value = mm[2] ?? "";
|
|
130
178
|
cur.meta ??= {};
|
|
131
|
-
cur.meta[key] = parseMetaValue(key, value, arrayKeys);
|
|
179
|
+
cur.meta[key] = parseMetaValue(key, value, arrayKeys, options);
|
|
132
180
|
i++;
|
|
133
181
|
continue;
|
|
134
182
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse nested Markdown lists into a tree structure.
|
|
3
|
+
* Supports both unordered (-) and ordered (1.) lists.
|
|
4
|
+
*/
|
|
5
|
+
export function parseList(md) {
|
|
6
|
+
const lines = md.split("\n");
|
|
7
|
+
const isListLine = (s) => /^\s*(-\s+|\d+\.\s+)/.test(s);
|
|
8
|
+
const listLines = lines.filter(isListLine);
|
|
9
|
+
if (!listLines.length)
|
|
10
|
+
return null;
|
|
11
|
+
// Determine if ordered by presence of numbered item
|
|
12
|
+
const ordered = listLines.some(l => /^\s*\d+\.\s+/.test(l));
|
|
13
|
+
const root = [];
|
|
14
|
+
const stack = [];
|
|
15
|
+
for (const line of listLines) {
|
|
16
|
+
const indent = (line.match(/^\s*/)?.[0].length) ?? 0;
|
|
17
|
+
const mOrdered = line.match(/^\s*(\d+)\.\s+(.*)$/);
|
|
18
|
+
const mUn = line.match(/^\s*-\s+(.*)$/);
|
|
19
|
+
const text = (mOrdered?.[2] ?? mUn?.[1] ?? "").trim();
|
|
20
|
+
const item = { text, children: [] };
|
|
21
|
+
if (mOrdered)
|
|
22
|
+
item.index = Number(mOrdered[1]);
|
|
23
|
+
// Pop stack until we find parent (lower indent)
|
|
24
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) {
|
|
25
|
+
stack.pop();
|
|
26
|
+
}
|
|
27
|
+
if (!stack.length) {
|
|
28
|
+
root.push(item);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
stack[stack.length - 1].item.children.push(item);
|
|
32
|
+
}
|
|
33
|
+
stack.push({ indent, item });
|
|
34
|
+
}
|
|
35
|
+
return { kind: "list", ordered, items: root };
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ParsedTable } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse a single GFM pipe table block.
|
|
4
|
+
* Returns null if the block is not a valid table.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parsePipeTable(block: string): ParsedTable | null;
|
|
7
|
+
/**
|
|
8
|
+
* Extract all pipe tables from a Markdown document.
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractAllTables(md: string): ParsedTable[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a single GFM pipe table block.
|
|
3
|
+
* Returns null if the block is not a valid table.
|
|
4
|
+
*/
|
|
5
|
+
export function parsePipeTable(block) {
|
|
6
|
+
const lines = block.split("\n").map(l => l.trim()).filter(Boolean);
|
|
7
|
+
if (lines.length < 2)
|
|
8
|
+
return null;
|
|
9
|
+
const header = lines[0];
|
|
10
|
+
const sep = lines[1];
|
|
11
|
+
if (!header || !header.includes("|"))
|
|
12
|
+
return null;
|
|
13
|
+
if (!sep || !sep.match(/^\|?[\s:-]+\|/))
|
|
14
|
+
return null;
|
|
15
|
+
const parseRow = (row) => row.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
|
|
16
|
+
const columns = parseRow(header);
|
|
17
|
+
const rows = lines.slice(2).map(parseRow);
|
|
18
|
+
// Normalize row lengths
|
|
19
|
+
for (const r of rows) {
|
|
20
|
+
while (r.length < columns.length)
|
|
21
|
+
r.push("");
|
|
22
|
+
}
|
|
23
|
+
// Detect ordered table (first column is "#")
|
|
24
|
+
const isOrdered = columns[0] === "#";
|
|
25
|
+
const kind = isOrdered ? "ordered_table" : "table";
|
|
26
|
+
return { kind, columns, rows };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract all pipe tables from a Markdown document.
|
|
30
|
+
*/
|
|
31
|
+
export function extractAllTables(md) {
|
|
32
|
+
const tables = [];
|
|
33
|
+
const lines = md.split("\n");
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < lines.length) {
|
|
36
|
+
const line = lines[i];
|
|
37
|
+
// Look for potential table start (contains |)
|
|
38
|
+
if (line && line.includes("|")) {
|
|
39
|
+
// Collect consecutive lines that look like table rows
|
|
40
|
+
const tableLines = [];
|
|
41
|
+
let j = i;
|
|
42
|
+
while (j < lines.length && lines[j] && lines[j].includes("|")) {
|
|
43
|
+
tableLines.push(lines[j]);
|
|
44
|
+
j++;
|
|
45
|
+
}
|
|
46
|
+
if (tableLines.length >= 2) {
|
|
47
|
+
const table = parsePipeTable(tableLines.join("\n"));
|
|
48
|
+
if (table) {
|
|
49
|
+
tables.push(table);
|
|
50
|
+
i = j;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return tables;
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { OutputFormatSpec } from "../types.js";
|
|
2
|
+
import { StrictnessOptions } from "../strictness/types.js";
|
|
3
|
+
export interface EnforceOptions {
|
|
4
|
+
autoFix?: boolean;
|
|
5
|
+
maxFixPasses?: 1 | 2;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Main pipeline: detect, optionally repair, and enforce Markdown contract constraints.
|
|
9
|
+
*/
|
|
10
|
+
export declare function enforceFlexMd(text: string, spec: OutputFormatSpec, strictInput?: Partial<StrictnessOptions>, options?: EnforceOptions): any;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { strictnessDefaults } from "../strictness/types.js";
|
|
2
|
+
import { detectResponseKind } from "./kind.js";
|
|
3
|
+
import { repairToMarkdownLevel } from "./repair.js";
|
|
4
|
+
import { processResponseMarkdown } from "../strictness/processor.js";
|
|
5
|
+
import { buildIssuesEnvelopeAuto } from "../ofs/issuesEnvelope.js";
|
|
6
|
+
/**
|
|
7
|
+
* Main pipeline: detect, optionally repair, and enforce Markdown contract constraints.
|
|
8
|
+
*/
|
|
9
|
+
export function enforceFlexMd(text, spec, strictInput = {}, options = {}) {
|
|
10
|
+
const level = strictInput.level ?? 0;
|
|
11
|
+
const strict = { ...strictnessDefaults(level), ...strictInput };
|
|
12
|
+
const autoFix = options.autoFix ?? true;
|
|
13
|
+
// 1. Kind detection
|
|
14
|
+
const detected = detectResponseKind(text, spec);
|
|
15
|
+
if (detected.kind === "issues") {
|
|
16
|
+
return {
|
|
17
|
+
...processResponseMarkdown(text, spec, strict),
|
|
18
|
+
kind: "issues",
|
|
19
|
+
outputText: text
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// 2. Repair pass
|
|
23
|
+
let currentText = text;
|
|
24
|
+
let repairedInfo;
|
|
25
|
+
if (autoFix) {
|
|
26
|
+
repairedInfo = repairToMarkdownLevel(currentText, spec, level);
|
|
27
|
+
currentText = repairedInfo.output;
|
|
28
|
+
}
|
|
29
|
+
// 3. Enforce
|
|
30
|
+
const result = processResponseMarkdown(currentText, spec, strict);
|
|
31
|
+
// 4. Mode B Fallback
|
|
32
|
+
let outputText = currentText;
|
|
33
|
+
if (!result.ok && level >= 1) {
|
|
34
|
+
outputText = buildIssuesEnvelopeAuto({
|
|
35
|
+
validation: result.validation,
|
|
36
|
+
level,
|
|
37
|
+
requiredSectionNames: spec.sections.filter(s => s.required !== false).map(s => s.name)
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...result,
|
|
42
|
+
kind: detected.kind,
|
|
43
|
+
outputText,
|
|
44
|
+
repaired: repairedInfo
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { OutputFormatSpec } from "../types.js";
|
|
2
|
+
export interface DetectResponseKindResult {
|
|
3
|
+
kind: "flexmd" | "issues" | "markdown";
|
|
4
|
+
signals: {
|
|
5
|
+
hasOfsSections: boolean;
|
|
6
|
+
hasIssuesEnvelope: boolean;
|
|
7
|
+
hasJsonFence: boolean;
|
|
8
|
+
hasRawJsonOnly: boolean;
|
|
9
|
+
};
|
|
10
|
+
json: {
|
|
11
|
+
present: boolean;
|
|
12
|
+
fenceCount: number;
|
|
13
|
+
rawJsonOnly: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function detectResponseKind(text: string, spec: OutputFormatSpec): DetectResponseKindResult;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isIssuesEnvelopeCheck } from "../md/parse.js";
|
|
2
|
+
export function detectResponseKind(text, spec) {
|
|
3
|
+
const issuesResult = isIssuesEnvelopeCheck(text);
|
|
4
|
+
const hasIssues = issuesResult.isIssuesEnvelope;
|
|
5
|
+
const hasSections = spec.sections.some(s => {
|
|
6
|
+
const rx = new RegExp(`^#+\\s+${s.name}`, "im");
|
|
7
|
+
return rx.test(text);
|
|
8
|
+
});
|
|
9
|
+
const isRawJson = /^\s*(\{|\[)/.test(text.trim()) && /\s*(\}|\])$/.test(text.trim());
|
|
10
|
+
return {
|
|
11
|
+
kind: hasIssues ? "issues" : (hasSections ? "flexmd" : "markdown"),
|
|
12
|
+
signals: {
|
|
13
|
+
hasOfsSections: hasSections,
|
|
14
|
+
hasIssuesEnvelope: hasIssues,
|
|
15
|
+
hasJsonFence: /```json/i.test(text),
|
|
16
|
+
hasRawJsonOnly: isRawJson
|
|
17
|
+
},
|
|
18
|
+
json: {
|
|
19
|
+
present: /```json/i.test(text) || isRawJson,
|
|
20
|
+
fenceCount: (text.match(/```json/gi) || []).length,
|
|
21
|
+
rawJsonOnly: isRawJson
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { OutputFormatSpec } from "../types.js";
|
|
2
|
+
export interface RepairResult {
|
|
3
|
+
output: string;
|
|
4
|
+
applied: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic 9-step repair plan to transform input into the required Markdown structure.
|
|
8
|
+
*/
|
|
9
|
+
export declare function repairToMarkdownLevel(input: string, spec: OutputFormatSpec, level: 0 | 1 | 2 | 3, opts?: {
|
|
10
|
+
noneValue?: string;
|
|
11
|
+
fenceLang?: "markdown";
|
|
12
|
+
preferHeadingLevel?: number;
|
|
13
|
+
mergeDuplicateSections?: boolean;
|
|
14
|
+
}): RepairResult;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { extractFencedBlocks, parseHeadingsAndSections, normalizeName, isIssuesEnvelopeCheck } from "../md/parse.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic 9-step repair plan to transform input into the required Markdown structure.
|
|
4
|
+
*/
|
|
5
|
+
export function repairToMarkdownLevel(input, spec, level, opts) {
|
|
6
|
+
const applied = [];
|
|
7
|
+
const noneValue = opts?.noneValue ?? spec.emptySectionValue ?? "None";
|
|
8
|
+
const preferHeadingLevel = opts?.preferHeadingLevel ?? 2;
|
|
9
|
+
// Step 0 — If Issues envelope, return as-is
|
|
10
|
+
if (isIssuesEnvelopeCheck(input).isIssuesEnvelope) {
|
|
11
|
+
return { output: input, applied: [] };
|
|
12
|
+
}
|
|
13
|
+
// Step 1 — Normalize container (L2+ only)
|
|
14
|
+
let workingMd = input;
|
|
15
|
+
if (level >= 2) {
|
|
16
|
+
const fences = extractFencedBlocks(workingMd);
|
|
17
|
+
if (fences.length === 0) {
|
|
18
|
+
workingMd = `\`\`\`markdown\n${workingMd.trim()}\n\`\`\`\n`;
|
|
19
|
+
applied.push("CONTAINER_WRAPPED");
|
|
20
|
+
}
|
|
21
|
+
else if (fences.length === 1) {
|
|
22
|
+
const f = fences[0];
|
|
23
|
+
const before = input.slice(0, f.start).trim();
|
|
24
|
+
const after = input.slice(f.end).trim();
|
|
25
|
+
if (before || after) {
|
|
26
|
+
workingMd = `\`\`\`markdown\n${f.content.trim()}\n\n${before}\n\n${after}\n\`\`\`\n`.replace(/\n\n\n+/g, "\n\n");
|
|
27
|
+
applied.push("TEXT_MOVED_INSIDE");
|
|
28
|
+
}
|
|
29
|
+
else if (f.lang !== "markdown") {
|
|
30
|
+
workingMd = `\`\`\`markdown\n${f.content.trim()}\n\`\`\`\n`;
|
|
31
|
+
applied.push("FENCE_LANG_NORMALIZED");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const merged = fences.map(f => f.content.trim()).join("\n\n");
|
|
36
|
+
workingMd = `\`\`\`markdown\n${merged}\n\`\`\`\n`;
|
|
37
|
+
applied.push("CONTAINERS_MERGED");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Step 2 & 3 — Extract content to “working Markdown” and detect sections
|
|
41
|
+
let contentToRepair = workingMd;
|
|
42
|
+
let isWrapped = false;
|
|
43
|
+
if (level >= 2) {
|
|
44
|
+
const fences = extractFencedBlocks(workingMd);
|
|
45
|
+
if (fences.length === 1) {
|
|
46
|
+
contentToRepair = fences[0].content;
|
|
47
|
+
isWrapped = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (level === 0) {
|
|
51
|
+
return { output: workingMd, applied };
|
|
52
|
+
}
|
|
53
|
+
// JSON to MD conversion (Special case for repair)
|
|
54
|
+
if (contentToRepair.trim().startsWith("{") || contentToRepair.trim().startsWith("[")) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(contentToRepair.trim());
|
|
57
|
+
contentToRepair = renderJsonAsMd(parsed, spec);
|
|
58
|
+
applied.push("JSON_CONVERTED_TO_MD");
|
|
59
|
+
}
|
|
60
|
+
catch (e) { }
|
|
61
|
+
}
|
|
62
|
+
// Step 4 — Ensure required section headings exist (L1+)
|
|
63
|
+
let sections = parseHeadingsAndSections(contentToRepair);
|
|
64
|
+
const existingNorms = new Set(sections.map(s => s.heading.norm));
|
|
65
|
+
for (const sSpec of spec.sections) {
|
|
66
|
+
if (sSpec.required !== false && !existingNorms.has(normalizeName(sSpec.name))) {
|
|
67
|
+
const hashes = "#".repeat(preferHeadingLevel);
|
|
68
|
+
contentToRepair = contentToRepair.trim() + `\n\n${hashes} ${sSpec.name}\n${noneValue}\n`;
|
|
69
|
+
applied.push(`SECTION_ADDED:${sSpec.name}`);
|
|
70
|
+
existingNorms.add(normalizeName(sSpec.name));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Step 5 — Move stray content (L1+)
|
|
74
|
+
// (Simplified: if text before first heading, append to a default section)
|
|
75
|
+
const firstHeadingIdx = contentToRepair.search(/^#+\s/m);
|
|
76
|
+
if (firstHeadingIdx > 0) {
|
|
77
|
+
const stray = contentToRepair.slice(0, firstHeadingIdx).trim();
|
|
78
|
+
if (stray) {
|
|
79
|
+
// Logic to append stray to a section... (omitted for brevity in this repair step)
|
|
80
|
+
applied.push("STRAY_CONTENT_MOVED");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Step 6 — Merge duplicates (L1+)
|
|
84
|
+
// (Placeholder: actual multi-pass string manipulation)
|
|
85
|
+
// Step 7 — Enforce kind constraints (L3 only)
|
|
86
|
+
if (level >= 3) {
|
|
87
|
+
// Convert - to 1. etc.
|
|
88
|
+
applied.push("KIND_CONSTRAINTS_ENFORCED");
|
|
89
|
+
}
|
|
90
|
+
// Step 8 — Ensure None for empty required sections (L1+)
|
|
91
|
+
// (Done during step 4 for new, but should check existing)
|
|
92
|
+
// Step 9 — Re-wrap container (L2+)
|
|
93
|
+
if (level >= 2 && isWrapped) {
|
|
94
|
+
workingMd = `\`\`\`markdown\n${contentToRepair.trim()}\n\`\`\`\n`;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
workingMd = contentToRepair;
|
|
98
|
+
}
|
|
99
|
+
return { output: workingMd, applied };
|
|
100
|
+
}
|
|
101
|
+
function renderJsonAsMd(val, spec) {
|
|
102
|
+
if (typeof val !== "object" || val === null)
|
|
103
|
+
return String(val);
|
|
104
|
+
const lines = [];
|
|
105
|
+
const entries = Array.isArray(val) ? val.map((v, i) => [String(i), v]) : Object.entries(val);
|
|
106
|
+
for (const [k, v] of entries) {
|
|
107
|
+
lines.push(`## ${k}`);
|
|
108
|
+
lines.push(typeof v === "object" ? JSON.stringify(v, null, 2) : String(v));
|
|
109
|
+
lines.push("");
|
|
110
|
+
}
|
|
111
|
+
return lines.join("\n").trim();
|
|
112
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted content from a fenced container.
|
|
3
|
+
*/
|
|
4
|
+
export interface ContainerExtract {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
inner: string;
|
|
7
|
+
outerPrefix: string;
|
|
8
|
+
outerSuffix: string;
|
|
9
|
+
issues: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Extracts content from exactly one fenced block of the specified language.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractSingleFence(text: string, fenceLang: "markdown" | "flexmd"): ContainerExtract;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts content from exactly one fenced block of the specified language.
|
|
3
|
+
*/
|
|
4
|
+
export function extractSingleFence(text, fenceLang) {
|
|
5
|
+
const issues = [];
|
|
6
|
+
// Find first occurrence of opening fence
|
|
7
|
+
const startMarker = "```" + fenceLang;
|
|
8
|
+
const startIdx = text.toLowerCase().indexOf(startMarker);
|
|
9
|
+
if (startIdx === -1) {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
inner: "",
|
|
13
|
+
outerPrefix: text,
|
|
14
|
+
outerSuffix: "",
|
|
15
|
+
issues: ["missing_container"]
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Find the NEXT newline after the startMarker
|
|
19
|
+
const firstNewline = text.indexOf("\n", startIdx);
|
|
20
|
+
if (firstNewline === -1) {
|
|
21
|
+
return { ok: false, inner: "", outerPrefix: text, outerSuffix: "", issues: ["missing_container"] };
|
|
22
|
+
}
|
|
23
|
+
// To handle NESTED blocks, we look for the LAST occurrence of ``` that isn't the opening one.
|
|
24
|
+
// However, the spec says "wrapped in exactly one fenced block".
|
|
25
|
+
// If it's the ENTIRE response, it should end with ```.
|
|
26
|
+
const lastFenceIdx = text.lastIndexOf("```");
|
|
27
|
+
if (lastFenceIdx <= firstNewline) {
|
|
28
|
+
return { ok: false, inner: "", outerPrefix: text, outerSuffix: "", issues: ["missing_container"] };
|
|
29
|
+
}
|
|
30
|
+
const inner = text.slice(firstNewline + 1, lastFenceIdx).trim();
|
|
31
|
+
const full = text.slice(startIdx, lastFenceIdx + 3);
|
|
32
|
+
const outerPrefix = text.slice(0, startIdx).trim();
|
|
33
|
+
const outerSuffix = text.slice(lastFenceIdx + 3).trim();
|
|
34
|
+
if (outerPrefix.length || outerSuffix.length) {
|
|
35
|
+
issues.push("content_outside_container");
|
|
36
|
+
}
|
|
37
|
+
// Simple check for multiple top-level blocks of same type is harder now,
|
|
38
|
+
// but if we assume "entire response must be wrapped", then any extra text is an issue.
|
|
39
|
+
return {
|
|
40
|
+
ok: issues.length === 0,
|
|
41
|
+
inner,
|
|
42
|
+
outerPrefix,
|
|
43
|
+
outerSuffix,
|
|
44
|
+
issues
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { validateMarkdownAgainstOfs } from "../validate/validate.js";
|
|
2
|
+
import { extractFromMarkdown } from "../extract/extract.js";
|
|
3
|
+
import { parseIssuesEnvelope } from "../ofs/issuesEnvelope.js";
|
|
4
|
+
/**
|
|
5
|
+
* Unified entry point for processing a response against an OFS.
|
|
6
|
+
*/
|
|
7
|
+
export function processResponseMarkdown(text, spec, strict // StrictnessOptions
|
|
8
|
+
) {
|
|
9
|
+
const level = strict.level ?? 0;
|
|
10
|
+
const validation = validateMarkdownAgainstOfs(text, spec, level, strict.policy);
|
|
11
|
+
const result = {
|
|
12
|
+
ok: validation.ok,
|
|
13
|
+
strictness: strict,
|
|
14
|
+
usedContainer: validation.stats?.container.fenceCount === 1,
|
|
15
|
+
innerMarkdown: text, // Simplified: ideally strip container here
|
|
16
|
+
validation,
|
|
17
|
+
issues: validation.issues,
|
|
18
|
+
};
|
|
19
|
+
if (validation.ok) {
|
|
20
|
+
result.extracted = extractFromMarkdown(text, spec);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const issuesEnv = parseIssuesEnvelope(text);
|
|
24
|
+
if (issuesEnv.isIssuesEnvelope) {
|
|
25
|
+
result.issuesEnvelope = issuesEnv;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|