@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.
@@ -12,15 +12,19 @@
12
12
  *
13
13
  * Fuzzy and semantic similarity are out of scope on purpose. They are product
14
14
  * direction, not a build decision, and inventing a ranking here would freeze it
15
- * into the contract before anyone chose it.
15
+ * into the contract before anyone chose it. What #69 added is not similarity:
16
+ * whitespace inside a name is not information, so a dictated "noon tide" and a
17
+ * written `Noontide` are the same name spelled two ways, and matching them is
18
+ * still an exact match — just of the right string.
16
19
  */
17
- import { readFile, stat } from 'node:fs/promises';
18
- import path from 'node:path';
19
- import matter from 'gray-matter';
20
20
  import { buildGraph, buildLinkLookup, buildUrlLookup, extractLinks, loadContentEntries, resolveLink, stripCode, } from "../lib/graph.js";
21
- import { SCHEMA, writeJson, writeLines } from "./render.js";
22
- import { EXIT_OK, failure } from "./errors.js";
23
- /** Shortest name worth matching. Below this, a mention is noise. */
21
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
22
+ import { EXIT_OK } from "./errors.js";
23
+ import { readSource } from "./source.js";
24
+ /**
25
+ * Shortest name worth matching, measured after normalisation. Below this, a
26
+ * mention is noise — `B` would make every capital B a connection.
27
+ */
24
28
  const MIN_MENTION_LENGTH = 3;
