diff --git a/boot/boot.js b/boot/boot.js index aa81ed40a0..a8a841b12a 100644 --- a/boot/boot.js +++ b/boot/boot.js @@ -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) { diff --git a/core/modules/startup/load-modules.js b/core/modules/startup/load-modules.js index 22b4dfd453..8df42ed343 100644 --- a/core/modules/startup/load-modules.js +++ b/core/modules/startup/load-modules.js @@ -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(); // -------------------------- diff --git a/editions/test/tiddlers/tests/test-filters.js b/editions/test/tiddlers/tests/test-filters.js index 9834090c78..a79a242c8f 100644 --- a/editions/test/tiddlers/tests/test-filters.js +++ b/editions/test/tiddlers/tests/test-filters.js @@ -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)"); } diff --git a/editions/test/tiddlers/tests/test-markdown-frontmatter.js b/editions/test/tiddlers/tests/test-markdown-frontmatter.js new file mode 100644 index 0000000000..61e0ded092 --- /dev/null +++ b/editions/test/tiddlers/tests/test-markdown-frontmatter.js @@ -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."); + }); + }); + +}); diff --git a/editions/test/tiddlywiki.info b/editions/test/tiddlywiki.info index cfaa65c0ee..3017fa17e7 100644 --- a/editions/test/tiddlywiki.info +++ b/editions/test/tiddlywiki.info @@ -3,7 +3,8 @@ "plugins": [ "tiddlywiki/jasmine", "tiddlywiki/wikitext-serialize", - "tiddlywiki/geospatial" + "tiddlywiki/geospatial", + "tiddlywiki/markdown" ], "themes": [ "tiddlywiki/vanilla", diff --git a/plugins/tiddlywiki/markdown/frontmatter-deserializer.js b/plugins/tiddlywiki/markdown/frontmatter-deserializer.js new file mode 100644 index 0000000000..38bd310897 --- /dev/null +++ b/plugins/tiddlywiki/markdown/frontmatter-deserializer.js @@ -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); +} diff --git a/plugins/tiddlywiki/markdown/frontmatter-serializer.js b/plugins/tiddlywiki/markdown/frontmatter-serializer.js new file mode 100644 index 0000000000..9750b1aeb9 --- /dev/null +++ b/plugins/tiddlywiki/markdown/frontmatter-serializer.js @@ -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; +}; diff --git a/plugins/tiddlywiki/markdown/yaml.js b/plugins/tiddlywiki/markdown/yaml.js new file mode 100644 index 0000000000..593a2311bb --- /dev/null +++ b/plugins/tiddlywiki/markdown/yaml.js @@ -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;