@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 +24 -3
- package/lib/cli/check.js +1 -1
- package/lib/cli/gate.js +1 -1
- package/lib/cli/main.js +36 -2
- package/lib/cli/output.d.ts +20 -0
- package/lib/cli/output.js +32 -0
- package/lib/cli/query.d.ts +1 -0
- package/lib/cli/query.js +32 -2
- package/lib/cli/related.d.ts +4 -1
- package/lib/cli/related.js +75 -76
- package/lib/cli/render.d.ts +18 -16
- package/lib/cli/render.js +67 -25
- package/lib/cli/site.d.ts +30 -0
- package/lib/cli/site.js +57 -0
- package/lib/cli/source.d.ts +34 -0
- package/lib/cli/source.js +74 -0
- package/lib/cli/update.js +1 -1
- package/lib/cli/usage.d.ts +1 -1
- package/lib/cli/usage.js +18 -1
- package/package.json +1 -1
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
|
-
|
|
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 "./
|
|
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 "./
|
|
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 "./
|
|
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
|
+
}
|
package/lib/cli/query.d.ts
CHANGED
|
@@ -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 "./
|
|
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;
|
package/lib/cli/related.d.ts
CHANGED
|
@@ -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>;
|
package/lib/cli/related.js
CHANGED
|
@@ -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 "./
|
|
22
|
-
import { EXIT_OK
|
|
23
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {
|
|
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
|
-
*
|
|
70
|
+
* Find every whole-word occurrence of `name`, across whitespace and case.
|
|
97
71
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
|
133
|
-
const hits =
|
|
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 ??=
|
|
135
|
+
count += hits.count;
|
|
136
|
+
matched ??= hits.matched;
|
|
138
137
|
}
|
|
139
138
|
return { ...toReference(entry), matched, count };
|
|
140
139
|
})
|
package/lib/cli/render.d.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `commune render` — a draft as the site will render it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
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
|
-
* `
|
|
18
|
-
*
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* `commune render` — a draft as the site will render it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
* `
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
30
|
-
|
|
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>;
|
package/lib/cli/site.js
ADDED
|
@@ -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 "./
|
|
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.
|
package/lib/cli/usage.d.ts
CHANGED
|
@@ -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.
|
|
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": [
|