25
29
  function toReference(entry) {
26
30
  return {
@@ -30,78 +34,73 @@ function toReference(entry) {
30
34
  file: entry.file,
31
35
  };
32
36
  }
37
+ const WHITESPACE = /\s/;
38
+ const WORD = /\w/;
33
39
  /**
34
- * Work out whether the positional is a path, stdin, or literal prose.
40
+ * Reduce a string to the form mentions are matched in.
41
+ *
42
+ * Whitespace is removed rather than collapsed, which is what makes "noon tide"
43
+ * and `Noontide` the same string — the distortion dictation reliably produces
44
+ * (#69), and the reason a whole-word matcher over the raw text missed the
45
+ * titles it most needed to find. The offsets are what keep the answer honest:
46
+ * the word boundaries removed *inside* a name are still there in the original
47
+ * at its *edges*, so `Cake` can be found in "pan cake" and refused in
48
+ * "pancake".
35
49
  *
36
- * A path is tried against the root before the cwd, because the root is the
37
- * vault being asked about and the cwd is wherever the operator happens to be
38
- * standing. `-` is stdin, which is how a draft that is not a file yet gets
39
- * asked "what does this connect to".
50
+ * Built one output character at a time because `toLowerCase()` is not always
51
+ * length-preserving (`'İ'.toLowerCase()` is two code units), and one offset per
52
+ * emitted unit is what keeps the map exact for any input.
40
53
  */
41
- async function readSource(input, root) {
42
- if (input === '-') {
43
- const text = await readStdin();
44
- const { content, data } = matter(text);
45
- return { kind: 'stdin', text: content, frontmatter: data };
46
- }
47
- const candidates = path.isAbsolute(input)
48
- ? [input]
49
- : [path.join(root, input), path.resolve(input)];
50
- for (const candidate of candidates) {
51
- if (!(await isFile(candidate)))
54
+ function normalize(text) {
55
+ let normalized = '';
56
+ const offsets = [];
57
+ for (let index = 0; index < text.length; index += 1) {
58
+ const char = text[index];
59
+ if (WHITESPACE.test(char))
52
60
  continue;
53
- let source;
54
- try {
55
- source = await readFile(candidate, 'utf8');
56
- }
57
- catch (error) {
58
- throw failure('EPARSE', `cannot read ${candidate}: ${error.message}`);
61
+ const lowered = char.toLowerCase();
62
+ for (let unit = 0; unit < lowered.length; unit += 1) {
63
+ normalized += lowered[unit];
64
+ offsets.push(index);
59
65
  }
60
- let parsed;
61
- try {
62
- parsed = matter(source);
63
- }
64
- catch (error) {
65
- throw failure('EPARSE', `cannot parse frontmatter in ${candidate}: ${error.message}`);
66
- }
67
- const relative = path.relative(root, candidate).split(path.sep).join('/');
68
- return {
69
- kind: 'file',
70
- file: relative,
71
- text: parsed.content,
72
- frontmatter: parsed.data,
73
- };
74
66
  }
75
- return { kind: 'text', text: input, frontmatter: {} };
76
- }
77
- async function isFile(candidate) {
78
- try {
79
- return (await stat(candidate)).isFile();
80
- }
81
- catch {
82
- return false;
83
- }
84
- }
85
- async function readStdin() {
86
- const chunks = [];
87
- for await (const chunk of process.stdin)
88
- chunks.push(chunk);
89
- return Buffer.concat(chunks).toString('utf8');
90
- }
91
- /** Escape a title so it can be matched literally: real titles contain `,`, `'`, `(`. */
92
- function escapeRegExp(value) {
93
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ return { text: normalized, offsets };
94
68
  }
95
69
  /**
96
- * Count whole-word occurrences of `name` in `text`.
70
+ * Find every whole-word occurrence of `name`, across whitespace and case.
97
71
  *
98
- * Lookarounds rather than `\b`, because `\b` is defined relative to the
99
- * character next to it: a title ending in `?` or `)` puts a non-word character
100
- * where `\b` expects a word one, and the match silently stops happening.
72
+ * The search runs on the normalised text and the boundary check on the original,
73
+ * because the two questions want different strings: "is this the same name"
74
+ * ignores the spaces, "is this a whole word" is only about the characters
75
+ * outside it. A character index either side is enough — `\w` is asked directly
76
+ * rather than through `\b`, which is defined relative to its neighbour and so
77
+ * silently stops matching a title that ends in `?` or `)`.
101
78
  */
102
- function countMentions(text, name) {
103
- const pattern = new RegExp(`(?<!\\w)${escapeRegExp(name)}(?!\\w)`, 'gi');
104
- return text.match(pattern)?.length ?? 0;
79
+ function findMentions(prose, haystack, name) {
80
+ const needle = normalize(name).text;
81
+ if (needle.length < MIN_MENTION_LENGTH)
82
+ return { count: 0 };
83
+ let count = 0;
84
+ let matched;
85
+ for (let at = haystack.text.indexOf(needle); at !== -1; at = haystack.text.indexOf(needle, at + 1)) {
86
+ const start = haystack.offsets[at];
87
+ const end = haystack.offsets[at + needle.length - 1];
88
+ if (WORD.test(prose[start - 1] ?? ''))
89
+ continue;
90
+ if (WORD.test(prose[end + 1] ?? ''))
91
+ continue;
92
+ count += 1;
93
+ // The surface form, not the title: the connect step offers the candidate
94
+ // back with the words Devon actually said, so he can see which phrase in
95
+ // his dump the link would replace. Lowercased because that is what the
96
+ // field has always carried, and case is not what distinguishes one
97
+ // surface form from another here.
98
+ matched ??= prose
99
+ .slice(start, end + 1)
100
+ .toLowerCase()
101
+ .replace(/\s+/g, ' ');
102
+ }
103
+ return { count, matched };
105
104
  }
106
105
  export async function relatedCommand(root, input, json) {
107
106
  const entries = await loadContentEntries({ root });
@@ -109,7 +108,7 @@ export async function relatedCommand(root, input, json) {
109
108
  const byName = buildLinkLookup(entries);
110
109
  const byUrl = buildUrlLookup(entries);
111
110
  const byUrlPath = new Map(entries.map((entry) => [entry.urlPath, entry]));
112
- const source = await readSource(input, root);
111
+ const source = await readSource(input, root, { allowText: true });
113
112
  // A file that happens to be a graph entry gets its inbound links too; a
114
113
  // draft outside the vault, or raw text, has none by definition.
115
114
  const self = source.file ? entries.find((entry) => entry.file === source.file) : undefined;
@@ -123,18 +122,18 @@ export async function relatedCommand(root, input, json) {
123
122
  };
124
123
  });
125
124
  const prose = stripCode(source.text);
125
+ const haystack = normalize(prose);
126
126
  const mentions = entries
127
127
  .filter((entry) => entry.urlPath !== self?.urlPath)
128
128
  .map((entry) => {
129
- const names = [entry.title, ...entry.aliases].filter((name) => name.length >= MIN_MENTION_LENGTH);
130
129
  let count = 0;
131
130
  let matched;
132
- for (const name of names) {
133
- const hits = countMentions(prose, name);
134
- if (!hits)
131
+ for (const name of [entry.title, ...entry.aliases]) {
132
+ const hits = findMentions(prose, haystack, name);
133
+ if (!hits.count)
135
134
  continue;
136
- count += hits;
137
- matched ??= name.toLowerCase();
135
+ count += hits.count;
136
+ matched ??= hits.matched;
138
137
  }
139
138
  return { ...toReference(entry), matched, count };
140
139
  })
@@ -1,20 +1,22 @@
1
1
  /**
2
- * How a command's result reaches the caller.
2
+ * `commune render` — a draft as the site will render it.
3
3
  *
4
- * One rule, and everything else follows from it: in `--json` mode stdout holds
5
- * exactly one JSON document and nothing else. Progress, warnings and errors go
6
- * to stderr, so `commune check --json > findings.json` yields a parseable file
7
- * even on the run that fails.
8
- */
9
- import type { CliError } from './errors.ts';
10
- /** Every payload carries the contract version, so #10's skills can pin it. */
11
- export declare const SCHEMA = 1;
12
- export declare function writeJson(payload: unknown): void;
13
- export declare function writeLines(lines: string[]): void;
14
- /**
15
- * Render a failure, on stderr, in whichever mode the caller asked for.
4
+ * The review surface the map listed as "not yet specified" needs one thing the
5
+ * rest of the CLI does not provide: the actual HTML, with `[[WikiLinks]]`
6
+ * resolved against the real content tree and external links marked, for a
7
+ * document that is not a page yet and may never be committed. Astro can produce
8
+ * that, but only by building the whole site, which is a per-turn cost nobody
9
+ * pays to look at one paragraph.
10
+ *
11
+ * So the pipeline is borrowed rather than reimplemented: `communeMarkdown` is
12
+ * the same processor `astro.config.mjs` hands to `markdown.processor`, and this
13
+ * verb drives it directly. The rule that keeps it honest is that this file adds
14
+ * no plugin, no option and no post-processing of its own — anything that made
15
+ * the CLI's HTML differ from the site's would make the review surface a liar.
16
16
  *
17
- * `json` is read from the raw argv rather than the parsed options, because the
18
- * errors most worth rendering as JSON are the ones thrown while parsing.
17
+ * `links` and `unresolved` come from the graph core rather than from the
18
+ * rendered tree. An unresolved wikilink renders as plain text on purpose, so by
19
+ * the time there is HTML the broken link is indistinguishable from a sentence,
20
+ * and the one thing a reviewer most needs to be told is gone.
19
21
  */
20
- export declare function writeError(error: CliError, json: boolean): void;
22
+ export declare function renderCommand(root: string, input: string, siteFlag: string | undefined, json: boolean): Promise<number>;
package/lib/cli/render.js CHANGED
@@ -1,32 +1,74 @@
1
1
  /**
2
- * How a command's result reaches the caller.
2
+ * `commune render` — a draft as the site will render it.
3
3
  *
4
- * One rule, and everything else follows from it: in `--json` mode stdout holds
5
- * exactly one JSON document and nothing else. Progress, warnings and errors go
6
- * to stderr, so `commune check --json > findings.json` yields a parseable file
7
- * even on the run that fails.
8
- */
9
- /** Every payload carries the contract version, so #10's skills can pin it. */
10
- export const SCHEMA = 1;
11
- export function writeJson(payload) {
12
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
13
- }
14
- export function writeLines(lines) {
15
- if (lines.length)
16
- process.stdout.write(lines.join('\n') + '\n');
17
- }
18
- /**
19
- * Render a failure, on stderr, in whichever mode the caller asked for.
4
+ * The review surface the map listed as "not yet specified" needs one thing the
5
+ * rest of the CLI does not provide: the actual HTML, with `[[WikiLinks]]`
6
+ * resolved against the real content tree and external links marked, for a
7
+ * document that is not a page yet and may never be committed. Astro can produce
8
+ * that, but only by building the whole site, which is a per-turn cost nobody
9
+ * pays to look at one paragraph.
10
+ *
11
+ * So the pipeline is borrowed rather than reimplemented: `communeMarkdown` is
12
+ * the same processor `astro.config.mjs` hands to `markdown.processor`, and this
13
+ * verb drives it directly. The rule that keeps it honest is that this file adds
14
+ * no plugin, no option and no post-processing of its own — anything that made
15
+ * the CLI's HTML differ from the site's would make the review surface a liar.
20
16
  *
21
- * `json` is read from the raw argv rather than the parsed options, because the
22
- * errors most worth rendering as JSON are the ones thrown while parsing.
17
+ * `links` and `unresolved` come from the graph core rather than from the
18
+ * rendered tree. An unresolved wikilink renders as plain text on purpose, so by
19
+ * the time there is HTML the broken link is indistinguishable from a sentence,
20
+ * and the one thing a reviewer most needs to be told is gone.
23
21
  */
24
- export function writeError(error, json) {
22
+ import { communeMarkdown } from "../markdown.js";
23
+ import { buildLinkLookup, buildUrlLookup, extractLinks, loadContentEntries, resolveLink, } from "../lib/graph.js";
24
+ import { SCHEMA, writeJson } from "./output.js";
25
+ import { EXIT_OK } from "./errors.js";
26
+ import { readSource } from "./source.js";
27
+ import { resolveSite } from "./site.js";
28
+ export async function renderCommand(root, input, siteFlag, json) {
29
+ // Read before anything else: `-` has to consume stdin before a slow scan of
30
+ // the content tree, or a producer writing into the pipe waits on us.
31
+ const source = await readSource(input, root, { allowText: false });
32
+ const { site, source: siteSource } = await resolveSite(siteFlag, root);
33
+ if (siteSource === 'default') {
34
+ // On stderr, and only when nothing named an origin: `site` decides which
35
+ // links are the wiki's own, so a caller who did not supply one is
36
+ // looking at every absolute link marked external and deserves to know
37
+ // why. In `--json` the field says it too, without the noise.
38
+ process.stderr.write(`commune: no --site and none found in the Astro config; rendering against ${site}, ` +
39
+ `so every absolute link counts as external\n`);
40
+ }
41
+ const entries = await loadContentEntries({ root });
42
+ const byName = buildLinkLookup(entries);
43
+ const byUrl = buildUrlLookup(entries);
44
+ const byUrlPath = new Map(entries.map((entry) => [entry.urlPath, entry]));
45
+ const links = extractLinks(source.text, source.frontmatter).map((link) => {
46
+ const target = resolveLink(link, byName, byUrl);
47
+ const entry = target ? byUrlPath.get(target.urlPath) : undefined;
48
+ return {
49
+ kind: link.kind,
50
+ target: link.target,
51
+ resolved: entry
52
+ ? { urlPath: entry.urlPath, title: entry.title, collection: entry.collection }
53
+ : null,
54
+ };
55
+ });
56
+ const renderer = await communeMarkdown({ site, root }).createRenderer({});
57
+ const { code } = await renderer.render(source.text, { frontmatter: source.frontmatter });
25
58
  if (json) {
26
- process.stderr.write(JSON.stringify({ error: { code: error.code, message: error.message } }, null, 2) + '\n');
27
- return;
59
+ writeJson({
60
+ schema: SCHEMA,
61
+ root,
62
+ site,
63
+ source: { kind: source.kind, ...(source.file ? { file: source.file } : {}) },
64
+ html: code,
65
+ links,
66
+ // The projection a reviewer acts on: the names in the draft that
67
+ // point at nothing, which the HTML has already turned into prose.
68
+ unresolved: links.filter((link) => !link.resolved).map((link) => link.target),
69
+ });
70
+ return EXIT_OK;
28
71
  }
29
- process.stderr.write(`commune: ${error.message}\n`);
30
- if (error.detail)
31
- process.stderr.write(`${error.detail}\n`);
72
+ process.stdout.write(code.endsWith('\n') ? code : `${code}\n`);
73
+ return EXIT_OK;
32
74
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Finding the site's own origin, without starting Astro.
3
+ *
4
+ * `site` is what tells the rehype plugin which links are the wiki's own, so
5
+ * rendering with the wrong one marks every internal absolute link as external.
6
+ * The value lives in the consumer's `astro.config.*` — but importing that file
7
+ * would load Astro, the integrations and whatever else the config pulls in,
8
+ * which is exactly the process this CLI exists to avoid, and would execute the
9
+ * consumer's code to answer a question about a string.
10
+ *
11
+ * So the config is read as text and searched for the two spellings that
12
+ * actually occur: the property inside `defineConfig`, and the `const` above it
13
+ * that the property is a shorthand for. Both real wikis use the second. This is
14
+ * a best effort by construction: `--site` overrides it, and when neither
15
+ * produces an answer the fallback is announced on stderr rather than assumed.
16
+ */
17
+ /**
18
+ * The origin used when nothing else names one.
19
+ *
20
+ * A reserved example domain, so a link to it can never collide with a real
21
+ * host: with this in force, every absolute link renders as external, which is
22
+ * the safe way to be wrong.
23
+ */
24
+ export declare const DEFAULT_SITE = "https://example.com";
25
+ export type SiteSource = 'flag' | 'config' | 'default';
26
+ export interface ResolvedSite {
27
+ site: string;
28
+ source: SiteSource;
29
+ }
30
+ export declare function resolveSite(flag: string | undefined, root: string): Promise<ResolvedSite>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Finding the site's own origin, without starting Astro.
3
+ *
4
+ * `site` is what tells the rehype plugin which links are the wiki's own, so
5
+ * rendering with the wrong one marks every internal absolute link as external.
6
+ * The value lives in the consumer's `astro.config.*` — but importing that file
7
+ * would load Astro, the integrations and whatever else the config pulls in,
8
+ * which is exactly the process this CLI exists to avoid, and would execute the
9
+ * consumer's code to answer a question about a string.
10
+ *
11
+ * So the config is read as text and searched for the two spellings that
12
+ * actually occur: the property inside `defineConfig`, and the `const` above it
13
+ * that the property is a shorthand for. Both real wikis use the second. This is
14
+ * a best effort by construction: `--site` overrides it, and when neither
15
+ * produces an answer the fallback is announced on stderr rather than assumed.
16
+ */
17
+ import { readFile } from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ /**
20
+ * The origin used when nothing else names one.
21
+ *
22
+ * A reserved example domain, so a link to it can never collide with a real
23
+ * host: with this in force, every absolute link renders as external, which is
24
+ * the safe way to be wrong.
25
+ */
26
+ export const DEFAULT_SITE = 'https://example.com';
27
+ const CONFIG_FILES = ['astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.mts'];
28
+ /** `site: 'https://…'` inside the config object, or `const site = 'https://…'` above it. */
29
+ const SITE_PATTERNS = [/\bsite\s*:\s*['"`]([^'"`]+)['"`]/, /\bsite\s*=\s*['"`]([^'"`]+)['"`]/];
30
+ /** Read `site` out of a project's Astro config, if it declares one this way. */
31
+ async function readSiteFromConfig(root) {
32
+ for (const name of CONFIG_FILES) {
33
+ let source;
34
+ try {
35
+ source = await readFile(path.join(root, name), 'utf8');
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ for (const pattern of SITE_PATTERNS) {
41
+ const value = pattern.exec(source)?.[1];
42
+ // Only a real origin: `site: undefined` and a templated string are
43
+ // both better answered by the default than by a guess.
44
+ if (value && URL.canParse(value))
45
+ return value;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ export async function resolveSite(flag, root) {
51
+ if (flag !== undefined)
52
+ return { site: flag, source: 'flag' };
53
+ const configured = await readSiteFromConfig(root);
54
+ if (configured !== undefined)
55
+ return { site: configured, source: 'config' };
56
+ return { site: DEFAULT_SITE, source: 'default' };
57
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where a verb's markdown comes from: a file, stdin, or the argument itself.
3
+ *
4
+ * One reader for `graph related` and `render`, because they take the same
5
+ * positional and have to agree about what it means — including the part that is
6
+ * easy to get subtly different, which is that a path is tried against `--root`
7
+ * before the cwd. The root is the vault being asked about; the cwd is wherever
8
+ * the operator happens to be standing.
9
+ *
10
+ * The one difference between the two callers is what a string that names no
11
+ * file means. `graph related` answers questions about prose, so it is text.
12
+ * `render` renders a document, and a mistyped path silently rendering as its
13
+ * own filename would be a wrong answer with no error anywhere, so it fails.
14
+ */
15
+ export interface Source {
16
+ kind: 'file' | 'text' | 'stdin';
17
+ /** Root-relative and POSIX-separated, for a file. */
18
+ file?: string;
19
+ /** The markdown body, with any frontmatter split off. */
20
+ text: string;
21
+ frontmatter: Record<string, unknown>;
22
+ }
23
+ export interface ReadSourceOptions {
24
+ /** Whether a string naming no file is prose (`graph related`) or an error (`render`). */
25
+ allowText: boolean;
26
+ }
27
+ /**
28
+ * Resolve the positional into markdown and its frontmatter.
29
+ *
30
+ * `-` is stdin, which is how a draft that is not a file yet gets asked about —
31
+ * and it is frontmatter-aware there too, so piping a whole note in behaves the
32
+ * same as naming it.
33
+ */
34
+ export declare function readSource(input: string, root: string, { allowText }: ReadSourceOptions): Promise<Source>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where a verb's markdown comes from: a file, stdin, or the argument itself.
3
+ *
4
+ * One reader for `graph related` and `render`, because they take the same
5
+ * positional and have to agree about what it means — including the part that is
6
+ * easy to get subtly different, which is that a path is tried against `--root`
7
+ * before the cwd. The root is the vault being asked about; the cwd is wherever
8
+ * the operator happens to be standing.
9
+ *
10
+ * The one difference between the two callers is what a string that names no
11
+ * file means. `graph related` answers questions about prose, so it is text.
12
+ * `render` renders a document, and a mistyped path silently rendering as its
13
+ * own filename would be a wrong answer with no error anywhere, so it fails.
14
+ */
15
+ import { readFile, stat } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import matter from 'gray-matter';
18
+ import { failure } from "./errors.js";
19
+ async function isFile(candidate) {
20
+ try {
21
+ return (await stat(candidate)).isFile();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function readStdin() {
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin)
30
+ chunks.push(chunk);
31
+ return Buffer.concat(chunks).toString('utf8');
32
+ }
33
+ /**
34
+ * Resolve the positional into markdown and its frontmatter.
35
+ *
36
+ * `-` is stdin, which is how a draft that is not a file yet gets asked about —
37
+ * and it is frontmatter-aware there too, so piping a whole note in behaves the
38
+ * same as naming it.
39
+ */
40
+ export async function readSource(input, root, { allowText }) {
41
+ if (input === '-') {
42
+ const { content, data } = matter(await readStdin());
43
+ return { kind: 'stdin', text: content, frontmatter: data };
44
+ }
45
+ const candidates = path.isAbsolute(input) ? [input] : [path.join(root, input), path.resolve(input)];
46
+ for (const candidate of candidates) {
47
+ if (!(await isFile(candidate)))
48
+ continue;
49
+ let source;
50
+ try {
51
+ source = await readFile(candidate, 'utf8');
52
+ }
53
+ catch (error) {
54
+ throw failure('EPARSE', `cannot read ${candidate}: ${error.message}`);
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = matter(source);
59
+ }
60
+ catch (error) {
61
+ throw failure('EPARSE', `cannot parse frontmatter in ${candidate}: ${error.message}`);
62
+ }
63
+ return {
64
+ kind: 'file',
65
+ file: path.relative(root, candidate).split(path.sep).join('/'),
66
+ text: parsed.content,
67
+ frontmatter: parsed.data,
68
+ };
69
+ }
70
+ if (!allowText) {
71
+ throw failure('EPARSE', `cannot read ${input}: no such file under ${root} or the cwd`);
72
+ }
73
+ return { kind: 'text', text: input, frontmatter: {} };
74
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `commune update` — a dated update entry, scaffolded from what changed.
3
+ *
4
+ * The one verb here that can write. Everything else in this CLI answers
5
+ * questions about a content tree; this one drafts a file into it, so the
6
+ * writing is opt-in: without `--write` it prints the entry on stdout, which is
7
+ * both a preview and a redirect away from a file of your choosing. With
8
+ * `--write` it refuses to overwrite an existing entry — a scaffold that
9
+ * clobbers a day's writing is worse than no scaffold.
10
+ *
11
+ * What it produces is a draft and says so: `summary` is empty, because a
12
+ * one-line summary of a week is a judgement and this command has no taste.
13
+ * The parts it can be trusted with — which pages moved, what they are called,
14
+ * what date it is — are filled in.
15
+ */
16
+ export declare function updateCommand(root: string, since: string, day: string, write: boolean, json: boolean): Promise<number>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `commune update` — a dated update entry, scaffolded from what changed.
3
+ *
4
+ * The one verb here that can write. Everything else in this CLI answers
5
+ * questions about a content tree; this one drafts a file into it, so the
6
+ * writing is opt-in: without `--write` it prints the entry on stdout, which is
7
+ * both a preview and a redirect away from a file of your choosing. With
8
+ * `--write` it refuses to overwrite an existing entry — a scaffold that
9
+ * clobbers a day's writing is worse than no scaffold.
10
+ *
11
+ * What it produces is a draft and says so: `summary` is empty, because a
12
+ * one-line summary of a week is a judgement and this command has no taste.
13
+ * The parts it can be trusted with — which pages moved, what they are called,
14
+ * what date it is — are filled in.
15
+ */
16
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { CONTENT_DIRS, loadContentEntries } from "../lib/graph.js";
19
+ import { SCHEMA, writeJson, writeLines } from "./output.js";
20
+ import { EXIT_OK, failure } from "./errors.js";
21
+ /**
22
+ * The entry, as markdown.
23
+ *
24
+ * `links:` carries the urlPaths and the body carries the titles, which is the
25
+ * same edge written twice on purpose: the frontmatter is what the graph reads
26
+ * without rendering anything, and the prose is what a reader reads. They
27
+ * deduplicate into one edge, so writing both costs nothing.
28
+ */
29
+ function scaffold(day, changed) {
30
+ const lines = [
31
+ '---',
32
+ `title: "Updates for ${day}"`,
33
+ `date: ${day}`,
34
+ 'summary: ""',
35
+ 'aiGenerated: false',
36
+ ];
37
+ if (changed.length) {
38
+ lines.push('links:', ...changed.map((entry) => ` - ${entry.urlPath}`));
39
+ }
40
+ lines.push('---', '');
41
+ lines.push(...(changed.length
42
+ ? changed.map((entry) => `- [[${entry.title}]]${entry.summary ? ` — ${entry.summary}` : ''}`)
43
+ : ['Nothing changed in this window.']));
44
+ return `${lines.join('\n')}\n`;
45
+ }
46
+ async function exists(filePath) {
47
+ try {
48
+ await stat(filePath);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ export async function updateCommand(root, since, day, write, json) {
56
+ const entries = await loadContentEntries({ root });
57
+ // Updates are excluded from their own roll-up: an update that lists last
58
+ // week's update says nothing about the wiki.
59
+ const changed = entries.filter((entry) => entry.collection !== 'updates' && entry.updated !== undefined && entry.updated >= since);
60
+ const file = `${CONTENT_DIRS.updates}/${day}.md`;
61
+ const content = scaffold(day, changed);
62
+ if (write) {
63
+ const destination = path.join(root, file);
64
+ if (await exists(destination)) {
65
+ throw failure('EEXISTS', `${file} already exists. Edit it, or delete it first — this command does not overwrite an update that has been written.`);
66
+ }
67
+ await mkdir(path.dirname(destination), { recursive: true });
68
+ await writeFile(destination, content);
69
+ }
70
+ if (json) {
71
+ writeJson({
72
+ schema: SCHEMA,
73
+ root,
74
+ since,
75
+ date: day,
76
+ file,
77
+ written: write,
78
+ entries: changed.map((entry) => ({
79
+ urlPath: entry.urlPath,
80
+ title: entry.title,
81
+ collection: entry.collection,
82
+ updated: entry.updated,
83
+ updatedSource: entry.updatedSource,
84
+ })),
85
+ content,
86
+ });
87
+ return EXIT_OK;
88
+ }
89
+ writeLines(write ? [`wrote ${file} — ${changed.length} entries since ${since}`] : [content.trimEnd()]);
90
+ return EXIT_OK;
91
+ }