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