@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
package/lib/cli/gate.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `commune gate` — the build check, as a verb.
|
|
3
|
+
*
|
|
4
|
+
* This is the one command in the CLI whose exit code answers a question about
|
|
5
|
+
* your *content* rather than about the command. Everywhere else the contract is
|
|
6
|
+
* "0 means I finished, findings or not", precisely so an agent can tell a dirty
|
|
7
|
+
* vault from a broken tool. A gate inverts that on purpose: its whole job is to
|
|
8
|
+
* stop a build, and a build stops on a non-zero exit. `usage.ts` says so out
|
|
9
|
+
* loud, because a reader who has internalised the rule needs to be told where
|
|
10
|
+
* the exception is.
|
|
11
|
+
*
|
|
12
|
+
* It was `scripts/test-search-index.mjs`, which imported the graph core by
|
|
13
|
+
* relative path. That works from a checkout and is unreachable from
|
|
14
|
+
* `node_modules`, so the one repo that most needs this check — a wiki built
|
|
15
|
+
* with the package — was the one repo that could not run it. The three
|
|
16
|
+
* assertions are unchanged; only the way you invoke them is.
|
|
17
|
+
*
|
|
18
|
+
* Three assertions:
|
|
19
|
+
* 1. every page in the `pages` collection is present in the search index
|
|
20
|
+
* 2. every WikiLink that resolves uses the target's exact title (no pipes,
|
|
21
|
+
* no case drift) — the canonical-title rule
|
|
22
|
+
* 3. WikiLinks pointing at standalone pages actually render as hrefs
|
|
23
|
+
*
|
|
24
|
+
* The canonical-title rule itself lives in the graph core, where `commune
|
|
25
|
+
* check` reports it as a `noncanonical-title` finding. This verb is the *gate*:
|
|
26
|
+
* same rule, same findings, but a build that violates it stops. Two copies of
|
|
27
|
+
* one rule is the bug #3 was opened to kill, so there is only ever one.
|
|
28
|
+
*/
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { findNoncanonicalTitles, loadContentEntries, stripCode, } from "../lib/graph.js";
|
|
32
|
+
import { EXIT_FAILED, EXIT_OK, failure } from "./errors.js";
|
|
33
|
+
import { SCHEMA, writeJson } from "./render.js";
|
|
34
|
+
const WIKILINK = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
35
|
+
async function readSearchIndex(root) {
|
|
36
|
+
// The public artifact, not the built one: `public/backlinks.json` is what
|
|
37
|
+
// the site imports at build time and what both repos commit, so it is the
|
|
38
|
+
// copy whose staleness would actually ship.
|
|
39
|
+
const file = path.join(root, 'public', 'backlinks.json');
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw failure('ENOCONTENT', `could not read the search index at ${file}: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
45
|
+
'`commune gate` runs after a build, which is what writes it.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 1. Every standalone page made it into the search index. */
|
|
49
|
+
function pagesAreIndexed(pages, index) {
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (const page of pages) {
|
|
52
|
+
const indexed = index[page.urlPath];
|
|
53
|
+
if (!indexed || indexed.title !== page.title || indexed.collection !== 'pages') {
|
|
54
|
+
missing.push(`${page.urlPath} (${page.title})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!missing.length)
|
|
58
|
+
return [];
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
assertion: 'pages-indexed',
|
|
62
|
+
message: `standalone pages missing from search index: ${missing.join(', ')}`,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 2. WikiLinks must name their target exactly.
|
|
68
|
+
*
|
|
69
|
+
* A piped link or a near-miss title still renders, which is what makes this
|
|
70
|
+
* worth checking: it fails silently and quietly decouples the vault from the
|
|
71
|
+
* site.
|
|
72
|
+
*/
|
|
73
|
+
function titlesAreCanonical(entries) {
|
|
74
|
+
const noncanonical = findNoncanonicalTitles(entries);
|
|
75
|
+
if (!noncanonical.length)
|
|
76
|
+
return [];
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
assertion: 'canonical-titles',
|
|
80
|
+
message: `WikiLinks must use exact page titles:\n${noncanonical
|
|
81
|
+
.map((finding) => `${finding.file}: ${finding.message}`)
|
|
82
|
+
.join('\n')}`,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 3. A note that links to a standalone page must actually render that href.
|
|
88
|
+
*
|
|
89
|
+
* The one assertion that catches a resolver regression rather than a content
|
|
90
|
+
* mistake, and the only one that needs the built output.
|
|
91
|
+
*/
|
|
92
|
+
async function pageLinksRender(entries, pages, dist) {
|
|
93
|
+
const pageTargets = new Map();
|
|
94
|
+
for (const page of pages) {
|
|
95
|
+
pageTargets.set(page.title.toLowerCase(), page.urlPath);
|
|
96
|
+
for (const alias of page.aliases) {
|
|
97
|
+
pageTargets.set(alias.toLowerCase(), page.urlPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const unresolved = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.collection !== 'notes')
|
|
103
|
+
continue;
|
|
104
|
+
const expected = new Set();
|
|
105
|
+
for (const match of stripCode(entry.body).matchAll(WIKILINK)) {
|
|
106
|
+
const url = pageTargets.get(match[1].trim().toLowerCase());
|
|
107
|
+
if (url)
|
|
108
|
+
expected.add(url);
|
|
109
|
+
}
|
|
110
|
+
if (!expected.size)
|
|
111
|
+
continue;
|
|
112
|
+
const page = path.join(dist, 'notes', entry.slug, 'index.html');
|
|
113
|
+
let html;
|
|
114
|
+
try {
|
|
115
|
+
html = await readFile(page, 'utf8');
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A page that links to a standalone page and has no built output has
|
|
119
|
+
// failed this assertion as surely as one whose href is wrong — and
|
|
120
|
+
// saying which file is missing beats an ENOENT stack.
|
|
121
|
+
unresolved.push(`${entry.slug} -> ${page} was not built`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const url of expected) {
|
|
125
|
+
if (!html.includes(`href="${url}"`))
|
|
126
|
+
unresolved.push(`${entry.slug} -> ${url}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!unresolved.length)
|
|
130
|
+
return [];
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
assertion: 'page-links-rendered',
|
|
134
|
+
message: `WikiLinks to standalone pages did not render: ${unresolved.join(', ')}`,
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
export async function gateCommand(root, dist, json) {
|
|
139
|
+
const index = await readSearchIndex(root);
|
|
140
|
+
const entries = await loadContentEntries({ root });
|
|
141
|
+
const pages = entries.filter((entry) => entry.collection === 'pages');
|
|
142
|
+
const distDir = path.resolve(root, dist);
|
|
143
|
+
// All three run, rather than stopping at the first: a build that broke two
|
|
144
|
+
// things should say so once, not across two rebuilds.
|
|
145
|
+
const failures = [
|
|
146
|
+
...pagesAreIndexed(pages, index),
|
|
147
|
+
...titlesAreCanonical(entries),
|
|
148
|
+
...(await pageLinksRender(entries, pages, distDir)),
|
|
149
|
+
];
|
|
150
|
+
const passed = failures.length === 0;
|
|
151
|
+
const summary = `${pages.length} standalone page${pages.length === 1 ? '' : 's'} indexed for search and linked from notes`;
|
|
152
|
+
if (json) {
|
|
153
|
+
writeJson({ schema: SCHEMA, passed, failures });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Both verdicts go to stderr, not just the failing one. A gate's output
|
|
157
|
+
// is a diagnostic either way, and keeping stdout empty is what lets
|
|
158
|
+
// `commune gate` sit in a build pipeline without polluting it.
|
|
159
|
+
for (const found of failures)
|
|
160
|
+
process.stderr.write(`FAIL: ${found.message}\n`);
|
|
161
|
+
if (passed)
|
|
162
|
+
process.stderr.write(`PASS: ${summary}\n`);
|
|
163
|
+
}
|
|
164
|
+
return passed ? EXIT_OK : EXIT_FAILED;
|
|
165
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing and dispatch.
|
|
3
|
+
*
|
|
4
|
+
* `node:util.parseArgs` has no notion of subcommands, so routing is two passes
|
|
5
|
+
* over the same argv. The first is non-strict and only looks for positionals —
|
|
6
|
+
* enough to learn that this is `graph query` — while still consuming `--root`'s
|
|
7
|
+
* value correctly, since declared string options keep their type even in
|
|
8
|
+
* non-strict mode. The second pass is strict against that subcommand's schema,
|
|
9
|
+
* which is what turns a typo into exit 2 instead of a silently ignored flag.
|
|
10
|
+
*
|
|
11
|
+
* Being non-strict first is also what lets a parse *failure* be rendered as
|
|
12
|
+
* JSON: `--json` is known before the strict parse that rejects the argv.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Run the CLI and return its exit code.
|
|
16
|
+
*
|
|
17
|
+
* Returns rather than calls `process.exit`, so a buffered stdout write is never
|
|
18
|
+
* truncated by the process going away underneath it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function run(args: string[]): Promise<number>;
|
package/lib/cli/main.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing and dispatch.
|
|
3
|
+
*
|
|
4
|
+
* `node:util.parseArgs` has no notion of subcommands, so routing is two passes
|
|
5
|
+
* over the same argv. The first is non-strict and only looks for positionals —
|
|
6
|
+
* enough to learn that this is `graph query` — while still consuming `--root`'s
|
|
7
|
+
* value correctly, since declared string options keep their type even in
|
|
8
|
+
* non-strict mode. The second pass is strict against that subcommand's schema,
|
|
9
|
+
* which is what turns a typo into exit 2 instead of a silently ignored flag.
|
|
10
|
+
*
|
|
11
|
+
* Being non-strict first is also what lets a parse *failure* be rendered as
|
|
12
|
+
* JSON: `--json` is known before the strict parse that rejects the argv.
|
|
13
|
+
*/
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
15
|
+
import { CliError, EXIT_OK, EXIT_USAGE, isParseArgsError, usageError } from "./errors.js";
|
|
16
|
+
import { writeError } from "./render.js";
|
|
17
|
+
import { resolveRoot } from "./root.js";
|
|
18
|
+
import { queryCommand } from "./query.js";
|
|
19
|
+
import { checkCommand } from "./check.js";
|
|
20
|
+
import { gateCommand } from "./gate.js";
|
|
21
|
+
import { relatedCommand } from "./related.js";
|
|
22
|
+
import { COMMAND_USAGE, USAGE } from "./usage.js";
|
|
23
|
+
import { readVersion } from "./version.js";
|
|
24
|
+
/** Options understood everywhere, in any position. */
|
|
25
|
+
const GLOBAL = {
|
|
26
|
+
root: { type: 'string' },
|
|
27
|
+
json: { type: 'boolean', default: false },
|
|
28
|
+
help: { type: 'boolean', default: false },
|
|
29
|
+
};
|
|
30
|
+
const QUERY_OPTIONS = {
|
|
31
|
+
...GLOBAL,
|
|
32
|
+
collection: { type: 'string', multiple: true, default: [] },
|
|
33
|
+
tag: { type: 'string', multiple: true, default: [] },
|
|
34
|
+
status: { type: 'string' },
|
|
35
|
+
orphans: { type: 'boolean', default: false },
|
|
36
|
+
deadends: { type: 'boolean', default: false },
|
|
37
|
+
};
|
|
38
|
+
const GATE_OPTIONS = {
|
|
39
|
+
...GLOBAL,
|
|
40
|
+
dist: { type: 'string' },
|
|
41
|
+
};
|
|
42
|
+
/** Every route, longest first, so `graph query` is matched before a bare `graph`. */
|
|
43
|
+
const ROUTES = ['graph query', 'graph related', 'check', 'gate'];
|
|
44
|
+
/**
|
|
45
|
+
* Find which command this argv names, without committing to its schema yet.
|
|
46
|
+
*
|
|
47
|
+
* Route words are positionals wherever they appear, so `--root x graph query`
|
|
48
|
+
* and `graph query --root x` route identically — which matters because the two
|
|
49
|
+
* spellings are equally natural and only one of them can be the documented one.
|
|
50
|
+
*/
|
|
51
|
+
function route(args) {
|
|
52
|
+
const { positionals, tokens } = parseArgs({
|
|
53
|
+
args,
|
|
54
|
+
options: GLOBAL,
|
|
55
|
+
allowPositionals: true,
|
|
56
|
+
strict: false,
|
|
57
|
+
tokens: true,
|
|
58
|
+
});
|
|
59
|
+
const indices = tokens.filter((token) => token.kind === 'positional').map((token) => token.index);
|
|
60
|
+
for (const candidate of ROUTES) {
|
|
61
|
+
const words = candidate.split(' ');
|
|
62
|
+
if (words.every((word, i) => positionals[i] === word)) {
|
|
63
|
+
const consumed = new Set(indices.slice(0, words.length));
|
|
64
|
+
return { name: candidate, rest: args.filter((_, i) => !consumed.has(i)) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!positionals.length) {
|
|
68
|
+
throw usageError('no command given', USAGE);
|
|
69
|
+
}
|
|
70
|
+
throw usageError(`unknown command: ${positionals.slice(0, 2).join(' ')}`, USAGE);
|
|
71
|
+
}
|
|
72
|
+
/** Turn `parseArgs`'s typed failures into the CLI's usage error. */
|
|
73
|
+
function parseStrict(args, options, allowPositionals, usage) {
|
|
74
|
+
try {
|
|
75
|
+
return parseArgs({ args, options, allowPositionals, strict: true });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isParseArgsError(error))
|
|
79
|
+
throw usageError(error.message, usage);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function dispatch(args) {
|
|
84
|
+
const { name, rest } = route(args);
|
|
85
|
+
const usage = COMMAND_USAGE[name];
|
|
86
|
+
switch (name) {
|
|
87
|
+
case 'graph query': {
|
|
88
|
+
const { values } = parseStrict(rest, QUERY_OPTIONS, false, usage);
|
|
89
|
+
if (values.help) {
|
|
90
|
+
process.stdout.write(`${usage}\n`);
|
|
91
|
+
return EXIT_OK;
|
|
92
|
+
}
|
|
93
|
+
const filters = {
|
|
94
|
+
collections: values.collection,
|
|
95
|
+
tags: values.tag,
|
|
96
|
+
status: values.status,
|
|
97
|
+
orphans: values.orphans,
|
|
98
|
+
deadends: values.deadends,
|
|
99
|
+
};
|
|
100
|
+
return queryCommand(await resolveRoot(values.root), filters, values.json);
|
|
101
|
+
}
|
|
102
|
+
case 'graph related': {
|
|
103
|
+
const { values, positionals } = parseStrict(rest, GLOBAL, true, usage);
|
|
104
|
+
if (values.help) {
|
|
105
|
+
process.stdout.write(`${usage}\n`);
|
|
106
|
+
return EXIT_OK;
|
|
107
|
+
}
|
|
108
|
+
if (positionals.length !== 1) {
|
|
109
|
+
throw usageError(positionals.length
|
|
110
|
+
? `graph related takes one argument, got ${positionals.length}: ${positionals.join(' ')}. A path containing spaces has to be quoted.`
|
|
111
|
+
: 'graph related needs a path, a quoted string of text, or - for stdin', usage);
|
|
112
|
+
}
|
|
113
|
+
return relatedCommand(await resolveRoot(values.root), positionals[0], values.json);
|
|
114
|
+
}
|
|
115
|
+
case 'check': {
|
|
116
|
+
const { values } = parseStrict(rest, GLOBAL, false, usage);
|
|
117
|
+
if (values.help) {
|
|
118
|
+
process.stdout.write(`${usage}\n`);
|
|
119
|
+
return EXIT_OK;
|
|
120
|
+
}
|
|
121
|
+
return checkCommand(await resolveRoot(values.root), values.json);
|
|
122
|
+
}
|
|
123
|
+
case 'gate': {
|
|
124
|
+
const { values } = parseStrict(rest, GATE_OPTIONS, false, usage);
|
|
125
|
+
if (values.help) {
|
|
126
|
+
process.stdout.write(`${usage}\n`);
|
|
127
|
+
return EXIT_OK;
|
|
128
|
+
}
|
|
129
|
+
return gateCommand(await resolveRoot(values.root), values.dist ?? 'dist', values.json);
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
throw usageError(`${name} is not implemented yet`, usage);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Run the CLI and return its exit code.
|
|
137
|
+
*
|
|
138
|
+
* Returns rather than calls `process.exit`, so a buffered stdout write is never
|
|
139
|
+
* truncated by the process going away underneath it.
|
|
140
|
+
*/
|
|
141
|
+
export async function run(args) {
|
|
142
|
+
// Read before parsing: the errors most worth emitting as JSON are the ones
|
|
143
|
+
// thrown by the parse itself.
|
|
144
|
+
const json = args.includes('--json');
|
|
145
|
+
try {
|
|
146
|
+
// Before the route table, not in it: `--version` is a question about the
|
|
147
|
+
// installed package, not about a project, so it must answer from
|
|
148
|
+
// anywhere — including a directory with no `src/content` in it, where
|
|
149
|
+
// every real verb exits 1. Plain stdout and nothing else, because the
|
|
150
|
+
// thing most likely to read it is a script.
|
|
151
|
+
//
|
|
152
|
+
// Read out of argv wherever it appears, like `--json` two lines above
|
|
153
|
+
// and for the same reason as `--root`: every other flag in this CLI is
|
|
154
|
+
// position-insensitive, and a `--version` that only worked first would
|
|
155
|
+
// be the one exception nobody would remember. No `-v` alias — `-v` is
|
|
156
|
+
// "verbose" in enough tools to be worth not claiming for something else.
|
|
157
|
+
if (args.includes('--version')) {
|
|
158
|
+
process.stdout.write(`${await readVersion()}\n`);
|
|
159
|
+
return EXIT_OK;
|
|
160
|
+
}
|
|
161
|
+
if (!args.length || args[0] === '--help' || args[0] === '-h') {
|
|
162
|
+
process.stdout.write(`${USAGE}\n`);
|
|
163
|
+
return args.length ? EXIT_OK : EXIT_USAGE;
|
|
164
|
+
}
|
|
165
|
+
return await dispatch(args);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error instanceof CliError) {
|
|
169
|
+
writeError(error, json);
|
|
170
|
+
return error.exitCode;
|
|
171
|
+
}
|
|
172
|
+
writeError(new CliError('EINTERNAL', error instanceof Error ? error.message : String(error), 1), json);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `commune graph query` — the graph as a list of entries.
|
|
3
|
+
*
|
|
4
|
+
* Each entry carries its resolved `outbound` and `inbound` urlPaths, so a
|
|
5
|
+
* consumer gets the whole neighbourhood in one document rather than one call
|
|
6
|
+
* per note. Filters narrow which entries are *returned*; they never change how
|
|
7
|
+
* links resolve, so an entry's degree is the same whether or not you filtered.
|
|
8
|
+
*/
|
|
9
|
+
import { type CollectionName } from '../lib/graph.ts';
|
|
10
|
+
export interface QueryFilters {
|
|
11
|
+
collections: string[];
|
|
12
|
+
tags: string[];
|
|
13
|
+
status?: string;
|
|
14
|
+
orphans: boolean;
|
|
15
|
+
deadends: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface QueryEntry {
|
|
18
|
+
urlPath: string;
|
|
19
|
+
title: string;
|
|
20
|
+
collection: CollectionName;
|
|
21
|
+
file: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
tags: string[];
|
|
24
|
+
status: string;
|
|
25
|
+
aliases: string[];
|
|
26
|
+
updated?: string;
|
|
27
|
+
outbound: string[];
|
|
28
|
+
inbound: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function queryCommand(root: string, filters: QueryFilters, json: boolean): Promise<number>;
|
package/lib/cli/query.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `commune graph query` — the graph as a list of entries.
|
|
3
|
+
*
|
|
4
|
+
* Each entry carries its resolved `outbound` and `inbound` urlPaths, so a
|
|
5
|
+
* consumer gets the whole neighbourhood in one document rather than one call
|
|
6
|
+
* per note. Filters narrow which entries are *returned*; they never change how
|
|
7
|
+
* links resolve, so an entry's degree is the same whether or not you filtered.
|
|
8
|
+
*/
|
|
9
|
+
import { buildGraph, loadContentEntries, } from "../lib/graph.js";
|
|
10
|
+
import { SCHEMA, writeJson, writeLines } from "./render.js";
|
|
11
|
+
import { EXIT_OK } from "./errors.js";
|
|
12
|
+
/** Project one loaded entry against the built graph. */
|
|
13
|
+
function toQueryEntry(entry, graph) {
|
|
14
|
+
const node = graph.nodes[entry.urlPath];
|
|
15
|
+
return {
|
|
16
|
+
urlPath: entry.urlPath,
|
|
17
|
+
title: entry.title,
|
|
18
|
+
collection: entry.collection,
|
|
19
|
+
file: entry.file,
|
|
20
|
+
slug: entry.slug,
|
|
21
|
+
tags: entry.tags,
|
|
22
|
+
status: entry.status,
|
|
23
|
+
aliases: entry.aliases,
|
|
24
|
+
...(entry.updated ? { updated: entry.updated } : {}),
|
|
25
|
+
outbound: node.outbound,
|
|
26
|
+
inbound: node.inbound,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Isolated: nothing links here and this links nowhere.
|
|
31
|
+
*
|
|
32
|
+
* Not "nobody links to it" — a note that links out but is linked to by nothing
|
|
33
|
+
* is a dead end read from the other direction, and conflating the two is what
|
|
34
|
+
* makes Obsidian's orphan list useless on a vault where most notes link out.
|
|
35
|
+
* One definition, used by both the filter and the summary, so `--orphans` can
|
|
36
|
+
* never disagree with `summary.orphans`.
|
|
37
|
+
*/
|
|
38
|
+
function isOrphan(entry) {
|
|
39
|
+
return entry.inbound.length === 0 && entry.outbound.length === 0;
|
|
40
|
+
}
|
|
41
|
+
/** Links to nothing. Whether anything links *here* is a separate question. */
|
|
42
|
+
function isDeadend(entry) {
|
|
43
|
+
return entry.outbound.length === 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Repeated values of one flag widen the match; different flags narrow it.
|
|
47
|
+
*
|
|
48
|
+
* `--collection notes --collection pages --tag seed` means "a note or a page,
|
|
49
|
+
* which is also tagged seed" — the reading that lets a caller build up a query
|
|
50
|
+
* without the flags fighting each other.
|
|
51
|
+
*/
|
|
52
|
+
function matches(entry, filters) {
|
|
53
|
+
if (filters.collections.length && !filters.collections.includes(entry.collection))
|
|
54
|
+
return false;
|
|
55
|
+
if (filters.tags.length && !entry.tags.some((tag) => filters.tags.includes(tag)))
|
|
56
|
+
return false;
|
|
57
|
+
if (filters.status !== undefined && entry.status !== filters.status)
|
|
58
|
+
return false;
|
|
59
|
+
if (filters.orphans && !isOrphan(entry))
|
|
60
|
+
return false;
|
|
61
|
+
if (filters.deadends && !isDeadend(entry))
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What came back, counted.
|
|
67
|
+
*
|
|
68
|
+
* Describes the *result set*, not the corpus, so it never contradicts `count`
|
|
69
|
+
* beside it. Degrees are still whole-graph — filtering changes which entries
|
|
70
|
+
* are returned, never how their links resolved — so an unfiltered query's
|
|
71
|
+
* `edges` is the same number `check` reports, counted from the outbound end
|
|
72
|
+
* rather than the inbound one.
|
|
73
|
+
*
|
|
74
|
+
* Free to compute: `outbound` and `inbound` are already materialized on every
|
|
75
|
+
* node by the time a query can be filtered at all.
|
|
76
|
+
*/
|
|
77
|
+
function summarize(results) {
|
|
78
|
+
return {
|
|
79
|
+
entries: results.length,
|
|
80
|
+
edges: results.reduce((total, entry) => total + entry.outbound.length, 0),
|
|
81
|
+
orphans: results.filter(isOrphan).length,
|
|
82
|
+
deadends: results.filter(isDeadend).length,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export async function queryCommand(root, filters, json) {
|
|
86
|
+
const entries = await loadContentEntries({ root });
|
|
87
|
+
const graph = buildGraph(entries);
|
|
88
|
+
const results = entries.map((entry) => toQueryEntry(entry, graph)).filter((entry) => matches(entry, filters));
|
|
89
|
+
const summary = summarize(results);
|
|
90
|
+
if (json) {
|
|
91
|
+
// `count` predates `summary` and stays as its alias: the field shipped in
|
|
92
|
+
// the contract #10 and #19 are written against, and removing it would be
|
|
93
|
+
// a schema bump for no gain.
|
|
94
|
+
writeJson({ schema: SCHEMA, root, count: summary.entries, summary, entries: results });
|
|
95
|
+
return EXIT_OK;
|
|
96
|
+
}
|
|
97
|
+
writeLines([
|
|
98
|
+
...results.map((entry) => `${entry.urlPath}\t${entry.title}\t${entry.collection}\t${entry.status}\t` +
|
|
99
|
+
`→${entry.outbound.length} ←${entry.inbound.length}`),
|
|
100
|
+
`${summary.entries} entries, ${summary.edges} edges, ${summary.orphans} orphans, ${summary.deadends} dead ends`,
|
|
101
|
+
]);
|
|
102
|
+
return EXIT_OK;
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
export declare function relatedCommand(root: string, input: string, json: boolean): Promise<number>;
|