windhamdavid 1 day ago
parent
commit
0cfb74c5b2

+ 1 - 1
docs/computers/squid.md

@@ -3,7 +3,7 @@
 Setting up a new server for a project. This one has to be easily replicated and provide access to multiple systems level administrators. The named comes from a portmantuau of the hosting data center and because cephalopod intelligence is the best of the invertebrates.
 
 ## Log
-- **25/07/21** bypass nginx [proxy rule](/docs/srh#production-config) changed by CVE-2024-42516
+- **25/07/21** bypass nginx [proxy rule](/docs/projects/srh#production-config) changed by CVE-2024-42516
 - **25/04/09** add a passive FTP rule to the firewall 
 
 

+ 1 - 1
docs/index.md

@@ -51,7 +51,7 @@ I use this library of documents as a quick reference to find technical answers,
 
 ### AI
 
-[ai](ai/ai)
+[ai](/docs/ai/ai.md)
 
 ### Computers
 

+ 184 - 0
package-lock.json

@@ -22,6 +22,7 @@
         "plugin-image-zoom": "github:flexanalytics/plugin-image-zoom",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
+        "react-force-graph-2d": "^1.29.1",
         "react-piano": "^3.1.3",
         "react-player": "^2.14.1"
       }
@@ -4887,6 +4888,12 @@
         "node": ">=14.16"
       }
     },
+    "node_modules/@tweenjs/tween.js": {
+      "version": "25.0.0",
+      "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
+      "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
+      "license": "MIT"
+    },
     "node_modules/@types/acorn": {
       "version": "4.0.6",
       "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
@@ -5745,6 +5752,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/accessor-fn": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
+      "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.14.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -6160,6 +6176,16 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/bezier-js": {
+      "version": "6.1.4",
+      "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
+      "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
+      "license": "MIT",
+      "funding": {
+        "type": "individual",
+        "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
+      }
+    },
     "node_modules/big.js": {
       "version": "5.2.2",
       "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -6503,6 +6529,18 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/canvas-color-tracker": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
+      "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "tinycolor2": "^1.6.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/ccount": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -7745,6 +7783,12 @@
         "node": ">=12"
       }
     },
+    "node_modules/d3-binarytree": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
+      "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
+      "license": "MIT"
+    },
     "node_modules/d3-brush": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
@@ -7897,6 +7941,22 @@
         "node": ">=12"
       }
     },
+    "node_modules/d3-force-3d": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
+      "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
+      "license": "MIT",
+      "dependencies": {
+        "d3-binarytree": "1",
+        "d3-dispatch": "1 - 3",
+        "d3-octree": "1",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-format": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
@@ -7939,6 +7999,12 @@
         "node": ">=12"
       }
     },
+    "node_modules/d3-octree": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
+      "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
+      "license": "MIT"
+    },
     "node_modules/d3-path": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
@@ -9375,6 +9441,20 @@
         "flat": "cli.js"
       }
     },
+    "node_modules/float-tooltip": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
+      "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
+      "license": "MIT",
+      "dependencies": {
+        "d3-selection": "2 - 3",
+        "kapsule": "^1.16",
+        "preact": "10"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/follow-redirects": {
       "version": "1.15.11",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -9395,6 +9475,32 @@
         }
       }
     },
+    "node_modules/force-graph": {
+      "version": "1.51.4",
+      "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
+      "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
+      "license": "MIT",
+      "dependencies": {
+        "@tweenjs/tween.js": "18 - 25",
+        "accessor-fn": "1",
+        "bezier-js": "3 - 6",
+        "canvas-color-tracker": "^1.3",
+        "d3-array": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-force-3d": "2 - 3",
+        "d3-scale": "1 - 4",
+        "d3-scale-chromatic": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-zoom": "2 - 3",
+        "float-tooltip": "^1.7",
+        "index-array-by": "1",
+        "kapsule": "^1.16",
+        "lodash-es": "4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/form-data-encoder": {
       "version": "2.1.4",
       "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
@@ -10814,6 +10920,15 @@
         "node": ">=8"
       }
     },
+    "node_modules/index-array-by": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
+      "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/infima": {
       "version": "0.2.0-alpha.45",
       "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.45.tgz",
@@ -11197,6 +11312,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/jerrypick": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
+      "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/jest-util": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
@@ -11336,6 +11460,18 @@
       "integrity": "sha512-JKHygNvIu+tX/+oOI+zNznQE0M8jM241fsjuac2i9OBlIXD7w4CGGuakXYc6Dne5vv9pEXgmCv8+WEFKwiGFTg==",
       "license": "MIT"
     },
+    "node_modules/kapsule": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
+      "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash-es": "4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/katex": {
       "version": "0.16.22",
       "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
@@ -17062,6 +17198,16 @@
         "postcss": "^8.4.31"
       }
     },
