flex-md 1.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 ADDED
@@ -0,0 +1,36 @@
1
+ # flex-md
2
+
3
+ Parse and stringify **FlexMD Frames**: semi-structured Markdown that is easy for humans + deterministic for machines.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i flex-md
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```javascript
14
+ import { parseFlexMd, stringifyFlexMd } from "flex-md";
15
+
16
+ const md = `[[message role=user id=m1]]
17
+ @tags: auth, login
18
+ Hello
19
+
20
+ @payload:name: input
21
+ \`\`\`json
22
+ {"a":1}
23
+ \`\`\`
24
+ `;
25
+
26
+ const doc = parseFlexMd(md);
27
+ console.log(doc.frames[0]?.type); // "message"
28
+
29
+ const back = stringifyFlexMd(doc, { skipEmpty: true });
30
+ console.log(back);
31
+ ```
32
+
33
+ ### Notes
34
+ - Frames start with: `[[type key=value ...]]`
35
+ - Metadata lines: `@key: value`
36
+ - Payload binding: `@payload:name: X` then the next fenced block is payload X
package/dist/index.cjs ADDED
@@ -0,0 +1,3 @@
1
+ // Auto-generated CJS bridge for flex-md
2
+ const m = await import('./index.js');
3
+ module.exports = m;
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export { parseFlexMd } from "./parser.js";
3
+ export { stringifyFlexMd } from "./stringify.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export { parseFlexMd } from "./parser.js";
3
+ export { stringifyFlexMd } from "./stringify.js";
@@ -0,0 +1,2 @@
1
+ import type { FlexDocument, ParseOptions } from "./types.js";
2
+ export declare function parseFlexMd(input: string, options?: ParseOptions): FlexDocument;
package/dist/parser.js ADDED
@@ -0,0 +1,151 @@
1
+ import { splitTokensPreservingQuotes, unquote } from "./util.js";
2
+ const HEADER_RE = /^\[\[(.+)\]\]\s*$/;
3
+ const META_RE = /^@([^:]+):\s*(.*)$/;
4
+ const PAYLOAD_DECL_RE = /^@payload:name:\s*(.+)\s*$/;
5
+ function parseHeader(inner) {
6
+ // Supports:
7
+ // 1) [[message role=user id=m1 ts=...]]
8
+ // 2) shorthand: [[user m1]] => type=message role=user id=m1
9
+ const tokens = splitTokensPreservingQuotes(inner.trim()).map(unquote);
10
+ if (tokens.length === 0)
11
+ return { type: "message" };
12
+ // shorthand: [[user m1]] or [[assistant m2]]
13
+ if (tokens.length === 2 && !tokens[0].includes("=") && !tokens[1].includes("=")) {
14
+ return { type: "message", role: tokens[0], id: tokens[1] };
15
+ }
16
+ const type = tokens[0];
17
+ const out = { type };
18
+ for (const t of tokens.slice(1)) {
19
+ const idx = t.indexOf("=");
20
+ if (idx <= 0)
21
+ continue;
22
+ const key = t.slice(0, idx).trim();
23
+ const val = unquote(t.slice(idx + 1).trim());
24
+ out[key] = val;
25
+ }
26
+ return out;
27
+ }
28
+ function parseMetaValue(key, value, arrayKeys) {
29
+ const v = value.trim();
30
+ if (!arrayKeys.has(key))
31
+ return v;
32
+ // comma-separated
33
+ const parts = v
34
+ .split(",")
35
+ .map((p) => p.trim())
36
+ .filter((p) => p.length > 0);
37
+ return parts;
38
+ }
39
+ function tryParsePayload(lang, raw) {
40
+ const l = (lang ?? "").toLowerCase();
41
+ if (l === "json") {
42
+ try {
43
+ const value = JSON.parse(raw);
44
+ return { lang, value, raw };
45
+ }
46
+ catch (e) {
47
+ return { lang, value: raw, raw, parseError: String(e?.message ?? e) };
48
+ }
49
+ }
50
+ return { lang, value: raw, raw };
51
+ }
52
+ export function parseFlexMd(input, options = {}) {
53
+ const arrayKeys = new Set((options.arrayMetaKeys ?? ["tags", "refs"]).map((s) => s.trim()));
54
+ const lines = input.split("\n");
55
+ const endsWithNewline = input.endsWith("\n");
56
+ const frames = [];
57
+ let cur = null;
58
+ // body accumulator for current frame (excluding meta and payload blocks)
59
+ let bodyLines = [];
60
+ // payload state: if declared, next code fence becomes its payload
61
+ let pendingPayloadName = null;
62
+ function flushCurrent() {
63
+ if (!cur)
64
+ return;
65
+ const body = bodyLines.join("\n");
66
+ // Preserve original trailing newline behavior inside body (best-effort):
67
+ cur.body_md = body.length ? body + "\n" : "";
68
+ // Trim to empty if it's only whitespace/newlines
69
+ if (cur.body_md.trim().length === 0)
70
+ delete cur.body_md;
71
+ frames.push(cur);
72
+ cur = null;
73
+ bodyLines = [];
74
+ pendingPayloadName = null;
75
+ }
76
+ let i = 0;
77
+ while (i < lines.length) {
78
+ const line = lines[i];
79
+ // Frame header
80
+ const hm = line.match(HEADER_RE);
81
+ if (hm) {
82
+ flushCurrent();
83
+ cur = { ...parseHeader(hm[1]) };
84
+ i++;
85
+ continue;
86
+ }
87
+ // If we haven't seen a header yet, start an implicit frame.
88
+ cur ??= { type: "message" };
89
+ // Payload declaration
90
+ const pm = line.match(PAYLOAD_DECL_RE);
91
+ if (pm) {
92
+ pendingPayloadName = pm[1].trim();
93
+ i++;
94
+ continue;
95
+ }
96
+ // If a payload was declared, capture the next fenced code block
97
+ if (pendingPayloadName) {
98
+ const fenceStart = line.match(/^(```|~~~)\s*([A-Za-z0-9_-]+)?\s*$/);
99
+ if (fenceStart) {
100
+ const fence = fenceStart[1];
101
+ const lang = fenceStart[2]?.trim();
102
+ const rawLines = [];
103
+ i++;
104
+ while (i < lines.length && lines[i].trimEnd() !== fence) {
105
+ rawLines.push(lines[i]);
106
+ i++;
107
+ }
108
+ // consume closing fence if present
109
+ if (i < lines.length && lines[i].trimEnd() === fence)
110
+ i++;
111
+ const raw = rawLines.join("\n");
112
+ cur.payloads ??= {};
113
+ cur.payloads[pendingPayloadName] = tryParsePayload(lang, raw);
114
+ pendingPayloadName = null;
115
+ continue;
116
+ }
117
+ else {
118
+ // payload declared but no fence; treat as body line
119
+ bodyLines.push(line);
120
+ pendingPayloadName = null;
121
+ i++;
122
+ continue;
123
+ }
124
+ }
125
+ // Metadata line
126
+ const mm = line.match(META_RE);
127
+ if (mm) {
128
+ const key = mm[1].trim();
129
+ const value = mm[2] ?? "";
130
+ cur.meta ??= {};
131
+ cur.meta[key] = parseMetaValue(key, value, arrayKeys);
132
+ i++;
133
+ continue;
134
+ }
135
+ // Default: part of body
136
+ bodyLines.push(line);
137
+ i++;
138
+ }
139
+ flushCurrent();
140
+ // Preserve overall trailing newline more closely: if input had no final newline,
141
+ // avoid forcing one by trimming last frame body newline.
142
+ if (!endsWithNewline && frames.length > 0) {
143
+ const last = frames[frames.length - 1];
144
+ if (typeof last.body_md === "string" && last.body_md.endsWith("\n")) {
145
+ last.body_md = last.body_md.slice(0, -1);
146
+ if (last.body_md.trim().length === 0)
147
+ delete last.body_md;
148
+ }
149
+ }
150
+ return { frames };
151
+ }
@@ -0,0 +1,2 @@
1
+ import type { FlexDocument, StringifyOptions } from "./types.js";
2
+ export declare function stringifyFlexMd(doc: FlexDocument, options?: StringifyOptions): string;
@@ -0,0 +1,110 @@
1
+ import { isBlank, isEmptyObject, quoteIfNeeded } from "./util.js";
2
+ function metaValueToString(key, v, arrayKeys) {
3
+ if (v === null)
4
+ return null;
5
+ if (Array.isArray(v)) {
6
+ if (v.length === 0)
7
+ return null;
8
+ // Render arrays as comma-separated by default for known keys, otherwise JSON
9
+ if (arrayKeys.has(key))
10
+ return v.join(", ");
11
+ return JSON.stringify(v);
12
+ }
13
+ if (typeof v === "string")
14
+ return v;
15
+ if (typeof v === "number" || typeof v === "boolean")
16
+ return String(v);
17
+ // Fallback
18
+ return String(v);
19
+ }
20
+ function shouldSkipMetaValue(v) {
21
+ if (v === null || v === undefined)
22
+ return true;
23
+ if (typeof v === "string" && v.trim() === "")
24
+ return true;
25
+ if (Array.isArray(v) && v.length === 0)
26
+ return true;
27
+ return false;
28
+ }
29
+ function buildHeader(frame) {
30
+ // [[type key=value ...]]
31
+ const parts = [`[[${frame.type}`];
32
+ // include common attrs if present (role/id/ts) and any other top-level string attrs
33
+ const knownOrder = ["role", "id", "ts"];
34
+ for (const k of knownOrder) {
35
+ const val = frame[k];
36
+ if (typeof val === "string" && val.trim().length) {
37
+ parts.push(`${String(k)}=${quoteIfNeeded(val)}`);
38
+ }
39
+ }
40
+ // If the user adds extra header-like attributes, you can support them by convention:
41
+ // we intentionally do NOT auto-include unknown keys to keep JSON schema clean.
42
+ return parts.join(" ") + "]]";
43
+ }
44
+ export function stringifyFlexMd(doc, options = {}) {
45
+ const skipEmpty = options.skipEmpty ?? true;
46
+ const fence = options.fence ?? "```";
47
+ const arrayKeys = new Set((options.arrayMetaKeys ?? ["tags", "refs"]).map((s) => s.trim()));
48
+ const out = [];
49
+ for (let idx = 0; idx < doc.frames.length; idx++) {
50
+ const frame = doc.frames[idx];
51
+ out.push(buildHeader(frame));
52
+ // META
53
+ if (!isEmptyObject(frame.meta)) {
54
+ const keys = Object.keys(frame.meta ?? {});
55
+ for (const k of keys) {
56
+ const v = frame.meta[k];
57
+ if (skipEmpty && shouldSkipMetaValue(v))
58
+ continue;
59
+ const rendered = metaValueToString(k, v, arrayKeys);
60
+ if (skipEmpty && (rendered === null || rendered.trim() === ""))
61
+ continue;
62
+ out.push(`@${k}: ${rendered ?? ""}`.trimEnd());
63
+ }
64
+ }
65
+ // BODY
66
+ if (typeof frame.body_md === "string") {
67
+ const body = frame.body_md;
68
+ if (!(skipEmpty && isBlank(body))) {
69
+ // Keep body verbatim, but avoid double-blanking: ensure we don't accidentally
70
+ // merge with next header by always preserving its internal newlines.
71
+ const normalized = body.endsWith("\n") ? body.slice(0, -1) : body;
72
+ if (normalized.length > 0)
73
+ out.push(normalized);
74
+ }
75
+ }
76
+ // PAYLOADS
77
+ const payloads = frame.payloads ?? {};
78
+ const payloadNames = Object.keys(payloads);
79
+ if (!(skipEmpty && payloadNames.length === 0)) {
80
+ for (const name of payloadNames) {
81
+ const p = payloads[name];
82
+ // Skip empty payload if requested
83
+ if (skipEmpty &&
84
+ (p == null ||
85
+ (typeof p.raw === "string" && p.raw.trim() === "") ||
86
+ (p.value == null && (p.raw ?? "").trim() === ""))) {
87
+ continue;
88
+ }
89
+ out.push(`@payload:name: ${name}`);
90
+ const lang = (p.lang ?? "").trim();
91
+ const header = lang ? `${fence}${lang}` : fence;
92
+ out.push(header);
93
+ // Prefer raw if present; if missing raw but value exists, serialize value
94
+ const raw = typeof p.raw === "string" && p.raw.length > 0
95
+ ? p.raw
96
+ : p.value !== undefined
97
+ ? typeof p.value === "string"
98
+ ? p.value
99
+ : JSON.stringify(p.value, null, 2)
100
+ : "";
101
+ out.push(raw);
102
+ out.push(fence);
103
+ }
104
+ }
105
+ // Frame separator: blank line between frames (readable) unless last
106
+ if (idx !== doc.frames.length - 1)
107
+ out.push("");
108
+ }
109
+ return out.join("\n") + "\n";
110
+ }
@@ -0,0 +1,49 @@
1
+ export type FlexMetaValue = string | string[] | number | boolean | null;
2
+ export interface FlexPayload {
3
+ /** Language from the fenced block, e.g. "json", "yaml", "text" */
4
+ lang?: string;
5
+ /** Parsed value for json fences; otherwise a string raw value */
6
+ value: unknown;
7
+ /** Raw text inside the fence (no surrounding ``` lines) */
8
+ raw: string;
9
+ /** If lang=json but parsing failed */
10
+ parseError?: string;
11
+ }
12
+ export interface FlexFrame {
13
+ type: string;
14
+ role?: string;
15
+ id?: string;
16
+ ts?: string;
17
+ /** Metadata from @key: lines */
18
+ meta?: Record<string, FlexMetaValue>;
19
+ /** Body markdown between header/meta and next frame header */
20
+ body_md?: string;
21
+ /** Payloads keyed by @payload:name: X */
22
+ payloads?: Record<string, FlexPayload>;
23
+ }
24
+ export interface FlexDocument {
25
+ frames: FlexFrame[];
26
+ }
27
+ export interface ParseOptions {
28
+ /**
29
+ * Which metadata keys should be split by commas into string[]
30
+ * Default: ["tags", "refs"]
31
+ */
32
+ arrayMetaKeys?: string[];
33
+ }
34
+ export interface StringifyOptions {
35
+ /**
36
+ * If true, omit empty sections when converting JSON -> FlexMD.
37
+ * Default: true
38
+ */
39
+ skipEmpty?: boolean;
40
+ /**
41
+ * Which meta keys should be rendered as comma-separated lists if arrays.
42
+ * Default: ["tags", "refs"]
43
+ */
44
+ arrayMetaKeys?: string[];
45
+ /**
46
+ * Preferred fence marker. Default: "```"
47
+ */
48
+ fence?: "```" | "~~~";
49
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function isBlank(s: unknown): boolean;
2
+ export declare function isEmptyObject(o: unknown): boolean;
3
+ export declare function splitTokensPreservingQuotes(input: string): string[];
4
+ export declare function unquote(s: string): string;
5
+ export declare function quoteIfNeeded(value: string): string;
package/dist/util.js ADDED
@@ -0,0 +1,64 @@
1
+ export function isBlank(s) {
2
+ return typeof s !== "string" || s.trim().length === 0;
3
+ }
4
+ export function isEmptyObject(o) {
5
+ if (!o || typeof o !== "object")
6
+ return true;
7
+ return Object.keys(o).length === 0;
8
+ }
9
+ export function splitTokensPreservingQuotes(input) {
10
+ // Splits by whitespace, but keeps "quoted strings" and 'quoted strings'
11
+ // Example: key="hello world" -> single token
12
+ const tokens = [];
13
+ let i = 0;
14
+ while (i < input.length) {
15
+ while (i < input.length && /\s/.test(input[i]))
16
+ i++;
17
+ if (i >= input.length)
18
+ break;
19
+ const ch = input[i];
20
+ if (ch === '"' || ch === "'") {
21
+ const quote = ch;
22
+ i++;
23
+ let buf = "";
24
+ while (i < input.length) {
25
+ const c = input[i];
26
+ if (c === "\\" && i + 1 < input.length) {
27
+ // minimal escape support
28
+ buf += input[i + 1];
29
+ i += 2;
30
+ continue;
31
+ }
32
+ if (c === quote) {
33
+ i++;
34
+ break;
35
+ }
36
+ buf += c;
37
+ i++;
38
+ }
39
+ tokens.push(quote + buf + quote);
40
+ continue;
41
+ }
42
+ // unquoted token
43
+ let start = i;
44
+ while (i < input.length && !/\s/.test(input[i]))
45
+ i++;
46
+ tokens.push(input.slice(start, i));
47
+ }
48
+ return tokens;
49
+ }
50
+ export function unquote(s) {
51
+ const t = s.trim();
52
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
53
+ return t.slice(1, -1);
54
+ }
55
+ return t;
56
+ }
57
+ export function quoteIfNeeded(value) {
58
+ // Only quote if whitespace or special chars likely to break header tokenization
59
+ if (/[\s\]]/.test(value) || value.includes('"') || value.includes("'")) {
60
+ // Use JSON-style quoting for safety
61
+ return JSON.stringify(value);
62
+ }
63
+ return value;
64
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "flex-md",
3
+ "version": "1.0.0",
4
+ "description": "Parse and stringify FlexMD Frames (semi-structured Markdown) to/from JSON.",
5
+ "license": "MIT",
6
+ "author": "",
7
+ "keywords": ["markdown", "parser", "serialization", "llm", "prompting", "semi-structured"],
8
+ "type": "module",
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "files": ["dist"],
20
+ "scripts": {
21
+ "build": "tsc && node ./scripts/cjs-bridge.mjs",
22
+ "test": "node ./dist/index.js"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.6.3"
26
+ }
27
+ }