@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/lib/lib/graph.js CHANGED
@@ -8,9 +8,9 @@
8
8
  * rules, and three subtly different opinions about trailing slashes.
9
9
  *
10
10
  * The rules this module owns:
11
- * - which collections participate (notes, research, pages)
12
- * - visibility (notes opt in with `visibility: public`; research and pages
13
- * are always public)
11
+ * - which collections participate (notes, research, pages, updates)
12
+ * - visibility (notes opt in with `visibility: public`; research, pages and
13
+ * updates are always public)
14
14
  * - canonical URLs, always with a trailing slash, matching Astro's
15
15
  * directory build format
16
16
  * - the title/alias lookup used to resolve `[[WikiLinks]]`
@@ -21,14 +21,23 @@ import { slug as githubSlug } from 'github-slugger';
21
21
  import matter from 'gray-matter';
22
22
  import { readFile } from 'node:fs/promises';
23
23
  import path from 'node:path';
24
+ import { readContentHistory, readMtimeDate, } from "./dates.js";
25
+ export { SHALLOW_WARNING, toIsoDay } from "./dates.js";
24
26
  /** Where each collection's markdown lives, relative to the project root. */
25
27
  export const CONTENT_DIRS = {
26
28
  notes: 'src/content/notes',
27
29
  research: 'src/content/research',
28
30
  pages: 'src/content/pages',
31
+ updates: 'src/content/updates',
29
32
  };