+    "node_modules/preact": {
+      "version": "10.29.2",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
+      "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
     "node_modules/pretty-error": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
@@ -17348,6 +17494,23 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
       "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
     },
+    "node_modules/react-force-graph-2d": {
+      "version": "1.29.1",
+      "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
+      "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
+      "license": "MIT",
+      "dependencies": {
+        "force-graph": "^1.51",
+        "prop-types": "15",
+        "react-kapsule": "^2.5"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
     "node_modules/react-helmet-async": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz",
@@ -17381,6 +17544,21 @@
         "react": "^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/react-kapsule": {
+      "version": "2.5.7",
+      "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
+      "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
+      "license": "MIT",
+      "dependencies": {
+        "jerrypick": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": ">=16.13.1"
+      }
+    },
     "node_modules/react-loadable": {
       "name": "@docusaurus/react-loadable",
       "version": "6.0.0",
@@ -19748,6 +19926,12 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "node_modules/tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
+      "license": "MIT"
+    },
     "node_modules/tinyexec": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",

+ 3 - 0
package.json

@@ -4,7 +4,9 @@
   "private": true,
   "scripts": {
     "docusaurus": "docusaurus",
+    "graph": "node scripts/build-graph.mjs",
     "start": "docusaurus start",
+    "prebuild": "node scripts/build-graph.mjs",
     "build": "docusaurus build",
     "swizzle": "docusaurus swizzle",
     "deploy": "docusaurus deploy",
@@ -28,6 +30,7 @@
     "plugin-image-zoom": "github:flexanalytics/plugin-image-zoom",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-force-graph-2d": "^1.29.1",
     "react-piano": "^3.1.3",
     "react-player": "^2.14.1"
   },

+ 1 - 1
posts/_posts.md

@@ -84,7 +84,7 @@
 [25.02.09](2025/2025-02-09-posts.md)
 [25.02.28](2025/2025-02-28-posts.md)
 [25.03.03](2025/2025-03-03-posts.md)
-[25.03.31](2025/2025-03-31-post.md)
+[25.03.31](2025/2025-03-31-posts.md)
 [25.04.25](2025/2025-04-25-posts.md)
 [25.06.14](2025/2025-06-14-posts.md)
 [25.07.05](2025/2025-07-05-posts.md)

+ 238 - 0
scripts/build-graph.mjs

@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * build-graph.mjs — generate a {nodes, links} JSON of the site's internal link
+ * graph for the on-site force-directed graph (src/pages/graph).
+ *
+ * Why this exists: md-graph (the VS Code extension) only resolves relative .md
+ * links, so it misses this site's cross-section links (posts → docs/notes/lists),
+ * which are written as absolute Docusaurus route URLs (/notes/house/helene).
+ * Here we normalize BOTH link styles to canonical routes, so those edges appear.
+ *
+ * Routes mirror Docusaurus, including the folder-index convention: a doc named
+ * `index`, `README`, OR the same as its parent folder (dogs/dogs.md) becomes the
+ * folder's route (/notes/dogs) — not /notes/dogs/dogs.
+ *
+ * Underscore files (_posts.md, _computers.md, …) are Docusaurus "partials" with
+ * no published route, but they're hand-maintained index/hub files that link to
+ * many pages. We include them as non-navigable "index" nodes so those hub
+ * connections show up (this is what md-graph does too).
+ *
+ * Output: src/data/graph.json  →  imported by the graph component.
+ * Run: node scripts/build-graph.mjs   (or `npm run graph`; DEBUG=1 for diagnostics)
+ */
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
+import { join, relative, dirname, basename } from 'path';
+import { fileURLToPath } from 'url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const DEBUG = process.env.DEBUG === '1';
+
+const DOC_SECTIONS = [
+  { dir: 'docs', base: '/docs', group: 'docs' },
+  { dir: 'notes', base: '/notes', group: 'notes' },
+  { dir: 'lists', base: '/lists', group: 'lists' },
+];
+const BLOG_SECTIONS = [{ dir: 'posts', base: '/posts', group: 'posts' }];
+const PAGES = [
+  { route: '/', label: 'Home', group: 'page' },
+  { route: '/ai', label: 'AI Assistant', group: 'page' },
+  { route: '/help', label: 'Help', group: 'page' },
+  { route: '/map', label: 'Map', group: 'page' },
+  { route: '/about', label: 'About', group: 'page' },
+  { route: '/posts', label: 'Posts (blog index)', group: 'page' },
+];
+
+// ---------- helpers ----------
+function walk(dir) {
+  const out = [];
+  let entries;
+  try { entries = readdirSync(dir); } catch { return out; }
+  for (const name of entries) {
+    if (name.startsWith('.') || name === 'node_modules') continue; // skip .obsidian etc.
+    const full = join(dir, name);
+    const st = statSync(full);
+    if (st.isDirectory()) out.push(...walk(full));
+    else if (/\.mdx?$/.test(name)) out.push(full);                // include _partials (hubs)
+  }
+  return out;
+}
+
+function parseFrontmatter(raw) {
+  if (!raw.startsWith('---')) return { data: {}, body: raw };
+  const end = raw.indexOf('\n---', 3);
+  if (end === -1) return { data: {}, body: raw };
+  const data = {};
+  for (const line of raw.slice(3, end).split('\n')) {
+    const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
+    if (m) data[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
+  }
+  return { data, body: raw.slice(end + 4) };
+}
+
+const isTrue = (v) => v === true || v === 'true';
+const tidy = (r) => (r.replace(/\/{2,}/g, '/').replace(/(.)\/+$/, '$1') || '/');
+
+function titleOf(data, body, fallback) {
+  if (data.title) return data.title;
+  const h1 = body.match(/^#\s+(.+)$/m);
+  return h1 ? h1[1].trim() : fallback;
+}
+
+const partialLabel = (rel) => {
+  const b = basename(rel).replace(/^_/, '');
+  return `${b.charAt(0).toUpperCase()}${b.slice(1)} (index)`;
+};
+
+// Docusaurus folder-index: index | README | same-name-as-folder → folder route.
+function folderIndex(rel) {
+  const parts = rel.split('/');
+  const last = parts[parts.length - 1].toLowerCase();
+  const parent = parts.length >= 2 ? parts[parts.length - 2].toLowerCase() : null;
+  const isIndex = last === 'index' || last === 'readme' || (parent && last === parent);
+  return { isIndex, dir: parts.slice(0, -1).join('/') };
+}
+
+function docRoute(base, rel, slug) {
+  if (slug && slug.startsWith('/')) return tidy(base + slug);
+  const fi = folderIndex(rel);
+  if (slug) {
+    const dir = fi.isIndex ? fi.dir : rel.split('/').slice(0, -1).join('/');
+    return tidy(`${base}/${dir}/${slug}`);
+  }
+  if (fi.isIndex) return tidy(fi.dir ? `${base}/${fi.dir}` : base);
+  return tidy(`${base}/${rel}`);
+}
+
+// ---------- pass 1: nodes + a section-relative path → route map ----------
+const nodes = new Map();      // route -> {id,label,group,partial?}
+const byLc = new Map();       // lower(route) -> route
+const fileRoute = new Map();  // `${base}|${relPathNoExt}` -> route (+ folder aliases)
+const files = [];             // {base, rel, route, body}
+
+const addNode = (route, label, group, partial = false) => {
+  if (!nodes.has(route)) nodes.set(route, partial ? { id: route, label, group, partial: true } : { id: route, label, group });
+  if (!byLc.has(route.toLowerCase())) byLc.set(route.toLowerCase(), route);
+};
+PAGES.forEach((p) => addNode(p.route, p.label, p.group));
+
+let skipped = 0, noSlug = 0;
+
+for (const { dir, base, group } of DOC_SECTIONS) {
+  for (const file of walk(join(ROOT, dir))) {
+    const { data, body } = parseFrontmatter(readFileSync(file, 'utf8'));
+    if (isTrue(data.draft) || isTrue(data.unlisted) || isTrue(data.private)) { skipped++; continue; }
+    const rel = relative(join(ROOT, dir), file).replace(/\.mdx?$/, '');
+    if (basename(file).startsWith('_')) {                          // hub/index partial
+      const id = `${base}/${rel}`;
+      addNode(id, partialLabel(rel), 'index', true);
+      files.push({ base, rel, route: id, body });
+      continue;
+    }
+    const route = docRoute(base, rel, data.slug);
+    addNode(route, titleOf(data, body, basename(rel)), group);
+    fileRoute.set(`${base}|${rel}`, route);
+    const fi = folderIndex(rel);
+    if (fi.isIndex) fileRoute.set(`${base}|${fi.dir}`, route);     // link to the folder itself
+    files.push({ base, rel, route, body });
+  }
+}
+
+for (const { dir, base, group } of BLOG_SECTIONS) {
+  for (const file of walk(join(ROOT, dir))) {
+    const { data, body } = parseFrontmatter(readFileSync(file, 'utf8'));
+    if (isTrue(data.draft) || isTrue(data.unlisted)) { skipped++; continue; }
+    const rel = relative(join(ROOT, dir), file).replace(/\.mdx?$/, '');
+    if (basename(file).startsWith('_')) {                          // _posts.md hub
+      const id = `${base}/${rel}`;
+      addNode(id, partialLabel(rel), 'index', true);
+      files.push({ base, rel, route: id, body });
+      continue;
+    }
+    let slug = data.slug;
+    if (!slug) { slug = rel.replace(/^\d{4}-\d{2}-\d{2}-/, ''); noSlug++; }
+    const route = tidy(`${base}/${slug}`);
+    addNode(route, titleOf(data, body, slug), group);
+    fileRoute.set(`${base}|${rel}`, route);                        // by filename (for _posts.md links)
+    fileRoute.set(`${base}|${slug}`, route);
+    files.push({ base, rel, route, body });
+  }
+}
+
+// ---------- link resolution ----------
+const matchRoute = (r) => (r == null ? null : nodes.has(r) ? r : byLc.get(r.toLowerCase()) || null);
+
+function resolveLink(href, base, rel) {
+  if (!href) return null;
+  let h = href.trim();
+  if (/^https?:\/\//i.test(h)) {
+    const m = h.match(/^https?:\/\/davidawindham\.com(\/.*)?$/i);
+    if (!m) return null;
+    h = m[1] || '/';
+  }
+  if (h.startsWith('mailto:') || h.startsWith('#')) return null;
+  h = h.split('#')[0].split('?')[0];
+  if (!h) return null;
+  if (h.startsWith('/til/')) h = h.slice(4);
+  else if (h === '/til') h = '/';
+  h = h.replace(/\.mdx?$/i, '');
+  if (h.startsWith('/')) {                                          // absolute route or file path
+    const exact = matchRoute(tidy(h));
+    if (exact) return exact;
+    const seg = h.replace(/^\//, '').split('/');                   // .md link to a folder-index doc
+    return fileRoute.get(`/${seg[0]}|${seg.slice(1).join('/')}`) || null;
+  }
+  const parts = (dirname(rel) + '/' + h).split('/');               // relative → resolve via file dir
+  const st = [];
+  for (const p of parts) { if (p === '' || p === '.') continue; if (p === '..') st.pop(); else st.push(p); }
+  return fileRoute.get(`${base}|${st.join('/')}`) || matchRoute(tidy(base + '/' + st.join('/')));
+}
+
+// ---------- pass 2: edges ----------
+const seen = new Set();
+const links = [];
+const unresolved = new Map();
+const LINK_RE = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
+const HREF_RE = /href=["']([^"']+)["']/g;
+
+for (const { base, rel, route, body } of files) {
+  const targets = new Set();
+  let m;
+  while ((m = LINK_RE.exec(body))) targets.add(m[1]);
+  while ((m = HREF_RE.exec(body))) targets.add(m[1]);
+  for (const t of targets) {
+    const dest = resolveLink(t, base, rel);
+    if (!dest) {
+      if (DEBUG && (t.startsWith('/') || (!/^https?:|^mailto:|^#/.test(t) && /\.mdx?($|#)/.test(t)))) {
+        const k = t.split('#')[0]; unresolved.set(k, (unresolved.get(k) || 0) + 1);
+      }
+      continue;
+    }
+    if (dest === route) continue;
+    const key = `${route}|${dest}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    links.push({ source: route, target: dest });
+  }
+}
+
+// size nodes by total degree so hubs read as hubs
+const deg = new Map();
+for (const e of links) { deg.set(e.source, (deg.get(e.source) || 0) + 1); deg.set(e.target, (deg.get(e.target) || 0) + 1); }
+const nodeList = [...nodes.values()].map((n) => ({ ...n, val: 1 + (deg.get(n.id) || 0) }));
+
+mkdirSync(join(ROOT, 'src', 'data'), { recursive: true });
+writeFileSync(join(ROOT, 'src', 'data', 'graph.json'), JSON.stringify({ nodes: nodeList, links }, null, 0) + '\n');
+
+// ---------- report ----------
+const g = (grp) => nodeList.filter((n) => n.group === grp).length;
+const orphans = nodeList.filter((n) => !deg.has(n.id));
+console.log(`graph.json → ${nodeList.length} nodes, ${links.length} edges`);
+console.log(`  nodes: docs=${g('docs')} notes=${g('notes')} lists=${g('lists')} posts=${g('posts')} pages=${g('page')} index=${g('index')}`);
+console.log(`  post → page edges: ${links.filter((e) => e.source.startsWith('/posts/')).length}`);
+console.log(`  orphans (no links in or out): ${orphans.length}`);
+console.log(`  skipped drafts: ${skipped}; posts w/o slug: ${noSlug}`);
+if (DEBUG) {
+  const top = [...unresolved.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30);
+  console.log('\n  unresolved internal-looking targets:');
+  for (const [t, c] of top) console.log(`    ${c}×  ${t}`);
+}

+ 134 - 0
src/components/KnowledgeGraph/index.jsx

@@ -0,0 +1,134 @@
+import React, {useEffect, useRef, useState, useCallback} from 'react';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import {useHistory} from '@docusaurus/router';
+import {useColorMode} from '@docusaurus/theme-common';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import graph from '@site/src/data/graph.json';
+
+// Color per section. Tweak to taste.
+const GROUP_COLORS = {
+  docs: '#4f9bff',
+  notes: '#34c759',
+  lists: '#ffcc00',
+  posts: '#ff6b6b',
+  page: '#b07cff',
+  index: '#9aa0a6',
+};
+const GROUPS = [
+  ['docs', 'Docs'],
+  ['notes', 'Notes'],
+  ['lists', 'Lists'],
+  ['posts', 'Posts'],
+  ['page', 'Pages'],
+  ['index', 'Index/hubs'],
+];
+
+function GraphInner() {
+  // Required client-side only (canvas lib touches window) — safe inside BrowserOnly.
+  const ForceGraph2D = require('react-force-graph-2d').default;
+  const history = useHistory();
+  const {colorMode} = useColorMode();
+  const {siteConfig: {baseUrl}} = useDocusaurusContext();
+  const dark = colorMode === 'dark';
+
+  const wrapRef = useRef(null);
+  const fgRef = useRef(null);
+  const [size, setSize] = useState({width: 800, height: 600});
+
+  // Fill from the graph's top edge to the bottom of the viewport, full width.
+  useEffect(() => {
+    const measure = () => {
+      const el = wrapRef.current;
+      if (!el) return;
+      const top = el.getBoundingClientRect().top;
+      setSize({width: el.offsetWidth, height: Math.max(420, window.innerHeight - top - 12)});
+    };
+    measure();
+    window.addEventListener('resize', measure);
+    return () => window.removeEventListener('resize', measure);
+  }, []);
+
+  // Clone so the lib's mutations (x/y) don't freeze the imported JSON module.
+  const data = useRef({
+    nodes: graph.nodes.map((n) => ({...n})),
+    links: graph.links.map((l) => ({...l})),
+  }).current;
+
+  const onClick = useCallback(
+    (node) => {
+      if (!node?.id || node.partial) return;                   // hub nodes have no published page
+      const base = baseUrl.replace(/\/$/, '');                 // '/til'
+      const path = node.id.startsWith(base + '/') ? node.id : base + node.id;
+      history.push(path);                                       // → /til/docs/…
+    },
+    [history, baseUrl],
+  );
+
+  const paintNode = useCallback(
+    (node, ctx, scale) => {
+      const r = Math.max(2, Math.sqrt(node.val) * 1.7);
+      ctx.beginPath();
+      ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
+      ctx.fillStyle = GROUP_COLORS[node.group] || '#888';
+      ctx.fill();
+      if (scale > 1.8) {
+        // Only draw labels when zoomed in, to avoid clutter.
+        ctx.font = `${10 / scale}px sans-serif`;
+        ctx.fillStyle = dark ? '#e6e6e6' : '#222';
+        ctx.textAlign = 'center';
+        ctx.fillText(node.label, node.x, node.y + r + 9 / scale);
+      }
+    },
+    [dark],
+  );
+
+  return (
+    <>
+      <div style={{display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', margin: '0 0 8px'}}>
+        {GROUPS.map(([key, label]) => (
+          <span key={key} style={{display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 14}}>
+            <span style={{width: 12, height: 12, borderRadius: '50%', background: GROUP_COLORS[key], display: 'inline-block'}} />
+            {label}
+          </span>
+        ))}
+        <span style={{fontSize: 13, opacity: 0.7}}>· click a node to open the page · scroll to zoom · drag to pan</span>
+      </div>
+      <div
+        ref={wrapRef}
+        style={{border: '1px solid var(--ifm-color-emphasis-300)', borderRadius: 8, overflow: 'hidden'}}
+      >
+        <ForceGraph2D
+          ref={fgRef}
+          graphData={data}
+          width={size.width}
+          height={size.height}
+          backgroundColor={dark ? '#2b2f37' : '#ffffff'}
+          nodeLabel="label"
+          nodeColor={(n) => GROUP_COLORS[n.group] || '#888'}
+          nodeRelSize={4}
+          nodeCanvasObject={paintNode}
+          nodePointerAreaPaint={(node, color, ctx) => {
+            const r = Math.max(4, Math.sqrt(node.val) * 1.7);
+            ctx.fillStyle = color;
+            ctx.beginPath();
+            ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
+            ctx.fill();
+          }}
+          linkColor={() => (dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)')}
+          linkDirectionalArrowLength={3}
+          linkDirectionalArrowRelPos={1}
+          onNodeClick={onClick}
+          cooldownTicks={120}
+        />
+      </div>
+    </>
+  );
+}
+
+export default function KnowledgeGraph() {
+  return (
+    <BrowserOnly fallback={<p><em>Loading graph…</em></p>}>
+      {() => <GraphInner />}
+    </BrowserOnly>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
src/data/graph.json


+ 1 - 1
src/pages/about.md

@@ -15,7 +15,7 @@ I'm often searching online documentation for answers to commands, configurations
 
 I'm hoping it'll help me keep my bookmarks as little less cluttered and it'll leave my [desk page](https://davidawindham.com/desk) free for longer form essays. I'll keep the LOG in the [README](https://code.davidawindham.com/david/til/src/master/README.md), add a [help page](/help), and some [vi cheat sheets](/docs/shell/vi) to get started because I'm always forgetting some of them.
 
-You could start with a [sitemap](/map/)
+You could start with a [sitemap](/map/) or the [graph](/graph/)
 
 Here's a map from June 2023👇🏼
 

+ 19 - 0
src/pages/graph.jsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import Layout from '@theme/Layout';
+import KnowledgeGraph from '@site/src/components/KnowledgeGraph';
+
+export default function GraphPage() {
+  return (
+    <Layout title="Graph" description="A force-directed graph of how this site's pages link to each other.">
+      <main style={{padding: '1rem 1.25rem 0'}}>
+        <h1 style={{marginBottom: '0.25rem'}}>Graph</h1>
+        <p style={{marginBottom: '0.75rem', maxWidth: 820}}>
+          How the pages here link to one another — docs, notes, lists, and posts. Unlike the
+          file-based view, this resolves the absolute route links too, so the posts' outgoing
+          links to docs/notes/lists show up as edges. Rebuilt with <code>npm run graph</code>.
+        </p>
+        <KnowledgeGraph />
+      </main>
+    </Layout>
+  );
+}

Some files were not shown because too many files changed in this diff