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,44 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { WORLD_CONFIG_FILENAME } from "../core/config/index.js";
|
|
4
|
+
function findSingleVaultUnderVaultsDir() {
|
|
5
|
+
const vaultsRoot = path.join(process.cwd(), "vaults");
|
|
6
|
+
let entries;
|
|
7
|
+
try {
|
|
8
|
+
entries = readdirSync(vaultsRoot).filter((name) => {
|
|
9
|
+
try {
|
|
10
|
+
return statSync(path.join(vaultsRoot, name)).isDirectory();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return entries.length === 1 ? path.join(vaultsRoot, entries[0]) : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** The current folder, if it's a vault — i.e. it has a world.config.json (the vault repo is the project, see claude-dev/DISTRIBUTION_PLAN.md). */
|
|
23
|
+
function currentFolderIfVault() {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
return existsSync(path.join(cwd, WORLD_CONFIG_FILENAME)) ? cwd : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Like resolveVaultDir, but returns undefined instead of throwing — for commands
|
|
29
|
+
* that are useful without a vault (e.g. `schema`).
|
|
30
|
+
*/
|
|
31
|
+
export function findVaultDir(cliOption) {
|
|
32
|
+
// The ./vaults/ fallback is the old fork-the-framework layout, kept until the
|
|
33
|
+
// world-repo layout fully replaces it.
|
|
34
|
+
return cliOption ?? process.env.WBF_VAULT_DIR ?? currentFolderIfVault() ?? findSingleVaultUnderVaultsDir();
|
|
35
|
+
}
|
|
36
|
+
/** Resolves which vault to operate on: explicit flag, then WBF_VAULT_DIR, then the current folder if it has a world.config.json, then a single directory under ./vaults/. */
|
|
37
|
+
export function resolveVaultDir(cliOption) {
|
|
38
|
+
const vaultDir = findVaultDir(cliOption);
|
|
39
|
+
if (!vaultDir) {
|
|
40
|
+
throw new Error("No vault specified — run this inside a vault (a folder with world.config.json), pass --vault <path>, " +
|
|
41
|
+
"set WBF_VAULT_DIR, or have exactly one directory under ./vaults/.");
|
|
42
|
+
}
|
|
43
|
+
return vaultDir;
|
|
44
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BuildVariantQuery } from "../query/index.js";
|
|
2
|
+
import type { WorldConfig } from "./schema.js";
|
|
3
|
+
export declare function listBuildVariants(config: WorldConfig): string[];
|
|
4
|
+
/** Looks up a named build variant's query. Throws if the name isn't declared — a typo'd variant name should fail loudly, not silently render everything. */
|
|
5
|
+
export declare function getBuildVariant(config: WorldConfig, name: string): BuildVariantQuery;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function listBuildVariants(config) {
|
|
2
|
+
return Object.keys(config.buildVariants ?? {});
|
|
3
|
+
}
|
|
4
|
+
/** Looks up a named build variant's query. Throws if the name isn't declared — a typo'd variant name should fail loudly, not silently render everything. */
|
|
5
|
+
export function getBuildVariant(config, name) {
|
|
6
|
+
const variant = config.buildVariants?.[name];
|
|
7
|
+
if (!variant) {
|
|
8
|
+
const known = listBuildVariants(config);
|
|
9
|
+
const knownText = known.length > 0 ? known.join(", ") : "(none declared)";
|
|
10
|
+
throw new Error(`No build variant named "${name}" in this vault's config. Declared variants: ${knownText}`);
|
|
11
|
+
}
|
|
12
|
+
return variant;
|
|
13
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type WorldConfig } from "./schema.js";
|
|
2
|
+
export declare const WORLD_CONFIG_FILENAME = "world.config.json";
|
|
3
|
+
/** Loads a vault's world.config.json. A missing file means no project-declared config yet — not an error. */
|
|
4
|
+
export declare function loadWorldConfig(vaultDir: string): Promise<WorldConfig>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { worldConfigSchema } from "./schema.js";
|
|
4
|
+
export const WORLD_CONFIG_FILENAME = "world.config.json";
|
|
5
|
+
/** Loads a vault's world.config.json. A missing file means no project-declared config yet — not an error. */
|
|
6
|
+
export async function loadWorldConfig(vaultDir) {
|
|
7
|
+
const filePath = path.join(vaultDir, WORLD_CONFIG_FILENAME);
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = await readFile(filePath, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (err.code === "ENOENT")
|
|
14
|
+
return {};
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
throw new Error(`${filePath} is not valid JSON: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
return worldConfigSchema.parse(parsed);
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A vault's own project-specific setup parameters (PROJECT_CONFIG.md).
|
|
4
|
+
* `title`, `entityTypes` and `buildVariants` are the fields with a decided consumer
|
|
5
|
+
* so far — tag vocabulary itself (as opposed to where it's used, buildVariants)
|
|
6
|
+
* and relation-field naming are still open, so this stays loose (unknown keys kept) rather
|
|
7
|
+
* than locking in a shape for those prematurely.
|
|
8
|
+
*/
|
|
9
|
+
export declare const worldConfigSchema: z.ZodObject<{
|
|
10
|
+
title: z.ZodOptional<z.ZodString>;
|
|
11
|
+
entityTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
|
+
buildVariants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
13
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
14
|
+
dateRange: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
from: z.ZodOptional<z.ZodObject<{
|
|
16
|
+
year: z.ZodNumber;
|
|
17
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
to: z.ZodOptional<z.ZodObject<{
|
|
20
|
+
year: z.ZodNumber;
|
|
21
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
}, z.core.$strip>>;
|
|
23
|
+
}, z.core.$strip>>;
|
|
24
|
+
}, z.core.$strip>>>;
|
|
25
|
+
}, z.core.$loose>;
|
|
26
|
+
export type WorldConfig = z.infer<typeof worldConfigSchema>;
|
|
27
|
+
export declare const DEFAULT_WORLD_TITLE = "World Wiki";
|
|
28
|
+
/** The world's display title, falling back to a neutral default (never Quartz's own "Quartz 5"). */
|
|
29
|
+
export declare function worldTitle(config: WorldConfig): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildVariantQuerySchema } from "../query/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* A vault's own project-specific setup parameters (PROJECT_CONFIG.md).
|
|
5
|
+
* `title`, `entityTypes` and `buildVariants` are the fields with a decided consumer
|
|
6
|
+
* so far — tag vocabulary itself (as opposed to where it's used, buildVariants)
|
|
7
|
+
* and relation-field naming are still open, so this stays loose (unknown keys kept) rather
|
|
8
|
+
* than locking in a shape for those prematurely.
|
|
9
|
+
*/
|
|
10
|
+
export const worldConfigSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
/** The world's name, shown as the published site's title (every variant and the landing page). */
|
|
13
|
+
title: z.string().min(1).optional(),
|
|
14
|
+
entityTypes: z.array(z.string()).optional(),
|
|
15
|
+
/** Named filter queries (render-pipeline's BuildVariantQuery) — PROJECT_CONFIG.md #3. */
|
|
16
|
+
buildVariants: z.record(z.string(), buildVariantQuerySchema).optional(),
|
|
17
|
+
})
|
|
18
|
+
.loose();
|
|
19
|
+
export const DEFAULT_WORLD_TITLE = "World Wiki";
|
|
20
|
+
/** The world's display title, falling back to a neutral default (never Quartz's own "Quartz 5"). */
|
|
21
|
+
export function worldTitle(config) {
|
|
22
|
+
return config.title ?? DEFAULT_WORLD_TITLE;
|
|
23
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ValidateOptions } from "../schema/validate.js";
|
|
2
|
+
import type { WorldConfig } from "./schema.js";
|
|
3
|
+
/** Bridges a loaded world.config.json into the shape core/schema's validateFrontmatter expects. */
|
|
4
|
+
export declare function validateOptionsFromConfig(config: WorldConfig): ValidateOptions;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds the relation graph from a set of vault files: nodes are files (by id),
|
|
5
|
+
* edges come from every wikilink-valued frontmatter field, whatever it's named
|
|
6
|
+
* (SCHEMA.md rule 2 — no fixed relation vocabulary).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildGraph(files: VaultFile[]): KnowledgeGraph;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
function extractWikilinks(value) {
|
|
3
|
+
if (typeof value === "string" && isWikilink(value)) {
|
|
4
|
+
return [parseWikilink(value)];
|
|
5
|
+
}
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return value.flatMap(extractWikilinks);
|
|
8
|
+
}
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
function push(map, key, edge) {
|
|
12
|
+
const list = map.get(key);
|
|
13
|
+
if (list) {
|
|
14
|
+
list.push(edge);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
map.set(key, [edge]);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Builds the relation graph from a set of vault files: nodes are files (by id),
|
|
22
|
+
* edges come from every wikilink-valued frontmatter field, whatever it's named
|
|
23
|
+
* (SCHEMA.md rule 2 — no fixed relation vocabulary).
|
|
24
|
+
*/
|
|
25
|
+
export function buildGraph(files) {
|
|
26
|
+
const nodes = new Map();
|
|
27
|
+
const edgesFrom = new Map();
|
|
28
|
+
const edgesTo = new Map();
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
nodes.set(file.id, file);
|
|
31
|
+
}
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
for (const [field, value] of Object.entries(file.frontmatter)) {
|
|
34
|
+
for (const link of extractWikilinks(value)) {
|
|
35
|
+
const edge = { field, from: file.id, to: link.targetId, display: link.display };
|
|
36
|
+
push(edgesFrom, file.id, edge);
|
|
37
|
+
push(edgesTo, link.targetId, edge);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { nodes, edgesFrom, edgesTo };
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
2
|
+
/** This event's own `after` targets — its causal predecessors. */
|
|
3
|
+
export declare function predecessors(graph: KnowledgeGraph, eventId: string): string[];
|
|
4
|
+
/** Events whose `after` points at this one — its causal successors. More than one means a branch. */
|
|
5
|
+
export declare function successors(graph: KnowledgeGraph, eventId: string): string[];
|
|
6
|
+
/** Events that fork into more than one successor (SCHEMA.md: a branch is implicit in the `after` graph). */
|
|
7
|
+
export declare function branchPoints(graph: KnowledgeGraph): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Finds a loop in `after` that runs through `startId`, following `afterOf`
|
|
10
|
+
* (an event id -> its `after` target ids). Returns the loop as ids, starting
|
|
11
|
+
* and ending at `startId` — each id is `after` the next one — or undefined.
|
|
12
|
+
* Takes a lookup rather than a graph so lint can check a not-yet-written
|
|
13
|
+
* change against the rest of the vault.
|
|
14
|
+
*/
|
|
15
|
+
export declare function findAfterLoop(startId: string, afterOf: (id: string) => string[]): string[] | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Topologically orders every event by `after` (predecessors before successors), earliest first.
|
|
18
|
+
* Where branches exist, events on different branches aren't causally comparable — this still
|
|
19
|
+
* returns one valid, consistent ordering among them, which is good enough to break ties
|
|
20
|
+
* (see graph/entities.ts) without needing full partial-order comparison logic yet.
|
|
21
|
+
*/
|
|
22
|
+
export declare function topologicalOrder(graph: KnowledgeGraph): string[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { outgoing, incoming } from "./queries.js";
|
|
2
|
+
function isEvent(file) {
|
|
3
|
+
return file.frontmatter.type === "event";
|
|
4
|
+
}
|
|
5
|
+
/** This event's own `after` targets — its causal predecessors. */
|
|
6
|
+
export function predecessors(graph, eventId) {
|
|
7
|
+
return outgoing(graph, eventId, "after").map((edge) => edge.to);
|
|
8
|
+
}
|
|
9
|
+
/** Events whose `after` points at this one — its causal successors. More than one means a branch. */
|
|
10
|
+
export function successors(graph, eventId) {
|
|
11
|
+
return incoming(graph, eventId, "after").map((edge) => edge.from);
|
|
12
|
+
}
|
|
13
|
+
/** Events that fork into more than one successor (SCHEMA.md: a branch is implicit in the `after` graph). */
|
|
14
|
+
export function branchPoints(graph) {
|
|
15
|
+
const points = [];
|
|
16
|
+
for (const [id, file] of graph.nodes) {
|
|
17
|
+
if (isEvent(file) && successors(graph, id).length > 1) {
|
|
18
|
+
points.push(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return points;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Finds a loop in `after` that runs through `startId`, following `afterOf`
|
|
25
|
+
* (an event id -> its `after` target ids). Returns the loop as ids, starting
|
|
26
|
+
* and ending at `startId` — each id is `after` the next one — or undefined.
|
|
27
|
+
* Takes a lookup rather than a graph so lint can check a not-yet-written
|
|
28
|
+
* change against the rest of the vault.
|
|
29
|
+
*/
|
|
30
|
+
export function findAfterLoop(startId, afterOf) {
|
|
31
|
+
const reachedFrom = new Map();
|
|
32
|
+
const visited = new Set([startId]);
|
|
33
|
+
const stack = [startId];
|
|
34
|
+
while (stack.length > 0) {
|
|
35
|
+
const id = stack.pop();
|
|
36
|
+
for (const next of afterOf(id)) {
|
|
37
|
+
if (next === startId) {
|
|
38
|
+
const loop = [id];
|
|
39
|
+
while (loop[0] !== startId)
|
|
40
|
+
loop.unshift(reachedFrom.get(loop[0]));
|
|
41
|
+
return [...loop, startId];
|
|
42
|
+
}
|
|
43
|
+
if (!visited.has(next)) {
|
|
44
|
+
visited.add(next);
|
|
45
|
+
reachedFrom.set(next, id);
|
|
46
|
+
stack.push(next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Topologically orders every event by `after` (predecessors before successors), earliest first.
|
|
54
|
+
* Where branches exist, events on different branches aren't causally comparable — this still
|
|
55
|
+
* returns one valid, consistent ordering among them, which is good enough to break ties
|
|
56
|
+
* (see graph/entities.ts) without needing full partial-order comparison logic yet.
|
|
57
|
+
*/
|
|
58
|
+
export function topologicalOrder(graph) {
|
|
59
|
+
const eventIds = [...graph.nodes.entries()].filter(([, file]) => isEvent(file)).map(([id]) => id);
|
|
60
|
+
const remainingPredecessors = new Map();
|
|
61
|
+
for (const id of eventIds) {
|
|
62
|
+
remainingPredecessors.set(id, new Set(predecessors(graph, id)));
|
|
63
|
+
}
|
|
64
|
+
const order = [];
|
|
65
|
+
const ready = eventIds.filter((id) => remainingPredecessors.get(id).size === 0);
|
|
66
|
+
while (ready.length > 0) {
|
|
67
|
+
const id = ready.shift();
|
|
68
|
+
order.push(id);
|
|
69
|
+
for (const childId of successors(graph, id)) {
|
|
70
|
+
const preds = remainingPredecessors.get(childId);
|
|
71
|
+
if (!preds)
|
|
72
|
+
continue;
|
|
73
|
+
preds.delete(id);
|
|
74
|
+
if (preds.size === 0)
|
|
75
|
+
ready.push(childId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (order.length !== eventIds.length) {
|
|
79
|
+
const ordered = new Set(order);
|
|
80
|
+
throw new Error(describeUnorderable(graph, eventIds.filter((id) => !ordered.has(id))));
|
|
81
|
+
}
|
|
82
|
+
return order;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Why some events couldn't be ordered, naming them — either a real loop, or an
|
|
86
|
+
* `after` that points at something which isn't an event in the vault (which
|
|
87
|
+
* also never becomes "ready"). Lint catches both before writing; this is for
|
|
88
|
+
* whatever got in anyway, e.g. a hand edit.
|
|
89
|
+
*/
|
|
90
|
+
function describeUnorderable(graph, stuckIds) {
|
|
91
|
+
const describe = (id) => {
|
|
92
|
+
const title = graph.nodes.get(id)?.frontmatter.title;
|
|
93
|
+
return `"${typeof title === "string" ? title : "untitled"}" (${id})`;
|
|
94
|
+
};
|
|
95
|
+
for (const id of stuckIds) {
|
|
96
|
+
const loop = findAfterLoop(id, (eventId) => predecessors(graph, eventId));
|
|
97
|
+
if (loop) {
|
|
98
|
+
return `Can't order events — loop in \`after\`, each event is after the next: ${loop.map(describe).join(" → ")}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const isEvent = (id) => graph.nodes.get(id)?.frontmatter.type === "event";
|
|
102
|
+
const badLinks = stuckIds.flatMap((id) => predecessors(graph, id)
|
|
103
|
+
.filter((target) => !isEvent(target))
|
|
104
|
+
.map((target) => `${describe(id)} is after ${target}, which isn't an event in the vault`));
|
|
105
|
+
return `Can't order events — ${badLinks.length > 0 ? badLinks.join("; ") : `unresolved: ${stuckIds.map(describe).join(", ")}`}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import { type DateTuple } from "../query/dates.js";
|
|
3
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
4
|
+
/** An event's own `date`, if it has a well-formed one. */
|
|
5
|
+
export declare function eventDate(file: VaultFile | undefined): DateTuple | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* The span an undated event must fall in, from its causal neighbours
|
|
8
|
+
* (REQUIREMENTS.md: "sometime after the Founding, before the War"):
|
|
9
|
+
* - `earliest`: the latest date among the dated events it's (transitively) `after`.
|
|
10
|
+
* - `latest`: the earliest date among the dated events (transitively) `after` it.
|
|
11
|
+
* Either side is undefined when no dated event bounds it on that side — the
|
|
12
|
+
* event could then be arbitrarily early or late.
|
|
13
|
+
*/
|
|
14
|
+
export interface DateWindow {
|
|
15
|
+
earliest?: DateTuple;
|
|
16
|
+
latest?: DateTuple;
|
|
17
|
+
}
|
|
18
|
+
export declare function undatedEventWindow(graph: KnowledgeGraph, eventId: string): DateWindow;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { compareDates } from "../query/dates.js";
|
|
2
|
+
import { predecessors, successors } from "./causal.js";
|
|
3
|
+
/** An event's own `date`, if it has a well-formed one. */
|
|
4
|
+
export function eventDate(file) {
|
|
5
|
+
const date = file?.frontmatter.date;
|
|
6
|
+
return typeof date?.year === "number" && typeof date.day === "number" ? { year: date.year, day: date.day } : undefined;
|
|
7
|
+
}
|
|
8
|
+
function extremeDate(graph, startId, next, pickLater) {
|
|
9
|
+
let best;
|
|
10
|
+
const visited = new Set([startId]);
|
|
11
|
+
const queue = next(graph, startId);
|
|
12
|
+
while (queue.length > 0) {
|
|
13
|
+
const id = queue.shift();
|
|
14
|
+
if (visited.has(id))
|
|
15
|
+
continue;
|
|
16
|
+
visited.add(id);
|
|
17
|
+
const date = eventDate(graph.nodes.get(id));
|
|
18
|
+
if (date && (!best || (pickLater ? compareDates(date, best) > 0 : compareDates(date, best) < 0))) {
|
|
19
|
+
best = date;
|
|
20
|
+
}
|
|
21
|
+
queue.push(...next(graph, id));
|
|
22
|
+
}
|
|
23
|
+
return best;
|
|
24
|
+
}
|
|
25
|
+
export function undatedEventWindow(graph, eventId) {
|
|
26
|
+
return {
|
|
27
|
+
earliest: extremeDate(graph, eventId, predecessors, true),
|
|
28
|
+
latest: extremeDate(graph, eventId, successors, false),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Events tied to this entity. Always queried against the anchor id — other files
|
|
4
|
+
* always link to an entity's anchor, never a later version (SCHEMA.md), so an
|
|
5
|
+
* event's `entities` field can never name anything but the anchor.
|
|
6
|
+
*/
|
|
7
|
+
export declare function entityTiedEvents(graph: KnowledgeGraph, anchorId: string): string[];
|
|
8
|
+
/**
|
|
9
|
+
* The chronologically-earliest event tied to this entity, by causal order — this is
|
|
10
|
+
* the entity's "effective date" for date-range build filtering (REQUIREMENTS.md).
|
|
11
|
+
* Returns undefined if the entity isn't tied to any event yet.
|
|
12
|
+
*/
|
|
13
|
+
export declare function entityFirstEventId(graph: KnowledgeGraph, anchorId: string): string | undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { incoming } from "./queries.js";
|
|
2
|
+
import { topologicalOrder } from "./causal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Events tied to this entity. Always queried against the anchor id — other files
|
|
5
|
+
* always link to an entity's anchor, never a later version (SCHEMA.md), so an
|
|
6
|
+
* event's `entities` field can never name anything but the anchor.
|
|
7
|
+
*/
|
|
8
|
+
export function entityTiedEvents(graph, anchorId) {
|
|
9
|
+
return incoming(graph, anchorId, "entities").map((edge) => edge.from);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The chronologically-earliest event tied to this entity, by causal order — this is
|
|
13
|
+
* the entity's "effective date" for date-range build filtering (REQUIREMENTS.md).
|
|
14
|
+
* Returns undefined if the entity isn't tied to any event yet.
|
|
15
|
+
*/
|
|
16
|
+
export function entityFirstEventId(graph, anchorId) {
|
|
17
|
+
const tied = new Set(entityTiedEvents(graph, anchorId));
|
|
18
|
+
if (tied.size === 0)
|
|
19
|
+
return undefined;
|
|
20
|
+
const order = topologicalOrder(graph);
|
|
21
|
+
return order.find((id) => tied.has(id));
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function outgoing(graph, id, field) {
|
|
2
|
+
const edges = graph.edgesFrom.get(id) ?? [];
|
|
3
|
+
return field ? edges.filter((edge) => edge.field === field) : edges;
|
|
4
|
+
}
|
|
5
|
+
export function incoming(graph, id, field) {
|
|
6
|
+
const edges = graph.edgesTo.get(id) ?? [];
|
|
7
|
+
return field ? edges.filter((edge) => edge.field === field) : edges;
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
export interface GraphEdge {
|
|
3
|
+
/** The frontmatter field this edge came from (e.g. "location", "after", "supersedes"). */
|
|
4
|
+
field: string;
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
/** The link's display text, as written at link-creation time (SCHEMA.md rule 7). */
|
|
8
|
+
display: string;
|
|
9
|
+
}
|
|
10
|
+
export interface KnowledgeGraph {
|
|
11
|
+
nodes: Map<string, VaultFile>;
|
|
12
|
+
edgesFrom: Map<string, GraphEdge[]>;
|
|
13
|
+
edgesTo: Map<string, GraphEdge[]>;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every version of an entity, starting at its anchor id, oldest first
|
|
5
|
+
* (SCHEMA.md rule 8). This is what the wiki's merge step uses to render an
|
|
6
|
+
* entity's full history rather than just its latest version.
|
|
7
|
+
*
|
|
8
|
+
* Usually the versions form a single chain. If two versions supersede the same
|
|
9
|
+
* file (a fork — a story branch, or more often a version pointed at the wrong
|
|
10
|
+
* predecessor), all of them are still returned rather than silently dropping
|
|
11
|
+
* one: a version always comes after the one it supersedes, and versions that
|
|
12
|
+
* are ready at the same time are ordered by their `causedBy` event's causal
|
|
13
|
+
* position, then by id, so the result never depends on vault read order.
|
|
14
|
+
*/
|
|
15
|
+
export declare function versionHistory(graph: KnowledgeGraph, anchorId: string): VaultFile[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { incoming, outgoing } from "./queries.js";
|
|
2
|
+
import { topologicalOrder } from "./causal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every version of an entity, starting at its anchor id, oldest first
|
|
5
|
+
* (SCHEMA.md rule 8). This is what the wiki's merge step uses to render an
|
|
6
|
+
* entity's full history rather than just its latest version.
|
|
7
|
+
*
|
|
8
|
+
* Usually the versions form a single chain. If two versions supersede the same
|
|
9
|
+
* file (a fork — a story branch, or more often a version pointed at the wrong
|
|
10
|
+
* predecessor), all of them are still returned rather than silently dropping
|
|
11
|
+
* one: a version always comes after the one it supersedes, and versions that
|
|
12
|
+
* are ready at the same time are ordered by their `causedBy` event's causal
|
|
13
|
+
* position, then by id, so the result never depends on vault read order.
|
|
14
|
+
*/
|
|
15
|
+
export function versionHistory(graph, anchorId) {
|
|
16
|
+
const anchor = graph.nodes.get(anchorId);
|
|
17
|
+
if (!anchor)
|
|
18
|
+
return [];
|
|
19
|
+
const history = [anchor];
|
|
20
|
+
const seen = new Set([anchorId]);
|
|
21
|
+
const ready = [];
|
|
22
|
+
const addSuccessors = (id) => {
|
|
23
|
+
for (const edge of incoming(graph, id, "supersedes")) {
|
|
24
|
+
if (seen.has(edge.from)) {
|
|
25
|
+
throw new Error(`Cycle detected in supersedes chain starting at ${anchorId}`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(edge.from);
|
|
28
|
+
const file = graph.nodes.get(edge.from);
|
|
29
|
+
if (file)
|
|
30
|
+
ready.push(file);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// Only needed to order a fork, so a plain chain never pays for (or depends on) the causal sort.
|
|
34
|
+
let causalIndex;
|
|
35
|
+
const rank = (file) => {
|
|
36
|
+
causalIndex ??= new Map(topologicalOrder(graph).map((id, i) => [id, i]));
|
|
37
|
+
const causedBy = outgoing(graph, file.id, "causedBy")[0];
|
|
38
|
+
return (causedBy && causalIndex.get(causedBy.to)) ?? Infinity;
|
|
39
|
+
};
|
|
40
|
+
addSuccessors(anchorId);
|
|
41
|
+
while (ready.length > 0) {
|
|
42
|
+
if (ready.length > 1) {
|
|
43
|
+
ready.sort((a, b) => rank(a) - rank(b) || (a.id < b.id ? -1 : 1));
|
|
44
|
+
}
|
|
45
|
+
const next = ready.shift();
|
|
46
|
+
history.push(next);
|
|
47
|
+
addSuccessors(next.id);
|
|
48
|
+
}
|
|
49
|
+
return history;
|
|
50
|
+
}
|
package/dist/core/id.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
/** Generates a new file identity (used as a vault file's filename, per SCHEMA.md rule 1). */
|
|
4
|
+
export function generateId() {
|
|
5
|
+
return uuidv4();
|
|
6
|
+
}
|
|
7
|
+
export function isUuid(value) {
|
|
8
|
+
return UUID_RE.test(value);
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/** The ids a file's `after` points at (valid wikilinks only). */
|
|
4
|
+
export declare function afterTargets(frontmatter: Record<string, unknown>): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Flags an event that's part of a loop in `after` (it would end up before
|
|
7
|
+
* itself). An error: unlike a fork, a loop is never meaningful, and it makes
|
|
8
|
+
* causal ordering — and with it the render — fail. Checks the file's own
|
|
9
|
+
* `after` as given (so an update is judged on its new content) against every
|
|
10
|
+
* other event's stored `after`. A file without an id is new: nothing links to
|
|
11
|
+
* it yet, so it can't be in a loop.
|
|
12
|
+
*/
|
|
13
|
+
export declare function findAfterLoops(file: Pick<VaultFile, "frontmatter"> & {
|
|
14
|
+
id?: string;
|
|
15
|
+
}, afterOf: Map<string, string[]>, titles: Map<string, string>): ValidationIssue[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
import { findAfterLoop } from "../graph/causal.js";
|
|
3
|
+
/** The ids a file's `after` points at (valid wikilinks only). */
|
|
4
|
+
export function afterTargets(frontmatter) {
|
|
5
|
+
const value = frontmatter.after;
|
|
6
|
+
if (!Array.isArray(value))
|
|
7
|
+
return [];
|
|
8
|
+
return value.filter(isWikilink).map((link) => parseWikilink(link).targetId);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Flags an event that's part of a loop in `after` (it would end up before
|
|
12
|
+
* itself). An error: unlike a fork, a loop is never meaningful, and it makes
|
|
13
|
+
* causal ordering — and with it the render — fail. Checks the file's own
|
|
14
|
+
* `after` as given (so an update is judged on its new content) against every
|
|
15
|
+
* other event's stored `after`. A file without an id is new: nothing links to
|
|
16
|
+
* it yet, so it can't be in a loop.
|
|
17
|
+
*/
|
|
18
|
+
export function findAfterLoops(file, afterOf, titles) {
|
|
19
|
+
if (!file.id || file.frontmatter.type !== "event")
|
|
20
|
+
return [];
|
|
21
|
+
const ownAfter = afterTargets(file.frontmatter);
|
|
22
|
+
const loop = findAfterLoop(file.id, (id) => (id === file.id ? ownAfter : (afterOf.get(id) ?? [])));
|
|
23
|
+
if (!loop)
|
|
24
|
+
return [];
|
|
25
|
+
const ownTitle = typeof file.frontmatter.title === "string" ? file.frontmatter.title : undefined;
|
|
26
|
+
const describe = (id) => `"${(id === file.id ? ownTitle : titles.get(id)) ?? "untitled"}" (${id})`;
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
path: "after",
|
|
30
|
+
message: `Loop in \`after\` — each event is after the next, so none can come first: ${loop.map(describe).join(" → ")}`,
|
|
31
|
+
severity: "error",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finds wikilinks — frontmatter or body — pointing at an id that doesn't exist
|
|
5
|
+
* anywhere in the vault. Checks body text too, not just frontmatter relations:
|
|
6
|
+
* a broken link is still bad to read even though body mentions don't count as
|
|
7
|
+
* relation edges (REQUIREMENTS.md's explicit-only relatedness rule).
|
|
8
|
+
*/
|
|
9
|
+
export declare function findDanglingLinks(file: Pick<VaultFile, "frontmatter" | "body">, knownIds: Set<string>): ValidationIssue[];
|