@dmthepm/commune 0.3.0 → 0.5.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
@@ -237,27 +237,48 @@ commune graph query --recent 7d
237
237
  commune update --recent 7d
238
238
  commune graph related src/content/notes/hello.md
239
239
  echo "a rough dump that mentions World" | commune graph related -
240
+ commune render src/content/notes/hello.md
241
+ echo '[[World]]' | commune render -
240
242
  commune gate
241
243
  ```
242
244
 
243
245
  | Verb | What it answers |
244
246
  | --- | --- |
245
- | `graph query` | Every entry with its edges and dates. Filter with `--collection`, `--tag`, `--status`, `--orphans`, `--deadends`, `--recent`. |
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
+ | `graph query` | Every entry with its edges and dates. Filter with `--collection`, `--tag`, `--status`, `--orphans`, `--deadends`, `--unreferenced`, `--recent`. |
248
+ | `graph related <path\|text\|->` | What this connects to. It takes stdin, so you can ask about a draft before it is a note. Titles are matched across whitespace and case, so a dictated "noon tide" still finds `Noontide`. |
249
+ | `render <path\|->` | The markdown as HTML, through the site's own pipeline: WikiLinks resolved, external links marked. Takes stdin, so you can see a draft before it is a page. |
247
250
  | `update` | Scaffold a dated update entry from what changed. Prints it; `--write` files it. |
248
251
  | `check` | Broken links, duplicate names, ambiguous targets, non-canonical titles. |
249
252
  | `gate` | Run after a build, against the built site. |
250
253
 
254
+ `--orphans` and `--unreferenced` are different questions and it is worth knowing which one you are asking. An orphan is *isolated* — nothing links to it and it links nowhere — which on a wiki where most notes link out returns almost nothing. `--unreferenced` is zero inbound with any outbound: the note you wrote, cited three others from, and never linked back to from anywhere. `updates` entries are left out of it, since a dated changelog entry is expected to have nothing pointing at it; `--collection updates` is how you say you meant them.
255
+
251
256
  `--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
257
 
253
258
  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.
254
259
 
