@dmthepm/commune 0.2.0 → 0.4.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.
@@ -1,3 +1,3 @@
1
1
  /** Usage text. Hand-written: `parseArgs` generates none, which is its one real cost. */
2
- export declare const USAGE = "commune \u2014 query the content graph without an Astro process\n\nUsage:\n commune [--root <dir>] graph query [filters] [--json]\n commune [--root <dir>] graph related <path|text|-> [--json]\n commune [--root <dir>] check [--json]\n commune [--root <dir>] gate [--dist <dir>] [--json]\n commune --version\n\nGlobal options:\n --root <dir> Project root: the directory containing src/content. Default: cwd.\n --json Emit one JSON document on stdout. Everything else goes to stderr.\n --help Show this text.\n --version Print the version of the installed package and exit.\n\ngraph query filters (any-of within a flag, all-of across flags):\n --collection <notes|research|pages> Repeatable.\n --tag <tag> Repeatable.\n --status <status>\n --orphans Zero inbound and zero outbound.\n --deadends Zero outbound.\n\ngate options:\n --dist <dir> The built site to check, relative to --root. Default: dist.\n\nExit codes:\n 0 finished, findings or not\n 1 could not finish\n 2 invalid invocation\n\n gate is the one exception, and the only verb whose exit code encodes a\n finding: it exits 1 when the build it checked is wrong. That is what a gate\n is for \u2014 a build stops on a non-zero exit \u2014 so gate cannot report a finding\n the way every other verb does, in the payload with exit 0.";
2
+ export declare const USAGE = "commune \u2014 query the content graph without an Astro process\n\nUsage:\n commune [--root <dir>] graph query [filters] [--json]\n commune [--root <dir>] graph related <path|text|-> [--json]\n commune [--root <dir>] render <path|-> [--site <origin>] [--json]\n commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]\n commune [--root <dir>] check [--json]\n commune [--root <dir>] gate [--dist <dir>] [--json]\n commune --version\n\nGlobal options:\n --root <dir> Project root: the directory containing src/content. Default: cwd.\n --json Emit one JSON document on stdout. Everything else goes to stderr.\n --help Show this text.\n --version Print the version of the installed package and exit.\n\ngraph query filters (any-of within a flag, all-of across flags):\n --collection <notes|research|pages|updates> Repeatable.\n --tag <tag> Repeatable.\n --status <status>\n --orphans Zero inbound and zero outbound.\n --deadends Zero outbound.\n --unreferenced Zero inbound, any outbound. Skips\n updates, which are expected to have\n none, unless --collection updates\n asks for them.\n --recent <7d|2w|2026-09-01> Updated on or since then. Entries\n with no date are not returned.\n\nrender options:\n --site <origin> The wiki's own origin, which is what decides whether a link\n is external. Read from the Astro config when it declares\n one; otherwise https://example.com, and it says so.\n\nupdate options:\n --recent <7d|2w|2026-09-01> What to roll up. Default: 7d.\n --write Write src/content/updates/<today>.md. Without it,\n the entry is printed on stdout. Never overwrites.\n\ngate options:\n --dist <dir> The built site to check, relative to --root. Default: dist.\n\nExit codes:\n 0 finished, findings or not\n 1 could not finish\n 2 invalid invocation\n\n gate is the one exception, and the only verb whose exit code encodes a\n finding: it exits 1 when the build it checked is wrong. That is what a gate\n is for \u2014 a build stops on a non-zero exit \u2014 so gate cannot report a finding\n the way every other verb does, in the payload with exit 0.";
3
3
  export declare const COMMAND_USAGE: Record<string, string>;