30
- /** Collection scan order. Stable so derived artifacts are deterministic. */
31
- export const COLLECTIONS = ['notes', 'research', 'pages'];
33
+ /**
34
+ * Collection scan order. Stable so derived artifacts are deterministic.
35
+ *
36
+ * `updates` is last because it was added last, and the order is the order
37
+ * `backlinks.json` is keyed in: putting it anywhere else would rewrite the
38
+ * whole committed artifact to say the same thing.
39
+ */
40
+ export const COLLECTIONS = ['notes', 'research', 'pages', 'updates'];
32
41
  /**
33
42
  * Convert a content file path to its collection-relative slug and canonical URL.
34
43
  *
@@ -117,7 +126,64 @@ export function normalizeDate(value) {
117
126
  return value.split('T')[0];
118
127
  return undefined;
119
128
  }
120
- /** Is this entry publicly visible? Notes opt in; research and pages are always public. */
129
+ /**
130
+ * The date an author wrote down, if they wrote one down.
131
+ *
132
+ * `updated` first, then `date` — the key the changelog-shaped collections use,
133
+ * where the entry *is* a dated thing rather than a page that happens to have
134
+ * been edited. Kept as one function so the graph, `--recent` and the site-wide
135
+ * last-updated value can never disagree about what an author claimed.
136
+ */
137
+ export function claimedDate(data) {
138
+ return normalizeDate(data.updated) ?? normalizeDate(data.date);
139
+ }
140
+ /**
141
+ * Decide an entry's dates, and say where the answer came from.
142
+ *
143
+ * Precedence, for both `created` and `updated`: what the author wrote, then
144
+ * what the repository records, then — only outside a repository — the file's
145
+ * mtime. The first is a claim and can be years stale; the second is a fact
146
+ * about the file and is what the ticket asked for; the third exists so a
147
+ * developer's unversioned folder produces dates instead of blanks.
148
+ *
149
+ * What is deliberately *not* here is a fourth fallback. Inside a shallow
150
+ * checkout there is no honest date to be had — every file's only commit is the
151
+ * one the CI runner fetched, and every file's mtime is the moment it was
152
+ * written to disk — so both are refused and `updatedSource` says `none`. A
153
+ * missing date a site can decline to render; a wrong one it renders
154
+ * confidently. The same goes for a file that has never been committed.
155
+ *
156
+ * `updatedSource` is reported and `createdSource` is not, deliberately: the
157
+ * date a site displays and a reader judges is `updated`, and one honest label
158
+ * beside it is worth more than two labels nobody reads. `created` follows the
159
+ * same precedence, and `modifiedInGit` is carried whenever git knew the file,
160
+ * so anything that needs to audit the pair has both halves.
161
+ */
162
+ async function resolveDates(root, file, data, history) {
163
+ const claimed = claimedDate(data);
164
+ const createdClaim = normalizeDate(data.created);
165
+ const committed = history.files.get(file);
166
+ // Read once, and only where an mtime is an answer at all: a versioned tree
167
+ // never stats a file, and a complete vault outside one never stats twice.
168
+ let mtime;
169
+ const fileMtime = async () => history.kind === 'unversioned' ? (mtime ??= await readMtimeDate(root, file)) : undefined;
170
+ const updated = claimed ?? committed?.updated ?? (await fileMtime());
171
+ const created = createdClaim ?? committed?.created ?? (await fileMtime());
172
+ const updatedSource = claimed
173
+ ? 'frontmatter'
174
+ : committed
175
+ ? 'git'
176
+ : updated
177
+ ? 'mtime'
178
+ : 'none';
179
+ return {
180
+ ...(updated ? { updated } : {}),
181
+ updatedSource,
182
+ ...(created ? { created } : {}),
183
+ ...(committed ? { modifiedInGit: committed.updated } : {}),
184
+ };
185
+ }
186
+ /** Is this entry publicly visible? Notes opt in; every other collection is public. */
121
187
  function isPublic(collection, data) {
122
188
  if (collection !== 'notes')
123
189
  return true;
@@ -136,6 +202,9 @@ function isPublic(collection, data) {
136
202
  export async function loadContentEntries(options = {}) {
137
203
  const root = options.root ?? process.cwd();
138
204
  const entries = [];
205
+ // One walk for the whole vault, before the scan rather than inside it: the
206
+ // alternative is a `git log` per file, which is a child process per note.
207
+ const history = await readContentHistory(root, Object.values(CONTENT_DIRS));
139
208
  for (const collection of COLLECTIONS) {
140
209
  // `cwd` keeps globby's results root-relative, which is exactly the
141
210
  // spelling `ContentEntry.file` promises; only the read needs the join.
@@ -148,7 +217,7 @@ export async function loadContentEntries(options = {}) {
148
217
  continue;
149
218
  const { slug, urlPath } = toUrlPath(file, collection, data);
150
219
  const summary = typeof data.summary === 'string' ? data.summary : undefined;
151
- const updated = normalizeDate(data.updated);
220
+ const dates = await resolveDates(root, file, data, history);
152
221
  entries.push({
153
222
  slug,
154
223
  urlPath,
@@ -158,7 +227,7 @@ export async function loadContentEntries(options = {}) {
158
227
  tags: data.tags || [],
159
228
  status: data.status || 'seed',
160
229
  ...(summary ? { summary } : {}),
161
- ...(updated ? { updated } : {}),
230
+ ...dates,
162
231
  body: content,
163
232
  frontmatter: data,
164
233
  file,
@@ -298,6 +367,17 @@ export function stripCode(content) {
298
367
  }
299
368
  /** Frontmatter keys whose values are vocabulary, not links. */
300
369
  const NON_LINK_KEYS = new Set(['aliases', 'tags']);
370
+ /**
371
+ * Frontmatter keys whose strings are link targets in their own right.
372
+ *
373
+ * Everywhere else in frontmatter a link has to be spelled `[[like this]]`,
374
+ * because a bare string is usually prose and guessing otherwise would turn a
375
+ * page's own `url:` into a self-edge. Under `links:` the guess is the whole
376
+ * point: an update declares the pages it rolls up, and writing them as
377
+ * `[[double brackets]]` inside a YAML list is ceremony for a field that means
378
+ * nothing else.
379
+ */
380
+ const LINK_KEYS = new Set(['links']);
301
381
  /**
302
382
  * Extract every outbound link target from a markdown body and its frontmatter.
303
383
  *
@@ -333,27 +413,34 @@ export function extractLinks(content, frontmatter = {}) {
333
413
  */
334
414
  function extractFrontmatterLinks(frontmatter) {
335
415
  const links = [];
336
- const walk = (value) => {
416
+ const walk = (value, bare) => {
337
417
  if (typeof value === 'string') {
338
418
  for (const match of value.matchAll(WIKILINK)) {
339
419
  const target = stripSubpath(match[1]);
340
420
  if (target)
341
421
  links.push({ kind: 'name', target });
342
422
  }
423
+ // A string that already spelled its link is not also a bare target.
424
+ if (bare && !value.includes('[[')) {
425
+ const link = classifyFrontmatterTarget(value);
426
+ if (link)
427
+ links.push(link);
428
+ }
343
429
  }
344
430
  else if (Array.isArray(value)) {
345
- value.forEach(walk);
431
+ for (const nested of value)
432
+ walk(nested, bare);
346
433
  }
347
434
  else if (value && typeof value === 'object') {
348
435
  for (const [key, nested] of Object.entries(value)) {
349
436
  if (!NON_LINK_KEYS.has(key))
350
- walk(nested);
437
+ walk(nested, bare || LINK_KEYS.has(key));
351
438
  }
352
439
  }
353
440
  };
354
441
  for (const [key, value] of Object.entries(frontmatter)) {
355
442
  if (!NON_LINK_KEYS.has(key))
356
- walk(value);
443
+ walk(value, LINK_KEYS.has(key));
357
444
  }
358
445
  return links;
359
446
  }
@@ -376,6 +463,26 @@ const MARKDOWN_LINK = /!?\[[^\]]*\]\(\s*(<[^>]*>|[^()\s]+)(?:\s+"[^"]*")?\s*\)/g
376
463
  * handed to the title lookup, where it only ever resolved by the coincidence of
377
464
  * a note listing its own slug as an alias.
378
465
  */
466
+ /**
467
+ * Classify one bare string from a `links:` list.
468
+ *
469
+ * A site path is a `url` link, a filename is the file's title, and anything
470
+ * else is read as a title — the same two namespaces every other edge resolves
471
+ * through, so `check` reports an unresolvable entry here exactly as it reports
472
+ * a broken `[[WikiLink]]`. External URLs are not edges, as everywhere else.
473
+ */
474
+ function classifyFrontmatterTarget(raw) {
475
+ const value = raw.trim();
476
+ if (!value)
477
+ return null;
478
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//'))
479
+ return null;
480
+ const classified = classifyMarkdownTarget(value);
481
+ if (classified)
482
+ return classified;
483
+ const title = stripSubpath(value);
484
+ return title ? { kind: 'name', target: title } : null;
485
+ }
379
486
  function classifyMarkdownTarget(raw) {
380
487
  const destination = raw.replace(/^<|>$/g, '').trim();
381
488
  // Anything with a scheme, and protocol-relative `//host`, leaves the site.
@@ -454,8 +561,10 @@ export const STAR_CONFIG = {
454
561
  */
455
562
  export function calculateStars(notes) {
456
563
  const starredSlugs = new Set();
457
- // Standalone pages belong in search and WikiLinks, not note rankings.
458
- const notesArray = Array.from(notes.values()).filter((note) => note.collection !== 'pages');
564
+ // Standalone pages and changelog updates belong in search and WikiLinks, not
565
+ // note rankings. An update links to everything it rolled up, so ranking it
566
+ // alongside notes would let the changelog crowd out the writing it describes.
567
+ const notesArray = Array.from(notes.values()).filter((note) => note.collection !== 'pages' && note.collection !== 'updates');
459
568
  // Skip if too few notes
460
569
  if (notesArray.length < STAR_CONFIG.minNotesForStars) {
461
570
  return starredSlugs;
@@ -540,6 +649,14 @@ export function buildGraph(entries) {
540
649
  for (const entry of entries) {
541
650
  extracted.set(entry.urlPath, extractLinks(entry.body, entry.frontmatter));
542
651
  files.set(entry.urlPath, entry.file);
652
+ // `updated` here is the author's claim, not the resolved date on the
653
+ // entry, and it stays that way on purpose. `backlinks.json` is committed,
654
+ // and a git-derived date cannot be committed alongside the commit that
655
+ // changes it: the file's new date does not exist until that commit does,
656
+ // so every content commit would land with the artifact already stale.
657
+ // The resolved date is on `ContentEntry`, in `graph query --json`, and in
658
+ // the build-time `site.json` — all of which are computed, not stored.
659
+ const claimed = claimedDate(entry.frontmatter);
543
660
  notes.set(entry.urlPath, {
544
661
  slug: entry.urlPath,
545
662
  title: entry.title,
@@ -550,7 +667,7 @@ export function buildGraph(entries) {
550
667
  tags: entry.tags,
551
668
  status: entry.status,
552
669
  ...(entry.summary ? { summary: entry.summary } : {}),
553
- ...(entry.updated ? { updated: entry.updated } : {}),
670
+ ...(claimed ? { updated: claimed } : {}),
554
671
  });
555
672
  }
556
673
  const diagnostics = [];
@@ -579,7 +696,14 @@ export function buildGraph(entries) {
579
696
  }
580
697
  }
581
698
  if (resolved && notes.has(resolved)) {
582
- resolvedOutbound.push(resolved);
699
+ // One edge per target, not one per spelling. `[[World]]` in the body
700
+ // and `/notes/world/` under `links:` are the same edge written two
701
+ // ways, and an update names both — the prose says it and the
702
+ // frontmatter lists it. `inbound` has always been deduplicated;
703
+ // `outbound` reaching the same page twice was the same fact
704
+ // counted twice.
705
+ if (!resolvedOutbound.includes(resolved))
706
+ resolvedOutbound.push(resolved);
583
707
  const target = notes.get(resolved);
584
708
  if (!target.inbound.includes(fromUrl)) {
585
709
  target.inbound.push(fromUrl);
@@ -635,6 +759,36 @@ export function formatDiagnostic(diagnostic) {
635
759
  }
636
760
  return `⚠️ ${diagnostic.rule} in ${diagnostic.file}: ${diagnostic.message}`;
637
761
  }
762
+ /**
763
+ * The newest change across the whole site, and which entry it was.
764
+ *
765
+ * "When did this wiki last change" is not the same question as "when did this
766
+ * page last change", and a home page that answers the second while appearing
767
+ * to answer the first is the bug this ticket opened on. Ties go to the entry
768
+ * scanned first, which is stable because the scan is.
769
+ */
770
+ export function summarizeSite(entries) {
771
+ let newest;
772
+ let newestInGit;
773
+ for (const entry of entries) {
774
+ if (entry.updated && (!newest || entry.updated > newest.updated))
775
+ newest = entry;
776
+ if (entry.modifiedInGit && (!newestInGit || entry.modifiedInGit > newestInGit)) {
777
+ newestInGit = entry.modifiedInGit;
778
+ }
779
+ }
780
+ return {
781
+ ...(newest
782
+ ? {
783
+ lastUpdated: newest.updated,
784
+ lastUpdatedPath: newest.urlPath,
785
+ lastUpdatedSource: newest.updatedSource,
786
+ }
787
+ : {}),
788
+ ...(newestInGit ? { lastModifiedInGit: newestInGit } : {}),
789
+ entries: entries.length,
790
+ };
791
+ }
638
792
  /**
639
793
  * The public artifact, exactly as `public/backlinks.json` stores it.
640
794
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dmthepm/commune",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "An Astro wiki engine with WikiLinks, sliding panes, backlinks and static search, plus a commune CLI that queries the content graph and checks links",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,114 @@
1
+ ---
2
+ /**
3
+ * The newest entries from the `updates` collection, as a card.
4
+ *
5
+ * A wiki's front door has to answer "what changed" before it answers anything
6
+ * else, and the answer is content the site already has: one dated entry per
7
+ * batch of work, in `src/content/updates/`. This renders the newest few.
8
+ *
9
+ * Everything happens at build time. There is no `fetch` here and no client
10
+ * script — unlike `Backlinks.astro`, which reads `/backlinks.json` in the
11
+ * browser because a note's inbound links depend on which note you are on. A
12
+ * changelog does not, so it is markup by the time the page is served, and it
13
+ * is in the HTML for a reader with no JavaScript and for anything that reads
14
+ * the page as a document.
15
+ *
16
+ * The collection has to be registered in the consumer's `src/content.config.ts`
17
+ * for `getCollection` to see it — the same requirement `notes` has. The schema
18
+ * this expects is in the README.
19
+ */
20
+
21
+ import { getCollection } from 'astro:content';
22
+
23
+ interface Props {
24
+ /** How many entries to show. */
25
+ limit?: number;
26
+ /** The card's heading. Passed rather than fixed, because a card that says
27
+ * "Recent updates" on the home page often wants to say nothing at all on
28
+ * the updates index itself. An empty string renders no heading. */
29
+ heading?: string;
30
+ }
31
+
32
+ const { limit = 5, heading = 'Recent updates' } = Astro.props;
33
+
34
+ // `date` is the update's subject — the day the work happened — not a fact
35
+ // about the file, so entries sort by it and not by their history. Descending,
36
+ // and by id as the tiebreak so two updates dated the same day have a stable
37
+ // order rather than the filesystem's.
38
+ const updates = (await getCollection('updates'))
39
+ .sort((a, b) => b.data.date.localeCompare(a.data.date) || b.id.localeCompare(a.id))
40
+ .slice(0, limit);
41
+ ---
42
+
43
+ <section class="commune-updates">
44
+ {heading && <h2 class="commune-updates-title">{heading}</h2>}
45
+
46
+ {updates.length === 0 ? (
47
+ <p class="commune-updates-empty">No updates yet.</p>
48
+ ) : (
49
+ <ul class="commune-updates-list">
50
+ {updates.map((update) => (
51
+ <li class="commune-updates-item">
52
+ <a href={`/updates/${update.id}/`} class="commune-updates-link">
53
+ {update.data.title}
54
+ </a>
55
+ <time class="commune-updates-date" datetime={update.data.date}>
56
+ {update.data.date}
57
+ </time>
58
+ {update.data.summary && (
59
+ <p class="commune-updates-summary">{update.data.summary}</p>
60
+ )}
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ )}
65
+ </section>
66
+
67
+ <style>
68
+ .commune-updates-title {
69
+ font-size: 1.1rem;
70
+ font-weight: 600;
71
+ margin: 0 0 1rem 0;
72
+ }
73
+
74
+ .commune-updates-list {
75
+ list-style: none;
76
+ padding: 0;
77
+ margin: 0;
78
+ }
79
+
80
+ .commune-updates-item {
81
+ margin-bottom: 1rem;
82
+ }
83
+
84
+ .commune-updates-item:last-child {
85
+ margin-bottom: 0;
86
+ }
87
+
88
+ .commune-updates-link {
89
+ font-weight: 500;
90
+ text-decoration: none;
91
+ }
92
+
93
+ .commune-updates-link:hover {
94
+ text-decoration: underline;
95
+ }
96
+
97
+ .commune-updates-date {
98
+ display: block;
99
+ font-size: 0.8rem;
100
+ opacity: 0.7;
101
+ }
102
+
103
+ .commune-updates-summary {
104
+ margin: 0.25rem 0 0 0;
105
+ font-size: 0.9rem;
106
+ line-height: 1.5;
107
+ }
108
+
109
+ .commune-updates-empty {
110
+ margin: 0;
111
+ font-style: italic;
112
+ opacity: 0.7;
113
+ }
114
+ </style>