@dmthepm/commune 0.1.1 → 0.3.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 +133 -1
- package/lib/cli/errors.d.ts +3 -2
- package/lib/cli/errors.js +2 -1
- package/lib/cli/main.js +40 -2
- package/lib/cli/query.d.ts +25 -1
- package/lib/cli/query.js +44 -4
- 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 +19 -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 -4
- package/src/components/Backlinks.astro +5 -1
- package/src/components/BacklinksScript.astro +11 -1
- package/src/components/Header.astro +76 -1
- package/src/components/HeaderStarScript.astro +41 -3
- package/src/components/SearchModal.astro +165 -32
- package/src/components/SlidingPanes.astro +782 -0
- package/src/components/Updates.astro +114 -0
- package/src/styles/design-system.css +88 -6
- package/src/styles/notes.css +36 -0
- package/src/components/PlausibleScript.astro +0 -13
- package/src/components/panes.ts +0 -61
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
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
|
458
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "0.3.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": [
|
|
@@ -85,15 +85,12 @@
|
|
|
85
85
|
"unist-util-visit": "^5.1.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@astrojs/check": "^0.9.10",
|
|
89
88
|
"@astrojs/markdown-remark": "^7.3.0",
|
|
90
89
|
"@astrojs/sitemap": "^3.7.4",
|
|
91
|
-
"@tailwindcss/typography": "^0.5.20",
|
|
92
90
|
"@types/hast": "^3.0.5",
|
|
93
91
|
"@types/mdast": "^4.0.4",
|
|
94
92
|
"@types/node": "^22.20.1",
|
|
95
93
|
"astro": "^7.2.10",
|
|
96
|
-
"autoprefixer": "^10.5.4",
|
|
97
94
|
"tailwindcss": "^3.4.0",
|
|
98
95
|
"typescript": "^5.6.0"
|
|
99
96
|
}
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
export interface Props { slug: string; title?: string; hideCount?: boolean; }
|
|
3
3
|
const { slug, hideCount = false } = Astro.props;
|
|
4
4
|
---
|
|
5
|
-
|
|
5
|
+
<!-- Starts hidden: the list is filled from `/backlinks.json` at runtime, so
|
|
6
|
+
without JavaScript, or on a note nothing links to, the heading would
|
|
7
|
+
otherwise stand over an empty list. `BacklinksScript` reveals it once it
|
|
8
|
+
has links to show. -->
|
|
9
|
+
<aside class="backlinks hidden" data-backlinks-for={slug} data-hide-count={hideCount ? 'true' : undefined}>
|
|
6
10
|
<h4>Links to this note</h4>
|
|
7
11
|
<ul class="backlinks-list"></ul>
|
|
8
12
|
</aside>
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
(function() {
|
|
6
6
|
let backlinksData = null;
|
|
7
7
|
|
|
8
|
+
// Note titles are the vault author's prose and go into markup below; a stray
|
|
9
|
+
// <, & or " in one should read as that character rather than break the list
|
|
10
|
+
// open.
|
|
11
|
+
const escapeHtml = (value) => String(value ?? '')
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''');
|
|
17
|
+
|
|
8
18
|
async function initializeBacklinks(scope = document) {
|
|
9
19
|
try {
|
|
10
20
|
// Fetch backlinks data if not already loaded
|
|
@@ -57,7 +67,7 @@
|
|
|
57
67
|
};
|
|
58
68
|
|
|
59
69
|
// Render links and then add stars as separate clickable elements
|
|
60
|
-
list.innerHTML = items.map(s => `<li><a href="${s}">${titleFor(s)}</a></li>`).join('');
|
|
70
|
+
list.innerHTML = items.map(s => `<li><a href="${escapeHtml(s)}">${escapeHtml(titleFor(s))}</a></li>`).join('');
|
|
61
71
|
|
|
62
72
|
// Add stars to starred notes
|
|
63
73
|
list.querySelectorAll('li').forEach((li, index) => {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
---
|
|
3
|
+
<!-- First thing in the tab order on every page: reading pages put the whole
|
|
4
|
+
note between the header and the footer, and a keyboard reader should not
|
|
5
|
+
have to walk the header on each one. #main is on the page's <main>, or on
|
|
6
|
+
the first pane's scroll container where <main> is the pane container. -->
|
|
7
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
8
|
+
|
|
3
9
|
<header class="commune-header">
|
|
4
10
|
<div class="wrap">
|
|
5
11
|
<a href="/" class="brand-section">
|
|
@@ -21,6 +27,30 @@
|
|
|
21
27
|
</header>
|
|
22
28
|
|
|
23
29
|
<style>
|
|
30
|
+
.skip-link{
|
|
31
|
+
/* Fixed, not absolute: the reader may be scrolled anywhere in a long note
|
|
32
|
+
when they reach for Tab, and the link has to be on screen to be useful. */
|
|
33
|
+
position:fixed;
|
|
34
|
+
left:0.5rem;
|
|
35
|
+
top:0.5rem;
|
|
36
|
+
z-index:calc(var(--z-modal) + 1);
|
|
37
|
+
padding:0.6rem 0.9rem;
|
|
38
|
+
border:1.5px solid var(--c-accent);
|
|
39
|
+
border-radius:var(--c-radius-md);
|
|
40
|
+
background:var(--c-bg);
|
|
41
|
+
color:var(--c-accent);
|
|
42
|
+
font-size:0.9rem;
|
|
43
|
+
font-weight:600;
|
|
44
|
+
box-shadow:var(--c-shadow-md);
|
|
45
|
+
transform:translateY(calc(-100% - 1rem));
|
|
46
|
+
transition:transform 0.15s ease;
|
|
47
|
+
}
|
|
48
|
+
.skip-link:focus{
|
|
49
|
+
transform:translateY(0);
|
|
50
|
+
}
|
|
51
|
+
@media print{
|
|
52
|
+
.skip-link{display:none}
|
|
53
|
+
}
|
|
24
54
|
.commune-header{
|
|
25
55
|
position:sticky;
|
|
26
56
|
top:0;
|
|
@@ -193,6 +223,16 @@
|
|
|
193
223
|
border-radius:var(--c-radius-sm);
|
|
194
224
|
}
|
|
195
225
|
|
|
226
|
+
/* A hover state whose whole content is movement loses the movement. The
|
|
227
|
+
rule lives here rather than in `design-system.css` because Astro scopes
|
|
228
|
+
this block's selectors, and a global `.theme-btn:hover` would lose the
|
|
229
|
+
specificity contest to the scoped rule above. */
|
|
230
|
+
@media (prefers-reduced-motion: reduce) {
|
|
231
|
+
.theme-btn:hover {
|
|
232
|
+
transform: none;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
196
236
|
/* Mobile: hide Command-K text and search text, show only icon */
|
|
197
237
|
@media (max-width: 768px) {
|
|
198
238
|
.search-btn kbd,
|
|
@@ -239,9 +279,44 @@
|
|
|
239
279
|
});
|
|
240
280
|
})();
|
|
241
281
|
|
|
282
|
+
// Publish the header's real height. The pane container positions itself
|
|
283
|
+
// against --header-height, and the fallback in the stylesheet is a
|
|
284
|
+
// hand-counted number the header has outgrown — it measures 73px at the
|
|
285
|
+
// default font size and more once the text is zoomed. Measuring keeps the
|
|
286
|
+
// pane's top gap equal to its bottom gap at any zoom level or font size,
|
|
287
|
+
// and means no consumer has to re-count the number after restyling the
|
|
288
|
+
// header.
|
|
289
|
+
(function trackHeaderHeight(){
|
|
290
|
+
const header = document.querySelector('.commune-header');
|
|
291
|
+
if (!header) return;
|
|
292
|
+
const publish = () => {
|
|
293
|
+
const height = Math.round(header.getBoundingClientRect().height);
|
|
294
|
+
if (height > 0) document.documentElement.style.setProperty('--header-height', height + 'px');
|
|
295
|
+
};
|
|
296
|
+
publish();
|
|
297
|
+
if ('ResizeObserver' in window) new ResizeObserver(publish).observe(header);
|
|
298
|
+
else addEventListener('resize', publish);
|
|
299
|
+
})();
|
|
300
|
+
|
|
301
|
+
// One platform check, shared with the search modal's own handler, so the
|
|
302
|
+
// printed shortcut and the key that actually works cannot drift apart.
|
|
303
|
+
// navigator.platform is deprecated but still the only thing every browser
|
|
304
|
+
// answers; userAgentData.platform is preferred where it exists.
|
|
305
|
+
window.CommuneIsMac = () => /mac/i.test(
|
|
306
|
+
(navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// The button printed ⌘K to everyone. On Windows and Linux the shortcut is
|
|
310
|
+
// Ctrl+K, so the label named a key combination that did nothing.
|
|
311
|
+
(function labelSearchShortcut(){
|
|
312
|
+
if (window.CommuneIsMac()) return;
|
|
313
|
+
document.querySelectorAll('.search-btn kbd').forEach((el) => { el.textContent = 'Ctrl K'; });
|
|
314
|
+
document.querySelectorAll('.search-btn').forEach((el) => { el.setAttribute('aria-label', 'Search (Ctrl K)'); });
|
|
315
|
+
})();
|
|
316
|
+
|
|
242
317
|
// cmd/ctrl-k opens modal
|
|
243
318
|
addEventListener('keydown', (e) => {
|
|
244
|
-
const mac =
|
|
319
|
+
const mac = window.CommuneIsMac();
|
|
245
320
|
if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
246
321
|
e.preventDefault();
|
|
247
322
|
dispatchEvent(new CustomEvent('commune:openSearch'));
|
|
@@ -5,13 +5,24 @@
|
|
|
5
5
|
(function() {
|
|
6
6
|
let backlinksData = null;
|
|
7
7
|
|
|
8
|
+
// Focus belongs to the dialog while it is open, and goes back to the star
|
|
9
|
+
// that opened it on close — otherwise a keyboard reader who opens the modal
|
|
10
|
+
// is dropped at the top of the document with no way back to where they were.
|
|
11
|
+
let starModalOpener = null;
|
|
12
|
+
|
|
13
|
+
const starModalFocusables = (modal) => Array.from(
|
|
14
|
+
modal.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')
|
|
15
|
+
).filter((el) => !el.hasAttribute('disabled'));
|
|
16
|
+
|
|
8
17
|
// Show the star modal
|
|
9
18
|
function showStarModal() {
|
|
10
19
|
const modal = document.getElementById('star-modal');
|
|
11
20
|
if (modal) {
|
|
21
|
+
starModalOpener = document.activeElement;
|
|
12
22
|
modal.style.display = 'flex';
|
|
13
23
|
// Prevent body scroll when modal is open
|
|
14
24
|
document.body.style.overflow = 'hidden';
|
|
25
|
+
starModalFocusables(modal)[0]?.focus();
|
|
15
26
|
}
|
|
16
27
|
}
|
|
17
28
|
|
|
@@ -21,6 +32,10 @@
|
|
|
21
32
|
if (modal) {
|
|
22
33
|
modal.style.display = 'none';
|
|
23
34
|
document.body.style.overflow = '';
|
|
35
|
+
if (starModalOpener instanceof HTMLElement && document.contains(starModalOpener)) {
|
|
36
|
+
starModalOpener.focus({ preventScroll: true });
|
|
37
|
+
}
|
|
38
|
+
starModalOpener = null;
|
|
24
39
|
}
|
|
25
40
|
}
|
|
26
41
|
|
|
@@ -31,6 +46,9 @@
|
|
|
31
46
|
const modal = document.createElement('div');
|
|
32
47
|
modal.id = 'star-modal';
|
|
33
48
|
modal.className = 'star-modal';
|
|
49
|
+
modal.setAttribute('role', 'dialog');
|
|
50
|
+
modal.setAttribute('aria-modal', 'true');
|
|
51
|
+
modal.setAttribute('aria-label', 'Top 5%');
|
|
34
52
|
modal.innerHTML = `
|
|
35
53
|
<div class="star-modal-backdrop"></div>
|
|
36
54
|
<div class="star-modal-content">
|
|
@@ -60,10 +78,25 @@
|
|
|
60
78
|
hideStarModal();
|
|
61
79
|
});
|
|
62
80
|
|
|
63
|
-
// Close on Escape
|
|
81
|
+
// Close on Escape, and keep Tab inside the dialog while it is open.
|
|
64
82
|
document.addEventListener('keydown', (e) => {
|
|
65
|
-
if (
|
|
83
|
+
if (modal.style.display !== 'flex') return;
|
|
84
|
+
if (e.key === 'Escape') {
|
|
66
85
|
hideStarModal();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (e.key !== 'Tab') return;
|
|
89
|
+
const items = starModalFocusables(modal);
|
|
90
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
91
|
+
const first = items[0];
|
|
92
|
+
const last = items[items.length - 1];
|
|
93
|
+
const active = document.activeElement;
|
|
94
|
+
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
last.focus();
|
|
97
|
+
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
first.focus();
|
|
67
100
|
}
|
|
68
101
|
});
|
|
69
102
|
}
|
|
@@ -110,9 +143,13 @@
|
|
|
110
143
|
star.setAttribute('role', 'button');
|
|
111
144
|
star.setAttribute('tabindex', '0');
|
|
112
145
|
|
|
113
|
-
// Click handler to open modal
|
|
146
|
+
// Click handler to open modal. stopPropagation matters: the pane
|
|
147
|
+
// container's delegated click handler treats any click that is not
|
|
148
|
+
// on a link or a real button as "focus this pane", and would pull
|
|
149
|
+
// focus straight back out of the modal we just opened.
|
|
114
150
|
star.addEventListener('click', (e) => {
|
|
115
151
|
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
116
153
|
showStarModal();
|
|
117
154
|
});
|
|
118
155
|
|
|
@@ -120,6 +157,7 @@
|
|
|
120
157
|
star.addEventListener('keydown', (e) => {
|
|
121
158
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
122
159
|
e.preventDefault();
|
|
160
|
+
e.stopPropagation();
|
|
123
161
|
showStarModal();
|
|
124
162
|
}
|
|
125
163
|
});
|