package/lib/cli/usage.js CHANGED
@@ -4,6 +4,8 @@ export const USAGE = `commune — query the content graph without an Astro proce
4
4
  Usage:
5
5
  commune [--root <dir>] graph query [filters] [--json]
6
6
  commune [--root <dir>] graph related <path|text|-> [--json]
7
+ commune [--root <dir>] render <path|-> [--site <origin>] [--json]
8
+ commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]
7
9
  commune [--root <dir>] check [--json]
8
10
  commune [--root <dir>] gate [--dist <dir>] [--json]
9
11
  commune --version
@@ -15,11 +17,27 @@ Global options:
15
17
  --version Print the version of the installed package and exit.
16
18
 
17
19
  graph query filters (any-of within a flag, all-of across flags):
18
- --collection <notes|research|pages> Repeatable.
19
- --tag <tag> Repeatable.
20
+ --collection <notes|research|pages|updates> Repeatable.
21
+ --tag <tag> Repeatable.
20
22
  --status <status>
21
- --orphans Zero inbound and zero outbound.
22
- --deadends Zero outbound.
23
+ --orphans Zero inbound and zero outbound.
24
+ --deadends Zero outbound.
25
+ --unreferenced Zero inbound, any outbound. Skips
26
+ updates, which are expected to have
27
+ none, unless --collection updates
28
+ asks for them.
29
+ --recent <7d|2w|2026-09-01> Updated on or since then. Entries
30
+ with no date are not returned.
31
+
32
+ render options:
33
+ --site <origin> The wiki's own origin, which is what decides whether a link
34
+ is external. Read from the Astro config when it declares
35
+ one; otherwise https://example.com, and it says so.
36
+
37
+ update options:
38
+ --recent <7d|2w|2026-09-01> What to roll up. Default: 7d.
39
+ --write Write src/content/updates/<today>.md. Without it,
40
+ the entry is printed on stdout. Never overwrites.
23
41
 
24
42
  gate options:
25
43
  --dist <dir> The built site to check, relative to --root. Default: dist.
@@ -34,8 +52,21 @@ Exit codes:
34
52
  is for — a build stops on a non-zero exit — so gate cannot report a finding
35
53
  the way every other verb does, in the payload with exit 0.`;
36
54
  export const COMMAND_USAGE = {
37
- 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--json]',
55
+ 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--unreferenced] [--recent <duration|date>] [--json]',
38
56
  'graph related': 'Usage: commune [--root <dir>] graph related <path|text|-> [--json]',
57
+ update: `Usage: commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]
58
+
59
+ Scaffold a dated update entry from the pages that changed. The draft is
60
+ printed on stdout unless --write is given, and --write refuses to overwrite an
61
+ update that already exists. \`summary\` is left empty on purpose: summarizing a
62
+ week is a judgement, and this command has none.`,
63
+ render: `Usage: commune [--root <dir>] render <path|-> [--site <origin>] [--json]
64
+
65
+ Render markdown to HTML through the site's own pipeline, with WikiLinks
66
+ resolved against the content tree and external links marked. Frontmatter is
67
+ split off and not rendered. --json adds the links the document contains and
68
+ the names among them that resolve to nothing — which the HTML cannot tell
69
+ you, since an unresolved WikiLink renders as plain text.`,
39
70
  check: 'Usage: commune [--root <dir>] check [--json]',
