@dmthepm/commune 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -99,8 +99,133 @@ That route is a starting point, not an interface. The package ships the mechanis
99
99
  - **Markdown twins.** Every published content entry gets its source written beside it, so `/notes/hello/` also answers at `/notes/hello.md`. Entries in the content directories only — a hand-written route under `src/pages/` has no source file to twin. Agents and readers get the same document without scraping HTML.
100
100
  - **External links.** Anything off your `site` origin gets `target="_blank" rel="noopener noreferrer"` without you marking it up.
101
101
  - **The graph as a library.** `@dmthepm/commune/graph` exports the content loader, the link resolver and the graph builder. The Astro build and the CLI both call it. That is the point: one resolver, not two that drift.
102
+ - **Updates.** A fourth collection, `src/content/updates/`, for the dated entries that say what changed. `Updates.astro` renders the newest few as a card. See [Updates](#updates) below.
103
+ - **Honest dates.** `updated:` in frontmatter wins where you wrote one; where you did not, the date comes from the file's last commit, and from its mtime in a tree with no history. Every entry says which, so a page can show the honest one. See [Dates](#dates) below.
104
+ - **A site-wide last-updated.** The build writes `site.json` beside `backlinks.json`: the newest date across the whole wiki, which entry it belongs to, and the newest commit date whatever the entries claim.
102
105
  - **Components and stylesheets.** `@dmthepm/commune/components/*.astro` and `@dmthepm/commune/styles/*.css`, shipped as source. These are the components off my own site rather than a theme system — take them as a starting point, not an API.
103
106
 
107
+ ## Dates
108
+
109
+ Two frontmatter fields decide a page's dates, and neither is required:
110
+
111
+ ```markdown
112
+ ---
113
+ created: 2025-10-09
114
+ updated: 2026-01-21
115
+ ---
116
+ ```
117
+
118
+ Where they are absent the engine reads the repository instead — `updated` is the file's last commit date, `created` its first — from one `git log` walk per build. A tree with no history at all (a tarball, a `COPY` in a Dockerfile, a host with no `git`) falls back to file mtimes rather than failing the build.
119
+
120
+ Every entry carries `updatedSource`, one of `frontmatter`, `git`, `mtime` or `none`, and `modifiedInGit`, which is the commit date whatever `updated` ended up being. The pair is the point: a note whose `updated:` says January and whose last commit was September changed on a day nobody wrote down, and a page that shows both says so.
121
+
122
+ ### Shallow clones
123
+
124
+ **A shallow checkout produces no derived dates at all.** In a `--depth 1` clone every file's only commit is the one that was fetched, so every file would date from the day of the build — one confident wrong answer on every entry at once. The engine refuses it rather than reporting it: dates come from frontmatter only, entries without one get `updatedSource: "none"` and no date, and the build prints this once on stderr:
125
+
126
+ ```
127
+ git history is shallow: dates come from frontmatter only. Fetch full history (fetch-depth: 0 / unshallow) to derive dates from commits.
128
+ ```
129
+
130
+ The same refusal applies to file mtimes anywhere inside a repository, and to a file that has never been committed. Inside a checkout an mtime is the moment the file reached that disk — on CI, the moment of the build — so it is the same falsehood wearing a different hat. mtimes are used in one place only: a project that is not in a repository at all.
131
+
132
+ The fix is to fetch the history. On **GitHub Actions**, `actions/checkout` defaults to depth 1, so set it explicitly:
133
+
134
+ ```yaml
135
+ - uses: actions/checkout@v7
136
+ with:
137
+ fetch-depth: 0
138
+ ```
139
+
140
+ On **Cloudflare Workers Builds**, check the build log for the warning above — if it is there, the clone was shallow. Whether Workers Builds exposes a clone-depth setting is not documented here; if it does not, an alternative is to keep `updated:` in frontmatter for anything whose date matters, which wins over history anyway. A build step that runs `git fetch --unshallow` before the build has the same effect wherever the build has network access and credentials for the repository.
141
+
142
+ **Known gap: renames.** History is read without `--follow`, so a file's derived `created` is the date of the commit that gave it its current path, not the date the writing began. Renaming a note therefore resets its `created` and leaves `updated` correct. `--follow` is per-file by design — it cannot be asked for in the single batched walk this uses — so the fix is a `created:` in frontmatter, which wins over history.
143
+
144
+ `commune graph query --json` carries all four fields. The build writes the site-wide version to `site.json`:
145
+
146
+ ```json
147
+ {
148
+ "lastUpdated": "2026-09-02",
149
+ "lastUpdatedPath": "/about-this-wiki/",
150
+ "lastUpdatedSource": "frontmatter",
151
+ "lastModifiedInGit": "2026-09-03",
152
+ "entries": 12
153
+ }
154
+ ```
155
+
156
+ It is a sibling of `backlinks.json` rather than a key inside it, because every top-level key of `backlinks.json` is a urlPath and its readers walk it as one. Generated, not committed: a date derived from history changes on the same commit that changes it, so a committed copy would be stale exactly when it mattered. Add `public/site.json` to your `.gitignore`.
157
+
158
+ ## Updates
159
+
160
+ A wiki's front door has to answer "what changed" before it answers anything else. Commune's answer is content: one dated entry per batch of work, in `src/content/updates/`, which the graph treats as a collection like any other — twins, backlinks, `check`, `graph query --collection updates`.
161
+
162
+ ```markdown
163
+ ---
164
+ title: "New notes and a working loop"
165
+ date: 2026-09-03
166
+ summary: "Rewrote the home note and added two notes."
167
+ links:
168
+ - Atomic Notes
169
+ - /notes/evergreen-notes/
170
+ ---
171
+
172
+ I rewrote the home note. [[Atomic Notes]] and [[Evergreen Notes]] are new.
173
+ ```
174
+
175
+ `links:` is the one place in frontmatter where a bare string is a link. Everywhere else a link has to be spelled `[[like this]]` — a page's own `url:` would otherwise become a self-edge — but `links:` means nothing else, so a title or a site path both resolve and both become real edges. Write it or don't: `[[wikilinks]]` in the body work the same way, and naming a page in both places is still one edge.
176
+
177
+ Register the collection alongside your notes in `src/content.config.ts`:
178
+
179
+ ```ts
180
+ updates: defineCollection({
181
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/updates' }),
182
+ schema: z.object({
183
+ title: z.string(),
184
+ date: z.string(),
185
+ summary: z.string(),
186
+ aiGenerated: z.boolean().default(false),
187
+ links: z.array(z.string()).default([]),
188
+ }),
189
+ }),
190
+ ```
191
+
192
+ Then render the card wherever it belongs — the home page, an index, a sidebar:
193
+
194
+ ```astro
195
+ ---
196
+ import Updates from '@dmthepm/commune/components/Updates.astro';
197
+ ---
198
+ <Updates limit={5} heading="Recent updates" />
199
+ ```
200
+
201
+ It reads the collection at build time and emits markup. No fetch, no client script.
202
+
203
+ ### A feed
204
+
205
+ The engine ships no routes, so it ships no RSS either — a feed is a route, and routes are yours. It is two lines with `@astrojs/rss`, in `src/pages/updates/rss.xml.ts`:
206
+
207
+ ```ts
208
+ import rss from '@astrojs/rss';
209
+ import { getCollection } from 'astro:content';
210
+
211
+ export async function GET(context) {
212
+ const updates = await getCollection('updates');
213
+ return rss({
214
+ title: 'Updates',
215
+ description: 'What changed',
216
+ site: context.site,
217
+ items: updates
218
+ .sort((a, b) => b.data.date.localeCompare(a.data.date))
219
+ .map((update) => ({
220
+ title: update.data.title,
221
+ description: update.data.summary,
222
+ pubDate: new Date(`${update.data.date}T12:00:00Z`),
223
+ link: `/updates/${update.id}/`,
224
+ })),
225
+ });
226
+ }
227
+ ```
228
+
104
229
  ## The CLI
105
230
 
106
231
  `commune` installs as a bin. It reads markdown off disk and answers without an Astro process running, which is what makes it useful while you are still writing.
@@ -108,6 +233,8 @@ That route is a starting point, not an interface. The package ships the mechanis
108
233
  ```bash
109
234
  commune check
110
235
  commune graph query --collection notes --orphans
236
+ commune graph query --recent 7d
237
+ commune update --recent 7d
111
238
  commune graph related src/content/notes/hello.md
112
239
  echo "a rough dump that mentions World" | commune graph related -
113
240
  commune gate
@@ -115,13 +242,18 @@ commune gate
115
242
 
116
243
  | Verb | What it answers |
117
244
  | --- | --- |
118
- | `graph query` | Every entry with its edges. Filter with `--collection`, `--tag`, `--status`, `--orphans`, `--deadends`. |
245
+ | `graph query` | Every entry with its edges and dates. Filter with `--collection`, `--tag`, `--status`, `--orphans`, `--deadends`, `--recent`. |
119
246
  | `graph related <path\|text\|->` | What this connects to. It takes stdin, so you can ask about a draft before it is a note. |
247
+ | `update` | Scaffold a dated update entry from what changed. Prints it; `--write` files it. |
120
248
  | `check` | Broken links, duplicate names, ambiguous targets, non-canonical titles. |
121
249
  | `gate` | Run after a build, against the built site. |
122
250
 
251
+ `--recent` takes `7d`, `2w` or a date, and reports the day it resolved to in the summary — which is what a weekly update job needs, since `7d` means a different day tomorrow. Entries with no date at all are not returned: "unchanged since Monday" and "nobody knows" are different answers.
252
+
123
253
  Every verb takes `--json` and emits one document on stdout with everything else on stderr. The human-readable text is the fallback rendering; the JSON is the contract.
124
254
 
255
+ `update` is the only verb that can write, and it only does so when asked: without `--write` the entry goes to stdout, and with it the command refuses to overwrite an update that already exists. `summary` comes out empty — summarizing a week is a judgement, and the CLI has none.
256
+
125
257
  Exit codes report whether the command finished, never what it found — `0` finished, `1` could not finish, `2` invalid invocation. Findings live in the payload. A command that exits non-zero because it *found* something is indistinguishable, to a shell, from one that crashed. `gate` is the one deliberate exception: a gate's entire job is a yes/no and a build has to stop on it, so `gate` exits `1` when the build it checked is wrong.
126
258
 
127
259
  `commune --help` prints the full surface. `commune --version` prints the installed version, which is the honest way to know what you have.
@@ -8,11 +8,12 @@
8
8
  */
9
9
  /** The command ran to completion. Findings, if any, are in the payload. */
10
10
  export declare const EXIT_OK = 0;
11
- /** The command could not finish: bad root, unreadable file, unparseable frontmatter. */
11
+ /** The command could not finish: bad root, unreadable file, unparseable frontmatter,
12
+ * or a file it would have written already being there. */
12
13
  export declare const EXIT_FAILED = 1;
13
14
  /** The command was invoked wrongly: unknown flag, missing value, unknown subcommand. */
14
15
  export declare const EXIT_USAGE = 2;
15
- export type ErrorCode = 'EUSAGE' | 'ENOCONTENT' | 'EPARSE' | 'EINTERNAL';
16
+ export type ErrorCode = 'EUSAGE' | 'ENOCONTENT' | 'EPARSE' | 'EEXISTS' | 'EINTERNAL';
16
17
  /** An error the CLI knows how to render on either side of the `--json` switch. */
17
18
  export declare class CliError extends Error {
18
19
  readonly code: ErrorCode;
package/lib/cli/errors.js CHANGED
@@ -8,7 +8,8 @@
8
8
  */
9
9
  /** The command ran to completion. Findings, if any, are in the payload. */
10
10
  export const EXIT_OK = 0;
11
- /** The command could not finish: bad root, unreadable file, unparseable frontmatter. */
11
+ /** The command could not finish: bad root, unreadable file, unparseable frontmatter,
12
+ * or a file it would have written already being there. */
12
13
  export const EXIT_FAILED = 1;
13
14
  /** The command was invoked wrongly: unknown flag, missing value, unknown subcommand. */
14
15
  export const EXIT_USAGE = 2;
package/lib/cli/main.js CHANGED
@@ -15,10 +15,12 @@ import { parseArgs } from 'node:util';
15
15
  import { CliError, EXIT_OK, EXIT_USAGE, isParseArgsError, usageError } from "./errors.js";
16
16
  import { writeError } from "./render.js";
17
17
  import { resolveRoot } from "./root.js";
18
- import { queryCommand } from "./query.js";
18
+ import { toIsoDay } from "../lib/graph.js";
19
+ import { parseRecent, queryCommand } from "./query.js";
19
20
  import { checkCommand } from "./check.js";
20
21
  import { gateCommand } from "./gate.js";
21
22
  import { relatedCommand } from "./related.js";
23
+ import { updateCommand } from "./update.js";
22
24
  import { COMMAND_USAGE, USAGE } from "./usage.js";
23
25
  import { readVersion } from "./version.js";
24
26
  /** Options understood everywhere, in any position. */
@@ -34,13 +36,19 @@ const QUERY_OPTIONS = {
34
36
  status: { type: 'string' },
35
37
  orphans: { type: 'boolean', default: false },
36
38
  deadends: { type: 'boolean', default: false },
39
+ recent: { type: 'string' },
40
+ };
41
+ const UPDATE_OPTIONS = {
42
+ ...GLOBAL,
43
+ recent: { type: 'string', default: '7d' },
44
+ write: { type: 'boolean', default: false },
37
45
  };
38
46
  const GATE_OPTIONS = {
39
47
  ...GLOBAL,
40
48
  dist: { type: 'string' },
41
49
  };
42
50
  /** Every route, longest first, so `graph query` is matched before a bare `graph`. */
43
- const ROUTES = ['graph query', 'graph related', 'check', 'gate'];
51
+ const ROUTES = ['graph query', 'graph related', 'check', 'gate', 'update'];
44
52
  /**
45
53
  * Find which command this argv names, without committing to its schema yet.
46
54
  *
@@ -69,6 +77,26 @@ function route(args) {
69
77
  }
70
78
  throw usageError(`unknown command: ${positionals.slice(0, 2).join(' ')}`, USAGE);
71
79
  }
80
+ /**
81
+ * Resolve `--recent` to a day, or fail the invocation.
82
+ *
83
+ * Resolved here rather than inside a command so an unparseable duration is
84
+ * exit 2 beside every other bad flag, instead of an empty result set that
85
+ * looks like an answer.
86
+ */
87
+ function resolveRecent(value, usage) {
88
+ if (value === undefined)
89
+ return undefined;
90
+ const since = parseRecent(value);
91
+ if (since === undefined) {
92
+ throw usageError(`--recent takes a number of days or weeks (7d, 2w) or a date (2026-09-01), not ${value}`, usage);
93
+ }
94
+ return since;
95
+ }
96
+ /** Today, as a local calendar day: the date a scaffolded update is filed under. */
97
+ function today() {
98
+ return toIsoDay(new Date());
99
+ }
72
100
  /** Turn `parseArgs`'s typed failures into the CLI's usage error. */
73
101
  function parseStrict(args, options, allowPositionals, usage) {
74
102
  try {
@@ -90,12 +118,14 @@ async function dispatch(args) {
90
118
  process.stdout.write(`${usage}\n`);
91
119
  return EXIT_OK;
92
120
  }
121
+ const since = resolveRecent(values.recent, usage);
93
122
  const filters = {
94
123
  collections: values.collection,
95
124
  tags: values.tag,
96
125
  status: values.status,
97
126
  orphans: values.orphans,
98
127
  deadends: values.deadends,
128
+ ...(since !== undefined ? { since } : {}),
99
129
  };
100
130
  return queryCommand(await resolveRoot(values.root), filters, values.json);
101
131
  }
@@ -120,6 +150,14 @@ async function dispatch(args) {
120
150
  }
121
151
  return checkCommand(await resolveRoot(values.root), values.json);
122
152
  }
153
+ case 'update': {
154
+ const { values } = parseStrict(rest, UPDATE_OPTIONS, false, usage);
155
+ if (values.help) {
156
+ process.stdout.write(`${usage}\n`);
157
+ return EXIT_OK;
158
+ }
159
+ return updateCommand(await resolveRoot(values.root), resolveRecent(values.recent, usage), today(), values.write, values.json);
160
+ }
123
161
  case 'gate': {
124
162
  const { values } = parseStrict(rest, GATE_OPTIONS, false, usage);
125
163
  if (values.help) {
@@ -6,14 +6,33 @@
6
6
  * per note. Filters narrow which entries are *returned*; they never change how
7
7
  * links resolve, so an entry's degree is the same whether or not you filtered.
8
8
  */
9
- import { type CollectionName } from '../lib/graph.ts';
9
+ import { type CollectionName, type DateSource } from '../lib/graph.ts';
10
10
  export interface QueryFilters {
11
11
  collections: string[];
12
12
  tags: string[];
13
13
  status?: string;
14
14
  orphans: boolean;
15
15
  deadends: boolean;
16
+ /** Inclusive `yyyy-mm-dd` cutoff from `--recent`. Entries older than it, and
17
+ * entries with no date at all, are not returned. */
18
+ since?: string;
16
19
  }
20
+ /**
21
+ * Resolve `--recent` to the day it means.
22
+ *
23
+ * Two spellings, because the two questions are different: `7d` is "since I
24
+ * last looked", which a weekly update job asks relative to now, and
25
+ * `2026-09-01` is "since this happened", which a person asks about a date they
26
+ * remember. `w` is offered because "the last two weeks" is a thing people say;
27
+ * months are not, since `m` would read as minutes to half the people who type
28
+ * it.
29
+ *
30
+ * Returns `undefined` for anything it cannot parse, so the caller can render
31
+ * it as the usage error it is rather than silently querying the epoch. The day
32
+ * is local: `7d` should mean seven of the reader's days, and the dates in
33
+ * content are calendar days with no timezone of their own.
34
+ */
35
+ export declare function parseRecent(value: string, today?: Date): string | undefined;
17
36
  export interface QueryEntry {
18
37
  urlPath: string;
19
38
  title: string;
@@ -24,6 +43,11 @@ export interface QueryEntry {
24
43
  status: string;
25
44
  aliases: string[];
26
45
  updated?: string;
46
+ /** Where `updated` came from: frontmatter, git history, the file's mtime, or nowhere. */
47
+ updatedSource: DateSource;
48
+ created?: string;
49
+ /** The file's last commit date, whatever `updated` ended up being. */
50
+ modifiedInGit?: string;
27
51
  outbound: string[];
28
52
  inbound: string[];
29
53
  }
package/lib/cli/query.js CHANGED
@@ -6,9 +6,35 @@
6
6
  * per note. Filters narrow which entries are *returned*; they never change how
7
7
  * links resolve, so an entry's degree is the same whether or not you filtered.
8
8
  */
9
- import { buildGraph, loadContentEntries, } from "../lib/graph.js";
9
+ import { buildGraph, loadContentEntries, toIsoDay, } from "../lib/graph.js";
10
10
  import { SCHEMA, writeJson, writeLines } from "./render.js";
11
11
  import { EXIT_OK } from "./errors.js";
12
+ /**
13
+ * Resolve `--recent` to the day it means.
14
+ *
15
+ * Two spellings, because the two questions are different: `7d` is "since I
16
+ * last looked", which a weekly update job asks relative to now, and
17
+ * `2026-09-01` is "since this happened", which a person asks about a date they
18
+ * remember. `w` is offered because "the last two weeks" is a thing people say;
19
+ * months are not, since `m` would read as minutes to half the people who type
20
+ * it.
21
+ *
22
+ * Returns `undefined` for anything it cannot parse, so the caller can render
23
+ * it as the usage error it is rather than silently querying the epoch. The day
24
+ * is local: `7d` should mean seven of the reader's days, and the dates in
25
+ * content are calendar days with no timezone of their own.
26
+ */
27
+ export function parseRecent(value, today = new Date()) {
28
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value))
29
+ return value;
30
+ const duration = /^(\d+)([dw])$/.exec(value);
31
+ if (!duration)
32
+ return undefined;
33
+ const days = Number(duration[1]) * (duration[2] === 'w' ? 7 : 1);
34
+ const cutoff = new Date(today);
35
+ cutoff.setDate(cutoff.getDate() - days);
36
+ return toIsoDay(cutoff);
37
+ }
12
38
  /** Project one loaded entry against the built graph. */
13
39
  function toQueryEntry(entry, graph) {
14
40
  const node = graph.nodes[entry.urlPath];
@@ -22,6 +48,9 @@ function toQueryEntry(entry, graph) {
22
48
  status: entry.status,
23
49
  aliases: entry.aliases,
24
50
  ...(entry.updated ? { updated: entry.updated } : {}),
51
+ updatedSource: entry.updatedSource,
52
+ ...(entry.created ? { created: entry.created } : {}),
53
+ ...(entry.modifiedInGit ? { modifiedInGit: entry.modifiedInGit } : {}),
25
54
  outbound: node.outbound,
26
55
  inbound: node.inbound,
27
56
  };
@@ -60,6 +89,11 @@ function matches(entry, filters) {
60
89
  return false;
61
90
  if (filters.deadends && !isDeadend(entry))
62
91
  return false;
92
+ // An entry with no date is not "unchanged since the cutoff", it is unknown —
93
+ // and a list of what changed this week is worth less with unknowns in it.
94
+ if (filters.since !== undefined && !(entry.updated && entry.updated >= filters.since)) {
95
+ return false;
96
+ }
63
97
  return true;
64
98
  }
65
99
  /**
@@ -74,19 +108,23 @@ function matches(entry, filters) {
74
108
  * Free to compute: `outbound` and `inbound` are already materialized on every
75
109
  * node by the time a query can be filtered at all.
76
110
  */
77
- function summarize(results) {
111
+ function summarize(results, since) {
78
112
  return {
79
113
  entries: results.length,
80
114
  edges: results.reduce((total, entry) => total + entry.outbound.length, 0),
81
115
  orphans: results.filter(isOrphan).length,
82
116
  deadends: results.filter(isDeadend).length,
117
+ // The resolved cutoff, not the string the caller typed: `--recent 7d`
118
+ // means a different day tomorrow, and a job that records what it asked
119
+ // needs the day, not the duration.
120
+ ...(since !== undefined ? { since } : {}),
83
121
  };
84
122
  }
85
123
  export async function queryCommand(root, filters, json) {
86
124
  const entries = await loadContentEntries({ root });
87
125
  const graph = buildGraph(entries);
88
126
  const results = entries.map((entry) => toQueryEntry(entry, graph)).filter((entry) => matches(entry, filters));
89
- const summary = summarize(results);
127
+ const summary = summarize(results, filters.since);
90
128
  if (json) {
91
129
  // `count` predates `summary` and stays as its alias: the field shipped in
92
130
  // the contract #10 and #19 are written against, and removing it would be
@@ -97,7 +135,9 @@ export async function queryCommand(root, filters, json) {
97
135
  writeLines([
98
136
  ...results.map((entry) => `${entry.urlPath}\t${entry.title}\t${entry.collection}\t${entry.status}\t` +
99
137
  `→${entry.outbound.length} ←${entry.inbound.length}`),
100
- `${summary.entries} entries, ${summary.edges} edges, ${summary.orphans} orphans, ${summary.deadends} dead ends`,
138
+ `${summary.entries} entries, ${summary.edges} edges, ${summary.orphans} orphans, ` +
139
+ `${summary.deadends} dead ends` +
140
+ (summary.since !== undefined ? `, updated since ${summary.since}` : ''),
101
141
  ]);
102
142
  return EXIT_OK;
103
143
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `commune update` — a dated update entry, scaffolded from what changed.
3
+ *
4
+ * The one verb here that can write. Everything else in this CLI answers
5
+ * questions about a content tree; this one drafts a file into it, so the
6
+ * writing is opt-in: without `--write` it prints the entry on stdout, which is
7
+ * both a preview and a redirect away from a file of your choosing. With
8
+ * `--write` it refuses to overwrite an existing entry — a scaffold that
9
+ * clobbers a day's writing is worse than no scaffold.
10
+ *
11
+ * What it produces is a draft and says so: `summary` is empty, because a
12
+ * one-line summary of a week is a judgement and this command has no taste.
13
+ * The parts it can be trusted with — which pages moved, what they are called,
14
+ * what date it is — are filled in.
15
+ */
16
+ export declare function updateCommand(root: string, since: string, day: string, write: boolean, json: boolean): Promise<number>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `commune update` — a dated update entry, scaffolded from what changed.
3
+ *
4
+ * The one verb here that can write. Everything else in this CLI answers
5
+ * questions about a content tree; this one drafts a file into it, so the
6
+ * writing is opt-in: without `--write` it prints the entry on stdout, which is
7
+ * both a preview and a redirect away from a file of your choosing. With
8
+ * `--write` it refuses to overwrite an existing entry — a scaffold that
9
+ * clobbers a day's writing is worse than no scaffold.
10
+ *
11
+ * What it produces is a draft and says so: `summary` is empty, because a
12
+ * one-line summary of a week is a judgement and this command has no taste.
13
+ * The parts it can be trusted with — which pages moved, what they are called,
14
+ * what date it is — are filled in.
15
+ */
16
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { CONTENT_DIRS, loadContentEntries } from "../lib/graph.js";
19
+ import { SCHEMA, writeJson, writeLines } from "./render.js";
20
+ import { EXIT_OK, failure } from "./errors.js";
21
+ /**
22
+ * The entry, as markdown.
23
+ *
24
+ * `links:` carries the urlPaths and the body carries the titles, which is the
25
+ * same edge written twice on purpose: the frontmatter is what the graph reads
26
+ * without rendering anything, and the prose is what a reader reads. They
27
+ * deduplicate into one edge, so writing both costs nothing.
28
+ */
29
+ function scaffold(day, changed) {
30
+ const lines = [
31
+ '---',
32
+ `title: "Updates for ${day}"`,
33
+ `date: ${day}`,
34
+ 'summary: ""',
35
+ 'aiGenerated: false',
36
+ ];
37
+ if (changed.length) {
38
+ lines.push('links:', ...changed.map((entry) => ` - ${entry.urlPath}`));
39
+ }
40
+ lines.push('---', '');
41
+ lines.push(...(changed.length
42
+ ? changed.map((entry) => `- [[${entry.title}]]${entry.summary ? ` — ${entry.summary}` : ''}`)
43
+ : ['Nothing changed in this window.']));
44
+ return `${lines.join('\n')}\n`;
45
+ }
46
+ async function exists(filePath) {
47
+ try {
48
+ await stat(filePath);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ export async function updateCommand(root, since, day, write, json) {
56
+ const entries = await loadContentEntries({ root });
57
+ // Updates are excluded from their own roll-up: an update that lists last
58
+ // week's update says nothing about the wiki.
59
+ const changed = entries.filter((entry) => entry.collection !== 'updates' && entry.updated !== undefined && entry.updated >= since);
60
+ const file = `${CONTENT_DIRS.updates}/${day}.md`;
61
+ const content = scaffold(day, changed);
62
+ if (write) {
63
+ const destination = path.join(root, file);
64
+ if (await exists(destination)) {
65
+ throw failure('EEXISTS', `${file} already exists. Edit it, or delete it first — this command does not overwrite an update that has been written.`);
66
+ }
67
+ await mkdir(path.dirname(destination), { recursive: true });
68
+ await writeFile(destination, content);
69
+ }
70
+ if (json) {
71
+ writeJson({
72
+ schema: SCHEMA,
73
+ root,
74
+ since,
75
+ date: day,
76
+ file,
77
+ written: write,
78
+ entries: changed.map((entry) => ({
79
+ urlPath: entry.urlPath,
80
+ title: entry.title,
81
+ collection: entry.collection,
82
+ updated: entry.updated,
83
+ updatedSource: entry.updatedSource,
84
+ })),
85
+ content,
86
+ });
87
+ return EXIT_OK;
88
+ }
89
+ writeLines(write ? [`wrote ${file} — ${changed.length} entries since ${since}`] : [content.trimEnd()]);
90
+ return EXIT_OK;
91
+ }
@@ -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>] 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 --recent <7d|2w|2026-09-01> Updated on or since then. Entries\n with no date are not returned.\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,7 @@ 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>] update [--recent <duration|date>] [--write] [--json]
7
8
  commune [--root <dir>] check [--json]
8
9
  commune [--root <dir>] gate [--dist <dir>] [--json]
9
10
  commune --version
@@ -15,11 +16,18 @@ Global options:
15
16
  --version Print the version of the installed package and exit.
16
17
 
17
18
  graph query filters (any-of within a flag, all-of across flags):
18
- --collection <notes|research|pages> Repeatable.
19
- --tag <tag> Repeatable.
19
+ --collection <notes|research|pages|updates> Repeatable.
20
+ --tag <tag> Repeatable.
20
21
  --status <status>
21
- --orphans Zero inbound and zero outbound.
22
- --deadends Zero outbound.
22
+ --orphans Zero inbound and zero outbound.
23
+ --deadends Zero outbound.
24
+ --recent <7d|2w|2026-09-01> Updated on or since then. Entries
25
+ with no date are not returned.
26
+
27
+ update options:
28
+ --recent <7d|2w|2026-09-01> What to roll up. Default: 7d.
29
+ --write Write src/content/updates/<today>.md. Without it,
30
+ the entry is printed on stdout. Never overwrites.
23
31
 
24
32
  gate options:
25
33
  --dist <dir> The built site to check, relative to --root. Default: dist.
@@ -34,8 +42,14 @@ Exit codes:
34
42
  is for — a build stops on a non-zero exit — so gate cannot report a finding
35
43
  the way every other verb does, in the payload with exit 0.`;
36
44
  export const COMMAND_USAGE = {
37
- 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--json]',
45
+ 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--recent <duration|date>] [--json]',
38
46
  'graph related': 'Usage: commune [--root <dir>] graph related <path|text|-> [--json]',
47
+ update: `Usage: commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]
48
+
49
+ Scaffold a dated update entry from the pages that changed. The draft is
50
+ printed on stdout unless --write is given, and --write refuses to overwrite an
51
+ update that already exists. \`summary\` is left empty on purpose: summarizing a
52
+ week is a judgement, and this command has none.`,
39
53
  check: 'Usage: commune [--root <dir>] check [--json]',
40
54
  gate: `Usage: commune [--root <dir>] gate [--dist <dir>] [--json]
41
55