world-building-framework 0.0.0-stage → 0.2.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.
Files changed (145) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +104 -2
  3. package/dist/cli/check.d.ts +13 -0
  4. package/dist/cli/check.js +11 -0
  5. package/dist/cli/commands/create.d.ts +26 -0
  6. package/dist/cli/commands/create.js +35 -0
  7. package/dist/cli/commands/init.d.ts +26 -0
  8. package/dist/cli/commands/init.js +119 -0
  9. package/dist/cli/commands/list-variants.d.ts +1 -0
  10. package/dist/cli/commands/list-variants.js +7 -0
  11. package/dist/cli/commands/publish.d.ts +24 -0
  12. package/dist/cli/commands/publish.js +71 -0
  13. package/dist/cli/commands/refresh.d.ts +29 -0
  14. package/dist/cli/commands/refresh.js +70 -0
  15. package/dist/cli/commands/render.d.ts +14 -0
  16. package/dist/cli/commands/render.js +27 -0
  17. package/dist/cli/commands/schema.d.ts +2 -0
  18. package/dist/cli/commands/schema.js +9 -0
  19. package/dist/cli/commands/search.d.ts +9 -0
  20. package/dist/cli/commands/search.js +20 -0
  21. package/dist/cli/commands/update.d.ts +23 -0
  22. package/dist/cli/commands/update.js +69 -0
  23. package/dist/cli/commands/validate.d.ts +21 -0
  24. package/dist/cli/commands/validate.js +44 -0
  25. package/dist/cli/index.d.ts +2 -0
  26. package/dist/cli/index.js +157 -0
  27. package/dist/cli/io.d.ts +4 -0
  28. package/dist/cli/io.js +25 -0
  29. package/dist/cli/managed-files.d.ts +35 -0
  30. package/dist/cli/managed-files.js +80 -0
  31. package/dist/cli/quartz-runner.d.ts +22 -0
  32. package/dist/cli/quartz-runner.js +76 -0
  33. package/dist/cli/quartz-setup.d.ts +10 -0
  34. package/dist/cli/quartz-setup.js +59 -0
  35. package/dist/cli/vault-path.d.ts +7 -0
  36. package/dist/cli/vault-path.js +44 -0
  37. package/dist/core/config/build-variants.d.ts +5 -0
  38. package/dist/core/config/build-variants.js +13 -0
  39. package/dist/core/config/index.d.ts +4 -0
  40. package/dist/core/config/index.js +4 -0
  41. package/dist/core/config/load.d.ts +4 -0
  42. package/dist/core/config/load.js +25 -0
  43. package/dist/core/config/schema.d.ts +229 -0
  44. package/dist/core/config/schema.js +23 -0
  45. package/dist/core/config/validate-options.d.ts +4 -0
  46. package/dist/core/config/validate-options.js +4 -0
  47. package/dist/core/graph/build.d.ts +8 -0
  48. package/dist/core/graph/build.js +42 -0
  49. package/dist/core/graph/causal.d.ts +22 -0
  50. package/dist/core/graph/causal.js +106 -0
  51. package/dist/core/graph/dating.d.ts +18 -0
  52. package/dist/core/graph/dating.js +30 -0
  53. package/dist/core/graph/entities.d.ts +13 -0
  54. package/dist/core/graph/entities.js +22 -0
  55. package/dist/core/graph/index.d.ts +7 -0
  56. package/dist/core/graph/index.js +7 -0
  57. package/dist/core/graph/queries.d.ts +3 -0
  58. package/dist/core/graph/queries.js +8 -0
  59. package/dist/core/graph/types.d.ts +14 -0
  60. package/dist/core/graph/types.js +1 -0
  61. package/dist/core/graph/versioning.d.ts +15 -0
  62. package/dist/core/graph/versioning.js +50 -0
  63. package/dist/core/id.d.ts +3 -0
  64. package/dist/core/id.js +9 -0
  65. package/dist/core/lint/after-loops.d.ts +15 -0
  66. package/dist/core/lint/after-loops.js +34 -0
  67. package/dist/core/lint/dangling-links.d.ts +9 -0
  68. package/dist/core/lint/dangling-links.js +46 -0
  69. package/dist/core/lint/events-without-entities.d.ts +4 -0
  70. package/dist/core/lint/events-without-entities.js +9 -0
  71. package/dist/core/lint/forked-versions.d.ts +14 -0
  72. package/dist/core/lint/forked-versions.js +29 -0
  73. package/dist/core/lint/index.d.ts +6 -0
  74. package/dist/core/lint/index.js +6 -0
  75. package/dist/core/lint/lint.d.ts +24 -0
  76. package/dist/core/lint/lint.js +50 -0
  77. package/dist/core/lint/target-types.d.ts +9 -0
  78. package/dist/core/lint/target-types.js +47 -0
  79. package/dist/core/query/dates.d.ts +70 -0
  80. package/dist/core/query/dates.js +43 -0
  81. package/dist/core/query/index.d.ts +2 -0
  82. package/dist/core/query/index.js +2 -0
  83. package/dist/core/query/types.d.ts +75 -0
  84. package/dist/core/query/types.js +12 -0
  85. package/dist/core/schema/describe.d.ts +8 -0
  86. package/dist/core/schema/describe.js +51 -0
  87. package/dist/core/schema/fields.d.ts +249 -0
  88. package/dist/core/schema/fields.js +43 -0
  89. package/dist/core/schema/index.d.ts +4 -0
  90. package/dist/core/schema/index.js +4 -0
  91. package/dist/core/schema/validate.d.ts +22 -0
  92. package/dist/core/schema/validate.js +34 -0
  93. package/dist/core/schema/wikilink.d.ts +8 -0
  94. package/dist/core/schema/wikilink.js +23 -0
  95. package/dist/core/search/build.d.ts +11 -0
  96. package/dist/core/search/build.js +26 -0
  97. package/dist/core/search/columns.d.ts +9 -0
  98. package/dist/core/search/columns.js +14 -0
  99. package/dist/core/search/index.d.ts +3 -0
  100. package/dist/core/search/index.js +3 -0
  101. package/dist/core/search/search.d.ts +3 -0
  102. package/dist/core/search/search.js +35 -0
  103. package/dist/core/search/types.d.ts +18 -0
  104. package/dist/core/search/types.js +1 -0
  105. package/dist/core/vault/index.d.ts +6 -0
  106. package/dist/core/vault/index.js +6 -0
  107. package/dist/core/vault/parse.d.ts +8 -0
  108. package/dist/core/vault/parse.js +11 -0
  109. package/dist/core/vault/read.d.ts +13 -0
  110. package/dist/core/vault/read.js +62 -0
  111. package/dist/core/vault/relink.d.ts +10 -0
  112. package/dist/core/vault/relink.js +33 -0
  113. package/dist/core/vault/serialize.d.ts +4 -0
  114. package/dist/core/vault/serialize.js +4 -0
  115. package/dist/core/vault/types.d.ts +5 -0
  116. package/dist/core/vault/types.js +1 -0
  117. package/dist/core/vault/write.d.ts +18 -0
  118. package/dist/core/vault/write.js +37 -0
  119. package/dist/index.d.ts +10 -0
  120. package/dist/index.js +11 -0
  121. package/dist/render-pipeline/build.d.ts +9 -0
  122. package/dist/render-pipeline/build.js +45 -0
  123. package/dist/render-pipeline/filter.d.ts +21 -0
  124. package/dist/render-pipeline/filter.js +80 -0
  125. package/dist/render-pipeline/index.d.ts +10 -0
  126. package/dist/render-pipeline/index.js +10 -0
  127. package/dist/render-pipeline/merge.d.ts +11 -0
  128. package/dist/render-pipeline/merge.js +21 -0
  129. package/dist/render-pipeline/quartz-compat.d.ts +1 -0
  130. package/dist/render-pipeline/quartz-compat.js +18 -0
  131. package/dist/render-pipeline/rewrite.d.ts +8 -0
  132. package/dist/render-pipeline/rewrite.js +23 -0
  133. package/dist/render-pipeline/slugs.d.ts +7 -0
  134. package/dist/render-pipeline/slugs.js +30 -0
  135. package/dist/render-pipeline/sync.d.ts +9 -0
  136. package/dist/render-pipeline/sync.js +18 -0
  137. package/dist/render-pipeline/types.d.ts +8 -0
  138. package/dist/render-pipeline/types.js +1 -0
  139. package/dist/render-pipeline/write.d.ts +3 -0
  140. package/dist/render-pipeline/write.js +12 -0
  141. package/package.json +46 -4
  142. package/skills/world-vault/SKILL.md +100 -0
  143. package/templates/AGENTS.md +11 -0
  144. package/templates/gitignore +5 -0
  145. package/templates/publish.yml +54 -0
