@dmthepm/commune 0.1.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/LICENSE +21 -0
- package/README.md +468 -0
- package/bin/commune.mjs +29 -0
- package/lib/cli/check.d.ts +13 -0
- package/lib/cli/check.js +58 -0
- package/lib/cli/errors.d.ts +29 -0
- package/lib/cli/errors.js +41 -0
- package/lib/cli/gate.d.ts +34 -0
- package/lib/cli/gate.js +165 -0
- package/lib/cli/main.d.ts +20 -0
- package/lib/cli/main.js +175 -0
- package/lib/cli/query.d.ts +30 -0
- package/lib/cli/query.js +103 -0
- package/lib/cli/related.d.ts +17 -0
- package/lib/cli/related.js +177 -0
- package/lib/cli/render.d.ts +20 -0
- package/lib/cli/render.js +32 -0
- package/lib/cli/root.d.ts +11 -0
- package/lib/cli/root.js +28 -0
- package/lib/cli/usage.d.ts +3 -0
- package/lib/cli/usage.js +46 -0
- package/lib/cli/version.d.ts +24 -0
- package/lib/cli/version.js +29 -0
- package/lib/integration.d.ts +24 -0
- package/lib/integration.js +111 -0
- package/lib/lib/graph.d.ts +354 -0
- package/lib/lib/graph.js +774 -0
- package/lib/markdown.d.ts +30 -0
- package/lib/markdown.js +24 -0
- package/lib/rehype-external-links.d.ts +15 -0
- package/lib/rehype-external-links.js +46 -0
- package/lib/remark-wikilinks.d.ts +25 -0
- package/lib/remark-wikilinks.js +108 -0
- package/package.json +101 -0
- package/src/components/Backlinks.astro +17 -0
- package/src/components/BacklinksScript.astro +117 -0
- package/src/components/Footer.astro +35 -0
- package/src/components/Header.astro +250 -0
- package/src/components/HeaderStarScript.astro +380 -0
- package/src/components/HomeFooterCards.astro +155 -0
- package/src/components/MarkdownLink.astro +36 -0
- package/src/components/PlausibleScript.astro +13 -0
- package/src/components/RelatedNotes.astro +77 -0
- package/src/components/SearchModal.astro +213 -0
- package/src/components/StarredLinksScript.astro +92 -0
- package/src/components/panes.ts +61 -0
- package/src/styles/design-system.css +157 -0
- package/src/styles/notes.css +85 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `commune graph related` — what a piece of text connects to.
|
|
3
|
+
*
|
|
4
|
+
* This is the primitive the connect step (#19) and the authoring skills (#10)
|
|
5
|
+
* are written against, so v1 is deliberately deterministic: two buckets, both
|
|
6
|
+
* computable from what the core already does, and no ranking.
|
|
7
|
+
*
|
|
8
|
+
* `links` — every edge the text actually contains, with its resolution.
|
|
9
|
+
* `mentions` — entries whose title or alias appears as a whole word in the
|
|
10
|
+
* prose, which is the "you wrote about this without linking it"
|
|
11
|
+
* signal a connect step needs.
|
|
12
|
+
*
|
|
13
|
+
* Fuzzy and semantic similarity are out of scope on purpose. They are product
|
|
14
|
+
* direction, not a build decision, and inventing a ranking here would freeze it
|
|
15
|
+
* into the contract before anyone chose it.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import matter from 'gray-matter';
|
|
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. */
|
|
24
|
+
const MIN_MENTION_LENGTH = 3;
|
|
25
|
+
function toReference(entry) {
|
|
26
|
+
return {
|
|
27
|
+
urlPath: entry.urlPath,
|
|
28
|
+
title: entry.title,
|
|
29
|
+
collection: entry.collection,
|
|
30
|
+
file: entry.file,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Work out whether the positional is a path, stdin, or literal prose.
|
|
35
|
+
*
|
|
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".
|
|
40
|
+
*/
|
|
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)))
|
|
52
|
+
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}`);
|
|
59
|
+
}
|
|
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
|
+
}
|
|
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, '\\$&');
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Count whole-word occurrences of `name` in `text`.
|
|
97
|
+
*
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
function countMentions(text, name) {
|
|
103
|
+
const pattern = new RegExp(`(?<!\\w)${escapeRegExp(name)}(?!\\w)`, 'gi');
|
|
104
|
+
return text.match(pattern)?.length ?? 0;
|
|
105
|
+
}
|
|
106
|
+
export async function relatedCommand(root, input, json) {
|
|
107
|
+
const entries = await loadContentEntries({ root });
|
|
108
|
+
const graph = buildGraph(entries);
|
|
109
|
+
const byName = buildLinkLookup(entries);
|
|
110
|
+
const byUrl = buildUrlLookup(entries);
|
|
111
|
+
const byUrlPath = new Map(entries.map((entry) => [entry.urlPath, entry]));
|
|
112
|
+
const source = await readSource(input, root);
|
|
113
|
+
// A file that happens to be a graph entry gets its inbound links too; a
|
|
114
|
+
// draft outside the vault, or raw text, has none by definition.
|
|
115
|
+
const self = source.file ? entries.find((entry) => entry.file === source.file) : undefined;
|
|
116
|
+
const links = extractLinks(source.text, source.frontmatter).map((link) => {
|
|
117
|
+
const target = resolveLink(link, byName, byUrl);
|
|
118
|
+
const entry = target ? byUrlPath.get(target.urlPath) : undefined;
|
|
119
|
+
return {
|
|
120
|
+
kind: link.kind,
|
|
121
|
+
target: link.target,
|
|
122
|
+
resolved: entry ? toReference(entry) : null,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const prose = stripCode(source.text);
|
|
126
|
+
const mentions = entries
|
|
127
|
+
.filter((entry) => entry.urlPath !== self?.urlPath)
|
|
128
|
+
.map((entry) => {
|
|
129
|
+
const names = [entry.title, ...entry.aliases].filter((name) => name.length >= MIN_MENTION_LENGTH);
|
|
130
|
+
let count = 0;
|
|
131
|
+
let matched;
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const hits = countMentions(prose, name);
|
|
134
|
+
if (!hits)
|
|
135
|
+
continue;
|
|
136
|
+
count += hits;
|
|
137
|
+
matched ??= name.toLowerCase();
|
|
138
|
+
}
|
|
139
|
+
return { ...toReference(entry), matched, count };
|
|
140
|
+
})
|
|
141
|
+
.filter((mention) => mention.count > 0)
|
|
142
|
+
.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title) || a.urlPath.localeCompare(b.urlPath));
|
|
143
|
+
const inbound = (self ? graph.nodes[self.urlPath].inbound : [])
|
|
144
|
+
.map((urlPath) => byUrlPath.get(urlPath))
|
|
145
|
+
.filter((entry) => Boolean(entry))
|
|
146
|
+
.map(toReference);
|
|
147
|
+
const summary = {
|
|
148
|
+
links: links.length,
|
|
149
|
+
resolved: links.filter((link) => link.resolved).length,
|
|
150
|
+
unresolved: links.filter((link) => !link.resolved).length,
|
|
151
|
+
mentions: mentions.length,
|
|
152
|
+
inbound: inbound.length,
|
|
153
|
+
};
|
|
154
|
+
if (json) {
|
|
155
|
+
writeJson({
|
|
156
|
+
schema: SCHEMA,
|
|
157
|
+
root,
|
|
158
|
+
source: {
|
|
159
|
+
kind: source.kind,
|
|
160
|
+
...(source.file ? { file: source.file } : {}),
|
|
161
|
+
...(self ? { urlPath: self.urlPath } : {}),
|
|
162
|
+
},
|
|
163
|
+
links,
|
|
164
|
+
mentions,
|
|
165
|
+
inbound,
|
|
166
|
+
summary,
|
|
167
|
+
});
|
|
168
|
+
return EXIT_OK;
|
|
169
|
+
}
|
|
170
|
+
writeLines([
|
|
171
|
+
...links.map((link) => `link\t${link.target}\t${link.resolved ? link.resolved.urlPath : 'UNRESOLVED'}`),
|
|
172
|
+
...mentions.map((mention) => `mention\t${mention.urlPath}\t${mention.count}`),
|
|
173
|
+
...inbound.map((entry) => `inbound\t${entry.urlPath}`),
|
|
174
|
+
`${summary.links} links (${summary.resolved} resolved), ${summary.mentions} mentions, ${summary.inbound} inbound`,
|
|
175
|
+
]);
|
|
176
|
+
return EXIT_OK;
|
|
177
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving `--root`.
|
|
3
|
+
*
|
|
4
|
+
* `--root` names the *project* root: the directory that contains `src/content`.
|
|
5
|
+
* Not `src/content` itself — every slug and every `file` value in the graph is
|
|
6
|
+
* derived by stripping `src/content/<collection>/` off the front of a path, so
|
|
7
|
+
* a root one level too deep produces a complete, confident, wrong answer with
|
|
8
|
+
* no error anywhere. Requiring `src/content` to exist under the root turns that
|
|
9
|
+
* silent corruption into an exit 1 on the first command.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRoot(value: string | undefined): Promise<string>;
|
package/lib/cli/root.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving `--root`.
|
|
3
|
+
*
|
|
4
|
+
* `--root` names the *project* root: the directory that contains `src/content`.
|
|
5
|
+
* Not `src/content` itself — every slug and every `file` value in the graph is
|
|
6
|
+
* derived by stripping `src/content/<collection>/` off the front of a path, so
|
|
7
|
+
* a root one level too deep produces a complete, confident, wrong answer with
|
|
8
|
+
* no error anywhere. Requiring `src/content` to exist under the root turns that
|
|
9
|
+
* silent corruption into an exit 1 on the first command.
|
|
10
|
+
*/
|
|
11
|
+
import { stat } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { failure } from "./errors.js";
|
|
14
|
+
export async function resolveRoot(value) {
|
|
15
|
+
const root = path.resolve(value ?? process.cwd());
|
|
16
|
+
const content = path.join(root, 'src', 'content');
|
|
17
|
+
let stats;
|
|
18
|
+
try {
|
|
19
|
+
stats = await stat(content);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw failure('ENOCONTENT', `${root} is not a project root: no src/content directory. --root names the directory that contains src/content, not src/content itself.`);
|
|
23
|
+
}
|
|
24
|
+
if (!stats.isDirectory()) {
|
|
25
|
+
throw failure('ENOCONTENT', `${content} is not a directory`);
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Usage text. Hand-written: `parseArgs` generates none, which is its one real cost. */
|
|
2
|
+
export declare const USAGE = "commune \u2014 query the content graph without an Astro process\n\nUsage:\n commune [--root <dir>] graph query [filters] [--json]\n commune [--root <dir>] graph related <path|text|-> [--json]\n commune [--root <dir>] check [--json]\n commune [--root <dir>] gate [--dist <dir>] [--json]\n commune --version\n\nGlobal options:\n --root <dir> Project root: the directory containing src/content. Default: cwd.\n --json Emit one JSON document on stdout. Everything else goes to stderr.\n --help Show this text.\n --version Print the version of the installed package and exit.\n\ngraph query filters (any-of within a flag, all-of across flags):\n --collection <notes|research|pages> Repeatable.\n --tag <tag> Repeatable.\n --status <status>\n --orphans Zero inbound and zero outbound.\n --deadends Zero outbound.\n\ngate options:\n --dist <dir> The built site to check, relative to --root. Default: dist.\n\nExit codes:\n 0 finished, findings or not\n 1 could not finish\n 2 invalid invocation\n\n gate is the one exception, and the only verb whose exit code encodes a\n finding: it exits 1 when the build it checked is wrong. That is what a gate\n is for \u2014 a build stops on a non-zero exit \u2014 so gate cannot report a finding\n the way every other verb does, in the payload with exit 0.";
|
|
3
|
+
export declare const COMMAND_USAGE: Record<string, string>;
|
package/lib/cli/usage.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Usage text. Hand-written: `parseArgs` generates none, which is its one real cost. */
|
|
2
|
+
export const USAGE = `commune — query the content graph without an Astro process
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
commune [--root <dir>] graph query [filters] [--json]
|
|
6
|
+
commune [--root <dir>] graph related <path|text|-> [--json]
|
|
7
|
+
commune [--root <dir>] check [--json]
|
|
8
|
+
commune [--root <dir>] gate [--dist <dir>] [--json]
|
|
9
|
+
commune --version
|
|
10
|
+
|
|
11
|
+
Global options:
|
|
12
|
+
--root <dir> Project root: the directory containing src/content. Default: cwd.
|
|
13
|
+
--json Emit one JSON document on stdout. Everything else goes to stderr.
|
|
14
|
+
--help Show this text.
|
|
15
|
+
--version Print the version of the installed package and exit.
|
|
16
|
+
|
|
17
|
+
graph query filters (any-of within a flag, all-of across flags):
|
|
18
|
+
--collection <notes|research|pages> Repeatable.
|
|
19
|
+
--tag <tag> Repeatable.
|
|
20
|
+
--status <status>
|
|
21
|
+
--orphans Zero inbound and zero outbound.
|
|
22
|
+
--deadends Zero outbound.
|
|
23
|
+
|
|
24
|
+
gate options:
|
|
25
|
+
--dist <dir> The built site to check, relative to --root. Default: dist.
|
|
26
|
+
|
|
27
|
+
Exit codes:
|
|
28
|
+
0 finished, findings or not
|
|
29
|
+
1 could not finish
|
|
30
|
+
2 invalid invocation
|
|
31
|
+
|
|
32
|
+
gate is the one exception, and the only verb whose exit code encodes a
|
|
33
|
+
finding: it exits 1 when the build it checked is wrong. That is what a gate
|
|
34
|
+
is for — a build stops on a non-zero exit — so gate cannot report a finding
|
|
35
|
+
the way every other verb does, in the payload with exit 0.`;
|
|
36
|
+
export const COMMAND_USAGE = {
|
|
37
|
+
'graph query': 'Usage: commune [--root <dir>] graph query [--collection <c>]... [--tag <t>]... [--status <s>] [--orphans] [--deadends] [--json]',
|
|
38
|
+
'graph related': 'Usage: commune [--root <dir>] graph related <path|text|-> [--json]',
|
|
39
|
+
check: 'Usage: commune [--root <dir>] check [--json]',
|
|
40
|
+
gate: `Usage: commune [--root <dir>] gate [--dist <dir>] [--json]
|
|
41
|
+
|
|
42
|
+
Run after a build. Asserts that every standalone page is in the search index,
|
|
43
|
+
that every resolving WikiLink uses its target's exact title, and that WikiLinks
|
|
44
|
+
to standalone pages rendered as hrefs. Exits 1 if any of that is false — the
|
|
45
|
+
one verb whose exit code encodes a finding.`,
|
|
46
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version this build answers to.
|
|
3
|
+
*
|
|
4
|
+
* Read out of `package.json` at run time rather than baked in at build time.
|
|
5
|
+
* There is nothing in the build that could bake it in — `tsc` compiles types
|
|
6
|
+
* away, not values — and release-please bumps exactly one file when it cuts a
|
|
7
|
+
* release. A second copy of the number would be a second thing to forget.
|
|
8
|
+
*
|
|
9
|
+
* `../../package.json` survives compilation because `src/cli/` and `lib/cli/`
|
|
10
|
+
* sit at the same depth: `tsconfig.build.json` sets `rootDir: src`, so this
|
|
11
|
+
* file becomes `lib/cli/version.js` and two levels up is the package root
|
|
12
|
+
* exactly as two levels up from `src/cli/version.ts` is the repo root. Only the
|
|
13
|
+
* `lib/` layout is ever exercised — nothing imports `src/cli/*.ts` directly,
|
|
14
|
+
* and the tests reach this code the way a consumer does, by spawning
|
|
15
|
+
* `bin/commune.mjs`, which imports `../lib/cli/main.js`. The `src/` half of
|
|
16
|
+
* that sentence is a property of the build, not a path anything walks.
|
|
17
|
+
*
|
|
18
|
+
* It resolves inside an installed `node_modules/@dmthepm/commune` too, and for
|
|
19
|
+
* a reason worth writing down: npm packs `package.json` into every tarball
|
|
20
|
+
* regardless of the `files` allowlist, so it is there even though `files` never
|
|
21
|
+
* names it.
|
|
22
|
+
*/
|
|
23
|
+
/** The `version` field of the package this file was loaded from. */
|
|
24
|
+
export declare function readVersion(): Promise<string>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version this build answers to.
|
|
3
|
+
*
|
|
4
|
+
* Read out of `package.json` at run time rather than baked in at build time.
|
|
5
|
+
* There is nothing in the build that could bake it in — `tsc` compiles types
|
|
6
|
+
* away, not values — and release-please bumps exactly one file when it cuts a
|
|
7
|
+
* release. A second copy of the number would be a second thing to forget.
|
|
8
|
+
*
|
|
9
|
+
* `../../package.json` survives compilation because `src/cli/` and `lib/cli/`
|
|
10
|
+
* sit at the same depth: `tsconfig.build.json` sets `rootDir: src`, so this
|
|
11
|
+
* file becomes `lib/cli/version.js` and two levels up is the package root
|
|
12
|
+
* exactly as two levels up from `src/cli/version.ts` is the repo root. Only the
|
|
13
|
+
* `lib/` layout is ever exercised — nothing imports `src/cli/*.ts` directly,
|
|
14
|
+
* and the tests reach this code the way a consumer does, by spawning
|
|
15
|
+
* `bin/commune.mjs`, which imports `../lib/cli/main.js`. The `src/` half of
|
|
16
|
+
* that sentence is a property of the build, not a path anything walks.
|
|
17
|
+
*
|
|
18
|
+
* It resolves inside an installed `node_modules/@dmthepm/commune` too, and for
|
|
19
|
+
* a reason worth writing down: npm packs `package.json` into every tarball
|
|
20
|
+
* regardless of the `files` allowlist, so it is there even though `files` never
|
|
21
|
+
* names it.
|
|
22
|
+
*/
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
/** The `version` field of the package this file was loaded from. */
|
|
25
|
+
export async function readVersion() {
|
|
26
|
+
const manifest = new URL('../../package.json', import.meta.url);
|
|
27
|
+
const { version } = JSON.parse(await readFile(manifest, 'utf8'));
|
|
28
|
+
return version;
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro integration for building backlinks and graph data at build time, and
|
|
3
|
+
* for writing each entry's source markdown beside its rendered page.
|
|
4
|
+
*
|
|
5
|
+
* Thin by design: it owns *when* the graph is built and *where* the artifacts
|
|
6
|
+
* are written, and nothing else. Which content exists, how links resolve, how
|
|
7
|
+
* stars are ranked and what counts as a finding are all decided by the graph
|
|
8
|
+
* core in `src/lib/graph.ts`, so the `commune` CLI produces the same graph
|
|
9
|
+
* without an Astro process anywhere in sight.
|
|
10
|
+
*/
|
|
11
|
+
import type { AstroIntegration } from 'astro';
|
|
12
|
+
/**
|
|
13
|
+
* Options for the integration.
|
|
14
|
+
*
|
|
15
|
+
* Empty, and deliberately so: everything the graph needs is already in the
|
|
16
|
+
* consumer's `defineConfig` — the root to read content from, the public
|
|
17
|
+
* directory to write the index to — and reading it from there is what stops a
|
|
18
|
+
* second, drifting copy of Astro's own configuration existing. The parameter
|
|
19
|
+
* is here so that the day something *is* configurable, `commune({ … })` is
|
|
20
|
+
* already the spelling in every consumer's config.
|
|
21
|
+
*/
|
|
22
|
+
export interface CommuneOptions {
|
|
23
|
+
}
|
|
24
|
+
export default function commune(_options?: CommuneOptions): AstroIntegration;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro integration for building backlinks and graph data at build time, and
|
|
3
|
+
* for writing each entry's source markdown beside its rendered page.
|
|
4
|
+
*
|
|
5
|
+
* Thin by design: it owns *when* the graph is built and *where* the artifacts
|
|
6
|
+
* are written, and nothing else. Which content exists, how links resolve, how
|
|
7
|
+
* stars are ranked and what counts as a finding are all decided by the graph
|
|
8
|
+
* core in `src/lib/graph.ts`, so the `commune` CLI produces the same graph
|
|
9
|
+
* without an Astro process anywhere in sight.
|
|
10
|
+
*/
|
|
11
|
+
import { copyFile, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { buildGraph, formatDiagnostic, loadContentEntries, toBacklinksJson, toMarkdownPath, } from "./lib/graph.js";
|
|
15
|
+
function buildBacklinksGraph(entries, logger) {
|
|
16
|
+
const graph = buildGraph(entries);
|
|
17
|
+
logger.info(`📝 Found ${Object.keys(graph.nodes).length} public content entries`);
|
|
18
|
+
for (const diagnostic of graph.diagnostics) {
|
|
19
|
+
logger.warn(formatDiagnostic(diagnostic));
|
|
20
|
+
}
|
|
21
|
+
const starred = Object.values(graph.nodes).filter((note) => note.isStarred).length;
|
|
22
|
+
if (starred > 0) {
|
|
23
|
+
logger.info(`⭐ ${starred} notes starred (top 5%)`);
|
|
24
|
+
}
|
|
25
|
+
return graph;
|
|
26
|
+
}
|
|
27
|
+
async function writeBacklinksFile(filePath, graph) {
|
|
28
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
29
|
+
await writeFile(filePath, JSON.stringify(graph, null, 2) + '\n');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Copy every entry's source file next to its rendered page, at `<url>.md`.
|
|
33
|
+
*
|
|
34
|
+
* `copyFile` rather than a re-serialization on purpose: the promise of the `.md`
|
|
35
|
+
* URL is that it returns *the file*, frontmatter and `[[wikilinks]]` untouched,
|
|
36
|
+
* so anything that reformats on the way through would break it. The graph
|
|
37
|
+
* already decided which entries exist and where each one lives; this only moves
|
|
38
|
+
* bytes.
|
|
39
|
+
*/
|
|
40
|
+
async function writeMarkdownFiles(entries, root, outDir) {
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const destination = path.join(outDir, toMarkdownPath(entry.urlPath));
|
|
43
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
44
|
+
// `entry.file` is relative to the project root — the graph's promise —
|
|
45
|
+
// so the read is joined to that root and not left to the cwd. They are
|
|
46
|
+
// the same directory when someone runs `astro build` in their project
|
|
47
|
+
// and different the moment they do not.
|
|
48
|
+
await copyFile(path.join(root, entry.file), destination);
|
|
49
|
+
}
|
|
50
|
+
return entries.length;
|
|
51
|
+
}
|
|
52
|
+
function summarize(graph) {
|
|
53
|
+
return `📊 ${graph.totalBacklinks} total backlinks across ${Object.keys(graph.nodes).length} entries`;
|
|
54
|
+
}
|
|
55
|
+
export default function commune(_options = {}) {
|
|
56
|
+
// `astro:build:done` is not handed the resolved config, so the two paths
|
|
57
|
+
// the graph needs are captured in `astro:config:setup` — which Astro always
|
|
58
|
+
// runs first — and closed over. Per-instance rather than module-level: one
|
|
59
|
+
// `commune()` call belongs to one Astro config, so two projects built in
|
|
60
|
+
// one process do not overwrite each other's roots.
|
|
61
|
+
let root;
|
|
62
|
+
let publicBacklinks;
|
|
63
|
+
return {
|
|
64
|
+
name: 'commune-backlinks',
|
|
65
|
+
hooks: {
|
|
66
|
+
// Write the public artifact before pages render. Note pages import
|
|
67
|
+
// backlinks.json at build time, so it has to be fresh on disk first —
|
|
68
|
+
// otherwise the build bakes in whatever the previous run left behind.
|
|
69
|
+
'astro:config:setup': async ({ config, logger }) => {
|
|
70
|
+
logger.info('🔗 Building public backlinks index...');
|
|
71
|
+
// Both are URLs, and `.pathname` percent-encodes: a project
|
|
72
|
+
// checked out under a path with a space would read from a
|
|
73
|
+
// literal `%20` directory and find no content at all.
|
|
74
|
+
// `fileURLToPath` is what Astro's own docs use.
|
|
75
|
+
root = fileURLToPath(config.root);
|
|
76
|
+
publicBacklinks = fileURLToPath(new URL('./backlinks.json', config.publicDir));
|
|
77
|
+
try {
|
|
78
|
+
const graph = buildBacklinksGraph(await loadContentEntries({ root }), logger);
|
|
79
|
+
await writeBacklinksFile(publicBacklinks, toBacklinksJson(graph));
|
|
80
|
+
logger.info(`✅ Backlinks index written to ${path.relative(root, publicBacklinks)}`);
|
|
81
|
+
logger.info(summarize(graph));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
logger.error('❌ Failed to build public backlinks index:');
|
|
85
|
+
logger.error(String(error));
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
90
|
+
logger.info('🔗 Building backlinks index...');
|
|
91
|
+
try {
|
|
92
|
+
const entries = await loadContentEntries({ root });
|
|
93
|
+
const graph = buildBacklinksGraph(entries, logger);
|
|
94
|
+
const json = toBacklinksJson(graph);
|
|
95
|
+
await writeBacklinksFile(fileURLToPath(new URL('./backlinks.json', dir)), json);
|
|
96
|
+
// Also written to the public directory, for dev server parity.
|
|
97
|
+
await writeBacklinksFile(publicBacklinks, json);
|
|
98
|
+
const written = await writeMarkdownFiles(entries, root, fileURLToPath(dir));
|
|
99
|
+
logger.info(`✅ Backlinks index written to /backlinks.json (dist + public)`);
|
|
100
|
+
logger.info(`📄 ${written} source files written as .md alongside their pages`);
|
|
101
|
+
logger.info(summarize(graph));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
logger.error('❌ Failed to build backlinks index:');
|
|
105
|
+
logger.error(String(error));
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|