260
+ `render` is the site's own markdown processor with no Astro process around it — the same `communeMarkdown()` the config hands to `markdown.processor`, so the HTML is the page's HTML rather than a lookalike. It needs the site's origin to decide which links are external: `--site` says it, and without one the Astro config is read for a `site` declaration, falling back to `https://example.com` with a line on stderr saying so. `--json` adds the document's links and the names among them that resolve to nothing, which the HTML cannot tell you — an unresolved WikiLink renders as plain text, exactly as it does on the site.
261
+
255
262
  `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
263
 
257
264
  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.
258
265
 
259
266
  `commune --help` prints the full surface. `commune --version` prints the installed version, which is the honest way to know what you have.
260
267
 
268
+ ## Author with the skills
269
+
270
+ The loop above the CLI ships as four agent skills, in `skills/`, installed straight from this repository rather than from npm:
271
+
272
+ ```bash
273
+ npx skills add dmthepm/commune-wiki
274
+ ```
275
+
276
+ They install for Claude Code and Codex, globally or into one project, and they drive the `commune` your wiki already has — `node_modules/.bin/commune`, called by path, never downloaded — so the CLI a skill runs is the one your site builds with. They need 0.4.0 or newer, they check that first, and they install nothing themselves.
277
+
278
+ The loop is one dump, four files and two places it stops for you. `commune-dump` takes a dictated or pasted dump, writes it verbatim to `dumps/<date>-<slug>.md`, and asks the graph what it already touches — what it mentions, which of the target's links a rewrite would put at risk, which subjects have no note yet — into `dumps/<slug>.connect.md`. `commune-write` reads your `WRITING.md`, asks one short round of questions whose answers each change a file, stops while you answer them in `dumps/<slug>.answers.md`, then drafts into the real note and renders the original and the draft side by side as `dumps/<slug>.review.html`. `commune-ship` diffs `check` against the baseline, files the `updates` entry, builds, gates, greps `dist/` for every new href, commits and opens the PR — and never merges, because the last word is yours. `commune-setup` runs once per wiki and writes the `WRITING.md` the other three obey.
279
+
280
+ Status, honestly: the skills, their test and the `WRITING.md` template are here. The loop has been run once end to end by hand, before it was skills; it has not yet been run as skills on a real wiki. That run is [#10](https://github.com/dmthepm/commune-wiki/issues/10), and this paragraph changes when it lands.
281
+
261
282
  ## What it is not
262
283
 
263
284
  It is not a note-taking app and it is not trying to replace one. I write in Obsidian; Commune is what turns the vault into a site. There is no editor here, no sync, no account, no server. The graph is computed from files on disk at build time, and the files are yours whether or not you ever run this.
@@ -266,7 +287,7 @@ It is not a note-taking app and it is not trying to replace one. I write in Obsi
266
287
 
267
288
  Commune is the engine under a larger idea: own your canon. The wiki is one output surface, not the product. What I am building toward is an authoring loop — dictate a dump, have agents find what it already connects to, grill it, draft it, ship it — where the graph is what makes connection-finding possible *before* a draft exists. That is why the graph is a queryable library with a CLI on top instead of a build artifact, and why `graph related` reads stdin.
268
289
 
269
- None of that loop is in this package. `pnpm add @dmthepm/commune` gives you the engine and the CLI above, and nothing else. The authoring skills and the email destination are tracked in [the issues](https://github.com/dmthepm/commune-wiki/issues); when they ship, this section shrinks and the one above it grows.
290
+ The authoring half of that loop is here now, as the four skills above; `pnpm add @dmthepm/commune` still gives you the engine and the CLI and nothing else, which is what those skills drive. What is left — the email destination, the weekly intake from GitHub activity — is tracked in [the issues](https://github.com/dmthepm/commune-wiki/issues).
270
291
 
271
292
  ## Deploy
272
293
 
package/lib/cli/check.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * collapse. Frontmatter drift is a follow-up.
12
12
  */
13
13
  import { buildGraph, checkEntries, loadContentEntries, } from "../lib/graph.js";
14
- import { SCHEMA, writeJson, writeLines } from "./render.js";
14
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
15
15
  import { EXIT_OK } from "./errors.js";
16
16
  const RULES = [
17
17
  'broken-link',
package/lib/cli/gate.js CHANGED
@@ -30,7 +30,7 @@ import { readFile } from 'node:fs/promises';
30
30
  import path from 'node:path';
31
31
  import { findNoncanonicalTitles, loadContentEntries, stripCode, } from "../lib/graph.js";
32
32
  import { EXIT_FAILED, EXIT_OK, failure } from "./errors.js";
33
- import { SCHEMA, writeJson } from "./render.js";
33
+ import { SCHEMA, writeJson } from "./output.js";
34
34
  const WIKILINK = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
35
35
  async function readSearchIndex(root) {
36
36
  // The public artifact, not the built one: `public/backlinks.json` is what
package/lib/cli/main.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { parseArgs } from 'node:util';
15
15
  import { CliError, EXIT_OK, EXIT_USAGE, isParseArgsError, usageError } from "./errors.js";
16
- import { writeError } from "./render.js";
16
+ import { writeError } from "./output.js";
17
17
  import { resolveRoot } from "./root.js";
18
18
  import { toIsoDay } from "../lib/graph.js";
19
19
  import { parseRecent, queryCommand } from "./query.js";
@@ -36,6 +36,7 @@ const QUERY_OPTIONS = {
36
36
  status: { type: 'string' },
37
37
  orphans: { type: 'boolean', default: false },
38
38
  deadends: { type: 'boolean', default: false },
39
+ unreferenced: { type: 'boolean', default: false },
39
40
  recent: { type: 'string' },
40
41
  };
41
42
  const UPDATE_OPTIONS = {
@@ -43,12 +44,16 @@ const UPDATE_OPTIONS = {
43
44
  recent: { type: 'string', default: '7d' },
44
45
  write: { type: 'boolean', default: false },
45
46
  };
47
+ const RENDER_OPTIONS = {
48
+ ...GLOBAL,
49
+ site: { type: 'string' },
50
+ };
46
51
  const GATE_OPTIONS = {
47
52
  ...GLOBAL,
48
53
  dist: { type: 'string' },
49
54
  };
50
55
  /** Every route, longest first, so `graph query` is matched before a bare `graph`. */
51
- const ROUTES = ['graph query', 'graph related', 'check', 'gate', 'update'];
56
+ const ROUTES = ['graph query', 'graph related', 'check', 'gate', 'update', 'render'];
52
57
  /**
53
58
  * Find which command this argv names, without committing to its schema yet.
54
59
  *
@@ -125,6 +130,7 @@ async function dispatch(args) {
125
130
  status: values.status,
126
131
  orphans: values.orphans,
127
132
  deadends: values.deadends,
133
+ unreferenced: values.unreferenced,
128
134
  ...(since !== undefined ? { since } : {}),
129
135
  };
130
136
  return queryCommand(await resolveRoot(values.root), filters, values.json);
@@ -142,6 +148,34 @@ async function dispatch(args) {
142
148
  }
143
149
  return relatedCommand(await resolveRoot(values.root), positionals[0], values.json);
144
150
  }
151
+ case 'render': {
152
+ const { values, positionals } = parseStrict(rest, RENDER_OPTIONS, true, usage);
153
+ if (values.help) {
154
+ process.stdout.write(`${usage}\n`);
155
+ return EXIT_OK;
156
+ }
157
+ if (positionals.length !== 1) {
158
+ throw usageError(positionals.length
159
+ ? `render takes one argument, got ${positionals.length}: ${positionals.join(' ')}. A path containing spaces has to be quoted.`
160
+ : 'render needs a path to a markdown file, or - for stdin', usage);
161
+ }
162
+ const site = values.site;
163
+ // Checked here rather than where it is used, so a typo is exit 2
164
+ // beside every other bad flag instead of an internal error from the
165
+ // URL parse three calls deeper.
166
+ if (site !== undefined && !URL.canParse(site)) {
167
+ throw usageError(`--site takes an origin like https://example.com, not ${site}`, usage);
168
+ }
169
+ // Imported here rather than at the top of the file: `render` is the
170
+ // one verb that needs the markdown pipeline, and
171
+ // `@astrojs/markdown-remark` is a peer dependency. A static import
172
+ // would make every other verb load it — a startup cost on every
173
+ // `check`, and an outright failure in a project that has the CLI but
174
+ // not the renderer, which is the opposite of the promise the rest of
175
+ // this CLI makes about running without Astro.
176
+ const { renderCommand } = await import("./render.js");
177
+ return renderCommand(await resolveRoot(values.root), positionals[0], site, values.json);
178
+ }
145
179
  case 'check': {
146
180
  const { values } = parseStrict(rest, GLOBAL, false, usage);
147
181
  if (values.help) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * How a command's result reaches the caller.
3
+ *
4
+ * One rule, and everything else follows from it: in `--json` mode stdout holds
5
+ * exactly one JSON document and nothing else. Progress, warnings and errors go
6
+ * to stderr, so `commune check --json > findings.json` yields a parseable file
7
+ * even on the run that fails.
8
+ */
9
+ import type { CliError } from './errors.ts';
10
+ /** Every payload carries the contract version, so #10's skills can pin it. */
11
+ export declare const SCHEMA = 1;
12
+ export declare function writeJson(payload: unknown): void;
13
+ export declare function writeLines(lines: string[]): void;
14
+ /**
15
+ * Render a failure, on stderr, in whichever mode the caller asked for.
16
+ *
17
+ * `json` is read from the raw argv rather than the parsed options, because the
18
+ * errors most worth rendering as JSON are the ones thrown while parsing.
19
+ */
20
+ export declare function writeError(error: CliError, json: boolean): void;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * How a command's result reaches the caller.
3
+ *
4
+ * One rule, and everything else follows from it: in `--json` mode stdout holds
5
+ * exactly one JSON document and nothing else. Progress, warnings and errors go
6
+ * to stderr, so `commune check --json > findings.json` yields a parseable file
7
+ * even on the run that fails.
8
+ */
9
+ /** Every payload carries the contract version, so #10's skills can pin it. */
10
+ export const SCHEMA = 1;
11
+ export function writeJson(payload) {
12
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
13
+ }
14
+ export function writeLines(lines) {
15
+ if (lines.length)
16
+ process.stdout.write(lines.join('\n') + '\n');
17
+ }
18
+ /**
19
+ * Render a failure, on stderr, in whichever mode the caller asked for.
20
+ *
21
+ * `json` is read from the raw argv rather than the parsed options, because the
22
+ * errors most worth rendering as JSON are the ones thrown while parsing.
23
+ */
24
+ export function writeError(error, json) {
25
+ if (json) {
26
+ process.stderr.write(JSON.stringify({ error: { code: error.code, message: error.message } }, null, 2) + '\n');
27
+ return;
28
+ }
29
+ process.stderr.write(`commune: ${error.message}\n`);
30
+ if (error.detail)
31
+ process.stderr.write(`${error.detail}\n`);
32
+ }
@@ -13,6 +13,7 @@ export interface QueryFilters {
13
13
  status?: string;
14
14
  orphans: boolean;
15
15
  deadends: boolean;
16
+ unreferenced: boolean;
16
17
  /** Inclusive `yyyy-mm-dd` cutoff from `--recent`. Entries older than it, and
17
18
  * entries with no date at all, are not returned. */
18
19
  since?: string;
package/lib/cli/query.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * links resolve, so an entry's degree is the same whether or not you filtered.
8
8
  */
9
9
  import { buildGraph, loadContentEntries, toIsoDay, } from "../lib/graph.js";
10
- import { SCHEMA, writeJson, writeLines } from "./render.js";
10
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
11
11
  import { EXIT_OK } from "./errors.js";
12
12
  /**
13
13
  * Resolve `--recent` to the day it means.
@@ -71,6 +71,19 @@ function isOrphan(entry) {
71
71
  function isDeadend(entry) {
72
72
  return entry.outbound.length === 0;
73
73
  }
74
+ /**
75
+ * Nobody links here. Whether it links *out* is a separate question.
76
+ *
77
+ * The other half of the orphan split, and the half the connect step actually
78
+ * wants: a note that cites three others but that nothing cites back is not
79
+ * isolated, it is unplaced — and a wiki accumulates those silently, because
80
+ * writing a note is the moment you think about its outbound links and never
81
+ * about its inbound ones. `--orphans` returns 0 on a vault like that, which is
82
+ * the true answer to a different question.
83
+ */
84
+ function isUnreferenced(entry) {
85
+ return entry.inbound.length === 0;
86
+ }
74
87
  /**
75
88
  * Repeated values of one flag widen the match; different flags narrow it.
76
89
  *
@@ -89,6 +102,17 @@ function matches(entry, filters) {
89
102
  return false;
90
103
  if (filters.deadends && !isDeadend(entry))
91
104
  return false;
105
+ if (filters.unreferenced) {
106
+ if (!isUnreferenced(entry))
107
+ return false;
108
+ // A dated changelog entry is *expected* to have nothing pointing at it,
109
+ // so leaving `updates` in would bury the notes this filter exists to
110
+ // surface under one row per week. Asking for the collection by name is
111
+ // the way to say you meant it — and it is the only way, so the exclusion
112
+ // can never quietly hide an answer somebody asked for.
113
+ if (entry.collection === 'updates' && !filters.collections.includes('updates'))
114
+ return false;
115
+ }
92
116
  // An entry with no date is not "unchanged since the cutoff", it is unknown —
93
117
  // and a list of what changed this week is worth less with unknowns in it.
94
118
  if (filters.since !== undefined && !(entry.updated && entry.updated >= filters.since)) {
@@ -114,6 +138,12 @@ function summarize(results, since) {
114
138
  edges: results.reduce((total, entry) => total + entry.outbound.length, 0),
115
139
  orphans: results.filter(isOrphan).length,
116
140
  deadends: results.filter(isDeadend).length,
141
+ // Counted over what came back, like every other number here, so it can
142
+ // never contradict `entries` beside it. Note that on an *unfiltered*
143
+ // query this includes `updates` entries — the exclusion belongs to
144
+ // `--unreferenced` the filter, not to "has nothing pointing at it" the
145
+ // property.
146
+ unreferenced: results.filter(isUnreferenced).length,
117
147
  // The resolved cutoff, not the string the caller typed: `--recent 7d`
118
148
  // means a different day tomorrow, and a job that records what it asked
119
149
  // needs the day, not the duration.
@@ -136,7 +166,7 @@ export async function queryCommand(root, filters, json) {
136
166
  ...results.map((entry) => `${entry.urlPath}\t${entry.title}\t${entry.collection}\t${entry.status}\t` +
137
167
  `→${entry.outbound.length} ←${entry.inbound.length}`),
138
168
  `${summary.entries} entries, ${summary.edges} edges, ${summary.orphans} orphans, ` +
139
- `${summary.deadends} dead ends` +
169
+ `${summary.deadends} dead ends, ${summary.unreferenced} unreferenced` +
140
170
  (summary.since !== undefined ? `, updated since ${summary.since}` : ''),
141
171
  ]);
142
172
  return EXIT_OK;
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * Fuzzy and semantic similarity are out of scope on purpose. They are product
14
14
  * direction, not a build decision, and inventing a ranking here would freeze it
15
- * into the contract before anyone chose it.
15
+ * into the contract before anyone chose it. What #69 added is not similarity:
16
+ * whitespace inside a name is not information, so a dictated "noon tide" and a
17
+ * written `Noontide` are the same name spelled two ways, and matching them is
18
+ * still an exact match — just of the right string.
16
19
  */
17
20
  export declare function relatedCommand(root: string, input: string, json: boolean): Promise<number>;
@@ -12,15 +12,19 @@
12
12
  *
13
13
  * Fuzzy and semantic similarity are out of scope on purpose. They are product
14
14
  * direction, not a build decision, and inventing a ranking here would freeze it
15
- * into the contract before anyone chose it.
15
+ * into the contract before anyone chose it. What #69 added is not similarity:
16
+ * whitespace inside a name is not information, so a dictated "noon tide" and a
17
+ * written `Noontide` are the same name spelled two ways, and matching them is
18
+ * still an exact match — just of the right string.
16
19
  */
17
- import { readFile, stat } from 'node:fs/promises';
18
- import path from 'node:path';
19
- import matter from 'gray-matter';
20
20
  import { buildGraph, buildLinkLookup, buildUrlLookup, extractLinks, loadContentEntries, resolveLink, stripCode, } from "../lib/graph.js";
21
- import { SCHEMA, writeJson, writeLines } from "./render.js";
22
- import { EXIT_OK, failure } from "./errors.js";
23
- /** Shortest name worth matching. Below this, a mention is noise. */
21
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
22
+ import { EXIT_OK } from "./errors.js";
23
+ import { readSource } from "./source.js";
24
+ /**
25
+ * Shortest name worth matching, measured after normalisation. Below this, a
26
+ * mention is noise — `B` would make every capital B a connection.
27
+ */
24
28
  const MIN_MENTION_LENGTH = 3;
25
29
  function toReference(entry) {
26
30
  return {
@@ -30,78 +34,73 @@ function toReference(entry) {
30
34
  file: entry.file,
31
35
  };
32
36
  }
37
+ const WHITESPACE = /\s/;
38
+ const WORD = /\w/;
33
39
  /**
34
- * Work out whether the positional is a path, stdin, or literal prose.
40
+ * Reduce a string to the form mentions are matched in.
41
+ *
42
+ * Whitespace is removed rather than collapsed, which is what makes "noon tide"
43
+ * and `Noontide` the same string — the distortion dictation reliably produces
44
+ * (#69), and the reason a whole-word matcher over the raw text missed the
45
+ * titles it most needed to find. The offsets are what keep the answer honest:
46
+ * the word boundaries removed *inside* a name are still there in the original
47
+ * at its *edges*, so `Cake` can be found in "pan cake" and refused in
48
+ * "pancake".
35
49
  *
36
- * A path is tried against the root before the cwd, because the root is the
37
- * vault being asked about and the cwd is wherever the operator happens to be
38
- * standing. `-` is stdin, which is how a draft that is not a file yet gets
39
- * asked "what does this connect to".
50
+ * Built one output character at a time because `toLowerCase()` is not always
51
+ * length-preserving (`'İ'.toLowerCase()` is two code units), and one offset per
52
+ * emitted unit is what keeps the map exact for any input.
40
53
  */
41
- async function readSource(input, root) {
42
- if (input === '-') {
43
- const text = await readStdin();
44
- const { content, data } = matter(text);
45
- return { kind: 'stdin', text: content, frontmatter: data };
46
- }
47
- const candidates = path.isAbsolute(input)
48
- ? [input]
49
- : [path.join(root, input), path.resolve(input)];
50
- for (const candidate of candidates) {
51
- if (!(await isFile(candidate)))
54
+ function normalize(text) {
55
+ let normalized = '';
56
+ const offsets = [];
57
+ for (let index = 0; index < text.length; index += 1) {
58
+ const char = text[index];
59
+ if (WHITESPACE.test(char))
52
60
  continue;
53
- let source;
54
- try {
55
- source = await readFile(candidate, 'utf8');
56
- }
57
- catch (error) {
58
- throw failure('EPARSE', `cannot read ${candidate}: ${error.message}`);
61
+ const lowered = char.toLowerCase();
62
+ for (let unit = 0; unit < lowered.length; unit += 1) {
63
+ normalized += lowered[unit];
64
+ offsets.push(index);
59
65
  }
60
- let parsed;
61
- try {
62
- parsed = matter(source);
63
- }
64
- catch (error) {
65
- throw failure('EPARSE', `cannot parse frontmatter in ${candidate}: ${error.message}`);
66
- }
67
- const relative = path.relative(root, candidate).split(path.sep).join('/');
68
- return {
69
- kind: 'file',
70
- file: relative,
71
- text: parsed.content,
72
- frontmatter: parsed.data,
73
- };
74
66
  }
75
- return { kind: 'text', text: input, frontmatter: {} };
76
- }
77
- async function isFile(candidate) {
78
- try {
79
- return (await stat(candidate)).isFile();
80
- }
81
- catch {
82
- return false;
83
- }
84
- }
85
- async function readStdin() {
86
- const chunks = [];
87
- for await (const chunk of process.stdin)
88
- chunks.push(chunk);
89
- return Buffer.concat(chunks).toString('utf8');
90
- }
91
- /** Escape a title so it can be matched literally: real titles contain `,`, `'`, `(`. */
92
- function escapeRegExp(value) {
93
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ return { text: normalized, offsets };
94
68
  }
95
69
  /**
96
- * Count whole-word occurrences of `name` in `text`.
70
+ * Find every whole-word occurrence of `name`, across whitespace and case.
97
71
  *
98
- * Lookarounds rather than `\b`, because `\b` is defined relative to the
99
- * character next to it: a title ending in `?` or `)` puts a non-word character
100
- * where `\b` expects a word one, and the match silently stops happening.
72
+ * The search runs on the normalised text and the boundary check on the original,
73
+ * because the two questions want different strings: "is this the same name"
74
+ * ignores the spaces, "is this a whole word" is only about the characters
75
+ * outside it. A character index either side is enough — `\w` is asked directly
76
+ * rather than through `\b`, which is defined relative to its neighbour and so
77
+ * silently stops matching a title that ends in `?` or `)`.
101
78
  */
102
- function countMentions(text, name) {
103
- const pattern = new RegExp(`(?<!\\w)${escapeRegExp(name)}(?!\\w)`, 'gi');
104
- return text.match(pattern)?.length ?? 0;
79
+ function findMentions(prose, haystack, name) {
80
+ const needle = normalize(name).text;
81
+ if (needle.length < MIN_MENTION_LENGTH)
82
+ return { count: 0 };
83
+ let count = 0;
84
+ let matched;
85
+ for (let at = haystack.text.indexOf(needle); at !== -1; at = haystack.text.indexOf(needle, at + 1)) {
86
+ const start = haystack.offsets[at];
87
+ const end = haystack.offsets[at + needle.length - 1];
88
+ if (WORD.test(prose[start - 1] ?? ''))
89
+ continue;
90
+ if (WORD.test(prose[end + 1] ?? ''))
91
+ continue;
92
+ count += 1;
93
+ // The surface form, not the title: the connect step offers the candidate
94
+ // back with the words Devon actually said, so he can see which phrase in
95
+ // his dump the link would replace. Lowercased because that is what the
96
+ // field has always carried, and case is not what distinguishes one
97
+ // surface form from another here.
98
+ matched ??= prose
99
+ .slice(start, end + 1)
100
+ .toLowerCase()
101
+ .replace(/\s+/g, ' ');
102
+ }
103
+ return { count, matched };
105
104
  }
106
105
  export async function relatedCommand(root, input, json) {
107
106
  const entries = await loadContentEntries({ root });
@@ -109,7 +108,7 @@ export async function relatedCommand(root, input, json) {
109
108
  const byName = buildLinkLookup(entries);
110
109
  const byUrl = buildUrlLookup(entries);
111
110
  const byUrlPath = new Map(entries.map((entry) => [entry.urlPath, entry]));
112
- const source = await readSource(input, root);
111
+ const source = await readSource(input, root, { allowText: true });
113
112
  // A file that happens to be a graph entry gets its inbound links too; a
114
113
  // draft outside the vault, or raw text, has none by definition.
115
114
  const self = source.file ? entries.find((entry) => entry.file === source.file) : undefined;
@@ -123,18 +122,18 @@ export async function relatedCommand(root, input, json) {
123
122
  };
124
123
  });
125
124
  const prose = stripCode(source.text);
125
+ const haystack = normalize(prose);
126
126
  const mentions = entries
127
127
  .filter((entry) => entry.urlPath !== self?.urlPath)
128
128
  .map((entry) => {
129
- const names = [entry.title, ...entry.aliases].filter((name) => name.length >= MIN_MENTION_LENGTH);
130
129
  let count = 0;
131
130
  let matched;
132
- for (const name of names) {
133
- const hits = countMentions(prose, name);
134
- if (!hits)
131
+ for (const name of [entry.title, ...entry.aliases]) {
132
+ const hits = findMentions(prose, haystack, name);
133
+ if (!hits.count)
135
134
  continue;
136
- count += hits;
137
- matched ??= name.toLowerCase();
135
+ count += hits.count;
136
+ matched ??= hits.matched;
138
137
  }
139
138
  return { ...toReference(entry), matched, count };
140
139
  })
@@ -1,20 +1,22 @@
1
1
  /**
2
- * How a command's result reaches the caller.
2
+ * `commune render` — a draft as the site will render it.
3
3
  *
4
- * One rule, and everything else follows from it: in `--json` mode stdout holds
5
- * exactly one JSON document and nothing else. Progress, warnings and errors go
6
- * to stderr, so `commune check --json > findings.json` yields a parseable file
7
- * even on the run that fails.
8
- */
9
- import type { CliError } from './errors.ts';
10
- /** Every payload carries the contract version, so #10's skills can pin it. */
11
- export declare const SCHEMA = 1;
12
- export declare function writeJson(payload: unknown): void;
13
- export declare function writeLines(lines: string[]): void;
14
- /**
15
- * Render a failure, on stderr, in whichever mode the caller asked for.
4
+ * The review surface the map listed as "not yet specified" needs one thing the
5
+ * rest of the CLI does not provide: the actual HTML, with `[[WikiLinks]]`
6
+ * resolved against the real content tree and external links marked, for a
7
+ * document that is not a page yet and may never be committed. Astro can produce
8
+ * that, but only by building the whole site, which is a per-turn cost nobody
9
+ * pays to look at one paragraph.
10
+ *
11
+ * So the pipeline is borrowed rather than reimplemented: `communeMarkdown` is
12
+ * the same processor `astro.config.mjs` hands to `markdown.processor`, and this
13
+ * verb drives it directly. The rule that keeps it honest is that this file adds
14
+ * no plugin, no option and no post-processing of its own — anything that made
15
+ * the CLI's HTML differ from the site's would make the review surface a liar.
16
16
  *
17
- * `json` is read from the raw argv rather than the parsed options, because the
18
- * errors most worth rendering as JSON are the ones thrown while parsing.
17
+ * `links` and `unresolved` come from the graph core rather than from the
18
+ * rendered tree. An unresolved wikilink renders as plain text on purpose, so by
19
+ * the time there is HTML the broken link is indistinguishable from a sentence,
20
+ * and the one thing a reviewer most needs to be told is gone.
19
21
  */
20
- export declare function writeError(error: CliError, json: boolean): void;
22
+ export declare function renderCommand(root: string, input: string, siteFlag: string | undefined, json: boolean): Promise<number>;
package/lib/cli/render.js CHANGED
@@ -1,32 +1,74 @@
1
1
  /**
2
- * How a command's result reaches the caller.
2
+ * `commune render` — a draft as the site will render it.
3
3
  *
4
- * One rule, and everything else follows from it: in `--json` mode stdout holds
5
- * exactly one JSON document and nothing else. Progress, warnings and errors go
6
- * to stderr, so `commune check --json > findings.json` yields a parseable file
7
- * even on the run that fails.
8
- */
9
- /** Every payload carries the contract version, so #10's skills can pin it. */
10
- export const SCHEMA = 1;
11
- export function writeJson(payload) {
12
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
13
- }
14
- export function writeLines(lines) {
15
- if (lines.length)
16
- process.stdout.write(lines.join('\n') + '\n');
17
- }
18
- /**
19
- * Render a failure, on stderr, in whichever mode the caller asked for.
4
+ * The review surface the map listed as "not yet specified" needs one thing the
5
+ * rest of the CLI does not provide: the actual HTML, with `[[WikiLinks]]`
6
+ * resolved against the real content tree and external links marked, for a
7
+ * document that is not a page yet and may never be committed. Astro can produce
8
+ * that, but only by building the whole site, which is a per-turn cost nobody
9
+ * pays to look at one paragraph.
10
+ *
11
+ * So the pipeline is borrowed rather than reimplemented: `communeMarkdown` is
12
+ * the same processor `astro.config.mjs` hands to `markdown.processor`, and this
13
+ * verb drives it directly. The rule that keeps it honest is that this file adds
14
+ * no plugin, no option and no post-processing of its own — anything that made
15
+ * the CLI's HTML differ from the site's would make the review surface a liar.
20
16
  *
21
- * `json` is read from the raw argv rather than the parsed options, because the
22
- * errors most worth rendering as JSON are the ones thrown while parsing.
17
+ * `links` and `unresolved` come from the graph core rather than from the
18
+ * rendered tree. An unresolved wikilink renders as plain text on purpose, so by
19
+ * the time there is HTML the broken link is indistinguishable from a sentence,
20
+ * and the one thing a reviewer most needs to be told is gone.
23
21
  */
24
- export function writeError(error, json) {
22
+ import { communeMarkdown } from "../markdown.js";
23
+ import { buildLinkLookup, buildUrlLookup, extractLinks, loadContentEntries, resolveLink, } from "../lib/graph.js";
24
+ import { SCHEMA, writeJson } from "./output.js";
25
+ import { EXIT_OK } from "./errors.js";
26
+ import { readSource } from "./source.js";
27
+ import { resolveSite } from "./site.js";
28
+ export async function renderCommand(root, input, siteFlag, json) {
29
+ // Read before anything else: `-` has to consume stdin before a slow scan of
30
+ // the content tree, or a producer writing into the pipe waits on us.
31
+ const source = await readSource(input, root, { allowText: false });
32
+ const { site, source: siteSource } = await resolveSite(siteFlag, root);
33
+ if (siteSource === 'default') {
34
+ // On stderr, and only when nothing named an origin: `site` decides which
35
+ // links are the wiki's own, so a caller who did not supply one is
36
+ // looking at every absolute link marked external and deserves to know
37
+ // why. In `--json` the field says it too, without the noise.
38
+ process.stderr.write(`commune: no --site and none found in the Astro config; rendering against ${site}, ` +
39
+ `so every absolute link counts as external\n`);
40
+ }
41
+ const entries = await loadContentEntries({ root });
42
+ const byName = buildLinkLookup(entries);
43
+ const byUrl = buildUrlLookup(entries);
44
+ const byUrlPath = new Map(entries.map((entry) => [entry.urlPath, entry]));
45
+ const links = extractLinks(source.text, source.frontmatter).map((link) => {
46
+ const target = resolveLink(link, byName, byUrl);
47
+ const entry = target ? byUrlPath.get(target.urlPath) : undefined;
48
+ return {
49
+ kind: link.kind,
50
+ target: link.target,
51
+ resolved: entry
52
+ ? { urlPath: entry.urlPath, title: entry.title, collection: entry.collection }
53
+ : null,
54
+ };
55
+ });
56
+ const renderer = await communeMarkdown({ site, root }).createRenderer({});
57
+ const { code } = await renderer.render(source.text, { frontmatter: source.frontmatter });
25
58
  if (json) {
26
- process.stderr.write(JSON.stringify({ error: { code: error.code, message: error.message } }, null, 2) + '\n');
27
- return;
59
+ writeJson({
60
+ schema: SCHEMA,
61
+ root,
62
+ site,
63
+ source: { kind: source.kind, ...(source.file ? { file: source.file } : {}) },
64
+ html: code,
65
+ links,
66
+ // The projection a reviewer acts on: the names in the draft that
67
+ // point at nothing, which the HTML has already turned into prose.
68
+ unresolved: links.filter((link) => !link.resolved).map((link) => link.target),
69
+ });
70
+ return EXIT_OK;
28
71
  }
29
- process.stderr.write(`commune: ${error.message}\n`);
30
- if (error.detail)
31
- process.stderr.write(`${error.detail}\n`);
72
+ process.stdout.write(code.endsWith('\n') ? code : `${code}\n`);
73
+ return EXIT_OK;
32
74
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Finding the site's own origin, without starting Astro.
3
+ *
4
+ * `site` is what tells the rehype plugin which links are the wiki's own, so
5
+ * rendering with the wrong one marks every internal absolute link as external.
6
+ * The value lives in the consumer's `astro.config.*` — but importing that file
7
+ * would load Astro, the integrations and whatever else the config pulls in,
8
+ * which is exactly the process this CLI exists to avoid, and would execute the
9
+ * consumer's code to answer a question about a string.
10
+ *
11
+ * So the config is read as text and searched for the two spellings that
12
+ * actually occur: the property inside `defineConfig`, and the `const` above it
13
+ * that the property is a shorthand for. Both real wikis use the second. This is
14
+ * a best effort by construction: `--site` overrides it, and when neither
15
+ * produces an answer the fallback is announced on stderr rather than assumed.
16
+ */
17
+ /**
18
+ * The origin used when nothing else names one.
19
+ *
20
+ * A reserved example domain, so a link to it can never collide with a real
21
+ * host: with this in force, every absolute link renders as external, which is
22
+ * the safe way to be wrong.
23
+ */
24
+ export declare const DEFAULT_SITE = "https://example.com";
25
+ export type SiteSource = 'flag' | 'config' | 'default';
26
+ export interface ResolvedSite {
27
+ site: string;
28
+ source: SiteSource;
29
+ }
30
+ export declare function resolveSite(flag: string | undefined, root: string): Promise<ResolvedSite>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Finding the site's own origin, without starting Astro.
3
+ *
4
+ * `site` is what tells the rehype plugin which links are the wiki's own, so
5
+ * rendering with the wrong one marks every internal absolute link as external.
6
+ * The value lives in the consumer's `astro.config.*` — but importing that file
7
+ * would load Astro, the integrations and whatever else the config pulls in,
8
+ * which is exactly the process this CLI exists to avoid, and would execute the
9
+ * consumer's code to answer a question about a string.
10
+ *
11
+ * So the config is read as text and searched for the two spellings that
12
+ * actually occur: the property inside `defineConfig`, and the `const` above it
13
+ * that the property is a shorthand for. Both real wikis use the second. This is
14
+ * a best effort by construction: `--site` overrides it, and when neither
15
+ * produces an answer the fallback is announced on stderr rather than assumed.
16
+ */
17
+ import { readFile } from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ /**
20
+ * The origin used when nothing else names one.
21
+ *
22
+ * A reserved example domain, so a link to it can never collide with a real
23
+ * host: with this in force, every absolute link renders as external, which is
24
+ * the safe way to be wrong.
25
+ */
26
+ export const DEFAULT_SITE = 'https://example.com';
27
+ const CONFIG_FILES = ['astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.mts'];
28
+ /** `site: 'https://…'` inside the config object, or `const site = 'https://…'` above it. */
29
+ const SITE_PATTERNS = [/\bsite\s*:\s*['"`]([^'"`]+)['"`]/, /\bsite\s*=\s*['"`]([^'"`]+)['"`]/];
30
+ /** Read `site` out of a project's Astro config, if it declares one this way. */
31
+ async function readSiteFromConfig(root) {
32
+ for (const name of CONFIG_FILES) {
33
+ let source;
34
+ try {
35
+ source = await readFile(path.join(root, name), 'utf8');
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ for (const pattern of SITE_PATTERNS) {
41
+ const value = pattern.exec(source)?.[1];
42
+ // Only a real origin: `site: undefined` and a templated string are
43
+ // both better answered by the default than by a guess.
44
+ if (value && URL.canParse(value))
45
+ return value;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ export async function resolveSite(flag, root) {
51
+ if (flag !== undefined)
52
+ return { site: flag, source: 'flag' };
53
+ const configured = await readSiteFromConfig(root);
54
+ if (configured !== undefined)
55
+ return { site: configured, source: 'config' };
56
+ return { site: DEFAULT_SITE, source: 'default' };
57
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where a verb's markdown comes from: a file, stdin, or the argument itself.
3
+ *
4
+ * One reader for `graph related` and `render`, because they take the same
5
+ * positional and have to agree about what it means — including the part that is
6
+ * easy to get subtly different, which is that a path is tried against `--root`
7
+ * before the cwd. The root is the vault being asked about; the cwd is wherever
8
+ * the operator happens to be standing.
9
+ *
10
+ * The one difference between the two callers is what a string that names no
11
+ * file means. `graph related` answers questions about prose, so it is text.
12
+ * `render` renders a document, and a mistyped path silently rendering as its
13
+ * own filename would be a wrong answer with no error anywhere, so it fails.
14
+ */
15
+ export interface Source {
16
+ kind: 'file' | 'text' | 'stdin';
17
+ /** Root-relative and POSIX-separated, for a file. */
18
+ file?: string;
19
+ /** The markdown body, with any frontmatter split off. */
20
+ text: string;
21
+ frontmatter: Record<string, unknown>;
22
+ }
23
+ export interface ReadSourceOptions {
24
+ /** Whether a string naming no file is prose (`graph related`) or an error (`render`). */
25
+ allowText: boolean;
26
+ }
27
+ /**
28
+ * Resolve the positional into markdown and its frontmatter.
29
+ *
30
+ * `-` is stdin, which is how a draft that is not a file yet gets asked about —
31
+ * and it is frontmatter-aware there too, so piping a whole note in behaves the
32
+ * same as naming it.
33
+ */
34
+ export declare function readSource(input: string, root: string, { allowText }: ReadSourceOptions): Promise<Source>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where a verb's markdown comes from: a file, stdin, or the argument itself.
3
+ *
4
+ * One reader for `graph related` and `render`, because they take the same
5
+ * positional and have to agree about what it means — including the part that is
6
+ * easy to get subtly different, which is that a path is tried against `--root`
7
+ * before the cwd. The root is the vault being asked about; the cwd is wherever
8
+ * the operator happens to be standing.
9
+ *
10
+ * The one difference between the two callers is what a string that names no
11
+ * file means. `graph related` answers questions about prose, so it is text.
12
+ * `render` renders a document, and a mistyped path silently rendering as its
13
+ * own filename would be a wrong answer with no error anywhere, so it fails.
14
+ */
15
+ import { readFile, stat } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import matter from 'gray-matter';
18
+ import { failure } from "./errors.js";
19
+ async function isFile(candidate) {
20
+ try {
21
+ return (await stat(candidate)).isFile();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function readStdin() {
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin)
30
+ chunks.push(chunk);
31
+ return Buffer.concat(chunks).toString('utf8');
32
+ }
33
+ /**
34
+ * Resolve the positional into markdown and its frontmatter.
35
+ *
36
+ * `-` is stdin, which is how a draft that is not a file yet gets asked about —
37
+ * and it is frontmatter-aware there too, so piping a whole note in behaves the
38
+ * same as naming it.
39
+ */
40
+ export async function readSource(input, root, { allowText }) {
41
+ if (input === '-') {
42
+ const { content, data } = matter(await readStdin());
43
+ return { kind: 'stdin', text: content, frontmatter: data };
44
+ }
45
+ const candidates = path.isAbsolute(input) ? [input] : [path.join(root, input), path.resolve(input)];
46
+ for (const candidate of candidates) {
47
+ if (!(await isFile(candidate)))
48
+ continue;
49
+ let source;
50
+ try {
51
+ source = await readFile(candidate, 'utf8');
52
+ }
53
+ catch (error) {
54
+ throw failure('EPARSE', `cannot read ${candidate}: ${error.message}`);
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = matter(source);
59
+ }
60
+ catch (error) {
61
+ throw failure('EPARSE', `cannot parse frontmatter in ${candidate}: ${error.message}`);
62
+ }
63
+ return {
64
+ kind: 'file',
65
+ file: path.relative(root, candidate).split(path.sep).join('/'),
66
+ text: parsed.content,
67
+ frontmatter: parsed.data,
68
+ };
69
+ }
70
+ if (!allowText) {
71
+ throw failure('EPARSE', `cannot read ${input}: no such file under ${root} or the cwd`);
72
+ }
73
+ return { kind: 'text', text: input, frontmatter: {} };
74
+ }
package/lib/cli/update.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { mkdir, stat, writeFile } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
18
  import { CONTENT_DIRS, loadContentEntries } from "../lib/graph.js";
19
- import { SCHEMA, writeJson, writeLines } from "./render.js";
19
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
20
20
  import { EXIT_OK, failure } from "./errors.js";
21
21
  /**
22
22
  * The entry, as markdown.
@@ -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>] 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.";
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,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>] render <path|-> [--site <origin>] [--json]
7
8
  commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]
8
9
  commune [--root <dir>] check [--json]
9
10
  commune [--root <dir>] gate [--dist <dir>] [--json]
@@ -21,9 +22,18 @@ graph query filters (any-of within a flag, all-of across flags):
21
22
  --status <status>
22
23
  --orphans Zero inbound and zero outbound.
23
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.
24
29
  --recent <7d|2w|2026-09-01> Updated on or since then. Entries
25
30
  with no date are not returned.
26
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
+
27
37
  update options:
28
38
  --recent <7d|2w|2026-09-01> What to roll up. Default: 7d.
29
39
  --write Write src/content/updates/<today>.md. Without it,
@@ -42,7 +52,7 @@ Exit codes:
42
52
  is for — a build stops on a non-zero exit — so gate cannot report a finding
43
53
  the way every other verb does, in the payload with exit 0.`;
44
54
  export const COMMAND_USAGE = {
45
- 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--recent <duration|date>] [--json]',
55
+ 'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--unreferenced] [--recent <duration|date>] [--json]',
46
56
  'graph related': 'Usage: commune [--root <dir>] graph related <path|text|-> [--json]',
47
57
  update: `Usage: commune [--root <dir>] update [--recent <duration|date>] [--write] [--json]
48
58
 
@@ -50,6 +60,13 @@ Scaffold a dated update entry from the pages that changed. The draft is
50
60
  printed on stdout unless --write is given, and --write refuses to overwrite an
51
61
  update that already exists. \`summary\` is left empty on purpose: summarizing a
52
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.`,
53
70
  check: 'Usage: commune [--root <dir>] check [--json]',
54
71
  gate: `Usage: commune [--root <dir>] gate [--dist <dir>] [--json]
55
72
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dmthepm/commune",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
5
  "description": "An Astro wiki engine with WikiLinks, sliding panes, backlinks and static search, plus a commune CLI that queries the content graph and checks links",
6
6
  "license": "MIT",
7
7
  "keywords": [