@@ -0,0 +1,62 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isUuid } from "../id.js";
4
+ import { parseVaultFile } from "./parse.js";
5
+ function idFromFilename(fileName) {
6
+ const base = path.basename(fileName, ".md");
7
+ return isUuid(base) ? base : undefined;
8
+ }
9
+ /**
10
+ * Folders that never hold world content: dependencies, and anything starting
11
+ * with "." (.git, .wbf with Quartz's working copy, .obsidian, agent folders
12
+ * like .claude). A world repo is the vault (claude-dev/DISTRIBUTION_PLAN.md),
13
+ * so these sit right next to the world's files.
14
+ */
15
+ function isToolingFolder(name) {
16
+ return name === "node_modules" || name.startsWith(".");
17
+ }
18
+ /** Every UUID-named `.md` file under vaultDir, as absolute paths. Never descends into tooling folders. */
19
+ async function findVaultFilePaths(vaultDir) {
20
+ const found = new Map();
21
+ const pending = [vaultDir];
22
+ while (pending.length > 0) {
23
+ const dir = pending.pop();
24
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
25
+ const fullPath = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ if (!isToolingFolder(entry.name))
28
+ pending.push(fullPath);
29
+ }
30
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
31
+ const id = idFromFilename(entry.name);
32
+ if (id)
33
+ found.set(id, fullPath);
34
+ }
35
+ }
36
+ }
37
+ return found;
38
+ }
39
+ /** Where an existing vault file actually lives — possibly in a local subfolder (see readVault). */
40
+ export async function findVaultFilePath(vaultDir, id) {
41
+ return (await findVaultFilePaths(vaultDir)).get(id);
42
+ }
43
+ /** Reads and parses one vault file by id. Never validates — see write.ts. */
44
+ export async function readVaultFile(vaultDir, id) {
45
+ const raw = await readFile(path.join(vaultDir, `${id}.md`), "utf8");
46
+ return parseVaultFile(id, raw);
47
+ }
48
+ /**
49
+ * Reads every vault file: UUID-named `.md` files anywhere under vaultDir.
50
+ * SCHEMA.md's flat pool needs no folders, but a project may still use them
51
+ * locally (rule 5), so this looks recursively rather than assuming a flat layout.
52
+ * Anything not UUID-named (README, config, etc.) is silently skipped, and
53
+ * tooling folders (node_modules, dot-folders) aren't searched at all.
54
+ */
55
+ export async function readVault(vaultDir) {
56
+ const files = [];
57
+ for (const [id, filePath] of await findVaultFilePaths(vaultDir)) {
58
+ const raw = await readFile(filePath, "utf8");
59
+ files.push(parseVaultFile(id, raw));
60
+ }
61
+ return files;
62
+ }
@@ -0,0 +1,10 @@
1
+ import type { VaultFile } from "./types.js";
2
+ /**
3
+ * Rewrites every link to `targetId` whose display text is exactly `from` so it
4
+ * shows `to` instead — in frontmatter values and body alike. Used when a file's
5
+ * title is corrected, so links written with the old title follow the rename.
6
+ * Links with any other display text (e.g. "the old castle" in prose) are left
7
+ * alone: that wording was chosen on purpose. Returns the new content, or
8
+ * undefined if nothing in the file changed.
9
+ */
10
+ export declare function replaceLinkDisplay(file: Pick<VaultFile, "frontmatter" | "body">, targetId: string, from: string, to: string): Pick<VaultFile, "frontmatter" | "body"> | undefined;
@@ -0,0 +1,33 @@
1
+ import { formatWikilink } from "../schema/wikilink.js";
2
+ /**
3
+ * Rewrites every link to `targetId` whose display text is exactly `from` so it
4
+ * shows `to` instead — in frontmatter values and body alike. Used when a file's
5
+ * title is corrected, so links written with the old title follow the rename.
6
+ * Links with any other display text (e.g. "the old castle" in prose) are left
7
+ * alone: that wording was chosen on purpose. Returns the new content, or
8
+ * undefined if nothing in the file changed.
9
+ */
10
+ export function replaceLinkDisplay(file, targetId, from, to) {
11
+ const oldLink = formatWikilink(targetId, from);
12
+ const newLink = formatWikilink(targetId, to);
13
+ let changed = false;
14
+ const replaceIn = (text) => {
15
+ if (!text.includes(oldLink))
16
+ return text;
17
+ changed = true;
18
+ return text.split(oldLink).join(newLink);
19
+ };
20
+ const replaceValue = (value) => {
21
+ if (typeof value === "string")
22
+ return replaceIn(value);
23
+ if (Array.isArray(value))
24
+ return value.map(replaceValue);
25
+ if (value !== null && typeof value === "object") {
26
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, replaceValue(inner)]));
27
+ }
28
+ return value;
29
+ };
30
+ const frontmatter = replaceValue(file.frontmatter);
31
+ const body = replaceIn(file.body);
32
+ return changed ? { frontmatter, body } : undefined;
33
+ }
@@ -0,0 +1,4 @@
1
+ export declare function serializeVaultFile(file: {
2
+ frontmatter: Record<string, unknown>;
3
+ body: string;
4
+ }): string;
@@ -0,0 +1,4 @@
1
+ import matter from "gray-matter";
2
+ export function serializeVaultFile(file) {
3
+ return matter.stringify(file.body, file.frontmatter);
4
+ }
@@ -0,0 +1,5 @@
1
+ export interface VaultFile {
2
+ id: string;
3
+ frontmatter: Record<string, unknown>;
4
+ body: string;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { type ValidateOptions, type ValidationIssue } from "../schema/validate.js";
2
+ export type WriteResult = {
3
+ written: true;
4
+ id: string;
5
+ path: string;
6
+ } | {
7
+ written: false;
8
+ issues: ValidationIssue[];
9
+ };
10
+ /** Creates a new vault file with a freshly generated id (SCHEMA.md rule 1). */
11
+ export declare function createVaultFile(vaultDir: string, frontmatter: Record<string, unknown>, body: string, options?: ValidateOptions): Promise<WriteResult>;
12
+ /**
13
+ * Overwrites an existing vault file's own content (e.g. fixing a typo, editing prose),
14
+ * in place — including when it lives in a local subfolder. Throws if no file has this id.
15
+ * To record a change to an entity over time, prefer createVaultFile with
16
+ * `supersedes`/`causedBy` instead (SCHEMA.md) — this does not create a new version.
17
+ */
18
+ export declare function writeVaultFile(vaultDir: string, id: string, frontmatter: Record<string, unknown>, body: string, options?: ValidateOptions): Promise<WriteResult>;
@@ -0,0 +1,37 @@
1
+ import { mkdir, writeFile as writeFileFs } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { generateId } from "../id.js";
4
+ import { validateFrontmatter } from "../schema/validate.js";
5
+ import { serializeVaultFile } from "./serialize.js";
6
+ import { findVaultFilePath } from "./read.js";
7
+ /**
8
+ * Validates before writing, and only then — this is the one place non-conforming
9
+ * frontmatter actually gets rejected (reading a vault file never validates, see read.ts).
10
+ */
11
+ async function writeValidated(filePath, id, frontmatter, body, options) {
12
+ const result = validateFrontmatter(frontmatter, options);
13
+ if (!result.valid) {
14
+ return { written: false, issues: result.issues };
15
+ }
16
+ await mkdir(path.dirname(filePath), { recursive: true });
17
+ await writeFileFs(filePath, serializeVaultFile({ frontmatter, body }), "utf8");
18
+ return { written: true, id, path: filePath };
19
+ }
20
+ /** Creates a new vault file with a freshly generated id (SCHEMA.md rule 1). */
21
+ export function createVaultFile(vaultDir, frontmatter, body, options) {
22
+ const id = generateId();
23
+ return writeValidated(path.join(vaultDir, `${id}.md`), id, frontmatter, body, options);
24
+ }
25
+ /**
26
+ * Overwrites an existing vault file's own content (e.g. fixing a typo, editing prose),
27
+ * in place — including when it lives in a local subfolder. Throws if no file has this id.
28
+ * To record a change to an entity over time, prefer createVaultFile with
29
+ * `supersedes`/`causedBy` instead (SCHEMA.md) — this does not create a new version.
30
+ */
31
+ export async function writeVaultFile(vaultDir, id, frontmatter, body, options) {
32
+ const filePath = await findVaultFilePath(vaultDir, id);
33
+ if (!filePath) {
34
+ throw new Error(`No file with id "${id}" in this vault — use createVaultFile for a new file.`);
35
+ }
36
+ return writeValidated(filePath, id, frontmatter, body, options);
37
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./core/schema/index.js";
2
+ export * from "./core/vault/index.js";
3
+ export * from "./core/config/index.js";
4
+ export * from "./core/query/index.js";
5
+ export * from "./core/graph/index.js";
6
+ export * from "./core/search/index.js";
7
+ export * from "./core/lint/index.js";
8
+ export { generateId, isUuid } from "./core/id.js";
9
+ export { buildDerivedTree } from "./render-pipeline/index.js";
10
+ export type { RenderedPage } from "./render-pipeline/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Library entry point: the shared core engine (AUTHORING_STACK.md's "one
2
+ // algorithm"), for tools built on top of a vault without going through the CLI.
3
+ export * from "./core/schema/index.js";
4
+ export * from "./core/vault/index.js";
5
+ export * from "./core/config/index.js";
6
+ export * from "./core/query/index.js";
7
+ export * from "./core/graph/index.js";
8
+ export * from "./core/search/index.js";
9
+ export * from "./core/lint/index.js";
10
+ export { generateId, isUuid } from "./core/id.js";
11
+ export { buildDerivedTree } from "./render-pipeline/index.js";
@@ -0,0 +1,9 @@
1
+ import type { VaultFile } from "../core/vault/types.js";
2
+ import type { BuildVariantQuery, RenderedPage } from "./types.js";
3
+ /**
4
+ * Stage 1: vault -> derived, Quartz-ready page set (RENDER_PIPELINE.md). Filters
5
+ * by the build variant's query, merges each included entity's version history
6
+ * into one page, and rewrites every link to a slug (or strips it if the target
7
+ * isn't included).
8
+ */
9
+ export declare function buildDerivedTree(files: VaultFile[], query: BuildVariantQuery): RenderedPage[];
@@ -0,0 +1,45 @@
1
+ import { buildGraph } from "../core/graph/index.js";
2
+ import { filterEvents, filterEntityAnchors } from "./filter.js";
3
+ import { mergeEntityBody } from "./merge.js";
4
+ import { assignSlugs } from "./slugs.js";
5
+ import { rewriteLinks, rewriteFrontmatterLinks } from "./rewrite.js";
6
+ import { renameQuartzReservedFields } from "./quartz-compat.js";
7
+ function titleOf(file) {
8
+ return typeof file.frontmatter.title === "string" ? file.frontmatter.title : file.id;
9
+ }
10
+ /**
11
+ * Stage 1: vault -> derived, Quartz-ready page set (RENDER_PIPELINE.md). Filters
12
+ * by the build variant's query, merges each included entity's version history
13
+ * into one page, and rewrites every link to a slug (or strips it if the target
14
+ * isn't included).
15
+ */
16
+ export function buildDerivedTree(files, query) {
17
+ const graph = buildGraph(files);
18
+ const includedEventIds = filterEvents(graph, files, query);
19
+ const includedAnchorIds = filterEntityAnchors(graph, files, query);
20
+ // Sorted for a deterministic slug assignment, independent of vault read order.
21
+ const includedIds = [...includedEventIds, ...includedAnchorIds].sort();
22
+ const titles = new Map(includedIds.map((id) => [id, titleOf(graph.nodes.get(id))]));
23
+ const slugsById = assignSlugs(titles);
24
+ const pages = [];
25
+ for (const id of includedEventIds) {
26
+ const file = graph.nodes.get(id);
27
+ pages.push({
28
+ id,
29
+ slug: slugsById.get(id),
30
+ frontmatter: renameQuartzReservedFields(rewriteFrontmatterLinks(file.frontmatter, slugsById)),
31
+ body: rewriteLinks(file.body, slugsById),
32
+ });
33
+ }
34
+ for (const anchorId of includedAnchorIds) {
35
+ const anchor = graph.nodes.get(anchorId);
36
+ const mergedBody = mergeEntityBody(graph, anchorId, includedEventIds);
37
+ pages.push({
38
+ id: anchorId,
39
+ slug: slugsById.get(anchorId),
40
+ frontmatter: renameQuartzReservedFields(rewriteFrontmatterLinks(anchor.frontmatter, slugsById)),
41
+ body: rewriteLinks(mergedBody, slugsById),
42
+ });
43
+ }
44
+ return pages;
45
+ }
@@ -0,0 +1,21 @@
1
+ import type { VaultFile } from "../core/vault/types.js";
2
+ import type { KnowledgeGraph } from "../core/graph/index.js";
3
+ import type { BuildVariantQuery } from "./types.js";
4
+ /**
5
+ * Events included in this build. A dated event is checked against the date
6
+ * range directly. An undated event is placed by its causal neighbours
7
+ * (undatedEventWindow) and included only if that places it inside the range
8
+ * for certain — one with no dated events on the relevant side is left out of
9
+ * a date-limited build. Date one to pin it.
10
+ */
11
+ export declare function filterEvents(graph: KnowledgeGraph, files: VaultFile[], query: BuildVariantQuery): Set<string>;
12
+ /**
13
+ * Entity anchors included in this build. Only the upper date bound gates
14
+ * inclusion (REQUIREMENTS.md); tags are checked against the anchor file only,
15
+ * never later versions. The entity's first event must be at or before the
16
+ * upper bound for certain — if that event is undated, the latest date its
17
+ * causal neighbours allow is used, same rule as filterEvents. An entity with no
18
+ * tied events at all is always included: it has no place on the timeline
19
+ * (background lore, most locations), so there's nothing to be unsure about.
20
+ */
21
+ export declare function filterEntityAnchors(graph: KnowledgeGraph, files: VaultFile[], query: BuildVariantQuery): Set<string>;
@@ -0,0 +1,80 @@
1
+ import { entityFirstEventId, eventDate, undatedEventWindow } from "../core/graph/index.js";
2
+ import { isDateWithinRange, isAtOrBeforeUpperBound, isAtOrAfterLowerBound, } from "../core/query/index.js";
3
+ function isEntityAnchor(file) {
4
+ return file.frontmatter.type === "entity" && file.frontmatter.supersedes === undefined;
5
+ }
6
+ function isEvent(file) {
7
+ return file.frontmatter.type === "event";
8
+ }
9
+ function hasAllTags(frontmatter, tags) {
10
+ const fileTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
11
+ return tags.every((tag) => fileTags.includes(tag));
12
+ }
13
+ /**
14
+ * True only if the window lies entirely inside the range: an undated event is
15
+ * excluded whenever it *could* fall outside, so a date-limited build never
16
+ * leaks a later event (spoiler-safe "exclude when in doubt"). A side of the
17
+ * window with no dated neighbour is unbounded, so it fails any bound on that side.
18
+ */
19
+ function windowWithinRange(window, range) {
20
+ if (range.from && !(window.earliest && isAtOrAfterLowerBound(window.earliest, range)))
21
+ return false;
22
+ if (range.to && !(window.latest && isAtOrBeforeUpperBound(window.latest, range)))
23
+ return false;
24
+ return true;
25
+ }
26
+ /**
27
+ * Events included in this build. A dated event is checked against the date
28
+ * range directly. An undated event is placed by its causal neighbours
29
+ * (undatedEventWindow) and included only if that places it inside the range
30
+ * for certain — one with no dated events on the relevant side is left out of
31
+ * a date-limited build. Date one to pin it.
32
+ */
33
+ export function filterEvents(graph, files, query) {
34
+ const included = new Set();
35
+ for (const file of files) {
36
+ if (!isEvent(file))
37
+ continue;
38
+ if (query.tags && query.tags.length > 0 && !hasAllTags(file.frontmatter, query.tags))
39
+ continue;
40
+ if (query.dateRange) {
41
+ const date = eventDate(file);
42
+ const inRange = date
43
+ ? isDateWithinRange(date, query.dateRange)
44
+ : windowWithinRange(undatedEventWindow(graph, file.id), query.dateRange);
45
+ if (!inRange)
46
+ continue;
47
+ }
48
+ included.add(file.id);
49
+ }
50
+ return included;
51
+ }
52
+ /**
53
+ * Entity anchors included in this build. Only the upper date bound gates
54
+ * inclusion (REQUIREMENTS.md); tags are checked against the anchor file only,
55
+ * never later versions. The entity's first event must be at or before the
56
+ * upper bound for certain — if that event is undated, the latest date its
57
+ * causal neighbours allow is used, same rule as filterEvents. An entity with no
58
+ * tied events at all is always included: it has no place on the timeline
59
+ * (background lore, most locations), so there's nothing to be unsure about.
60
+ */
61
+ export function filterEntityAnchors(graph, files, query) {
62
+ const included = new Set();
63
+ for (const file of files) {
64
+ if (!isEntityAnchor(file))
65
+ continue;
66
+ if (query.tags && query.tags.length > 0 && !hasAllTags(file.frontmatter, query.tags))
67
+ continue;
68
+ if (query.dateRange?.to) {
69
+ const firstEventId = entityFirstEventId(graph, file.id);
70
+ if (firstEventId) {
71
+ const date = eventDate(graph.nodes.get(firstEventId));
72
+ const latest = date ?? undatedEventWindow(graph, firstEventId).latest;
73
+ if (!latest || !isAtOrBeforeUpperBound(latest, query.dateRange))
74
+ continue;
75
+ }
76
+ }
77
+ included.add(file.id);
78
+ }
79
+ return included;
80
+ }
@@ -0,0 +1,10 @@
1
+ export * from "../core/query/index.js";
2
+ export * from "./types.js";
3
+ export * from "./filter.js";
4
+ export * from "./merge.js";
5
+ export * from "./slugs.js";
6
+ export * from "./rewrite.js";
7
+ export * from "./quartz-compat.js";
8
+ export * from "./build.js";
9
+ export * from "./write.js";
10
+ export * from "./sync.js";
@@ -0,0 +1,10 @@
1
+ export * from "../core/query/index.js";
2
+ export * from "./types.js";
3
+ export * from "./filter.js";
4
+ export * from "./merge.js";
5
+ export * from "./slugs.js";
6
+ export * from "./rewrite.js";
7
+ export * from "./quartz-compat.js";
8
+ export * from "./build.js";
9
+ export * from "./write.js";
10
+ export * from "./sync.js";
@@ -0,0 +1,11 @@
1
+ import type { KnowledgeGraph } from "../core/graph/index.js";
2
+ /**
3
+ * The full body for an included entity: its anchor text, followed by each later
4
+ * version whose causing event is itself included in this build. Using the
5
+ * events-included set (rather than re-checking dates here) means one mechanism
6
+ * handles both date-range truncation and tag-based hiding — a version caused by
7
+ * a hidden event never shows up as an unexplained state change. Each version's
8
+ * body is a full rewrite (SCHEMA.md), not a diff, so skipping a hidden version
9
+ * and keeping a later one still reads coherently.
10
+ */
11
+ export declare function mergeEntityBody(graph: KnowledgeGraph, anchorId: string, includedEventIds: Set<string>): string;
@@ -0,0 +1,21 @@
1
+ import { versionHistory, outgoing } from "../core/graph/index.js";
2
+ /**
3
+ * The full body for an included entity: its anchor text, followed by each later
4
+ * version whose causing event is itself included in this build. Using the
5
+ * events-included set (rather than re-checking dates here) means one mechanism
6
+ * handles both date-range truncation and tag-based hiding — a version caused by
7
+ * a hidden event never shows up as an unexplained state change. Each version's
8
+ * body is a full rewrite (SCHEMA.md), not a diff, so skipping a hidden version
9
+ * and keeping a later one still reads coherently.
10
+ */
11
+ export function mergeEntityBody(graph, anchorId, includedEventIds) {
12
+ const history = versionHistory(graph, anchorId);
13
+ const sections = [history[0]?.body ?? ""];
14
+ for (const version of history.slice(1)) {
15
+ const causedByEdge = outgoing(graph, version.id, "causedBy")[0];
16
+ if (!causedByEdge || !includedEventIds.has(causedByEdge.to))
17
+ continue;
18
+ sections.push(`## After: ${causedByEdge.display}\n\n${version.body}`);
19
+ }
20
+ return sections.join("\n\n");
21
+ }
@@ -0,0 +1 @@
1
+ export declare function renameQuartzReservedFields(frontmatter: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Quartz reserves the `date` frontmatter key for its own created/modified
3
+ * handling — verified hands-on: a real Quartz build logged "invalid date
4
+ * [object Object]" warnings for our event `date: {year, day}` objects, and
5
+ * renaming the key made them disappear. This only affects Stage 1's output —
6
+ * the vault's own schema keeps `date` (SCHEMA.md); this rewrite is purely
7
+ * cosmetic for what Quartz sees.
8
+ */
9
+ const QUARTZ_RESERVED_FIELD_RENAMES = {
10
+ date: "worldDate",
11
+ };
12
+ export function renameQuartzReservedFields(frontmatter) {
13
+ const renamed = {};
14
+ for (const [key, value] of Object.entries(frontmatter)) {
15
+ renamed[QUARTZ_RESERVED_FIELD_RENAMES[key] ?? key] = value;
16
+ }
17
+ return renamed;
18
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Rewrites every `[[uuid|Display]]` link (SCHEMA.md rule 7): to `[[slug|Display]]`
3
+ * — a Quartz-compatible wikilink — if the target is included in this build, or
4
+ * strips it to plain `Display` text otherwise (the resolved link-integrity rule,
5
+ * RENDER_PIPELINE.md).
6
+ */
7
+ export declare function rewriteLinks(text: string, slugsById: Map<string, string>): string;
8
+ export declare function rewriteFrontmatterLinks(frontmatter: Record<string, unknown>, slugsById: Map<string, string>): Record<string, unknown>;
@@ -0,0 +1,23 @@
1
+ const WIKILINK_RE = /\[\[([0-9a-f-]{36})\|([^\]]*)\]\]/gi;
2
+ /**
3
+ * Rewrites every `[[uuid|Display]]` link (SCHEMA.md rule 7): to `[[slug|Display]]`
4
+ * — a Quartz-compatible wikilink — if the target is included in this build, or
5
+ * strips it to plain `Display` text otherwise (the resolved link-integrity rule,
6
+ * RENDER_PIPELINE.md).
7
+ */
8
+ export function rewriteLinks(text, slugsById) {
9
+ return text.replace(WIKILINK_RE, (_match, targetId, display) => {
10
+ const slug = slugsById.get(targetId);
11
+ return slug ? `[[${slug}|${display}]]` : display;
12
+ });
13
+ }
14
+ function rewriteValue(value, slugsById) {
15
+ if (typeof value === "string")
16
+ return rewriteLinks(value, slugsById);
17
+ if (Array.isArray(value))
18
+ return value.map((entry) => rewriteValue(entry, slugsById));
19
+ return value;
20
+ }
21
+ export function rewriteFrontmatterLinks(frontmatter, slugsById) {
22
+ return Object.fromEntries(Object.entries(frontmatter).map(([key, value]) => [key, rewriteValue(value, slugsById)]));
23
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Assigns a unique, readable slug per id — vault files are UUID-named (SCHEMA.md),
3
+ * meaningless as a published URL; this is what Stage 1 uses instead for the
4
+ * derived tree. Iterates `titles` in whatever order the caller provides, so pass
5
+ * ids in a deterministic order for reproducible builds.
6
+ */
7
+ export declare function assignSlugs(titles: Map<string, string>): Map<string, string>;
@@ -0,0 +1,30 @@
1
+ function slugify(title) {
2
+ const slug = title
3
+ .toLowerCase()
4
+ .trim()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "");
7
+ return slug || "untitled";
8
+ }
9
+ /**
10
+ * Assigns a unique, readable slug per id — vault files are UUID-named (SCHEMA.md),
11
+ * meaningless as a published URL; this is what Stage 1 uses instead for the
12
+ * derived tree. Iterates `titles` in whatever order the caller provides, so pass
13
+ * ids in a deterministic order for reproducible builds.
14
+ */
15
+ export function assignSlugs(titles) {
16
+ const used = new Set();
17
+ const slugs = new Map();
18
+ for (const [id, title] of titles) {
19
+ const base = slugify(title);
20
+ let slug = base;
21
+ let suffix = 2;
22
+ while (used.has(slug)) {
23
+ slug = `${base}-${suffix}`;
24
+ suffix += 1;
25
+ }
26
+ used.add(slug);
27
+ slugs.set(id, slug);
28
+ }
29
+ return slugs;
30
+ }
@@ -0,0 +1,9 @@
1
+ import type { RenderedPage } from "./types.js";
2
+ /**
3
+ * Writes the derived tree into a Quartz content folder, first clearing any
4
+ * `.md` files already there. Plain writeDerivedTree alone isn't enough here —
5
+ * without clearing first, a page removed/renamed by a later build (e.g. a
6
+ * build-variant query change, or an entity dropping out of range) would leave
7
+ * a stale, orphaned page behind in Quartz's content folder.
8
+ */
9
+ export declare function syncDerivedTreeToQuartz(quartzContentDir: string, pages: RenderedPage[]): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeDerivedTree } from "./write.js";
4
+ /**
5
+ * Writes the derived tree into a Quartz content folder, first clearing any
6
+ * `.md` files already there. Plain writeDerivedTree alone isn't enough here —
7
+ * without clearing first, a page removed/renamed by a later build (e.g. a
8
+ * build-variant query change, or an entity dropping out of range) would leave
9
+ * a stale, orphaned page behind in Quartz's content folder.
10
+ */
11
+ export async function syncDerivedTreeToQuartz(quartzContentDir, pages) {
12
+ await mkdir(quartzContentDir, { recursive: true });
13
+ const existing = await readdir(quartzContentDir);
14
+ await Promise.all(existing
15
+ .filter((name) => name.endsWith(".md"))
16
+ .map((name) => rm(path.join(quartzContentDir, name))));
17
+ await writeDerivedTree(quartzContentDir, pages);
18
+ }
@@ -0,0 +1,8 @@
1
+ export type { BuildVariantQuery } from "../core/query/index.js";
2
+ export interface RenderedPage {
3
+ /** The original vault id (an event id, or an entity's anchor id). */
4
+ id: string;
5
+ slug: string;
6
+ frontmatter: Record<string, unknown>;
7
+ body: string;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { RenderedPage } from "./types.js";
2
+ /** Writes the derived tree to disk as plain markdown, ready for Stage 2 (Quartz) to build from later. */
3
+ export declare function writeDerivedTree(outputDir: string, pages: RenderedPage[]): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { serializeVaultFile } from "../core/vault/index.js";
5
+ /** Writes the derived tree to disk as plain markdown, ready for Stage 2 (Quartz) to build from later. */
6
+ export async function writeDerivedTree(outputDir, pages) {
7
+ await mkdir(outputDir, { recursive: true });
8
+ for (const page of pages) {
9
+ const content = serializeVaultFile({ frontmatter: page.frontmatter, body: page.body });
10
+ await writeFile(path.join(outputDir, `${page.slug}.md`), content, "utf8");
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "world-building-framework",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Build, search and publish fictional worlds from a vault of Markdown files: the wbf CLI and its core engine.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "bin": {
11
+ "wbf": "dist/cli/index.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "skills",
23
+ "templates"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "node scripts/run-tests.mjs",
29
+ "cli": "tsx src/cli/index.ts",
30
+ "setup:quartz": "tsx src/cli/index.ts setup-quartz",
31
+ "prepack": "npm run build",
32
+ "prepublishOnly": "npm run typecheck && npm test"
33
+ },
34
+ "dependencies": {
35
+ "better-sqlite3": "^13.0.3",
36
+ "commander": "^15.0.0",
37
+ "gray-matter": "^4.0.3",
38
+ "uuid": "^14.0.2",
39
+ "zod": "^3.23.8"
40
+ },
41
+ "devDependencies": {
42
+ "@types/better-sqlite3": "^9.6.0",
43
+ "@types/node": "^22.20.1",
44
+ "glob": "^13.0.6",
45
+ "tsx": "^4.16.2",
46
+ "typescript": "^5.5.4"
47
+ }
48
+ }