Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions skills/story-maintenance/scripts/story.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ function parseYaml(source) {
continue;
}
const parsed = parseArray(lines, index + 1);
if (parsed.nextIndex === index + 1) {
data[key] = "";
index += 1;
continue;
}
data[key] = parsed.items;
index = parsed.nextIndex;
}
Expand Down Expand Up @@ -1643,9 +1648,10 @@ function ensureFile(filePath, contents, changed, root) {
assertSafeProjectPath(filePath, root);
}
function replaceEntityReferences(root, oldId, newId) {
const pattern = new RegExp(`(?<![a-z0-9-])${escapeRegExp(oldId)}(?![a-z0-9-])`, "g");
for (const file of markdownFiles(root)) {
const text = safeRead(file, root);
const updated = text.replaceAll(oldId, newId);
const updated = text.replace(pattern, newId);
if (updated !== text) {
writeFile(file, updated, { root });
}
Expand All @@ -1658,7 +1664,9 @@ function removeEntityReferences(root, id) {
}
const markdown = readMarkdown(file, root);
const data = removeReferenceFromData(markdown.data, id);
writeFile(file, replaceFrontmatter(markdown.rawMarkdown, data), { root });
if (JSON.stringify(data) !== JSON.stringify(markdown.data)) {
writeFile(file, replaceFrontmatter(markdown.rawMarkdown, data), { root });
}
}
}
function applyEntityBacklinks(root, kind, id, data) {
Expand Down Expand Up @@ -1753,10 +1761,11 @@ function writeEpub(outFile, storyId, manuscript, writeOptions = {}) {
chapterItems.push(`<item id="${id}" href="${id}.xhtml" media-type="application/xhtml+xml"/>`);
spineItems.push(`<itemref idref="${id}"/>`);
}
const modified = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
writeZip(outFile, [
{ name: "mimetype", content: "application/epub+zip" },
{ name: "META-INF/container.xml", content: `<?xml version="1.0" encoding="UTF-8"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>` },
{ name: "OEBPS/content.opf", content: `<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">${xmlEscape(storyId)}</dc:identifier><dc:title>${xmlEscape(manuscript.title)}</dc:title><dc:language>en</dc:language></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>${chapterItems.join("")}</manifest><spine>${spineItems.join("")}</spine></package>` },
{ name: "OEBPS/content.opf", content: `<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">${xmlEscape(storyId)}</dc:identifier><dc:title>${xmlEscape(manuscript.title)}</dc:title><dc:language>en</dc:language><meta property="dcterms:modified">${modified}</meta></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>${chapterItems.join("")}</manifest><spine>${spineItems.join("")}</spine></package>` },
{ name: "OEBPS/nav.xhtml", content: navXhtml(manuscript) },
...chapterEntries
], writeOptions);
Expand Down Expand Up @@ -1785,8 +1794,10 @@ function writeDocx(outFile, manuscript, writeOptions = {}) {
}
const body = bodyParts.join("");
writeZip(outFile, [
{ name: "[Content_Types].xml", content: `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>` },
{ name: "[Content_Types].xml", content: `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>` },
{ name: "_rels/.rels", content: `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>` },
{ name: "word/_rels/document.xml.rels", content: `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>` },
{ name: "word/styles.xml", content: `<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/><w:jc w:val="center"/></w:pPr><w:rPr><w:b/><w:sz w:val="56"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:spacing w:before="480" w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style></w:styles>` },
{ name: "word/document.xml", content: `<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr/></w:body></w:document>` }
], writeOptions);
}
Expand Down Expand Up @@ -2418,6 +2429,8 @@ Options:
--category <name> Category for add term
--alias <name> Alias for add term; repeatable
-h, --help Show this help

Option values that begin with a dash must use the --option=value form.
`;
function runCli(argv, io) {
const parsed = parseArgs(argv);
Expand Down
2 changes: 2 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Options:
--category <name> Category for add term
--alias <name> Alias for add term; repeatable
-h, --help Show this help

Option values that begin with a dash must use the --option=value form.
`;

