Add Markdown serializer/deserializer

Not strictly related to dynamic stores, but very useful in connection with them
This commit is contained in:
Jeremy Ruston
2026-04-14 10:48:40 +01:00
parent f95966d5bb
commit 99a00ab1b4
8 changed files with 958 additions and 4 deletions
+11 -2
View File
@@ -1539,8 +1539,8 @@ Register all the module tiddlers that have a module type
$tw.Wiki.prototype.defineShadowModules = function() {
var self = this;
this.eachShadow(function(tiddler,title) {
// Don't define the module if it is overidden by an ordinary tiddler
if(!self.tiddlerExists(title) && tiddler.hasField("module-type")) {
// Don't define the module if it is overidden by an ordinary tiddler, or has already been defined
if(!self.tiddlerExists(title) && tiddler.hasField("module-type") && !$tw.utils.hop($tw.modules.titles,title)) {
if(tiddler.hasField("draft.of")) {
// Report a fundamental problem
console.warn(`TiddlyWiki: Plugins should not contain tiddlers with a 'draft.of' field: ${tiddler.fields.title}`);
@@ -2309,6 +2309,15 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
$tw.loadPlugins(wikiInfo.plugins,$tw.config.pluginsPath,$tw.config.pluginsEnvVar);
$tw.loadPlugins(wikiInfo.themes,$tw.config.themesPath,$tw.config.themesEnvVar);
$tw.loadPlugins(wikiInfo.languages,$tw.config.languagesPath,$tw.config.languagesEnvVar);
// Register plugin-provided tiddlerdeserializer and tiddlerserializer modules now,
// so they are available when the wiki tiddler files are read from disk below.
// (The same steps run again later in execStartup; the operations are idempotent.)
$tw.wiki.readPluginInfo();
$tw.wiki.registerPluginTiddlers("plugin");
$tw.wiki.unpackPluginTiddlers();
$tw.wiki.defineShadowModules();
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
// Load the wiki files, registering them as writable
var resolvedWikiPath = path.resolve(wikiPath,$tw.config.wikiTiddlersSubDir);
$tw.utils.each($tw.loadTiddlersFromPath(resolvedWikiPath),function(tiddlerFile) {
+1
View File
@@ -34,6 +34,7 @@ exports.startup = function() {
$tw.modules.applyMethods("wikimethod",$tw.Wiki.prototype);
$tw.wiki.addIndexersToWiki();
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
$tw.macros = $tw.modules.getModulesByTypeAsHashmap("macro");
$tw.wiki.initParsers();
// --------------------------
+1 -1
View File
@@ -1062,7 +1062,7 @@ describe("Filter tests", function() {
});
it("should handle the deserializers operator", function() {
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/plain"];
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/plain","text/x-markdown"];
if($tw.browser) {
expectedDeserializers.unshift("(DOM)");
}
@@ -0,0 +1,267 @@
/*\
title: test-markdown-frontmatter.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests for the markdown plugin's YAML frontmatter parser, deserializer,
and serializer.
\*/
/* eslint-env node, browser, jasmine */
/* eslint no-mixed-spaces-and-tabs: ["error", "smart-tabs"]*/
"use strict";
describe("markdown YAML frontmatter", function() {
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
var deserializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js");
var serializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-serializer.js");
// --- YAML parser ---
describe("yaml.load scalars", function() {
it("parses null forms", function() {
expect(yaml.load("null")).toBe(null);
expect(yaml.load("~")).toBe(null);
expect(yaml.load("")).toBe(null);
});
it("parses booleans", function() {
expect(yaml.load("true")).toBe(true);
expect(yaml.load("True")).toBe(true);
expect(yaml.load("false")).toBe(false);
});
it("parses numbers", function() {
expect(yaml.load("42")).toBe(42);
expect(yaml.load("-7")).toBe(-7);
expect(yaml.load("3.14")).toBe(3.14);
expect(yaml.load("1e10")).toBe(1e10);
expect(yaml.load("0xFF")).toBe(255);
expect(yaml.load("0o17")).toBe(15);
});
it("parses special floats", function() {
expect(yaml.load(".inf")).toBe(Infinity);
expect(yaml.load("-.inf")).toBe(-Infinity);
});
it("parses quoted strings", function() {
expect(yaml.load('"hello world"')).toBe("hello world");
expect(yaml.load("'hello world'")).toBe("hello world");
expect(yaml.load('"line1\\nline2"')).toBe("line1\nline2");
});
it("parses plain strings", function() {
expect(yaml.load("hello")).toBe("hello");
});
it("rejects non-strings", function() {
expect(function() { yaml.load(123); }).toThrowError(yaml.YAMLException);
});
});
describe("yaml.load flow collections", function() {
it("parses flow sequences", function() {
expect(yaml.load("[a, b, c]")).toEqual(["a","b","c"]);
expect(yaml.load("[1, 2, 3]")).toEqual([1,2,3]);
expect(yaml.load('[1, "two", true, null]')).toEqual([1,"two",true,null]);
expect(yaml.load("[]")).toEqual([]);
expect(yaml.load('["multi word", simple]')).toEqual(["multi word","simple"]);
});
it("parses flow mappings", function() {
expect(yaml.load("{a: 1, b: 2}")).toEqual({a:1,b:2});
expect(yaml.load("{}")).toEqual({});
});
});
describe("yaml.load block collections", function() {
it("parses simple block mappings", function() {
expect(yaml.load("title: Hello\ntags: foo bar\nrating: 6")).toEqual({
title: "Hello",
tags: "foo bar",
rating: 6
});
});
it("parses block mapping with flow array value", function() {
expect(yaml.load("title: Test\ntags: [concept, synthesis, multi word tag]")).toEqual({
title: "Test",
tags: ["concept","synthesis","multi word tag"]
});
});
it("parses block mapping with quoted value", function() {
expect(yaml.load('title: "A: Subtitle"')).toEqual({title: "A: Subtitle"});
});
it("parses block mapping with null value", function() {
expect(yaml.load("title: Test\ndescription:")).toEqual({
title: "Test",
description: null
});
});
it("parses block sequences", function() {
expect(yaml.load("- alpha\n- beta\n- gamma")).toEqual(["alpha","beta","gamma"]);
expect(yaml.load("- 1\n- two\n- true")).toEqual([1,"two",true]);
});
it("parses nested block mappings", function() {
expect(yaml.load("outer:\n inner: value\n count: 3")).toEqual({
outer: {inner: "value", count: 3}
});
});
it("parses block mapping with block sequence value", function() {
expect(yaml.load("title: Test\ntags:\n - concept\n - synthesis")).toEqual({
title: "Test",
tags: ["concept","synthesis"]
});
});
it("ignores comments and blank lines", function() {
expect(yaml.load("# comment\ntitle: Test\n# more\nrating: 5")).toEqual({
title: "Test",
rating: 5
});
});
});
describe("yaml.dump", function() {
it("dumps simple mappings", function() {
expect(yaml.dump({title: "Hello", rating: 6}).trim()).toBe("title: Hello\nrating: 6");
});
it("dumps arrays", function() {
expect(yaml.dump({tags: ["a","b"]}).trim()).toBe("tags:\n - a\n - b");
});
it("dumps null and booleans", function() {
expect(yaml.dump({x: null}).trim()).toBe("x: null");
expect(yaml.dump({x: true, y: false}).trim()).toBe("x: true\ny: false");
});
it("dumps empty containers", function() {
expect(yaml.dump({}).trim()).toBe("{}");
expect(yaml.dump({x: []}).trim()).toBe("x: []");
});
it("quotes string values that look like numbers", function() {
expect(yaml.dump({rating: "9"}).trim()).toBe('rating: "9"');
});
});
// --- Deserializer ---
describe("frontmatter deserializer", function() {
var ds = deserializer["text/x-markdown"];
it("extracts simple frontmatter into fields", function() {
var result = ds("---\ntitle: Foo\ntags: [a, b]\n---\n\nBody text.",{});
expect(result.length).toBe(1);
expect(result[0].title).toBe("Foo");
expect(result[0].tags).toBe("a b");
expect(result[0].text).toBe("Body text.");
expect(result[0].type).toBe("text/x-markdown");
});
it("converts YAML arrays for list fields to TW bracketed lists", function() {
var result = ds("---\ntags: [concept, multi word tag, simple]\n---\n\nbody",{});
expect(result[0].tags).toBe("concept [[multi word tag]] simple");
});
it("falls back to plain body when no frontmatter present", function() {
var result = ds("Just a body, no frontmatter.",{});
expect(result[0].text).toBe("Just a body, no frontmatter.");
expect(result[0].title).toBeUndefined();
});
it("falls back to plain body when frontmatter is malformed", function() {
var result = ds("---\nnot: [valid yaml: at all\n---\n\nbody",{});
// Malformed YAML still parses something; we just ensure body is set
expect(result[0].text).toBeDefined();
});
it("ignores created and modified per collision policy", function() {
var result = ds("---\ntitle: T\ncreated: 2026-01-01\nmodified: 2026-02-02\n---\n\nb",{});
expect(result[0].created).toBeUndefined();
expect(result[0].modified).toBeUndefined();
});
it("merges existing tags with frontmatter tags", function() {
var result = ds("---\ntags: [b, c]\n---\n\nbody",{tags: "a"});
// Order: existing first, then new uniques
expect(result[0].tags).toBe("a b c");
});
it("emits non-string non-array values as JSON", function() {
var result = ds("---\ntitle: T\nmeta: {nested: deep}\n---\n\nb",{});
expect(result[0].meta).toBe('{"nested":"deep"}');
});
it("handles CRLF line endings around frontmatter", function() {
var result = ds("---\r\ntitle: T\r\n---\r\n\r\nbody",{});
expect(result[0].title).toBe("T");
expect(result[0].text).toBe("body");
});
});
// --- Serializer ---
describe("frontmatter serializer", function() {
var ser = serializer["text/x-markdown"];
it("emits frontmatter and body", function() {
var t = new $tw.Tiddler({title: "Foo", text: "body", tags: "a b"});
var out = ser(t);
expect(out).toContain("---\n");
expect(out).toContain("title: Foo");
expect(out).toContain("tags:\n - a\n - b");
expect(out.split("\n---\n\n")[1]).toBe("body");
});
it("emits list fields as YAML arrays preserving multi-word tags", function() {
var t = new $tw.Tiddler({title: "X", tags: "concept [[multi word tag]] simple", text: "b"});
var out = ser(t);
expect(out).toContain("- concept");
expect(out).toContain("- multi word tag");
expect(out).toContain("- simple");
});
it("skips text, created, modified, bag, revision", function() {
var t = new $tw.Tiddler({
title: "X",
text: "body",
created: "20260101000000000",
modified: "20260101000000000",
bag: "default",
revision: "1"
});
var out = ser(t);
expect(out).not.toContain("created:");
expect(out).not.toContain("modified:");
expect(out).not.toContain("bag:");
expect(out).not.toContain("revision:");
expect(out).not.toContain("text:");
});
it("skips type when it equals text/x-markdown", function() {
var t = new $tw.Tiddler({title: "X", type: "text/x-markdown", text: "b"});
expect(ser(t)).not.toContain("type:");
});
it("emits type when it differs from text/x-markdown", function() {
var t = new $tw.Tiddler({title: "X", type: "text/html", text: "b"});
expect(ser(t)).toContain("type: text/html");
});
it("emits no frontmatter when only skipped fields are present", function() {
var t = new $tw.Tiddler({text: "body only"});
expect(ser(t)).toBe("body only");
});
it("returns empty string for null tiddler", function() {
expect(ser(null)).toBe("");
});
it("title appears first in output", function() {
var t = new $tw.Tiddler({title: "Z", rating: "9", tags: "a", text: "b"});
var out = ser(t);
var lines = out.split("\n");
// First line is "---", second should be "title: Z"
expect(lines[0]).toBe("---");
expect(lines[1]).toBe("title: Z");
});
});
// --- Round-trip ---
describe("frontmatter round-trip", function() {
var ds = deserializer["text/x-markdown"];
var ser = serializer["text/x-markdown"];
it("preserves title, tags, and body across deserialize → serialize", function() {
var input = "---\ntitle: My Tiddler\ntags: [concept, synthesis]\nrating: \"7\"\n---\n\nThis is the body.";
var fields = ds(input,{})[0];
var t = new $tw.Tiddler(fields);
var out = ser(t);
var reparsed = ds(out,{})[0];
expect(reparsed.title).toBe("My Tiddler");
expect(reparsed.tags).toBe("concept synthesis");
expect(reparsed.rating).toBe("7");
expect(reparsed.text).toBe("This is the body.");
});
});
});
+2 -1
View File
@@ -3,7 +3,8 @@
"plugins": [
"tiddlywiki/jasmine",
"tiddlywiki/wikitext-serialize",
"tiddlywiki/geospatial"
"tiddlywiki/geospatial",
"tiddlywiki/markdown"
],
"themes": [
"tiddlywiki/vanilla",
@@ -0,0 +1,136 @@
/*\
title: $:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js
type: application/javascript
module-type: tiddlerdeserializer
Markdown deserializer with YAML frontmatter extraction.
Parses YAML frontmatter delimited by `---` markers and maps extracted
values to tiddler fields. Array values on list fields (tags, list, any
field with a registered `stringify` method) are converted to TiddlyWiki
bracketed lists. Non-string, non-array values are stored as their JSON
representation.
\*/
"use strict";
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
exports["text/x-markdown"] = function(text,fields) {
var result = Object.create(null),
body = text,
frontmatter = null;
// Copy incoming fields (e.g. from .meta file or filename)
for(var f in fields) {
result[f] = fields[f];
}
// Extract YAML frontmatter if present
if(text.indexOf("---") === 0) {
var endMarker = text.indexOf("\n---",3);
if(endMarker !== -1) {
var yamlText = text.substring(3,endMarker).trim();
// Body starts after the closing --- and its newline
var afterMarker = endMarker + 4;
if(text[afterMarker] === "\n") {
afterMarker++;
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
afterMarker += 2;
}
// Skip one blank line if present (conventional separator between frontmatter and body)
if(text[afterMarker] === "\n") {
afterMarker++;
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
afterMarker += 2;
}
body = text.substring(afterMarker);
try {
frontmatter = yaml.load(yamlText);
} catch(e) {
// If YAML parsing fails, treat the whole text as body
body = text;
frontmatter = null;
}
}
}
// Map frontmatter fields to tiddler fields
if(frontmatter && typeof frontmatter === "object" && !Array.isArray(frontmatter)) {
var keys = Object.keys(frontmatter);
for(var i = 0; i < keys.length; i++) {
var key = keys[i],
value = frontmatter[key];
// Apply field collision policy
if(key === "created" || key === "modified") {
// Defer to TiddlyWiki's own timestamps; ignore YAML values
continue;
}
if(key === "tags" && result[key]) {
// Merge: parse existing tags, add new ones
result[key] = mergeTagValue(result[key],value);
continue;
}
result[key] = fieldValueToString(key,value);
}
}
result.text = body;
if(!result.type) {
result.type = "text/x-markdown";
}
return [result];
};
/*
Convert a parsed YAML value to a tiddler field string.
- Arrays on list fields (tags, list, etc.) → TW bracketed list format
- Strings → as-is
- Everything else → JSON
*/
function fieldValueToString(key,value) {
if(value === null || value === undefined) {
return "";
}
if(typeof value === "string") {
return value;
}
if(Array.isArray(value)) {
// Check if this field has a stringify method (i.e. it's a list field)
if($tw.Tiddler.fieldModules[key] && $tw.Tiddler.fieldModules[key].stringify) {
var stringItems = [];
for(var i = 0; i < value.length; i++) {
stringItems.push(value[i] == null ? "" : String(value[i]));
}
return $tw.utils.stringifyList(stringItems);
}
return JSON.stringify(value);
}
if(typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
/*
Merge incoming tag value with existing tags string.
The incoming value may be a string (TW bracketed list) or an array (from YAML).
*/
function mergeTagValue(existing,incoming) {
var existingTags = $tw.utils.parseStringArray(existing) || [];
var newTags;
if(Array.isArray(incoming)) {
newTags = incoming.map(function(t) { return t == null ? "" : String(t); });
} else if(typeof incoming === "string") {
newTags = $tw.utils.parseStringArray(incoming) || [];
} else {
return existing;
}
var seen = Object.create(null);
for(var i = 0; i < existingTags.length; i++) {
seen[existingTags[i]] = true;
}
for(var j = 0; j < newTags.length; j++) {
if(!seen[newTags[j]]) {
existingTags.push(newTags[j]);
seen[newTags[j]] = true;
}
}
return $tw.utils.stringifyList(existingTags);
}
@@ -0,0 +1,75 @@
/*\
title: $:/plugins/tiddlywiki/markdown/frontmatter-serializer.js
type: application/javascript
module-type: tiddlerserializer
Markdown serializer with YAML frontmatter.
Inverse of `frontmatter-deserializer.js`. Given a tiddler, returns a
Markdown file body whose first lines are a YAML frontmatter block
(`---` … `---`), followed by the tiddler's `text` field.
Field handling:
- `title` is always emitted (frontmatter wins over filename when reloaded).
- `text` is the body; not emitted in the frontmatter.
- `created`, `modified` are skipped (TiddlyWiki manages timestamps via filesystem).
- `type` is skipped when it equals `text/x-markdown` (the default for `.md` files).
- `bag`, `revision` are skipped (sync metadata, not authored content).
- List fields (those with a registered `stringify` method) are emitted as YAML arrays.
- All other fields are emitted as YAML strings (preserving their on-disk type).
\*/
"use strict";
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
// Field names to skip when emitting frontmatter
var SKIP_FIELDS = {
text: true,
created: true,
modified: true,
bag: true,
revision: true
};
exports["text/x-markdown"] = function(tiddler) {
if(!tiddler) {
return "";
}
var fields = tiddler.fields || {},
frontmatter = Object.create(null);
// Always include title first
if(fields.title) {
frontmatter.title = fields.title;
}
// Add other fields
$tw.utils.each(fields,function(value,name) {
if(SKIP_FIELDS[name] || name === "title") {
return;
}
if(name === "type" && value === "text/x-markdown") {
return;
}
// List fields → YAML arrays
if($tw.Tiddler.fieldModules[name] && $tw.Tiddler.fieldModules[name].stringify) {
var items;
if(Array.isArray(value)) {
items = value.slice();
} else {
items = $tw.utils.parseStringArray(value || "") || [];
}
frontmatter[name] = items;
} else if(typeof value === "string") {
frontmatter[name] = value;
} else {
// Fallback: stringify whatever it is
frontmatter[name] = String(value);
}
});
var body = fields.text || "";
var hasFrontmatter = Object.keys(frontmatter).length > 0;
if(!hasFrontmatter) {
return body;
}
return "---\n" + yaml.dump(frontmatter) + "\n---\n\n" + body;
};
+465
View File
@@ -0,0 +1,465 @@
/*\
title: $:/plugins/tiddlywiki/markdown/yaml.js
type: application/javascript
module-type: library
Minimal YAML parser for frontmatter extraction.
API-compatible subset of js-yaml: load(string) → object, dump(object) → string.
Handles scalars, flow/block arrays, and simple nested maps.
\*/
"use strict";
function YAMLException(message, mark) {
this.name = "YAMLException";
this.message = message;
this.mark = mark || null;
}
YAMLException.prototype = Object.create(Error.prototype);
YAMLException.prototype.constructor = YAMLException;
// -- Scalar parsing --
function parseScalar(raw) {
if(raw === "" || raw === "null" || raw === "Null" || raw === "NULL" || raw === "~") {
return null;
}
if(raw === "true" || raw === "True" || raw === "TRUE") {
return true;
}
if(raw === "false" || raw === "False" || raw === "FALSE") {
return false;
}
// Quoted strings
if((raw[0] === '"' && raw[raw.length - 1] === '"') ||
(raw[0] === "'" && raw[raw.length - 1] === "'")) {
var inner = raw.slice(1, -1);
if(raw[0] === '"') {
// Handle basic escape sequences in double-quoted strings
inner = inner.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\r/g, "\r")
.replace(/\\\\/g, "\\")
.replace(/\\"/g, '"');
}
return inner;
}
// Numbers: integers and floats
if(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(raw)) {
var num = Number(raw);
if(!isNaN(num)) {
return num;
}
}
// Hex integers
if(/^0x[0-9a-fA-F]+$/.test(raw)) {
return parseInt(raw, 16);
}
// Octal integers
if(/^0o[0-7]+$/.test(raw)) {
return parseInt(raw.slice(2), 8);
}
// Special floats
if(raw === ".inf" || raw === ".Inf" || raw === ".INF") {
return Infinity;
}
if(raw === "-.inf" || raw === "-.Inf" || raw === "-.INF") {
return -Infinity;
}
if(raw === ".nan" || raw === ".NaN" || raw === ".NAN") {
return NaN;
}
return raw;
}
// -- Flow sequence parser: [item, item, ...] --
function parseFlowSequence(str) {
// Strip outer brackets and split respecting nested brackets and quotes
var inner = str.slice(1, -1).trim();
if(inner === "") {
return [];
}
var items = [],
current = "",
depth = 0,
inSingle = false,
inDouble = false;
for(var i = 0; i < inner.length; i++) {
var ch = inner[i];
if(ch === "\\" && inDouble) {
current += ch + (inner[i + 1] || "");
i++;
continue;
}
if(ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if(ch === "'" && !inDouble) {
inSingle = !inSingle;
current += ch;
continue;
}
if(!inSingle && !inDouble) {
if(ch === "[" || ch === "{") {
depth++;
} else if(ch === "]" || ch === "}") {
depth--;
} else if(ch === "," && depth === 0) {
items.push(parseScalar(current.trim()));
current = "";
continue;
}
}
current += ch;
}
if(current.trim() !== "") {
items.push(parseScalar(current.trim()));
}
return items;
}
// -- Flow mapping parser: {key: value, ...} --
function parseFlowMapping(str) {
var inner = str.slice(1, -1).trim();
if(inner === "") {
return {};
}
var result = Object.create(null),
pairs = [],
current = "",
depth = 0,
inSingle = false,
inDouble = false;
for(var i = 0; i < inner.length; i++) {
var ch = inner[i];
if(ch === "\\" && inDouble) {
current += ch + (inner[i + 1] || "");
i++;
continue;
}
if(ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if(ch === "'" && !inDouble) {
inSingle = !inSingle;
current += ch;
continue;
}
if(!inSingle && !inDouble) {
if(ch === "[" || ch === "{") {
depth++;
} else if(ch === "]" || ch === "}") {
depth--;
} else if(ch === "," && depth === 0) {
pairs.push(current.trim());
current = "";
continue;
}
}
current += ch;
}
if(current.trim() !== "") {
pairs.push(current.trim());
}
for(var p = 0; p < pairs.length; p++) {
var colonIdx = pairs[p].indexOf(":");
if(colonIdx !== -1) {
var key = pairs[p].slice(0, colonIdx).trim(),
val = pairs[p].slice(colonIdx + 1).trim();
result[parseScalar(key)] = parseScalar(val);
}
}
return result;
}
// -- Block parser (indentation-based) --
/*
Parse block YAML from an array of {indent, raw} line objects.
Returns the parsed value (object, array, or scalar).
*/
function parseBlock(lines, start, baseIndent) {
if(start >= lines.length) {
return {value: null, nextIndex: start};
}
var firstLine = lines[start];
// Block sequence: lines starting with "- "
if(firstLine.raw.indexOf("- ") === 0 || firstLine.raw === "-") {
return parseBlockSequence(lines, start, firstLine.indent);
}
// Block mapping: lines containing ":"
if(firstLine.raw.indexOf(":") !== -1) {
return parseBlockMapping(lines, start, firstLine.indent);
}
// Bare scalar
return {value: parseScalar(firstLine.raw), nextIndex: start + 1};
}
function parseBlockSequence(lines, start, seqIndent) {
var result = [],
i = start;
while(i < lines.length && lines[i].indent === seqIndent && (lines[i].raw.indexOf("- ") === 0 || lines[i].raw === "-")) {
var itemRaw = lines[i].raw.slice(2); // After "- "
// Check for inline flow value
var trimmed = itemRaw.trim();
if(trimmed[0] === "[") {
result.push(parseFlowSequence(trimmed));
i++;
} else if(trimmed[0] === "{") {
result.push(parseFlowMapping(trimmed));
i++;
} else if(trimmed === "" || trimmed === undefined) {
// Multi-line block item — collect indented children
i++;
var childLines = [];
while(i < lines.length && lines[i].indent > seqIndent) {
childLines.push(lines[i]);
i++;
}
if(childLines.length > 0) {
var parsed = parseBlock(childLines, 0, childLines[0].indent);
result.push(parsed.value);
} else {
result.push(null);
}
} else if(trimmed.indexOf(":") !== -1 && !isQuotedColonValue(trimmed)) {
// Inline mapping start as sequence item
// Collect this line (re-indented) plus any deeper-indented children
var mappingLines = [{indent: seqIndent + 2, raw: trimmed}];
i++;
while(i < lines.length && lines[i].indent > seqIndent) {
mappingLines.push(lines[i]);
i++;
}
var parsedMap = parseBlock(mappingLines, 0, mappingLines[0].indent);
result.push(parsedMap.value);
} else {
result.push(parseScalar(trimmed));
i++;
}
}
return {value: result, nextIndex: i};
}
function isQuotedColonValue(str) {
// Check if the colon is inside quotes (meaning it's a scalar, not a mapping)
var colonIdx = str.indexOf(":");
if(colonIdx === -1) {
return false;
}
// If the value starts with a quote and the colon is inside, it's a quoted scalar
if((str[0] === '"' || str[0] === "'") && colonIdx > 0) {
var quote = str[0];
var closeIdx = str.indexOf(quote, 1);
if(closeIdx > colonIdx) {
return true;
}
}
return false;
}
function parseBlockMapping(lines, start, mapIndent) {
var result = Object.create(null),
i = start;
while(i < lines.length && lines[i].indent === mapIndent) {
var line = lines[i].raw,
colonIdx = line.indexOf(":");
if(colonIdx === -1) {
break;
}
var key = line.slice(0, colonIdx).trim(),
valRaw = line.slice(colonIdx + 1).trim();
if(valRaw !== "") {
// Inline value
if(valRaw[0] === "[") {
result[key] = parseFlowSequence(valRaw);
} else if(valRaw[0] === "{") {
result[key] = parseFlowMapping(valRaw);
} else {
result[key] = parseScalar(valRaw);
}
i++;
} else {
// Block value on subsequent indented lines
i++;
var childLines = [];
while(i < lines.length && lines[i].indent > mapIndent) {
childLines.push(lines[i]);
i++;
}
if(childLines.length > 0) {
var parsed = parseBlock(childLines, 0, childLines[0].indent);
result[key] = parsed.value;
} else {
result[key] = null;
}
}
}
return {value: result, nextIndex: i};
}
// -- Main API --
/*
Parse a YAML string into a JavaScript value.
Compatible with js-yaml's load() function.
Handles the subset of YAML used in frontmatter:
scalars, flow/block arrays, flow/block mappings, nested maps.
*/
function load(text) {
if(typeof text !== "string") {
throw new YAMLException("Input must be a string");
}
text = text.trim();
if(text === "") {
return null;
}
// Tokenise into lines with indent tracking
var rawLines = text.split(/\r?\n/),
lines = [];
for(var i = 0; i < rawLines.length; i++) {
var raw = rawLines[i];
// Skip blank lines and comment-only lines
var trimmed = raw.trim();
if(trimmed === "" || trimmed[0] === "#") {
continue;
}
var indent = 0;
while(indent < raw.length && raw[indent] === " ") {
indent++;
}
lines.push({indent: indent, raw: trimmed});
}
if(lines.length === 0) {
return null;
}
// Single-line flow values
if(lines.length === 1) {
var single = lines[0].raw;
if(single[0] === "[") {
return parseFlowSequence(single);
}
if(single[0] === "{") {
return parseFlowMapping(single);
}
}
var parsed = parseBlock(lines, 0, lines[0].indent);
return parsed.value;
}
/*
Serialise a JavaScript value to a YAML string.
Compatible with js-yaml's dump() function.
Handles the subset of YAML used in frontmatter.
*/
function dump(obj, options) {
options = options || {};
var indent = options.indent || 2;
return dumpValue(obj, 0, indent);
}
function dumpValue(val, level, indentSize) {
if(val === null || val === undefined) {
return "null";
}
if(typeof val === "boolean") {
return val ? "true" : "false";
}
if(typeof val === "number") {
if(val !== val) { return ".nan"; }
if(val === Infinity) { return ".inf"; }
if(val === -Infinity) { return "-.inf"; }
return String(val);
}
if(typeof val === "string") {
return dumpString(val);
}
if(Array.isArray(val)) {
return dumpArray(val, level, indentSize);
}
if(typeof val === "object") {
return dumpObject(val, level, indentSize);
}
return String(val);
}
function dumpString(str) {
// Use plain style if safe, otherwise double-quote
if(str === "") {
return "''";
}
if(/^[\w][\w\s\-\.\/]*$/.test(str) &&
str !== "true" && str !== "false" && str !== "null" &&
str !== "True" && str !== "False" && str !== "Null" &&
str !== "TRUE" && str !== "FALSE" && str !== "NULL" &&
!/^-?\d/.test(str)) {
return str;
}
// Double-quote with escaping
return '"' + str.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t") + '"';
}
function dumpArray(arr, level, indentSize) {
if(arr.length === 0) {
return "[]";
}
var prefix = repeat(" ", level * indentSize);
var lines = [];
for(var i = 0; i < arr.length; i++) {
var val = dumpValue(arr[i], level + 1, indentSize);
if(typeof arr[i] === "object" && arr[i] !== null && !Array.isArray(arr[i])) {
// Object items: first key on same line as dash, rest indented
var objLines = val.split("\n");
lines.push(prefix + "- " + objLines[0]);
for(var j = 1; j < objLines.length; j++) {
lines.push(prefix + " " + objLines[j]);
}
} else {
lines.push(prefix + "- " + val);
}
}
return "\n" + lines.join("\n");
}
function dumpObject(obj, level, indentSize) {
var keys = Object.keys(obj);
if(keys.length === 0) {
return "{}";
}
var prefix = repeat(" ", level * indentSize);
var lines = [];
for(var i = 0; i < keys.length; i++) {
var key = keys[i],
val = obj[key];
var dumpedVal = dumpValue(val, level + 1, indentSize);
if((typeof val === "object" && val !== null) &&
((Array.isArray(val) && val.length > 0) || (!Array.isArray(val) && Object.keys(val).length > 0))) {
lines.push(prefix + dumpString(key) + ":" + dumpedVal);
} else {
lines.push(prefix + dumpString(key) + ": " + dumpedVal);
}
}
return lines.join("\n");
}
function repeat(str, count) {
var result = "";
for(var i = 0; i < count; i++) {
result += str;
}
return result;
}
exports.load = load;
exports.dump = dump;
exports.YAMLException = YAMLException;