world-building-framework 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +104 -2
- package/dist/cli/check.d.ts +13 -0
- package/dist/cli/check.js +11 -0
- package/dist/cli/commands/create.d.ts +20 -0
- package/dist/cli/commands/create.js +35 -0
- package/dist/cli/commands/init.d.ts +26 -0
- package/dist/cli/commands/init.js +119 -0
- package/dist/cli/commands/list-variants.d.ts +1 -0
- package/dist/cli/commands/list-variants.js +7 -0
- package/dist/cli/commands/publish.d.ts +24 -0
- package/dist/cli/commands/publish.js +71 -0
- package/dist/cli/commands/refresh.d.ts +29 -0
- package/dist/cli/commands/refresh.js +70 -0
- package/dist/cli/commands/render.d.ts +14 -0
- package/dist/cli/commands/render.js +27 -0
- package/dist/cli/commands/schema.d.ts +2 -0
- package/dist/cli/commands/schema.js +9 -0
- package/dist/cli/commands/search.d.ts +9 -0
- package/dist/cli/commands/search.js +20 -0
- package/dist/cli/commands/update.d.ts +23 -0
- package/dist/cli/commands/update.js +69 -0
- package/dist/cli/commands/validate.d.ts +21 -0
- package/dist/cli/commands/validate.js +44 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +157 -0
- package/dist/cli/io.d.ts +4 -0
- package/dist/cli/io.js +25 -0
- package/dist/cli/managed-files.d.ts +35 -0
- package/dist/cli/managed-files.js +80 -0
- package/dist/cli/quartz-runner.d.ts +22 -0
- package/dist/cli/quartz-runner.js +76 -0
- package/dist/cli/quartz-setup.d.ts +10 -0
- package/dist/cli/quartz-setup.js +59 -0
- package/dist/cli/vault-path.d.ts +7 -0
- package/dist/cli/vault-path.js +44 -0
- package/dist/core/config/build-variants.d.ts +5 -0
- package/dist/core/config/build-variants.js +13 -0
- package/dist/core/config/index.d.ts +4 -0
- package/dist/core/config/index.js +4 -0
- package/dist/core/config/load.d.ts +4 -0
- package/dist/core/config/load.js +25 -0
- package/dist/core/config/schema.d.ts +29 -0
- package/dist/core/config/schema.js +23 -0
- package/dist/core/config/validate-options.d.ts +4 -0
- package/dist/core/config/validate-options.js +4 -0
- package/dist/core/graph/build.d.ts +8 -0
- package/dist/core/graph/build.js +42 -0
- package/dist/core/graph/causal.d.ts +22 -0
- package/dist/core/graph/causal.js +106 -0
- package/dist/core/graph/dating.d.ts +18 -0
- package/dist/core/graph/dating.js +30 -0
- package/dist/core/graph/entities.d.ts +13 -0
- package/dist/core/graph/entities.js +22 -0
- package/dist/core/graph/index.d.ts +7 -0
- package/dist/core/graph/index.js +7 -0
- package/dist/core/graph/queries.d.ts +3 -0
- package/dist/core/graph/queries.js +8 -0
- package/dist/core/graph/types.d.ts +14 -0
- package/dist/core/graph/types.js +1 -0
- package/dist/core/graph/versioning.d.ts +15 -0
- package/dist/core/graph/versioning.js +50 -0
- package/dist/core/id.d.ts +3 -0
- package/dist/core/id.js +9 -0
- package/dist/core/lint/after-loops.d.ts +15 -0
- package/dist/core/lint/after-loops.js +34 -0
- package/dist/core/lint/dangling-links.d.ts +9 -0
- package/dist/core/lint/dangling-links.js +46 -0
- package/dist/core/lint/events-without-entities.d.ts +4 -0
- package/dist/core/lint/events-without-entities.js +9 -0
- package/dist/core/lint/forked-versions.d.ts +14 -0
- package/dist/core/lint/forked-versions.js +29 -0
- package/dist/core/lint/index.d.ts +6 -0
- package/dist/core/lint/index.js +6 -0
- package/dist/core/lint/lint.d.ts +24 -0
- package/dist/core/lint/lint.js +50 -0
- package/dist/core/lint/target-types.d.ts +9 -0
- package/dist/core/lint/target-types.js +47 -0
- package/dist/core/query/dates.d.ts +34 -0
- package/dist/core/query/dates.js +43 -0
- package/dist/core/query/index.d.ts +2 -0
- package/dist/core/query/index.js +2 -0
- package/dist/core/query/types.d.ts +21 -0
- package/dist/core/query/types.js +12 -0
- package/dist/core/schema/describe.d.ts +34 -0
- package/dist/core/schema/describe.js +99 -0
- package/dist/core/schema/fields.d.ts +57 -0
- package/dist/core/schema/fields.js +43 -0
- package/dist/core/schema/index.d.ts +4 -0
- package/dist/core/schema/index.js +4 -0
- package/dist/core/schema/validate.d.ts +22 -0
- package/dist/core/schema/validate.js +34 -0
- package/dist/core/schema/wikilink.d.ts +8 -0
- package/dist/core/schema/wikilink.js +23 -0
- package/dist/core/search/build.d.ts +11 -0
- package/dist/core/search/build.js +26 -0
- package/dist/core/search/columns.d.ts +9 -0
- package/dist/core/search/columns.js +14 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/search.d.ts +3 -0
- package/dist/core/search/search.js +35 -0
- package/dist/core/search/types.d.ts +18 -0
- package/dist/core/search/types.js +1 -0
- package/dist/core/vault/index.d.ts +6 -0
- package/dist/core/vault/index.js +6 -0
- package/dist/core/vault/parse.d.ts +8 -0
- package/dist/core/vault/parse.js +11 -0
- package/dist/core/vault/read.d.ts +13 -0
- package/dist/core/vault/read.js +62 -0
- package/dist/core/vault/relink.d.ts +10 -0
- package/dist/core/vault/relink.js +33 -0
- package/dist/core/vault/serialize.d.ts +4 -0
- package/dist/core/vault/serialize.js +4 -0
- package/dist/core/vault/types.d.ts +5 -0
- package/dist/core/vault/types.js +1 -0
- package/dist/core/vault/write.d.ts +18 -0
- package/dist/core/vault/write.js +37 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/render-pipeline/build.d.ts +12 -0
- package/dist/render-pipeline/build.js +49 -0
- package/dist/render-pipeline/filter.d.ts +21 -0
- package/dist/render-pipeline/filter.js +80 -0
- package/dist/render-pipeline/index.d.ts +11 -0
- package/dist/render-pipeline/index.js +11 -0
- package/dist/render-pipeline/merge.d.ts +11 -0
- package/dist/render-pipeline/merge.js +21 -0
- package/dist/render-pipeline/quartz-compat.d.ts +1 -0
- package/dist/render-pipeline/quartz-compat.js +18 -0
- package/dist/render-pipeline/rewrite.d.ts +8 -0
- package/dist/render-pipeline/rewrite.js +23 -0
- package/dist/render-pipeline/slugs.d.ts +8 -0
- package/dist/render-pipeline/slugs.js +31 -0
- package/dist/render-pipeline/start-page.d.ts +9 -0
- package/dist/render-pipeline/start-page.js +44 -0
- package/dist/render-pipeline/sync.d.ts +9 -0
- package/dist/render-pipeline/sync.js +18 -0
- package/dist/render-pipeline/types.d.ts +8 -0
- package/dist/render-pipeline/types.js +1 -0
- package/dist/render-pipeline/write.d.ts +3 -0
- package/dist/render-pipeline/write.js +12 -0
- package/package.json +46 -4
- package/skills/world-vault/SKILL.md +100 -0
- package/templates/AGENTS.md +11 -0
- package/templates/gitignore +5 -0
- package/templates/publish.yml +54 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { knowledgeFileFrontmatterSchema } from "./fields.js";
|
|
2
|
+
export function hasErrors(issues) {
|
|
3
|
+
return issues.some((issue) => issue.severity === "error");
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The one place a knowledge file's frontmatter gets checked (PROJECT_STRUCTURE.md:
|
|
7
|
+
* used by vault parsing, CLI `validate`/`create`, and the CLI's `schema` introspection).
|
|
8
|
+
*/
|
|
9
|
+
export function validateFrontmatter(raw, options = {}) {
|
|
10
|
+
const result = knowledgeFileFrontmatterSchema.safeParse(raw);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
return {
|
|
13
|
+
valid: false,
|
|
14
|
+
issues: result.error.issues.map((issue) => ({
|
|
15
|
+
path: issue.path.join("."),
|
|
16
|
+
message: issue.message,
|
|
17
|
+
severity: "error",
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const frontmatter = result.data;
|
|
22
|
+
const issues = [];
|
|
23
|
+
if (frontmatter.type === "entity" &&
|
|
24
|
+
frontmatter.entityType &&
|
|
25
|
+
options.entityTypeVocabulary &&
|
|
26
|
+
!options.entityTypeVocabulary.includes(frontmatter.entityType)) {
|
|
27
|
+
issues.push({
|
|
28
|
+
path: "entityType",
|
|
29
|
+
message: `"${frontmatter.entityType}" is not in this project's declared entityType vocabulary (${options.entityTypeVocabulary.join(", ")})`,
|
|
30
|
+
severity: "error",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { valid: issues.length === 0, frontmatter, issues };
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface Wikilink {
|
|
2
|
+
targetId: string;
|
|
3
|
+
display: string;
|
|
4
|
+
}
|
|
5
|
+
/** True if `value` is a wikilink in the framework's required form: `[[uuid|Display Name]]` (SCHEMA.md rule 7). */
|
|
6
|
+
export declare function isWikilink(value: unknown): value is string;
|
|
7
|
+
export declare function parseWikilink(raw: string): Wikilink;
|
|
8
|
+
export declare function formatWikilink(targetId: string, display: string): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isUuid } from "../id.js";
|
|
2
|
+
const WIKILINK_RE = /^\[\[([0-9a-f-]{36})\|(.+)\]\]$/i;
|
|
3
|
+
/** True if `value` is a wikilink in the framework's required form: `[[uuid|Display Name]]` (SCHEMA.md rule 7). */
|
|
4
|
+
export function isWikilink(value) {
|
|
5
|
+
if (typeof value !== "string")
|
|
6
|
+
return false;
|
|
7
|
+
const match = WIKILINK_RE.exec(value);
|
|
8
|
+
return match !== null && isUuid(match[1]);
|
|
9
|
+
}
|
|
10
|
+
export function parseWikilink(raw) {
|
|
11
|
+
const match = WIKILINK_RE.exec(raw);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(`Not a valid wikilink (expected "[[uuid|Display Name]]"): ${raw}`);
|
|
14
|
+
}
|
|
15
|
+
const [, targetId, display] = match;
|
|
16
|
+
if (!isUuid(targetId)) {
|
|
17
|
+
throw new Error(`Wikilink target is not a valid UUID: ${targetId}`);
|
|
18
|
+
}
|
|
19
|
+
return { targetId, display };
|
|
20
|
+
}
|
|
21
|
+
export function formatWikilink(targetId, display) {
|
|
22
|
+
return `[[${targetId}|${display}]]`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { SearchIndex } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds an in-memory FTS5 search index from the current vault state. This is a
|
|
5
|
+
* disposable cache, not a source of truth — rebuild it whenever the vault changes
|
|
6
|
+
* rather than trying to keep it in sync incrementally (see claude-dev/PROJECT_STRUCTURE.md
|
|
7
|
+
* discussion: not worth the complexity until indexing hundreds of files is ever
|
|
8
|
+
* actually slow, which it isn't).
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildSearchIndex(files: VaultFile[]): SearchIndex;
|
|
11
|
+
export declare function closeSearchIndex(index: SearchIndex): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { COLUMNS, toSpaceJoined } from "./columns.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds an in-memory FTS5 search index from the current vault state. This is a
|
|
5
|
+
* disposable cache, not a source of truth — rebuild it whenever the vault changes
|
|
6
|
+
* rather than trying to keep it in sync incrementally (see claude-dev/PROJECT_STRUCTURE.md
|
|
7
|
+
* discussion: not worth the complexity until indexing hundreds of files is ever
|
|
8
|
+
* actually slow, which it isn't).
|
|
9
|
+
*/
|
|
10
|
+
export function buildSearchIndex(files) {
|
|
11
|
+
const db = new Database(":memory:");
|
|
12
|
+
db.exec(`CREATE VIRTUAL TABLE files USING fts5(${COLUMNS.map((c) => (c === "id" ? `${c} UNINDEXED` : c)).join(", ")});`);
|
|
13
|
+
const insert = db.prepare(`INSERT INTO files (${COLUMNS.join(", ")}) VALUES (${COLUMNS.map(() => "?").join(", ")})`);
|
|
14
|
+
const filesById = new Map();
|
|
15
|
+
const insertAll = db.transaction((rows) => {
|
|
16
|
+
for (const file of rows) {
|
|
17
|
+
filesById.set(file.id, file);
|
|
18
|
+
insert.run(file.id, toSpaceJoined(file.frontmatter.title), toSpaceJoined(file.frontmatter.aliases), toSpaceJoined(file.frontmatter.tags), toSpaceJoined(file.frontmatter.entityType), file.body);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
insertAll(files);
|
|
22
|
+
return { db, filesById };
|
|
23
|
+
}
|
|
24
|
+
export function closeSearchIndex(index) {
|
|
25
|
+
index.db.close();
|
|
26
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTS5 column order for the `files` virtual table: id, title, aliases, tags,
|
|
3
|
+
* entityType, body. Weights boost title/aliases/tags/entityType matches over
|
|
4
|
+
* plain body-text matches; id is UNINDEXED so its weight is irrelevant.
|
|
5
|
+
* Keep build.ts and search.ts in sync via these constants.
|
|
6
|
+
*/
|
|
7
|
+
export declare const COLUMNS: readonly ["id", "title", "aliases", "tags", "entityType", "body"];
|
|
8
|
+
export declare const COLUMN_WEIGHTS = "0.0, 5.0, 3.0, 2.0, 2.0, 1.0";
|
|
9
|
+
export declare function toSpaceJoined(value: unknown): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTS5 column order for the `files` virtual table: id, title, aliases, tags,
|
|
3
|
+
* entityType, body. Weights boost title/aliases/tags/entityType matches over
|
|
4
|
+
* plain body-text matches; id is UNINDEXED so its weight is irrelevant.
|
|
5
|
+
* Keep build.ts and search.ts in sync via these constants.
|
|
6
|
+
*/
|
|
7
|
+
export const COLUMNS = ["id", "title", "aliases", "tags", "entityType", "body"];
|
|
8
|
+
export const COLUMN_WEIGHTS = "0.0, 5.0, 3.0, 2.0, 2.0, 1.0";
|
|
9
|
+
export function toSpaceJoined(value) {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.filter((entry) => typeof entry === "string").join(" ");
|
|
12
|
+
}
|
|
13
|
+
return typeof value === "string" ? value : "";
|
|
14
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SearchIndex, SearchOptions, SearchResult } from "./types.js";
|
|
2
|
+
/** Keyword search over the index. Title/aliases/tags/entityType matches outrank plain body-text matches. */
|
|
3
|
+
export declare function search(index: SearchIndex, query: string, options?: SearchOptions): SearchResult[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { COLUMN_WEIGHTS } from "./columns.js";
|
|
2
|
+
function buildMatchQuery(query) {
|
|
3
|
+
const tokens = query
|
|
4
|
+
.split(/\s+/)
|
|
5
|
+
.map((token) => token.trim())
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.map((token) => `"${token.replace(/"/g, '""')}"`);
|
|
8
|
+
return tokens.length > 0 ? tokens.join(" ") : undefined;
|
|
9
|
+
}
|
|
10
|
+
function hasAllTags(file, tags) {
|
|
11
|
+
const fileTags = Array.isArray(file.frontmatter.tags) ? file.frontmatter.tags : [];
|
|
12
|
+
return tags.every((tag) => fileTags.includes(tag));
|
|
13
|
+
}
|
|
14
|
+
/** Keyword search over the index. Title/aliases/tags/entityType matches outrank plain body-text matches. */
|
|
15
|
+
export function search(index, query, options = {}) {
|
|
16
|
+
const matchQuery = buildMatchQuery(query);
|
|
17
|
+
if (!matchQuery)
|
|
18
|
+
return [];
|
|
19
|
+
const rows = index.db
|
|
20
|
+
.prepare(`SELECT id, -bm25(files, ${COLUMN_WEIGHTS}) AS score
|
|
21
|
+
FROM files WHERE files MATCH ?
|
|
22
|
+
ORDER BY score DESC`)
|
|
23
|
+
.all(matchQuery);
|
|
24
|
+
let results = rows
|
|
25
|
+
.map((row) => ({ id: row.id, score: row.score, file: index.filesById.get(row.id) }))
|
|
26
|
+
.filter((result) => result.file !== undefined);
|
|
27
|
+
if (options.tags && options.tags.length > 0) {
|
|
28
|
+
const tags = options.tags;
|
|
29
|
+
results = results.filter((result) => hasAllTags(result.file, tags));
|
|
30
|
+
}
|
|
31
|
+
if (options.limit !== undefined) {
|
|
32
|
+
results = results.slice(0, options.limit);
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { VaultFile } from "../vault/types.js";
|
|
3
|
+
/** An in-memory FTS5 index over a vault snapshot — disposable, rebuilt from the vault, never persisted. */
|
|
4
|
+
export interface SearchIndex {
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
filesById: Map<string, VaultFile>;
|
|
7
|
+
}
|
|
8
|
+
export interface SearchOptions {
|
|
9
|
+
/** Only include files carrying every one of these tags. */
|
|
10
|
+
tags?: string[];
|
|
11
|
+
limit?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface SearchResult {
|
|
14
|
+
id: string;
|
|
15
|
+
file: VaultFile;
|
|
16
|
+
/** Relevance score — higher is a better match. */
|
|
17
|
+
score: number;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { VaultFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parses a vault file's raw content. Never validates against the schema —
|
|
4
|
+
* reading a vault file must always succeed, even if its frontmatter doesn't
|
|
5
|
+
* conform (e.g. it predates a schema change). Validation happens on write only,
|
|
6
|
+
* see write.ts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseVaultFile(id: string, raw: string): VaultFile;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
/**
|
|
3
|
+
* Parses a vault file's raw content. Never validates against the schema —
|
|
4
|
+
* reading a vault file must always succeed, even if its frontmatter doesn't
|
|
5
|
+
* conform (e.g. it predates a schema change). Validation happens on write only,
|
|
6
|
+
* see write.ts.
|
|
7
|
+
*/
|
|
8
|
+
export function parseVaultFile(id, raw) {
|
|
9
|
+
const { data, content } = matter(raw);
|
|
10
|
+
return { id, frontmatter: data, body: content };
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { VaultFile } from "./types.js";
|
|
2
|
+
/** Where an existing vault file actually lives — possibly in a local subfolder (see readVault). */
|
|
3
|
+
export declare function findVaultFilePath(vaultDir: string, id: string): Promise<string | undefined>;
|
|
4
|
+
/** Reads and parses one vault file by id. Never validates — see write.ts. */
|
|
5
|
+
export declare function readVaultFile(vaultDir: string, id: string): Promise<VaultFile>;
|
|
6
|
+
/**
|
|
7
|
+
* Reads every vault file: UUID-named `.md` files anywhere under vaultDir.
|
|
8
|
+
* SCHEMA.md's flat pool needs no folders, but a project may still use them
|
|
9
|
+
* locally (rule 5), so this looks recursively rather than assuming a flat layout.
|
|
10
|
+
* Anything not UUID-named (README, config, etc.) is silently skipped, and
|
|
11
|
+
* tooling folders (node_modules, dot-folders) aren't searched at all.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readVault(vaultDir: string): Promise<VaultFile[]>;
|
|
@@ -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 @@
|
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,12 @@
|
|
|
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). Adds a start page listing everything included, titled
|
|
8
|
+
* `options.title` (the world's title).
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildDerivedTree(files: VaultFile[], query: BuildVariantQuery, options?: {
|
|
11
|
+
title?: string;
|
|
12
|
+
}): RenderedPage[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { buildGraph, topologicalOrder } 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
|
+
import { buildStartPage, START_PAGE_SLUG } from "./start-page.js";
|
|
8
|
+
function titleOf(file) {
|
|
9
|
+
return typeof file.frontmatter.title === "string" ? file.frontmatter.title : file.id;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Stage 1: vault -> derived, Quartz-ready page set (RENDER_PIPELINE.md). Filters
|
|
13
|
+
* by the build variant's query, merges each included entity's version history
|
|
14
|
+
* into one page, and rewrites every link to a slug (or strips it if the target
|
|
15
|
+
* isn't included). Adds a start page listing everything included, titled
|
|
16
|
+
* `options.title` (the world's title).
|
|
17
|
+
*/
|
|
18
|
+
export function buildDerivedTree(files, query, options = {}) {
|
|
19
|
+
const graph = buildGraph(files);
|
|
20
|
+
const includedEventIds = filterEvents(graph, files, query);
|
|
21
|
+
const includedAnchorIds = filterEntityAnchors(graph, files, query);
|
|
22
|
+
// Sorted for a deterministic slug assignment, independent of vault read order.
|
|
23
|
+
const includedIds = [...includedEventIds, ...includedAnchorIds].sort();
|
|
24
|
+
const titles = new Map(includedIds.map((id) => [id, titleOf(graph.nodes.get(id))]));
|
|
25
|
+
const slugsById = assignSlugs(titles, [START_PAGE_SLUG]);
|
|
26
|
+
const eventPages = [];
|
|
27
|
+
const entityPages = [];
|
|
28
|
+
for (const id of topologicalOrder(graph).filter((eventId) => includedEventIds.has(eventId))) {
|
|
29
|
+
const file = graph.nodes.get(id);
|
|
30
|
+
eventPages.push({
|
|
31
|
+
id,
|
|
32
|
+
slug: slugsById.get(id),
|
|
33
|
+
frontmatter: renameQuartzReservedFields(rewriteFrontmatterLinks(file.frontmatter, slugsById)),
|
|
34
|
+
body: rewriteLinks(file.body, slugsById),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
for (const anchorId of includedAnchorIds) {
|
|
38
|
+
const anchor = graph.nodes.get(anchorId);
|
|
39
|
+
const mergedBody = mergeEntityBody(graph, anchorId, includedEventIds);
|
|
40
|
+
entityPages.push({
|
|
41
|
+
id: anchorId,
|
|
42
|
+
slug: slugsById.get(anchorId),
|
|
43
|
+
frontmatter: renameQuartzReservedFields(rewriteFrontmatterLinks(anchor.frontmatter, slugsById)),
|
|
44
|
+
body: rewriteLinks(mergedBody, slugsById),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const startPage = buildStartPage(options.title ?? "World Wiki", entityPages, eventPages);
|
|
48
|
+
return [...eventPages, ...entityPages, startPage];
|
|
49
|
+
}
|
|
@@ -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
|
+
}
|