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