@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 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 "./render.js";
14
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
15
15
  import { EXIT_OK } from "./errors.js";
16
16
  const RULES = [
17
17
  'broken-link',
@@ -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 "./render.js";
33
+ import { SCHEMA, writeJson } from "./output.js";
34
34
  const WIKILINK = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
35
35
  async function readSearchIndex(root) {
36
36
  // The public artifact, not the built one: `public/backlinks.json` is what
package/lib/cli/main.js CHANGED
@@ -13,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 "./render.js";
16
+ import { writeError } from "./output.js";
17
17
  import { resolveRoot } from "./root.js";
18
- import { queryCommand } from "./query.js";
18
+ import { toIsoDay } from "../lib/graph.js";
19
+ import { parseRecent, queryCommand } from "./query.js";
19
20
  import { checkCommand } from "./check.js";
20
21
  import { gateCommand } from "./gate.js";
21
22
  import { relatedCommand } from "./related.js";
23
+ import { updateCommand } from "./update.js";
22
24
  import { COMMAND_USAGE, USAGE } from "./usage.js";
23
25
  import { readVersion } from "./version.js";
24
26
  /** Options understood everywhere, in any position. */
@@ -34,13 +36,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
+ }
@@ -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 "./render.js";
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, ${summary.deadends} dead ends`,
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
  }
@@ -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>;