six-degrees 0.4.7 → 0.4.8

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.
Files changed (172) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  7. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.rsc +13 -13
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  15. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +11 -11
  16. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/favicon.ico.body +0 -0
  48. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/import.html +1 -1
  51. package/.next/standalone/.next/server/app/import.rsc +15 -15
  52. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +15 -15
  53. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +13 -13
  55. package/.next/standalone/.next/server/app/index.html +1 -1
  56. package/.next/standalone/.next/server/app/index.rsc +15 -15
  57. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  58. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  59. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/launch.html +2 -2
  63. package/.next/standalone/.next/server/app/launch.rsc +15 -15
  64. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +15 -15
  65. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
  66. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +13 -13
  67. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/paths.html +1 -1
  72. package/.next/standalone/.next/server/app/paths.rsc +15 -15
  73. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +15 -15
  74. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
  75. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +13 -13
  76. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  77. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  78. package/.next/standalone/.next/server/app/profile.html +1 -1
  79. package/.next/standalone/.next/server/app/profile.rsc +15 -15
  80. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +15 -15
  81. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
  82. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +13 -13
  83. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  85. package/.next/standalone/.next/server/app/queue.html +1 -1
  86. package/.next/standalone/.next/server/app/queue.rsc +15 -15
  87. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +15 -15
  88. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
  89. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +13 -13
  90. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  91. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
  92. package/.next/standalone/.next/server/app/scores.html +1 -1
  93. package/.next/standalone/.next/server/app/scores.rsc +15 -15
  94. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +15 -15
  95. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
  96. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +13 -13
  97. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  98. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  99. package/.next/standalone/.next/server/app/settings.html +1 -1
  100. package/.next/standalone/.next/server/app/settings.rsc +15 -15
  101. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  102. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  103. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +13 -13
  104. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  105. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  106. package/.next/standalone/.next/server/app/setup.html +1 -1
  107. package/.next/standalone/.next/server/app/setup.rsc +15 -15
  108. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +15 -15
  109. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
  110. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +13 -13
  111. package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
  112. package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
  113. package/.next/standalone/.next/server/app/social.html +1 -1
  114. package/.next/standalone/.next/server/app/social.rsc +15 -15
  115. package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +15 -15
  116. package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +1 -1
  117. package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +13 -13
  118. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/_1mxn_lb._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +1 -1
  121. package/.next/standalone/.next/server/chunks/lib_rpc_0c9d9ti.js +5 -5
  122. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00d8snt._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00g61b8._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08lf3gy._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hg5gib._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ho-u0w._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ze473p._.js +1 -1
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +1 -1
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cnxxc9._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jeztd-._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ll97ry._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m0255u._.js +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1w9z0zv._.js +1 -1
  134. package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
  135. package/.next/standalone/.next/server/chunks/ssr/lib_0i8v-s3._.js +1 -1
  136. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  137. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  138. package/.next/standalone/.next/server/pages/404.html +1 -1
  139. package/.next/standalone/.next/server/pages/500.html +1 -1
  140. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  141. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  142. package/.next/{static/chunks/2_zu7rmb8flzl.js → standalone/.next/static/chunks/1z-zp93cyoxvz.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/24tvdun1cb3g1.js +1 -0
  144. package/.next/standalone/.next/static/media/favicon.23vm2o6v3er2z.ico +0 -0
  145. package/.next/standalone/CHANGELOG.md +33 -0
  146. package/.next/standalone/CONTRIBUTING.md +5 -0
  147. package/.next/standalone/README.md +168 -56
  148. package/.next/standalone/app/favicon.ico +0 -0
  149. package/.next/standalone/app/paths/page.js +25 -18
  150. package/.next/standalone/lib/scoring.js +42 -1
  151. package/.next/standalone/package-lock.json +2 -2
  152. package/.next/standalone/package.json +1 -1
  153. package/.next/standalone/scripts/build-site.mjs +537 -0
  154. package/.next/standalone/scripts/site-markdown.mjs +188 -0
  155. package/.next/standalone/scripts/site-news.mjs +311 -0
  156. package/.next/{standalone/.next/static/chunks/2_zu7rmb8flzl.js → static/chunks/1z-zp93cyoxvz.js} +1 -1
  157. package/.next/static/chunks/24tvdun1cb3g1.js +1 -0
  158. package/.next/static/media/favicon.23vm2o6v3er2z.ico +0 -0
  159. package/CHANGELOG.md +33 -0
  160. package/README.md +168 -56
  161. package/lib/scoring.js +42 -1
  162. package/package.json +1 -1
  163. package/.next/standalone/.next/static/chunks/3p_pwu5gx2i9h.js +0 -1
  164. package/.next/standalone/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  165. package/.next/static/chunks/3p_pwu5gx2i9h.js +0 -1
  166. package/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  167. /package/.next/standalone/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_buildManifest.js +0 -0
  168. /package/.next/standalone/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_clientMiddlewareManifest.js +0 -0
  169. /package/.next/standalone/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_ssgManifest.js +0 -0
  170. /package/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_buildManifest.js +0 -0
  171. /package/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_clientMiddlewareManifest.js +0 -0
  172. /package/.next/static/{YNRcnda8lvMnnrOKL1L5l → T0eWMsKjgSiPnmx1E0q3G}/_ssgManifest.js +0 -0
@@ -0,0 +1,188 @@
1
+ // A small Markdown renderer for the website's blog posts, pages and release notes.
2
+ // No dependencies, on purpose: the site builds with nothing but Node. It covers
3
+ // what the posts and CHANGELOG.md use, and nothing more:
4
+ //
5
+ // # to ####### headings (with an id from their text), paragraphs, blank-line breaks
6
+ // - and 1. lists, nested by two spaces, with continuation lines
7
+ // > block quotes, ``` fenced code, --- rules, | tables |
8
+ // **bold**, *italic*, `code`, [links](url), ![images](src "caption")
9
+ //
10
+ // Raw HTML is escaped, never passed through: a post can't inject markup or script.
11
+
12
+ export const escapeHtml = (s) => String(s)
13
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+
15
+ /** "What it does, for you!" → "what-it-does-for-you" */
16
+ export function slugify(text) {
17
+ return String(text).toLowerCase()
18
+ .replace(/<[^>]+>/g, '')
19
+ .replace(/&[a-z]+;/g, '')
20
+ .replace(/[^a-z0-9\s-]/g, '')
21
+ .trim().replace(/\s+/g, '-').replace(/-+/g, '-');
22
+ }
23
+
24
+ const safeUrl = (url) => (/^(https?:|mailto:|\/|#|\.\.?\/)/i.test(url) || !/^[a-z]+:/i.test(url) ? url : '#');
25
+
26
+ /** Inline marks, on text that is escaped first. */
27
+ export function inline(md) {
28
+ let s = escapeHtml(md);
29
+ const codes = [];
30
+ s = s.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return `\u0000${codes.length - 1}\u0000`; });
31
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g,
32
+ (_, alt, src) => `<img src="${safeUrl(src)}" alt="${alt}" loading="lazy">`);
33
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, url) => `<a href="${safeUrl(url)}">${text}</a>`);
34
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
35
+ s = s.replace(/(^|[^\w*])\*(?!\s)(.+?)\*(?!\w)/g, '$1<em>$2</em>');
36
+ s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${codes[Number(i)]}</code>`);
37
+ return s;
38
+ }
39
+
40
+ /**
41
+ * Front matter between --- lines: `key: value`, with [a, b] lists. Returns
42
+ * { data, body }. A file without it is all body.
43
+ */
44
+ export function frontMatter(text) {
45
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
46
+ if (!m) return { data: {}, body: text };
47
+ const data = {};
48
+ for (const line of m[1].split('\n')) {
49
+ const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
50
+ if (!kv) continue;
51
+ let v = kv[2].trim();
52
+ if (/^\[.*\]$/.test(v)) v = v.slice(1, -1).split(',').map((x) => x.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
53
+ else v = v.replace(/^["'](.*)["']$/, '$1');
54
+ data[kv[1]] = v;
55
+ }
56
+ return { data, body: text.slice(m[0].length) };
57
+ }
58
+
59
+ /** The same, for an HTML page whose first thing is a <!-- key: value --> comment. */
60
+ export function htmlFrontMatter(text) {
61
+ const m = text.match(/^<!--\n([\s\S]*?)\n-->\n?/);
62
+ if (!m) return { data: {}, body: text };
63
+ return { data: frontMatter(`---\n${m[1]}\n---\n`).data, body: text.slice(m[0].length) };
64
+ }
65
+
66
+ function table(lines) {
67
+ const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
68
+ const head = cells(lines[0]);
69
+ const rows = lines.slice(2).map(cells);
70
+ return `<div class="table-wrap prose-table"><table>\n<thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead>\n<tbody>\n${
71
+ rows.map((r) => `<tr>${r.map((c, i) => (i === 0 ? `<th scope="row">${inline(c)}</th>` : `<td>${inline(c)}</td>`)).join('')}</tr>`).join('\n')
72
+ }\n</tbody></table></div>`;
73
+ }
74
+
75
+ /**
76
+ * Lists: items start with "- " or "1. " at an indent; deeper indents nest. An
77
+ * item's text, its sub-lists and any later paragraphs stay in their order.
78
+ */
79
+ function list(lines, start) {
80
+ const indentOf = (l) => l.match(/^(\s*)/)[1].length;
81
+ const base = indentOf(lines[start]);
82
+ const ordered = /^\s*\d+\.\s/.test(lines[start]);
83
+ const items = [];
84
+ let i = start;
85
+ let gap = false; // a blank line: the next text inside this item is a new paragraph
86
+ while (i < lines.length) {
87
+ const l = lines[i];
88
+ if (!l.trim()) {
89
+ // A blank line inside a list continues it only if an indented line follows.
90
+ const next = lines[i + 1];
91
+ if (next && indentOf(next) > base && next.trim()) { gap = true; i += 1; continue; }
92
+ break;
93
+ }
94
+ const ind = indentOf(l);
95
+ const bullet = l.match(/^\s*(?:[-*]|\d+\.)\s+(.*)$/);
96
+ if (ind === base && bullet) { items.push({ parts: [bullet[1]] }); gap = false; i += 1; continue; }
97
+ if (ind < base || (!bullet && ind === base) || !items.length) break;
98
+ const parts = items.at(-1).parts;
99
+ if (bullet && ind > base) {
100
+ const [html, next] = list(lines, i);
101
+ parts.push({ html });
102
+ gap = false;
103
+ i = next;
104
+ continue;
105
+ }
106
+ if (gap || typeof parts.at(-1) !== 'string') parts.push(l.trim());
107
+ else parts[parts.length - 1] += ` ${l.trim()}`;
108
+ gap = false;
109
+ i += 1;
110
+ }
111
+ const tag = ordered ? 'ol' : 'ul';
112
+ const li = (it) => it.parts.map((p, n) => (typeof p !== 'string' ? p.html : n === 0 ? inline(p) : `<p>${inline(p)}</p>`)).join('');
113
+ const html = `<${tag}>\n${items.map((it) => `<li>${li(it)}</li>`).join('\n')}\n</${tag}>`;
114
+ return [html, i];
115
+ }
116
+
117
+ /**
118
+ * Markdown → HTML. `headingOffset` shifts heading levels (1 makes # an <h2>),
119
+ * `ids` adds an id to each heading.
120
+ */
121
+ export function markdown(md, { headingOffset = 0, ids = true } = {}) {
122
+ const lines = md.replace(/\r\n/g, '\n').split('\n');
123
+ const out = [];
124
+ let i = 0;
125
+ while (i < lines.length) {
126
+ const l = lines[i];
127
+ if (!l.trim()) { i += 1; continue; }
128
+ const fence = l.match(/^```(\w*)/);
129
+ if (fence) {
130
+ const code = [];
131
+ i += 1;
132
+ while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
133
+ i += 1;
134
+ out.push(`<pre><code${fence[1] ? ` class="lang-${fence[1]}"` : ''}>${escapeHtml(code.join('\n'))}</code></pre>`);
135
+ continue;
136
+ }
137
+ const h = l.match(/^(#{1,6})\s+(.*)$/);
138
+ if (h) {
139
+ const level = Math.min(6, h[1].length + headingOffset);
140
+ const html = inline(h[2]);
141
+ out.push(`<h${level}${ids ? ` id="${slugify(h[2])}"` : ''}>${html}</h${level}>`);
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (/^(-{3,}|\*{3,})\s*$/.test(l)) { out.push('<hr>'); i += 1; continue; }
146
+ if (/^\s*(?:[-*]|\d+\.)\s+/.test(l)) {
147
+ const [html, next] = list(lines, i);
148
+ out.push(html);
149
+ i = next;
150
+ continue;
151
+ }
152
+ if (/^>\s?/.test(l)) {
153
+ const q = [];
154
+ while (i < lines.length && /^>\s?/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
155
+ out.push(`<blockquote>${markdown(q.join('\n'), { headingOffset, ids: false })}</blockquote>`);
156
+ continue;
157
+ }
158
+ if (/^\|.*\|\s*$/.test(l) && /^\|?\s*:?-{2,}/.test(lines[i + 1] || '')) {
159
+ const t = [];
160
+ while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) t.push(lines[i++]);
161
+ out.push(table(t));
162
+ continue;
163
+ }
164
+ const img = l.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$/);
165
+ if (img) {
166
+ out.push(`<figure><img src="${escapeHtml(safeUrl(img[2]))}" alt="${escapeHtml(img[1])}" loading="lazy">${img[3] ? `<figcaption>${inline(img[3])}</figcaption>` : ''}</figure>`);
167
+ i += 1;
168
+ continue;
169
+ }
170
+ const para = [];
171
+ while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|>\s?|\s*(?:[-*]|\d+\.)\s+|\|)/.test(lines[i])) para.push(lines[i++].trim());
172
+ if (!para.length) { para.push(lines[i++].trim()); }
173
+ out.push(`<p>${inline(para.join(' '))}</p>`);
174
+ }
175
+ return out.join('\n');
176
+ }
177
+
178
+ /** Plain words from Markdown, for descriptions and feeds. */
179
+ export function plainText(md) {
180
+ return md
181
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, '')
182
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
183
+ .replace(/\*\*(.+?)\*\*/g, '$1')
184
+ .replace(/(^|[^\w*])\*(?!\s)(.+?)\*(?!\w)/g, '$1$2')
185
+ .replace(/`([^`]+)`/g, '$1')
186
+ .replace(/\s+/g, ' ')
187
+ .trim();
188
+ }
@@ -0,0 +1,311 @@
1
+ // The website's numbers and news, from files in this repository: used by
2
+ // scripts/build-site.mjs, which builds the site (see site/README.md).
3
+ //
4
+ // Where each number comes from, so every one of them can be checked:
5
+ // - releases, their dates, what each changed: CHANGELOG.md's "## [x.y.z] - date"
6
+ // headings and the top-level bullets under their "### Added/Changed/…" headings.
7
+ // A pre-release (x.y.z-beta.n) is folded into the full release it became;
8
+ // one that hasn't become a full release yet isn't counted.
9
+ // - tests: every line starting "test(" in tests/*.test.mjs, which is what
10
+ // `npm test` counts.
11
+ // - the sample network: public/demo-data.json, the invented network that ships
12
+ // with the app (read only; scripts/gen-synthetic.mjs makes it).
13
+ //
14
+ // In site/index.html each generated part sits between <!-- gen:NAME --> and
15
+ // <!-- /gen:NAME -->; everything else on the page is written by hand.
16
+
17
+ import { readFileSync, readdirSync } from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ const REPO_URL = 'https://github.com/blakeb056/six-degrees';
21
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
22
+ 'August', 'September', 'October', 'November', 'December'];
23
+ const KINDS = ['added', 'changed', 'fixed', 'removed', 'security', 'deprecated'];
24
+ // Which bullets make the headline: new things first, then changes, then fixes.
25
+ const LEAD_ORDER = ['added', 'changed', 'fixed', 'security', 'removed', 'deprecated'];
26
+ export const NEWS_POSTS = 4;
27
+
28
+ const escapeHtml = (s) => String(s)
29
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
30
+
31
+ /** "2026-09-29" → "29 September 2026". */
32
+ export function longDate(iso) {
33
+ const [y, m, d] = iso.split('-').map(Number);
34
+ return `${d} ${MONTHS[m - 1]} ${y}`;
35
+ }
36
+
37
+ /** Markdown inline marks and links dropped: plain words. */
38
+ export function plainText(md) {
39
+ return md
40
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
41
+ .replace(/\*\*(.+?)\*\*/g, '$1')
42
+ .replace(/(^|[^\w*])\*(?!\s)(.+?)\*(?!\w)/g, '$1$2')
43
+ .replace(/`([^`]+)`/g, '$1')
44
+ .replace(/\s+/g, ' ')
45
+ .trim();
46
+ }
47
+
48
+ /**
49
+ * A bullet's bold lead-in ("**Names on or off** for the Galaxy" → "Names on or
50
+ * off"), tidied for a headline: no trailing full stop or colon, and a bracket
51
+ * kept only when it says the thing is experimental. Null when it has none.
52
+ */
53
+ export function leadOf(bullet) {
54
+ const m = bullet.match(/^\*\*(.+?)\*\*/);
55
+ if (!m) return null;
56
+ let lead = plainText(m[1]);
57
+ lead = lead.replace(/\s*\(([^)]*)\)/g, (_, inner) => (/experimental/i.test(inner) ? ' (experimental)' : ''));
58
+ return lead.replace(/[\s.:,;]+$/, '').trim() || null;
59
+ }
60
+
61
+ /**
62
+ * CHANGELOG.md as releases, newest first:
63
+ * { version, date, prerelease, intro, bullets: [{ kind, text }] }
64
+ * Only "## [x.y.z] - YYYY-MM-DD" headings count ("Unreleased" has no date).
65
+ */
66
+ export function parseChangelog(md) {
67
+ const releases = [];
68
+ let release = null;
69
+ let kind = null;
70
+ let bullet = null;
71
+ const end = () => { bullet = null; };
72
+ for (const line of md.split('\n')) {
73
+ const head = line.match(/^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\] - (\d{4}-\d{2}-\d{2})/);
74
+ if (head) {
75
+ release = { version: head[1], date: head[2], prerelease: head[1].includes('-'), intro: '', bullets: [], body: '' };
76
+ releases.push(release);
77
+ kind = null;
78
+ end();
79
+ continue;
80
+ }
81
+ if (/^## /.test(line)) { release = null; kind = null; end(); continue; }
82
+ if (!release) continue;
83
+ release.body += `${line}\n`;
84
+ const sub = line.match(/^### (\w+)/);
85
+ if (sub) { kind = sub[1].toLowerCase(); end(); continue; }
86
+ if (!kind) {
87
+ if (line.trim()) release.intro = `${release.intro} ${line.trim()}`.trim();
88
+ continue;
89
+ }
90
+ if (/^- /.test(line)) {
91
+ bullet = { kind, text: line.slice(2).trim() };
92
+ release.bullets.push(bullet);
93
+ } else if (bullet && /^\s+\S/.test(line) && !/^\s+- /.test(line)) {
94
+ bullet.text += ` ${line.trim()}`;
95
+ } else if (/^\S/.test(line)) {
96
+ end();
97
+ }
98
+ }
99
+ return releases;
100
+ }
101
+
102
+ /**
103
+ * Full releases, newest first, each with its pre-releases folded in (what
104
+ * 0.4.0-beta.1 added shipped to everyone in 0.4.0). A pre-release that hasn't
105
+ * become a full release yet is left out: the site describes full releases.
106
+ */
107
+ export function fullReleases(releases) {
108
+ const full = releases.filter((r) => !r.prerelease).map((r) => ({ ...r, betas: [] }));
109
+ const byVersion = new Map(full.map((r) => [r.version, r]));
110
+ for (const r of releases) {
111
+ if (!r.prerelease) continue;
112
+ const parent = byVersion.get(r.version.split('-')[0]);
113
+ if (parent) parent.betas.push(r);
114
+ }
115
+ return full;
116
+ }
117
+
118
+ /** How many top-level bullets a release (and its betas) has of each kind. */
119
+ export function changeCounts(release) {
120
+ const counts = {};
121
+ for (const b of [release, ...(release.betas || [])].flatMap((r) => r.bullets)) {
122
+ const k = KINDS.includes(b.kind) ? b.kind : 'changed';
123
+ counts[k] = (counts[k] || 0) + 1;
124
+ }
125
+ return counts;
126
+ }
127
+
128
+ /** A post's headline and up to three more highlights, from bold lead-ins. */
129
+ export function highlights(release, more = 3) {
130
+ const all = [release, ...(release.betas || [])].flatMap((r) => r.bullets);
131
+ const ordered = LEAD_ORDER.flatMap((k) => all.filter((b) => b.kind === k));
132
+ const leads = [...new Set(ordered.map((b) => leadOf(b.text)).filter(Boolean))];
133
+ let title = leads.shift();
134
+ if (!title) {
135
+ const first = ordered[0] ? plainText(ordered[0].text) : `Six Degrees ${release.version}`;
136
+ title = first.length > 90 ? `${first.slice(0, 90).replace(/\s+\S*$/, '')}…` : first.replace(/[.:]$/, '');
137
+ }
138
+ return { title, more: leads.slice(0, more) };
139
+ }
140
+
141
+ const releaseUrl = (version) => `${REPO_URL}/releases/tag/v${version}`;
142
+
143
+ function chips(counts) {
144
+ const items = KINDS.filter((k) => counts[k]).map((k) => `<li class="k-${k}">${counts[k]} ${k}</li>`);
145
+ return `<ul class="chips" aria-label="What changed">${items.join('')}</ul>`;
146
+ }
147
+
148
+ export function newsHtml(full, count = NEWS_POSTS) {
149
+ const posts = full.slice(0, count).map((r, i) => {
150
+ const { title, more } = highlights(r);
151
+ const also = more.length ? `\n <p class="post-more">Also: ${more.map(escapeHtml).join(' · ')}</p>` : '';
152
+ const latest = i === 0 ? '<span class="tag latest">Latest</span>' : '';
153
+ return ` <article class="post${i === 0 ? ' featured' : ''}">
154
+ <p class="post-meta"><time datetime="${r.date}">${longDate(r.date)}</time><span class="tag">v${escapeHtml(r.version)}</span>${latest}</p>
155
+ <h3><a href="${releaseUrl(r.version)}">${escapeHtml(title)}</a></h3>${also}
156
+ ${chips(changeCounts(r))}
157
+ <a class="more" href="${releaseUrl(r.version)}">Release notes<span class="sr-only"> for ${escapeHtml(r.version)}</span> →</a>
158
+ </article>`;
159
+ });
160
+ return `\n${posts.join('\n')}\n `;
161
+ }
162
+
163
+ const round = (n) => Math.round(n * 100) / 100;
164
+
165
+ /**
166
+ * Columns of stacked bars as an SVG that stretches to its box (no text inside,
167
+ * so nothing distorts), each column with a tooltip. The labels are HTML beside it.
168
+ */
169
+ export function columnSvg(columns, { label, max = Math.max(1, ...columns.map((c) => c.parts.reduce((s, p) => s + p.value, 0))) } = {}) {
170
+ const H = 100;
171
+ const step = 10;
172
+ const barW = 7;
173
+ const bars = columns.map((c, i) => {
174
+ let below = 0;
175
+ const x = round(i * step + (step - barW) / 2);
176
+ const rects = c.parts.filter((p) => p.value > 0).map((p) => {
177
+ const top = Math.max(0, H - ((below + p.value) / max) * H);
178
+ const bottom = H - (below / max) * H;
179
+ below += p.value;
180
+ return `<rect class="${p.cls}" x="${x}" y="${round(top)}" width="${barW}" height="${round(bottom - top)}"/>`;
181
+ }).join('');
182
+ return `<g><title>${escapeHtml(c.title)}</title>${rects}</g>`;
183
+ }).join('');
184
+ return `<svg class="bars" viewBox="0 0 ${columns.length * step} ${H}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(label)}">${bars}</svg>`;
185
+ }
186
+
187
+ function labelRow(labels) {
188
+ return `<ol class="bar-labels" aria-hidden="true">${labels.map((l) => `<li>${l ? escapeHtml(l) : ''}</li>`).join('')}</ol>`;
189
+ }
190
+
191
+ /** Every full release, oldest to newest, by how many changes of each kind. */
192
+ export function releaseChartHtml(full) {
193
+ const oldestFirst = [...full].reverse();
194
+ const columns = oldestFirst.map((r) => {
195
+ const c = changeCounts(r);
196
+ const total = KINDS.reduce((s, k) => s + (c[k] || 0), 0);
197
+ const parts = KINDS.map((k) => ({ value: c[k] || 0, cls: `k-${k}` }));
198
+ const said = KINDS.filter((k) => c[k]).map((k) => `${c[k]} ${k}`).join(', ');
199
+ return { parts, title: `${r.version} · ${longDate(r.date)}: ${total} changes (${said})` };
200
+ });
201
+ const total = full.reduce((s, r) => s + Object.values(changeCounts(r)).reduce((a, b) => a + b, 0), 0);
202
+ // The first and the newest only: at phone width the columns are too narrow to name more.
203
+ const labels = oldestFirst.map((r, i) => (i === 0 || i === oldestFirst.length - 1 ? `${r.version} · ${longDate(r.date).replace(/ (\w{3})\w* \d{4}$/, ' $1')}` : ''));
204
+ const svg = columnSvg(columns, {
205
+ label: `Changes per release, ${oldestFirst.length} releases from ${oldestFirst[0].version} to ${full[0].version}, ${total} in all`,
206
+ });
207
+ return `\n ${svg}\n ${labelRow(labels)}\n `;
208
+ }
209
+
210
+ /** The sample network's shape: who's two steps away, and through how many of your connections. */
211
+ export function sampleStats(data) {
212
+ const byPerson = new Map();
213
+ for (const p of data.degree2 || []) {
214
+ const key = p.profile_url || p.id || p.name;
215
+ if (!byPerson.has(key)) byPerson.set(key, new Set());
216
+ byPerson.get(key).add(p.source_connection_id);
217
+ }
218
+ const ways = { one: 0, two: 0, more: 0 };
219
+ const only = new Map(); // connection → people only they reach
220
+ for (const sources of byPerson.values()) {
221
+ if (sources.size === 1) {
222
+ ways.one += 1;
223
+ const [via] = sources;
224
+ only.set(via, (only.get(via) || 0) + 1);
225
+ } else if (sources.size === 2) ways.two += 1;
226
+ else ways.more += 1;
227
+ }
228
+ const bridges = new Set((data.degree2 || []).map((p) => p.source_connection_id)).size;
229
+ return {
230
+ connections: (data.degree1 || []).length,
231
+ degree2: byPerson.size,
232
+ bridges,
233
+ ways,
234
+ exclusive: [...only.values()].sort((a, b) => b - a),
235
+ };
236
+ }
237
+
238
+ export function exclusiveChartHtml(s) {
239
+ const columns = s.exclusive.map((n, i) => ({
240
+ parts: [{ value: n, cls: i === 0 ? 'k-top' : 'k-bridge' }],
241
+ title: `Connection ${i + 1}: ${n} people only they reach`,
242
+ }));
243
+ const svg = columnSvg(columns, {
244
+ label: `The ${s.exclusive.length} connections with a scanned circle in the sample network, by how many people only they reach: from ${s.exclusive[0]} down to ${s.exclusive.at(-1)}`,
245
+ });
246
+ return `\n ${svg}\n ${labelRow(s.exclusive.map(String))}\n `;
247
+ }
248
+
249
+ export function waysBarHtml(s) {
250
+ const total = s.degree2 || 1;
251
+ const parts = [['one', 'k-one'], ['two', 'k-two'], ['more', 'k-more']];
252
+ let x = 0;
253
+ const rects = parts.map(([k, cls]) => {
254
+ const w = round((s.ways[k] / total) * 100);
255
+ const r = `<rect class="${cls}" x="${round(x)}" y="0" width="${w}" height="10"/>`;
256
+ x += w;
257
+ return r;
258
+ }).join('');
259
+ const label = `Of ${s.degree2} people two steps away: ${s.ways.one} through one connection only, ${s.ways.two} through two, ${s.ways.more} through three or more`;
260
+ return `<svg class="stack" viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${label}">${rects}</svg>`;
261
+ }
262
+
263
+ /** Every test the suite runs: node:test's test( calls, one per line, in tests/*.test.mjs. */
264
+ export function countTests(testsDir) {
265
+ let n = 0;
266
+ for (const f of readdirSync(testsDir)) {
267
+ if (!f.endsWith('.test.mjs')) continue;
268
+ n += (readFileSync(path.join(testsDir, f), 'utf8').match(/^test\(/gm) || []).length;
269
+ }
270
+ return n;
271
+ }
272
+
273
+ const thousands = (n) => n.toLocaleString('en-US');
274
+ const percent = (a, b) => `${Math.round((a / b) * 100)}%`;
275
+
276
+ /** Everything the page's <!-- gen:NAME --> slots get, by name. */
277
+ export function generate({ changelog, testCount, sample }) {
278
+ const full = fullReleases(parseChangelog(changelog));
279
+ if (!full.length) throw new Error('CHANGELOG.md has no "## [x.y.z] - YYYY-MM-DD" release heading.');
280
+ const latest = full[0];
281
+ const first = full.at(-1);
282
+ const s = sampleStats(sample);
283
+ return {
284
+ 'latest-version': escapeHtml(latest.version),
285
+ 'latest-date': `<time datetime="${latest.date}">${longDate(latest.date)}</time>`,
286
+ 'release-count': String(full.length),
287
+ 'first-release': `<time datetime="${first.date}">${longDate(first.date)}</time>`,
288
+ 'change-count': thousands(full.reduce((sum, r) => sum + Object.values(changeCounts(r)).reduce((a, b) => a + b, 0), 0)),
289
+ 'test-count': thousands(testCount),
290
+ news: newsHtml(full),
291
+ 'release-chart': releaseChartHtml(full),
292
+ 'sample-connections': thousands(s.connections),
293
+ 'sample-degree2': thousands(s.degree2),
294
+ 'sample-bridges': String(s.bridges),
295
+ 'sample-one-way': thousands(s.ways.one),
296
+ 'sample-one-way-share': percent(s.ways.one, s.degree2),
297
+ 'sample-two-way': thousands(s.ways.two),
298
+ 'sample-more-way': thousands(s.ways.more),
299
+ 'sample-top-exclusive': String(s.exclusive[0] ?? 0),
300
+ 'sample-ways-bar': waysBarHtml(s),
301
+ 'sample-exclusive-chart': exclusiveChartHtml(s),
302
+ };
303
+ }
304
+
305
+ /** The page with every gen slot filled; throws on a slot nothing fills. */
306
+ export function fill(html, values) {
307
+ return html.replace(/<!-- gen:([a-z0-9-]+) -->([\s\S]*?)<!-- \/gen:\1 -->/g, (_, name) => {
308
+ if (!(name in values)) throw new Error(`a page has a slot nothing fills: gen:${name}`);
309
+ return `<!-- gen:${name} -->${values[name]}<!-- /gen:${name} -->`;
310
+ });
311
+ }