40
71
  gate: `Usage: commune [--root <dir>] gate [--dist <dir>] [--json]
41
72
 
@@ -11,7 +11,7 @@
11
11
  import { copyFile, writeFile, mkdir } from 'node:fs/promises';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import path from 'node:path';
14
- import { buildGraph, formatDiagnostic, loadContentEntries, toBacklinksJson, toMarkdownPath, } from "./lib/graph.js";
14
+ import { buildGraph, formatDiagnostic, loadContentEntries, summarizeSite, toBacklinksJson, toMarkdownPath, } from "./lib/graph.js";
15
15
  function buildBacklinksGraph(entries, logger) {
16
16
  const graph = buildGraph(entries);
17
17
  logger.info(`📝 Found ${Object.keys(graph.nodes).length} public content entries`);
@@ -24,9 +24,24 @@ function buildBacklinksGraph(entries, logger) {
24
24
  }
25
25
  return graph;
26
26
  }
27
- async function writeBacklinksFile(filePath, graph) {
27
+ async function writeJsonFile(filePath, value) {
28
28
  await mkdir(path.dirname(filePath), { recursive: true });
29
- await writeFile(filePath, JSON.stringify(graph, null, 2) + '\n');
29
+ await writeFile(filePath, JSON.stringify(value, null, 2) + '\n');
30
+ }
31
+ async function writeBacklinksFile(filePath, graph) {
32
+ await writeJsonFile(filePath, graph);
33
+ }
34
+ /**
35
+ * The site-wide summary, beside the graph rather than inside it.
36
+ *
37
+ * `backlinks.json` is keyed by urlPath and two of its readers walk it with
38
+ * `Object.entries`, so a top-level `lastUpdated` there would be a malformed
39
+ * node rather than a new field. It is also committed, and this value changes
40
+ * on the same commit that changes it — a committed copy is stale exactly when
41
+ * it matters. So: a sibling file, generated, gitignored.
42
+ */
43
+ async function writeSiteFile(filePath, summary) {
44
+ await writeJsonFile(filePath, summary);
30
45
  }
31
46
  /**
32
47
  * Copy every entry's source file next to its rendered page, at `<url>.md`.
@@ -60,6 +75,7 @@ export default function commune(_options = {}) {
60
75
  // one process do not overwrite each other's roots.
61
76
  let root;
62
77
  let publicBacklinks;
78
+ let publicSite;
63
79
  return {
64
80
  name: 'commune-backlinks',
65
81
  hooks: {
@@ -74,9 +90,12 @@ export default function commune(_options = {}) {
74
90
  // `fileURLToPath` is what Astro's own docs use.
75
91
  root = fileURLToPath(config.root);
76
92
  publicBacklinks = fileURLToPath(new URL('./backlinks.json', config.publicDir));
93
+ publicSite = fileURLToPath(new URL('./site.json', config.publicDir));
77
94
  try {
78
- const graph = buildBacklinksGraph(await loadContentEntries({ root }), logger);
95
+ const entries = await loadContentEntries({ root });
96
+ const graph = buildBacklinksGraph(entries, logger);
79
97
  await writeBacklinksFile(publicBacklinks, toBacklinksJson(graph));
98
+ await writeSiteFile(publicSite, summarizeSite(entries));
80
99
  logger.info(`✅ Backlinks index written to ${path.relative(root, publicBacklinks)}`);
81
100
  logger.info(summarize(graph));
82
101
  }
@@ -95,10 +114,16 @@ export default function commune(_options = {}) {
95
114
  await writeBacklinksFile(fileURLToPath(new URL('./backlinks.json', dir)), json);
96
115
  // Also written to the public directory, for dev server parity.
97
116
  await writeBacklinksFile(publicBacklinks, json);
117
+ const site = summarizeSite(entries);
118
+ await writeSiteFile(fileURLToPath(new URL('./site.json', dir)), site);
119
+ await writeSiteFile(publicSite, site);
98
120
  const written = await writeMarkdownFiles(entries, root, fileURLToPath(dir));
99
121
  logger.info(`✅ Backlinks index written to /backlinks.json (dist + public)`);
100
122
  logger.info(`📄 ${written} source files written as .md alongside their pages`);
101
123
  logger.info(summarize(graph));
124
+ logger.info(site.lastUpdated
125
+ ? `🕒 Site last updated ${site.lastUpdated} (${site.lastUpdatedPath}, from ${site.lastUpdatedSource})`
126
+ : '🕒 No entry carries a date, so site.json has no lastUpdated');
102
127
  }
103
128
  catch (error) {
104
129
  logger.error('❌ Failed to build backlinks index:');
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Where a content entry's dates come from when nobody wrote them down.
3
+ *
4
+ * `updated:` in frontmatter is a hand-maintained field, and a hand-maintained
5
+ * field is only true while somebody remembers to maintain it. The repository
6
+ * already knows the answer — a file's last commit *is* the day it last
7
+ * changed — so this module reads it from there and the graph prefers
8
+ * frontmatter only when frontmatter exists.
9
+ *
10
+ * Three rules the rest of the engine depends on:
11
+ *
12
+ * - **One walk, not one process per file.** `git log --name-only` over the
13
+ * content directories yields every file's whole history in a single
14
+ * child process. A vault has hundreds of notes; spawning `git` for each
15
+ * one turns a build into a minute of process creation.
16
+ * - **A shallow clone has no dates to give.** `actions/checkout` fetches
17
+ * depth 1 by default, and in that tree every file's "first and last
18
+ * commit" is the same single commit — the day of the build. That is not a
19
+ * worse date, it is a false one, and it would be false on every entry at
20
+ * once. So a shallow checkout produces no derived dates at all, and says
21
+ * so once on stderr.
22
+ * - **Never fatal, and never a guess dressed as a fact.** A tree that is not
23
+ * in a repository at all falls back to file mtimes. A tree that *is* in one
24
+ * never does: inside a checkout an mtime is the day the files were written
25
+ * to disk, which on CI is the day of the build and on a fresh clone is
26
+ * today, so it would launder the same falsehood the shallow rule exists to
27
+ * refuse.
28
+ */
29
+ /**
30
+ * How an entry's `updated` was decided, so a site can show the honest one.
31
+ *
32
+ * `frontmatter` is an author's claim, `git` is the repository's record,
33
+ * `mtime` is the filesystem's guess in a directory with no repository around
34
+ * it, and `none` means nothing could answer — an uncommitted file, or any file
35
+ * in a shallow checkout.
36
+ */
37
+ export type DateSource = 'frontmatter' | 'git' | 'mtime' | 'none';
38
+ /** The first and last commit dates of one file, as `yyyy-mm-dd`. */
39
+ export interface FileHistory {
40
+ /** Date of the file's most recent commit. */
41
+ updated: string;
42
+ /** Date of the file's oldest commit. */
43
+ created: string;
44
+ }
45
+ /**
46
+ * What the project's history can and cannot answer.
47
+ *
48
+ * Three states, because the two failures are not the same failure and the
49
+ * graph has to treat them differently. `unversioned` means no repository, and
50
+ * an mtime is the best honest answer available. `shallow` means a repository
51
+ * whose history was truncated, where both a commit date and an mtime would say
52
+ * "today" about every file at once.
53
+ */
54
+ export type HistoryKind = 'history' | 'shallow' | 'unversioned';
55
+ export interface ContentHistory {
56
+ kind: HistoryKind;
57
+ /** Keyed by root-relative file path. Empty unless `kind` is `history`. */
58
+ files: Map<string, FileHistory>;
59
+ }
60
+ /**
61
+ * Said once, on stderr, when a build or a query runs in a truncated checkout.
62
+ *
63
+ * On stderr rather than in `Graph.diagnostics` because it is not a finding
64
+ * about anyone's content — it is a fact about the environment the command ran
65
+ * in, and the person who needs it is reading a build log. stderr also keeps it
66
+ * out of the way of `--json`, whose stdout carries one document and nothing
67
+ * else.
68
+ */
69
+ export declare const SHALLOW_WARNING: string;
70
+ /** Test seam: forget which roots have been warned about. */
71
+ export declare function resetShallowWarnings(): void;
72
+ /** A local calendar day as `yyyy-mm-dd`.
73
+ *
74
+ * The one spelling of "what day is it" in the engine. Local rather than UTC on
75
+ * purpose, and shared rather than reimplemented: `git log --date=short` renders
76
+ * a commit in its own recorded timezone — the committer's local day — so an
77
+ * mtime, a `--recent 7d` cutoff and the date a scaffolded update is filed under
78
+ * all have to be local days too, or the four would disagree for the hours
79
+ * either side of midnight.
80
+ */
81
+ export declare function toIsoDay(date: Date): string;
82
+ /**
83
+ * Every content file's first and last commit date, from one `git log` walk.
84
+ *
85
+ * `--relative` (with the child process's cwd set to `root`) is what keeps the
86
+ * returned paths in the same spelling `ContentEntry.file` uses: a project root
87
+ * nested inside a larger repository would otherwise get repository-relative
88
+ * paths back and match nothing. `core.quotePath=false` keeps non-ASCII
89
+ * filenames literal — notes are named for their titles, and titles have
90
+ * accents.
91
+ *
92
+ * `%D` rides along on each commit line as the second belt: `rev-parse` has
93
+ * already answered whether the repository is shallow, and a walk that reaches
94
+ * the grafted boundary anyway says the same thing from the other direction.
95
+ * Either one means the same commit stands in for every commit before it, so
96
+ * neither the dates nor a first-commit `created` can be trusted.
97
+ */
98
+ export declare function readContentHistory(root: string, dirs: string[]): Promise<ContentHistory>;
99
+ /** A file's modification time as a local `yyyy-mm-dd`, or `undefined` if unreadable. */
100
+ export declare function readMtimeDate(root: string, file: string): Promise<string | undefined>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Where a content entry's dates come from when nobody wrote them down.
3
+ *
4
+ * `updated:` in frontmatter is a hand-maintained field, and a hand-maintained
5
+ * field is only true while somebody remembers to maintain it. The repository
6
+ * already knows the answer — a file's last commit *is* the day it last
7
+ * changed — so this module reads it from there and the graph prefers
8
+ * frontmatter only when frontmatter exists.
9
+ *
10
+ * Three rules the rest of the engine depends on:
11
+ *
12
+ * - **One walk, not one process per file.** `git log --name-only` over the
13
+ * content directories yields every file's whole history in a single
14
+ * child process. A vault has hundreds of notes; spawning `git` for each
15
+ * one turns a build into a minute of process creation.
16
+ * - **A shallow clone has no dates to give.** `actions/checkout` fetches
17
+ * depth 1 by default, and in that tree every file's "first and last
18
+ * commit" is the same single commit — the day of the build. That is not a
19
+ * worse date, it is a false one, and it would be false on every entry at
20
+ * once. So a shallow checkout produces no derived dates at all, and says
21
+ * so once on stderr.
22
+ * - **Never fatal, and never a guess dressed as a fact.** A tree that is not
23
+ * in a repository at all falls back to file mtimes. A tree that *is* in one
24
+ * never does: inside a checkout an mtime is the day the files were written
25
+ * to disk, which on CI is the day of the build and on a fresh clone is
26
+ * today, so it would launder the same falsehood the shallow rule exists to
27
+ * refuse.
28
+ */
29
+ import { execFile } from 'node:child_process';
30
+ import { stat } from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { promisify } from 'node:util';
33
+ const run = promisify(execFile);
34
+ /**
35
+ * Said once, on stderr, when a build or a query runs in a truncated checkout.
36
+ *
37
+ * On stderr rather than in `Graph.diagnostics` because it is not a finding
38
+ * about anyone's content — it is a fact about the environment the command ran
39
+ * in, and the person who needs it is reading a build log. stderr also keeps it
40
+ * out of the way of `--json`, whose stdout carries one document and nothing
41
+ * else.
42
+ */
43
+ export const SHALLOW_WARNING = 'git history is shallow: dates come from frontmatter only. ' +
44
+ 'Fetch full history (fetch-depth: 0 / unshallow) to derive dates from commits.';
45
+ /** One warning per project root per process, however many times the graph is loaded. */
46
+ const warned = new Set();
47
+ function warnShallow(root) {
48
+ const key = path.resolve(root);
49
+ if (warned.has(key))
50
+ return;
51
+ warned.add(key);
52
+ process.stderr.write(`${SHALLOW_WARNING}\n`);
53
+ }
54
+ /** Test seam: forget which roots have been warned about. */
55
+ export function resetShallowWarnings() {
56
+ warned.clear();
57
+ }
58
+ /** A local calendar day as `yyyy-mm-dd`.
59
+ *
60
+ * The one spelling of "what day is it" in the engine. Local rather than UTC on
61
+ * purpose, and shared rather than reimplemented: `git log --date=short` renders
62
+ * a commit in its own recorded timezone — the committer's local day — so an
63
+ * mtime, a `--recent 7d` cutoff and the date a scaffolded update is filed under
64
+ * all have to be local days too, or the four would disagree for the hours
65
+ * either side of midnight.
66
+ */
67
+ export function toIsoDay(date) {
68
+ const month = String(date.getMonth() + 1).padStart(2, '0');
69
+ const day = String(date.getDate()).padStart(2, '0');
70
+ return `${date.getFullYear()}-${month}-${day}`;
71
+ }
72
+ /** Is this root a shallow checkout, a full one, or not a repository at all? */
73
+ async function classify(root) {
74
+ try {
75
+ const { stdout } = await run('git', ['rev-parse', '--is-shallow-repository'], { cwd: root });
76
+ return stdout.trim() === 'true' ? 'shallow' : 'history';
77
+ }
78
+ catch {
79
+ // Not a repository, or no `git` on the PATH. The flag itself is not in
80
+ // question: it has shipped since git 2.15 (2017).
81
+ return 'unversioned';
82
+ }
83
+ }
84
+ /**
85
+ * Every content file's first and last commit date, from one `git log` walk.
86
+ *
87
+ * `--relative` (with the child process's cwd set to `root`) is what keeps the
88
+ * returned paths in the same spelling `ContentEntry.file` uses: a project root
89
+ * nested inside a larger repository would otherwise get repository-relative
90
+ * paths back and match nothing. `core.quotePath=false` keeps non-ASCII
91
+ * filenames literal — notes are named for their titles, and titles have
92
+ * accents.
93
+ *
94
+ * `%D` rides along on each commit line as the second belt: `rev-parse` has
95
+ * already answered whether the repository is shallow, and a walk that reaches
96
+ * the grafted boundary anyway says the same thing from the other direction.
97
+ * Either one means the same commit stands in for every commit before it, so
98
+ * neither the dates nor a first-commit `created` can be trusted.
99
+ */
100
+ export async function readContentHistory(root, dirs) {
101
+ const kind = await classify(root);
102
+ if (kind !== 'history') {
103
+ if (kind === 'shallow')
104
+ warnShallow(root);
105
+ return { kind, files: new Map() };
106
+ }
107
+ let stdout;
108
+ try {
109
+ ({ stdout } = await run('git', [
110
+ '-c',
111
+ 'core.quotePath=false',
112
+ 'log',
113
+ // A NUL before each commit, then its date and its decoration: no
114
+ // line of this can be confused with a filename, whatever a file is
115
+ // called.
116
+ '--format=%x00%cd%x09%D',
117
+ '--date=short',
118
+ '--name-only',
119
+ '--relative',
120
+ '--',
121
+ ...dirs,
122
+ ], { cwd: root, maxBuffer: 64 * 1024 * 1024 }));
123
+ }
124
+ catch {
125
+ // A repository the walk cannot read — most often one with no commits yet.
126
+ // Still a repository, so still not a place to reach for an mtime: it has
127
+ // nothing to say, and `none` is what "nothing to say" looks like.
128
+ return { kind: 'history', files: new Map() };
129
+ }
130
+ const files = new Map();
131
+ // `git log` walks newest first, so the first sighting of a file is its last
132
+ // commit and every later sighting is an older one.
133
+ for (const commit of stdout.split('\u0000')) {
134
+ const [header, ...lines] = commit.split('\n');
135
+ const [date, decoration = ''] = header.split('\t');
136
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
137
+ continue;
138
+ if (decoration.includes('grafted')) {
139
+ warnShallow(root);
140
+ return { kind: 'shallow', files: new Map() };
141
+ }
142
+ for (const line of lines) {
143
+ if (!line)
144
+ continue;
145
+ const file = unquotePath(line);
146
+ const seen = files.get(file);
147
+ if (seen)
148
+ seen.created = date;
149
+ else
150
+ files.set(file, { updated: date, created: date });
151
+ }
152
+ }
153
+ return { kind: 'history', files };
154
+ }
155
+ /**
156
+ * Undo git's C-style quoting of a path.
157
+ *
158
+ * With `core.quotePath=false` only paths containing a quote, a backslash or a
159
+ * control character are quoted at all, and JSON's escapes cover those. A path
160
+ * git quoted some other way is returned as git printed it: a filename that odd
161
+ * is better shown verbatim than silently mangled by a guess.
162
+ */
163
+ function unquotePath(line) {
164
+ if (!line.startsWith('"'))
165
+ return line;
166
+ try {
167
+ return JSON.parse(line);
168
+ }
169
+ catch {
170
+ return line;
171
+ }
172
+ }
173
+ /** A file's modification time as a local `yyyy-mm-dd`, or `undefined` if unreadable. */
174
+ export async function readMtimeDate(root, file) {
175
+ try {
176
+ const stats = await stat(path.join(root, file));
177
+ return toIsoDay(stats.mtime);
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
@@ -8,18 +8,27 @@
8
8
  * rules, and three subtly different opinions about trailing slashes.
9
9
  *
10
10
  * The rules this module owns:
11
- * - which collections participate (notes, research, pages)
12
- * - visibility (notes opt in with `visibility: public`; research and pages
13
- * are always public)
11
+ * - which collections participate (notes, research, pages, updates)
12
+ * - visibility (notes opt in with `visibility: public`; research, pages and
13
+ * updates are always public)
14
14
  * - canonical URLs, always with a trailing slash, matching Astro's
15
15
  * directory build format
16
16
  * - the title/alias lookup used to resolve `[[WikiLinks]]`
17
17
  * - which link forms count as an edge, and how each one resolves
18
18
  */
19
- export type CollectionName = 'notes' | 'research' | 'pages';
19
+ import { type DateSource } from './dates.ts';
20
+ export type { ContentHistory, DateSource, HistoryKind } from './dates.ts';
21
+ export { SHALLOW_WARNING, toIsoDay } from './dates.ts';
22
+ export type CollectionName = 'notes' | 'research' | 'pages' | 'updates';
20
23
  /** Where each collection's markdown lives, relative to the project root. */
21
24
  export declare const CONTENT_DIRS: Record<CollectionName, string>;
22
- /** Collection scan order. Stable so derived artifacts are deterministic. */
25
+ /**
26
+ * Collection scan order. Stable so derived artifacts are deterministic.
27
+ *
28
+ * `updates` is last because it was added last, and the order is the order
29
+ * `backlinks.json` is keyed in: putting it anywhere else would rewrite the
30
+ * whole committed artifact to say the same thing.
31
+ */
23
32
  export declare const COLLECTIONS: CollectionName[];
24
33
  /** One piece of public content, as the graph sees it. */
25
34
  export interface ContentEntry {
@@ -33,7 +42,28 @@ export interface ContentEntry {
33
42
  tags: string[];
34
43
  status: string;
35
44
  summary?: string;
45
+ /**
46
+ * When this entry last changed, by the best answer available.
47
+ *
48
+ * Frontmatter wins when an author wrote one down; otherwise the file's
49
+ * last commit date, and in a tree with no history its mtime. `updatedSource`
50
+ * says which of those this is, so a site can decide whether to show it.
51
+ */
36
52
  updated?: string;
53
+ /** Which of the three answers `updated` is. */
54
+ updatedSource: DateSource;
55
+ /** When this entry first appeared, by the same precedence as `updated`. */
56
+ created?: string;
57
+ /**
58
+ * The date of the file's last commit, whatever `updated` ended up being.
59
+ *
60
+ * Carried separately so a site can show both — "updated 2026-01-21, last
61
+ * touched 2026-09-03" is the sentence that catches a stale `updated:` field,
62
+ * and it cannot be written if the two dates have already been collapsed
63
+ * into one. Absent when the project is not a git checkout, or when the file
64
+ * has never been committed.
65
+ */
66
+ modifiedInGit?: string;
37
67
  /** Markdown body with frontmatter stripped. */
38
68
  body: string;
39
69
  /** Parsed frontmatter. Kept because links can live in it. */
@@ -93,6 +123,15 @@ export declare function toMarkdownPath(urlPath: string): string;
93
123
  export declare function toMarkdownHref(urlPath: string): string;
94
124
  /** Coerce a frontmatter date to `yyyy-mm-dd`, so build output has no timestamp drift. */
95
125
  export declare function normalizeDate(value: unknown): string | undefined;
126
+ /**
127
+ * The date an author wrote down, if they wrote one down.
128
+ *
129
+ * `updated` first, then `date` — the key the changelog-shaped collections use,
130
+ * where the entry *is* a dated thing rather than a page that happens to have
131
+ * been edited. Kept as one function so the graph, `--recent` and the site-wide
132
+ * last-updated value can never disagree about what an author claimed.
133
+ */
134
+ export declare function claimedDate(data: Record<string, unknown>): string | undefined;
96
135
  /** Where to read content from. */
97
136
  export interface GraphOptions {
98
137
  /**
@@ -251,6 +290,7 @@ export interface NoteMetadata {
251
290
  tags: string[];
252
291
  status: string;
253
292
  summary?: string;
293
+ /** The author's claimed date. Not the resolved one — see `buildGraph`. */
254
294
  updated?: string;
255
295
  isStarred?: boolean;
256
296
  }
@@ -311,6 +351,48 @@ export declare function buildGraph(entries: ContentEntry[]): Graph;
311
351
  * as it was when the integration formatted it itself.
312
352
  */
313
353
  export declare function formatDiagnostic(diagnostic: Diagnostic): string;
354
+ /**
355
+ * What the whole site says about itself, as one small object.
356
+ *
357
+ * Written to `site.json` beside `backlinks.json`, and deliberately not *into*
358
+ * it. Two reasons, and either alone would settle it:
359
+ *
360
+ * - Every top-level key of `backlinks.json` is a urlPath. Two of its readers
361
+ * walk it with `Object.entries` and treat what they find as a node, so a
362
+ * `lastUpdated` string at the top level is not a new field — it is a
363
+ * malformed entry in a runtime contract.
364
+ * - `backlinks.json` is committed, and this value moves with history: it
365
+ * changes on the commit that changes it, so a committed copy is stale the
366
+ * moment it is right. `site.json` is generated and ignored, like `dist/`.
367
+ */
368
+ export interface SiteSummary {
369
+ /** The newest `updated` across every public entry. Absent if nothing has a date. */
370
+ lastUpdated?: string;
371
+ /** The entry that date belongs to, so a page can link to what changed. */
372
+ lastUpdatedPath?: string;
373
+ /** Where that date came from — an author's claim, git, or an mtime. */
374
+ lastUpdatedSource?: DateSource;
375
+ /**
376
+ * The newest commit date across every entry, whatever the entries claim.
377
+ *
378
+ * `lastUpdated` obeys frontmatter, because an author who writes a date down
379
+ * means it. This does not, and the pair is the point: when they disagree,
380
+ * the wiki changed on a day nobody wrote down, and a site can say so
381
+ * instead of quietly showing the older number.
382
+ */
383
+ lastModifiedInGit?: string;
384
+ /** How many public entries the site has. */
385
+ entries: number;
386
+ }
387
+ /**
388
+ * The newest change across the whole site, and which entry it was.
389
+ *
390
+ * "When did this wiki last change" is not the same question as "when did this
391
+ * page last change", and a home page that answers the second while appearing
392
+ * to answer the first is the bug this ticket opened on. Ties go to the entry
393
+ * scanned first, which is stable because the scan is.
394
+ */
395
+ export declare function summarizeSite(entries: ContentEntry[]): SiteSummary;
314
396
  /**
315
397
  * The public artifact, exactly as `public/backlinks.json` stores it.
316
398
  *