flex-md 1.0.0 → 2.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 +312 -6
- package/SPEC.md +559 -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/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/ofs/enricher.d.ts +6 -0
- package/dist/ofs/enricher.js +29 -0
- package/dist/ofs/extractor.d.ts +9 -0
- package/dist/ofs/extractor.js +75 -0
- package/dist/ofs/parser.d.ts +21 -0
- package/dist/ofs/parser.js +64 -0
- package/dist/ofs/stringify.d.ts +5 -0
- package/dist/ofs/stringify.js +30 -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/test-runner.d.ts +1 -0
- package/dist/test-runner.js +328 -0
- package/dist/types.d.ts +91 -0
- package/dist/validator.d.ts +2 -0
- package/dist/validator.js +80 -0
- package/package.json +20 -6
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { detectObjects } from "./detector.js";
|
|
2
|
+
import { parseFlexMd } from "../parser.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse any text and extract all FlexMD documents and Markdown snippets.
|
|
5
|
+
*/
|
|
6
|
+
export function parseAny(text) {
|
|
7
|
+
const detected = detectObjects(text);
|
|
8
|
+
const flexDocs = [];
|
|
9
|
+
const markdownSnippets = [];
|
|
10
|
+
for (const obj of detected) {
|
|
11
|
+
if (obj.kind === "flexmd_fence" && obj.inner) {
|
|
12
|
+
// Parse fenced FlexMD
|
|
13
|
+
try {
|
|
14
|
+
const doc = parseFlexMd(obj.inner);
|
|
15
|
+
flexDocs.push(doc);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Parse failed, treat as markdown snippet
|
|
19
|
+
markdownSnippets.push(obj.inner);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (obj.kind === "flexdoc_json_fence" && obj.inner) {
|
|
23
|
+
// Parse JSON FlexDocument
|
|
24
|
+
try {
|
|
25
|
+
const doc = JSON.parse(obj.inner);
|
|
26
|
+
flexDocs.push(doc);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Parse failed, skip
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (obj.kind === "raw_flexmd") {
|
|
33
|
+
// Parse raw FlexMD
|
|
34
|
+
try {
|
|
35
|
+
const doc = parseFlexMd(obj.raw);
|
|
36
|
+
flexDocs.push(doc);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Parse failed, treat as markdown snippet
|
|
40
|
+
markdownSnippets.push(obj.raw);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Calculate remainder (text not covered by detected objects)
|
|
45
|
+
let remainder = text;
|
|
46
|
+
for (const obj of detected) {
|
|
47
|
+
remainder = remainder.replace(obj.raw, "");
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
flexDocs,
|
|
51
|
+
markdownSnippets,
|
|
52
|
+
remainder: remainder.trim()
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { parseFlexMd } from "./parser.js";
|
|
3
3
|
export { stringifyFlexMd } from "./stringify.js";
|
|
4
|
+
export { validateFlexMd } from "./validator.js";
|
|
5
|
+
export { parseOutputFormatSpec } from "./ofs/parser.js";
|
|
6
|
+
export { stringifyOutputFormatSpec } from "./ofs/stringify.js";
|
|
7
|
+
export { enrichInstructions } from "./ofs/enricher.js";
|
|
8
|
+
export { validateOutput } from "./ofs/validator.js";
|
|
9
|
+
export type { OfsValidationResult } from "./ofs/validator.js";
|
|
10
|
+
export { extractOutput } from "./ofs/extractor.js";
|
|
11
|
+
export type { ExtractOptions } from "./ofs/extractor.js";
|
|
12
|
+
export { buildOutline, slugify } from "./outline/builder.js";
|
|
13
|
+
export { renderOutline } from "./outline/renderer.js";
|
|
14
|
+
export { parseList } from "./parsers/lists.js";
|
|
15
|
+
export { parsePipeTable, extractAllTables } from "./parsers/tables.js";
|
|
16
|
+
export { detectObjects } from "./detection/detector.js";
|
|
17
|
+
export { parseAny } from "./detection/extractor.js";
|
|
18
|
+
export type { ParseAnyResult } from "./detection/extractor.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
// Layer A - FlexMD Frames
|
|
1
2
|
export * from "./types.js";
|
|
2
3
|
export { parseFlexMd } from "./parser.js";
|
|
3
4
|
export { stringifyFlexMd } from "./stringify.js";
|
|
5
|
+
export { validateFlexMd } from "./validator.js";
|
|
6
|
+
// Layer B - Output Format Spec (OFS)
|
|
7
|
+
export { parseOutputFormatSpec } from "./ofs/parser.js";
|
|
8
|
+
export { stringifyOutputFormatSpec } from "./ofs/stringify.js";
|
|
9
|
+
export { enrichInstructions } from "./ofs/enricher.js";
|
|
10
|
+
export { validateOutput } from "./ofs/validator.js";
|
|
11
|
+
export { extractOutput } from "./ofs/extractor.js";
|
|
12
|
+
// Outline & Tree
|
|
13
|
+
export { buildOutline, slugify } from "./outline/builder.js";
|
|
14
|
+
export { renderOutline } from "./outline/renderer.js";
|
|
15
|
+
// Parsers
|
|
16
|
+
export { parseList } from "./parsers/lists.js";
|
|
17
|
+
export { parsePipeTable, extractAllTables } from "./parsers/tables.js";
|
|
18
|
+
// Layer C - Detection & Extraction
|
|
19
|
+
export { detectObjects } from "./detection/detector.js";
|
|
20
|
+
export { parseAny } from "./detection/extractor.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OutputFormatSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Generate minimal, feature-driven LLM guidance from an OutputFormatSpec.
|
|
4
|
+
* Only includes rules for features actually used in the spec.
|
|
5
|
+
*/
|
|
6
|
+
export declare function enrichInstructions(spec: OutputFormatSpec): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate minimal, feature-driven LLM guidance from an OutputFormatSpec.
|
|
3
|
+
* Only includes rules for features actually used in the spec.
|
|
4
|
+
*/
|
|
5
|
+
export function enrichInstructions(spec) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
if (spec.emptySectionValue) {
|
|
8
|
+
lines.push(`If a section is empty, write \`${spec.emptySectionValue}\`.`);
|
|
9
|
+
}
|
|
10
|
+
const hasList = spec.sections.some(s => s.kind === "list");
|
|
11
|
+
const hasOrderedList = spec.sections.some(s => s.kind === "ordered_list");
|
|
12
|
+
const hasTable = spec.tables.length > 0;
|
|
13
|
+
const hasOrderedTable = spec.tables.some(t => t.kind === "ordered_table");
|
|
14
|
+
if (hasList) {
|
|
15
|
+
lines.push("List sections must use '-' bullets (nested allowed).");
|
|
16
|
+
}
|
|
17
|
+
if (hasOrderedList) {
|
|
18
|
+
lines.push("Ordered-list sections must use numbered items (nested allowed).");
|
|
19
|
+
}
|
|
20
|
+
if (hasTable) {
|
|
21
|
+
lines.push("Tables must be Markdown pipe tables with the specified columns.");
|
|
22
|
+
}
|
|
23
|
+
if (hasOrderedTable) {
|
|
24
|
+
lines.push("Ordered tables must add a first column named '#' with rows numbered 1..N.");
|
|
25
|
+
}
|
|
26
|
+
if (lines.length === 0)
|
|
27
|
+
return "";
|
|
28
|
+
return "Rules:\n" + lines.map(l => `- ${l}`).join("\n") + "\n";
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OutputFormatSpec, ExtractedResult } from "../types.js";
|
|
2
|
+
export interface ExtractOptions {
|
|
3
|
+
/** Parse lists even for prose sections */
|
|
4
|
+
parseAllLists?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Extract structured data from Markdown based on an OutputFormatSpec.
|
|
8
|
+
*/
|
|
9
|
+
export declare function extractOutput(md: string, spec: OutputFormatSpec, opts?: ExtractOptions): ExtractedResult;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { buildOutline } from "../outline/builder.js";
|
|
2
|
+
import { parseList } from "../parsers/lists.js";
|
|
3
|
+
import { extractAllTables } from "../parsers/tables.js";
|
|
4
|
+
/**
|
|
5
|
+
* Extract structured data from Markdown based on an OutputFormatSpec.
|
|
6
|
+
*/
|
|
7
|
+
export function extractOutput(md, spec, opts = {}) {
|
|
8
|
+
const outline = buildOutline(md);
|
|
9
|
+
const tables = extractAllTables(md);
|
|
10
|
+
// Index nodes by normalized title
|
|
11
|
+
const matches = collectMatches(outline.nodes);
|
|
12
|
+
const sectionsByName = {};
|
|
13
|
+
// Extract each section
|
|
14
|
+
for (const section of spec.sections) {
|
|
15
|
+
const key = normalizeTitle(section.name);
|
|
16
|
+
const nodes = matches.get(key) ?? [];
|
|
17
|
+
if (nodes.length === 0) {
|
|
18
|
+
// Section not found - could add to a "missing" array if needed
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
// Choose best node (highest level)
|
|
22
|
+
const chosen = chooseBestNode(nodes);
|
|
23
|
+
const body = chosen.content_md.trim();
|
|
24
|
+
const extracted = {
|
|
25
|
+
nodeKey: chosen.key,
|
|
26
|
+
nodeLevel: chosen.level,
|
|
27
|
+
md: body
|
|
28
|
+
};
|
|
29
|
+
// Parse lists if required by section kind or if parseAllLists is enabled
|
|
30
|
+
if (section.kind === "list" || section.kind === "ordered_list" || opts.parseAllLists) {
|
|
31
|
+
const list = parseList(body);
|
|
32
|
+
if (list) {
|
|
33
|
+
extracted.list = list;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
sectionsByName[section.name] = extracted;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
outline,
|
|
40
|
+
sectionsByName,
|
|
41
|
+
tables
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Collect all nodes by normalized title.
|
|
46
|
+
*/
|
|
47
|
+
function collectMatches(nodes) {
|
|
48
|
+
const map = new Map();
|
|
49
|
+
function visit(node) {
|
|
50
|
+
const key = normalizeTitle(node.title);
|
|
51
|
+
const existing = map.get(key) ?? [];
|
|
52
|
+
existing.push(node);
|
|
53
|
+
map.set(key, existing);
|
|
54
|
+
node.children.forEach(visit);
|
|
55
|
+
}
|
|
56
|
+
nodes.forEach(visit);
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Choose the best node from multiple matches.
|
|
61
|
+
* Prefer highest-level heading (smallest level number).
|
|
62
|
+
*/
|
|
63
|
+
function chooseBestNode(nodes) {
|
|
64
|
+
if (nodes.length === 1)
|
|
65
|
+
return nodes[0];
|
|
66
|
+
// Sort by level (ascending) and take first
|
|
67
|
+
const sorted = [...nodes].sort((a, b) => a.level - b.level);
|
|
68
|
+
return sorted[0];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalize title for comparison: lowercase, remove trailing punctuation.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeTitle(t) {
|
|
74
|
+
return t.trim().replace(/[:\-–—]\s*$/, "").trim().toLowerCase();
|
|
75
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { OutputFormatSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse an Output Format Spec block from Markdown.
|
|
4
|
+
*
|
|
5
|
+
* Expected format:
|
|
6
|
+
* ## Output format (Markdown)
|
|
7
|
+
* Include these sections somewhere (order does not matter):
|
|
8
|
+
*
|
|
9
|
+
* - Short answer — prose
|
|
10
|
+
* - Long answer — prose
|
|
11
|
+
* - Reasoning — ordered list
|
|
12
|
+
* - Assumptions — list
|
|
13
|
+
*
|
|
14
|
+
* Tables (only if needed):
|
|
15
|
+
* - (property1, property2, property3 — table)
|
|
16
|
+
* - (property1, property2, property3 — ordered table, by property2)
|
|
17
|
+
*
|
|
18
|
+
* Empty sections:
|
|
19
|
+
* - If a section is empty, write `None`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseOutputFormatSpec(md: string): OutputFormatSpec;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an Output Format Spec block from Markdown.
|
|
3
|
+
*
|
|
4
|
+
* Expected format:
|
|
5
|
+
* ## Output format (Markdown)
|
|
6
|
+
* Include these sections somewhere (order does not matter):
|
|
7
|
+
*
|
|
8
|
+
* - Short answer — prose
|
|
9
|
+
* - Long answer — prose
|
|
10
|
+
* - Reasoning — ordered list
|
|
11
|
+
* - Assumptions — list
|
|
12
|
+
*
|
|
13
|
+
* Tables (only if needed):
|
|
14
|
+
* - (property1, property2, property3 — table)
|
|
15
|
+
* - (property1, property2, property3 — ordered table, by property2)
|
|
16
|
+
*
|
|
17
|
+
* Empty sections:
|
|
18
|
+
* - If a section is empty, write `None`.
|
|
19
|
+
*/
|
|
20
|
+
export function parseOutputFormatSpec(md) {
|
|
21
|
+
const sections = [];
|
|
22
|
+
const tables = [];
|
|
23
|
+
let emptySectionValue;
|
|
24
|
+
const lines = md.split("\n");
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
// Parse section definitions: "- Section name — kind"
|
|
28
|
+
const sectionMatch = trimmed.match(/^-\s+(.+?)\s+[—–-]\s+(prose|list|ordered list)(.*)$/i);
|
|
29
|
+
if (sectionMatch) {
|
|
30
|
+
const name = sectionMatch[1].trim();
|
|
31
|
+
const kindStr = sectionMatch[2].trim().toLowerCase().replace(/\s+/g, "_");
|
|
32
|
+
const kind = kindStr;
|
|
33
|
+
const hint = sectionMatch[3]?.trim();
|
|
34
|
+
sections.push({ name, kind, hint: hint || undefined });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Parse table definitions: "- (col1, col2, col3 — table)" or "- (col1, col2 — ordered table, by col1)"
|
|
38
|
+
const tableMatch = trimmed.match(/^-\s+\(([^)]+)\s+[—–-]\s+(table|ordered table)(?:,\s*by\s+([^)]+))?\)$/i);
|
|
39
|
+
if (tableMatch) {
|
|
40
|
+
const columnsStr = tableMatch[1].trim();
|
|
41
|
+
const columns = columnsStr.split(",").map(c => c.trim());
|
|
42
|
+
const kindStr = tableMatch[2].trim().toLowerCase().replace(/\s+/g, "_");
|
|
43
|
+
const kind = kindStr;
|
|
44
|
+
const by = tableMatch[3]?.trim();
|
|
45
|
+
tables.push({ columns, kind, by });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Parse empty section value: "If a section is empty, write `None`."
|
|
49
|
+
const emptyMatch = trimmed.match(/If a section is empty.*?`([^`]+)`/i);
|
|
50
|
+
if (emptyMatch) {
|
|
51
|
+
emptySectionValue = emptyMatch[1];
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
descriptorType: "output_format_spec",
|
|
57
|
+
format: "markdown",
|
|
58
|
+
sectionOrderMatters: false,
|
|
59
|
+
sections,
|
|
60
|
+
tablesOptional: true,
|
|
61
|
+
tables,
|
|
62
|
+
emptySectionValue
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an OutputFormatSpec to canonical Markdown format.
|
|
3
|
+
*/
|
|
4
|
+
export function stringifyOutputFormatSpec(spec) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
parts.push("## Output format (Markdown)\n");
|
|
7
|
+
parts.push("Include these sections somewhere (order does not matter):\n\n");
|
|
8
|
+
// Sections
|
|
9
|
+
for (const section of spec.sections) {
|
|
10
|
+
const kindStr = section.kind.replace(/_/g, " ");
|
|
11
|
+
const hint = section.hint ? ` ${section.hint}` : "";
|
|
12
|
+
parts.push(`- ${section.name} — ${kindStr}${hint}\n`);
|
|
13
|
+
}
|
|
14
|
+
// Tables
|
|
15
|
+
if (spec.tables.length > 0) {
|
|
16
|
+
parts.push("\nTables (only if needed):\n");
|
|
17
|
+
for (const table of spec.tables) {
|
|
18
|
+
const kindStr = table.kind.replace(/_/g, " ");
|
|
19
|
+
const cols = table.columns.join(", ");
|
|
20
|
+
const byStr = table.by ? `, by ${table.by}` : "";
|
|
21
|
+
parts.push(`- (${cols} — ${kindStr}${byStr})\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Empty section value
|
|
25
|
+
if (spec.emptySectionValue) {
|
|
26
|
+
parts.push("\nEmpty sections:\n");
|
|
27
|
+
parts.push(`- If a section is empty, write \`${spec.emptySectionValue}\`.\n`);
|
|
28
|
+
}
|
|
29
|
+
return parts.join("");
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OutputFormatSpec } from "../types.js";
|
|
2
|
+
export interface OfsValidationResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate Markdown output against an OutputFormatSpec.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateOutput(md: string, spec: OutputFormatSpec): OfsValidationResult;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { buildOutline } from "../outline/builder.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate Markdown output against an OutputFormatSpec.
|
|
4
|
+
*/
|
|
5
|
+
export function validateOutput(md, spec) {
|
|
6
|
+
const outline = buildOutline(md);
|
|
7
|
+
const errors = [];
|
|
8
|
+
const warnings = [];
|
|
9
|
+
// Index nodes by normalized title
|
|
10
|
+
const matches = collectMatches(outline.nodes);
|
|
11
|
+
// Validate each required section
|
|
12
|
+
for (const section of spec.sections) {
|
|
13
|
+
const key = normalizeTitle(section.name);
|
|
14
|
+
const nodes = matches.get(key) ?? [];
|
|
15
|
+
if (nodes.length === 0) {
|
|
16
|
+
errors.push(`missing_section:${section.name}`);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Choose best node (highest level, i.e., smallest level number)
|
|
20
|
+
const chosen = chooseBestNode(nodes);
|
|
21
|
+
const body = chosen.content_md.trim();
|
|
22
|
+
// Check for empty sections
|
|
23
|
+
if (spec.emptySectionValue) {
|
|
24
|
+
if (body === "" && !normalizeNone(body, spec.emptySectionValue)) {
|
|
25
|
+
errors.push(`empty_section_without_none:${section.name}`);
|
|
26
|
+
}
|
|
27
|
+
// If it's "None", skip further validation
|
|
28
|
+
if (normalizeNone(body, spec.emptySectionValue)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Validate section kind
|
|
33
|
+
if (section.kind === "list") {
|
|
34
|
+
if (!/^\s*-\s+/m.test(body)) {
|
|
35
|
+
errors.push(`section_not_bullets:${section.name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (section.kind === "ordered_list") {
|
|
39
|
+
if (!/^\s*\d+\.\s+/m.test(body)) {
|
|
40
|
+
errors.push(`section_not_numbered:${section.name}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Validate tables (if any)
|
|
45
|
+
// Note: Full table validation would require parsing tables from the markdown
|
|
46
|
+
// For now, we just check if tables exist when required
|
|
47
|
+
// This is a simplified version; full implementation would use extractAllTables
|
|
48
|
+
return {
|
|
49
|
+
ok: errors.length === 0,
|
|
50
|
+
errors,
|
|
51
|
+
warnings
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Collect all nodes by normalized title.
|
|
56
|
+
*/
|
|
57
|
+
function collectMatches(nodes) {
|
|
58
|
+
const map = new Map();
|
|
59
|
+
function visit(node) {
|
|
60
|
+
const key = normalizeTitle(node.title);
|
|
61
|
+
const existing = map.get(key) ?? [];
|
|
62
|
+
existing.push(node);
|
|
63
|
+
map.set(key, existing);
|
|
64
|
+
node.children.forEach(visit);
|
|
65
|
+
}
|
|
66
|
+
nodes.forEach(visit);
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Choose the best node from multiple matches.
|
|
71
|
+
* Prefer highest-level heading (smallest level number).
|
|
72
|
+
*/
|
|
73
|
+
function chooseBestNode(nodes) {
|
|
74
|
+
if (nodes.length === 1)
|
|
75
|
+
return nodes[0];
|
|
76
|
+
// Sort by level (ascending) and take first
|
|
77
|
+
const sorted = [...nodes].sort((a, b) => a.level - b.level);
|
|
78
|
+
return sorted[0];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Normalize title for comparison: lowercase, remove trailing punctuation.
|
|
82
|
+
*/
|
|
83
|
+
function normalizeTitle(t) {
|
|
84
|
+
return t.trim().replace(/[:\-–—]\s*$/, "").trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check if body is the "None" value.
|
|
88
|
+
*/
|
|
89
|
+
function normalizeNone(body, noneValue) {
|
|
90
|
+
return body.trim().toLowerCase() === noneValue.toLowerCase();
|
|
91
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MdOutline } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build a nested outline tree from Markdown headings.
|
|
4
|
+
* Accepts any heading level (#..######) and builds parent/child relationships.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildOutline(md: string): MdOutline;
|
|
7
|
+
/**
|
|
8
|
+
* Convert title to slug: lowercase, replace spaces with _, remove special chars
|
|
9
|
+
*/
|
|
10
|
+
export declare function slugify(t: string): string;
|
|
@@ -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
|
}
|