export function runCli(argv, io) {
Expand Down
6 changes: 6 additions & 0 deletions src/frontmatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ function parseYaml(source) {
}

const parsed = parseArray(lines, index + 1);
if (parsed.nextIndex === index + 1) {
data[key] = "";
index += 1;
continue;
}

data[key] = parsed.items;
index = parsed.nextIndex;
}
Expand Down
2 changes: 1 addition & 1 deletion src/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function extractSection(markdown, heading) {
return (next ? rest.slice(0, next.index) : rest).trim();
}

function escapeRegExp(value) {
export function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Expand Down
18 changes: 13 additions & 5 deletions src/story.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import fs from "node:fs";
import path from "node:path";
import { parseFrontmatter, replaceFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
import { chapterProse, extractSection, kebabCase, titleCaseSlug, wordCount } from "./markdown.js";
import { chapterProse, escapeRegExp, extractSection, kebabCase, titleCaseSlug, wordCount } from "./markdown.js";

export const STORY_SCHEMA_VERSION = 2;

Expand Down Expand Up @@ -1615,9 +1615,12 @@ function ensureFile(filePath, contents, changed, root) {
}

function replaceEntityReferences(root, oldId, newId) {
// Only replace whole ids: an id can be a substring of another id or of a
// prose word, so matches adjacent to id characters must be left alone.
const pattern = new RegExp(`(?<![a-z0-9-])${escapeRegExp(oldId)}(?![a-z0-9-])`, "g");
for (const file of markdownFiles(root)) {
const text = safeRead(file, root);
const updated = text.replaceAll(oldId, newId);
const updated = text.replace(pattern, newId);
if (updated !== text) {
writeFile(file, updated, { root });
}
Expand All @@ -1631,7 +1634,9 @@ function removeEntityReferences(root, id) {
}
const markdown = readMarkdown(file, root);
const data = removeReferenceFromData(markdown.data, id);
writeFile(file, replaceFrontmatter(markdown.rawMarkdown, data), { root });
if (JSON.stringify(data) !== JSON.stringify(markdown.data)) {
writeFile(file, replaceFrontmatter(markdown.rawMarkdown, data), { root });
}
}
}

Expand Down Expand Up @@ -1737,10 +1742,11 @@ function writeEpub(outFile, storyId, manuscript, writeOptions = {}) {
spineItems.push(`<itemref idref="${id}"/>`);
}

const modified = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
writeZip(outFile, [
{ name: "mimetype", content: "application/epub+zip" },
{ name: "META-INF/container.xml", content: `<?xml version="1.0" encoding="UTF-8"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>` },
{ name: "OEBPS/content.opf", content: `<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">${xmlEscape(storyId)}</dc:identifier><dc:title>${xmlEscape(manuscript.title)}</dc:title><dc:language>en</dc:language></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>${chapterItems.join("")}</manifest><spine>${spineItems.join("")}</spine></package>` },
{ name: "OEBPS/content.opf", content: `<?xml version="1.0" encoding="UTF-8"?><package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">${xmlEscape(storyId)}</dc:identifier><dc:title>${xmlEscape(manuscript.title)}</dc:title><dc:language>en</dc:language><meta property="dcterms:modified">${modified}</meta></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>${chapterItems.join("")}</manifest><spine>${spineItems.join("")}</spine></package>` },
{ name: "OEBPS/nav.xhtml", content: navXhtml(manuscript) },
...chapterEntries
], writeOptions);
Expand Down Expand Up @@ -1773,8 +1779,10 @@ function writeDocx(outFile, manuscript, writeOptions = {}) {
const body = bodyParts.join("");

writeZip(outFile, [
{ name: "[Content_Types].xml", content: `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>` },
{ name: "[Content_Types].xml", content: `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>` },
{ name: "_rels/.rels", content: `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>` },
{ name: "word/_rels/document.xml.rels", content: `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>` },
{ name: "word/styles.xml", content: `<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/><w:jc w:val="center"/></w:pPr><w:rPr><w:b/><w:sz w:val="56"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:spacing w:before="480" w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style></w:styles>` },
{ name: "word/document.xml", content: `<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr/></w:body></w:document>` }
], writeOptions);
}
Expand Down
11 changes: 11 additions & 0 deletions test/frontmatter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ Body`);
expect(parsed.raw).toContain("name:");
});

test("parses bare keys with no value as empty strings", () => {
const parsed = parseFrontmatter(`---
introduced:
aliases: []
resolved:
---
Body`);

expect(parsed.data).toEqual({ introduced: "", aliases: [], resolved: "" });
});

test("stringifies and replaces frontmatter", () => {
const yaml = stringifyFrontmatter({
title: "The Last Ember",
Expand Down
36 changes: 36 additions & 0 deletions test/story.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,40 @@ word-count: 1
expect(scanProject(created.root).story.data["schema-version"]).toBe(STORY_SCHEMA_VERSION);
});

test("remove scrubs references without rewriting untouched files", () => {
const cwd = makeTempDir();
const created = createStoryProject({ cwd, title: "Remove Touch", force: false });
createEntity(created.root, { kind: "character", name: "Keeper", role: "supporting" });
createEntity(created.root, { kind: "promise", name: "Old Setup", character: "keeper" });
const storyPath = path.join(created.root, "story.md");
fs.writeFileSync(storyPath, fs.readFileSync(storyPath, "utf8").replace("title:", "# hand-written note\ntitle:"), "utf8");
const annotated = fs.readFileSync(storyPath, "utf8");

removeEntity(created.root, { kind: "character", id: "keeper" });

expect(fs.readFileSync(storyPath, "utf8")).toBe(annotated);
expect(fs.readFileSync(path.join(created.root, "continuity", "promises", "old-setup.md"), "utf8")).not.toContain("keeper");
});

test("rename leaves overlapping entity ids and prose words intact", () => {
const cwd = makeTempDir();
const created = createStoryProject({ cwd, title: "Rename Overlap", force: false });
createEntity(created.root, { kind: "character", name: "Mara", role: "protagonist" });
createEntity(created.root, { kind: "character", name: "Mara Quill", role: "supporting" });
createEntity(created.root, { kind: "chapter", name: "Arrival", number: 1, pov: "mara-quill", character: "mara-quill,mara" });
const chapterPath = path.join(created.root, "chapters", "chapter-01.md");
fs.appendFileSync(chapterPath, "The marathon passed mara on the docks.\n", "utf8");

renameEntity(created.root, { kind: "character", id: "mara", name: "Tess" });

const chapterText = fs.readFileSync(chapterPath, "utf8");
expect(chapterText).toContain("pov: mara-quill");
expect(chapterText).toContain("- tess");
expect(chapterText).toContain("The marathon passed tess on the docks.");
expect(fs.existsSync(path.join(created.root, "characters", "mara-quill.md"))).toBe(true);
expect(validateLinks(created.root)).toEqual({ ok: true, errors: [], warnings: [] });
});

test("covers helper errors, fallback branches, and malformed continuity state", () => {
const cwd = makeTempDir();
const created = createStoryProject({ cwd, title: "Coverage Branches", force: false });
Expand Down Expand Up @@ -818,7 +852,9 @@ word-count: 0
format: "docx"
});
expect(fs.readFileSync(epub.outFile).readUInt32LE(0)).toBe(0x04034b50);
expect(fs.readFileSync(epub.outFile).toString("utf8")).toContain("dcterms:modified");
expect(fs.readFileSync(docx.outFile).toString("utf8")).toContain("word/document.xml");
expect(fs.readFileSync(docx.outFile).toString("utf8")).toContain("word/styles.xml");
expect(() => buildBook(created.root, { format: "pdf" })).toThrow("Unsupported build format: pdf");
});
});
Loading