wiki-formant 0.19.0 → 0.20.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 CHANGED
@@ -370,6 +370,7 @@ const block = licenseBlock({ license, scope: 'The protocol compilation and prose
370
370
 
371
371
  ```ts
372
372
  addCopyButtons(el); // every <pre> gets one, once
373
+ sortTables(el); // every column-headed table sorts by its headers
373
374
  hydrateTweetEmbeds(el); // placeholders get a live src
374
375
  const off = onTweetResize(h => sizeTweetEmbeds(el, h));
375
376
  ```
@@ -378,6 +379,8 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
378
379
 
379
380
  `activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
380
381
 
382
+ `sortTables` makes the tables stored in article HTML sortable by their headers. They arrive as a string a `dangerouslySetInnerHTML` wrote, so React never sees their rows and cannot sort them. A column is dates if every filled cell starts with one, numbers if every one does, and text otherwise — one stray value makes the whole column text, which beats sorting half of it by one rule and half by another. A third press restores the author's order, which is often chronological or ranked and otherwise needs a reload. Label/value tables and tables with merged cells are left alone. The markup it writes — `aria-sort` on the cell, a `.sort-header` button inside it — is the markup a React sortable header should write too, so both kinds of table draw their arrows from one stylesheet rule.
383
+
381
384
  `TWITTER_ORIGIN` is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
382
385
 
383
386
  ## Editor nodes
package/dist/dom.d.ts CHANGED
@@ -65,3 +65,27 @@ export interface TabGroupClassNames {
65
65
  * silently reset it to the first tab.
66
66
  */
67
67
  export declare function activateTabGroups(root: ParentNode, classNames?: TabGroupClassNames): void;
68
+ export interface SortTablesOptions {
69
+ /** Class on the injected header button. Style it in your own stylesheet. */
70
+ className?: string;
71
+ }
72
+ /**
73
+ * Make every column-headed table under `root` sortable by its headers, once.
74
+ *
75
+ * Pressing a header sorts by that column. Text sorts A–Z first and numbers and
76
+ * dates largest first, the same as a React table sorted by `useTableSort`. A
77
+ * second press reverses the order and a third restores the author's order,
78
+ * which is often meaningful (chronological, or ranked) and otherwise could only
79
+ * be recovered by a reload.
80
+ *
81
+ * Tables stored in article HTML arrive as a string a `dangerouslySetInnerHTML`
82
+ * wrote, so React never sees their rows and cannot sort them — which is why
83
+ * this is a DOM pass beside `addCopyButtons` rather than a component. The
84
+ * markup it writes, `aria-sort` on the cell and a button inside it, is the
85
+ * markup a React sortable header should write too, so both kinds of table draw
86
+ * their arrows from one stylesheet rule.
87
+ *
88
+ * Skipped: tables with merged cells, where moving a row would break the grid;
89
+ * tables with fewer than two rows to sort; and rows that span several `<tbody>`s.
90
+ */
91
+ export declare function sortTables(root: ParentNode, options?: SortTablesOptions): void;
package/dist/dom.js CHANGED
@@ -163,3 +163,138 @@ export function activateTabGroups(root, classNames = {}) {
163
163
  group.appendChild(tabPanels);
164
164
  }
165
165
  }
166
+ const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];
167
+ const monthIndex = (name) => MONTHS.findIndex(m => m.startsWith(name.toLowerCase()));
168
+ // A dash, a question mark or "n/a" means the cell has no value. It sorts last
169
+ // in both directions, so it never lands above real values.
170
+ const BLANK = /^(?:[-–—?]|n\/?a|tb[ad])?$/i;
171
+ const DAY_FIRST = /^(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]{3,9})\.?,?\s+(\d{4})(?:,?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/i;
172
+ const MONTH_FIRST = /^([a-z]{3,9})\.?\s+(?:(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{4})/i;
173
+ /** The time a cell starts with, as ms. Accepts ISO dates, "4 Jun 2026", "June 4, 2026", "June 2026" and a bare year. */
174
+ function parseDate(s) {
175
+ const iso = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?(?:[ T](\d{2}):(\d{2}))?)?(?![\d,.])/.exec(s);
176
+ if (iso)
177
+ return Date.UTC(+iso[1], +(iso[2] ?? 1) - 1, +(iso[3] ?? 1), +(iso[4] ?? 0), +(iso[5] ?? 0));
178
+ const d = DAY_FIRST.exec(s);
179
+ if (d && monthIndex(d[2]) >= 0)
180
+ return Date.UTC(+d[3], monthIndex(d[2]), +d[1], +(d[4] ?? 0), +(d[5] ?? 0), +(d[6] ?? 0));
181
+ const m = MONTH_FIRST.exec(s);
182
+ if (m && monthIndex(m[1]) >= 0)
183
+ return Date.UTC(+m[3], monthIndex(m[1]), +(m[2] ?? 1));
184
+ return null;
185
+ }
186
+ const MULTIPLIER = {
187
+ k: 1e3, thousand: 1e3, m: 1e6, million: 1e6, b: 1e9, bn: 1e9, billion: 1e9, t: 1e12, trillion: 1e12,
188
+ kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 2 ** 10, mib: 2 ** 20, gib: 2 ** 30, tib: 2 ** 40,
189
+ };
190
+ // The number a cell starts with: "~$3,500", "+137%", "−0.4", "142M XRD",
191
+ // "14.00 million", "3.4 MB". A version string like "1.18.4" is not a number, so a column
192
+ // of versions falls through to the natural text order, where 1.9 comes before 1.10.
193
+ const NUMBER = /^[~≈<>≤≥]?\s*([+\-−]?)\s*[#$€£¥]?\s*(\d[\d,]*(?:\.\d+)?|\.\d+)(?![.\d])(?:\s*(%|(?:thousand|million|billion|trillion|[kmgt]i?b|bn|[kmbt])(?![a-z])))?/i;
194
+ function parseNumber(s) {
195
+ const n = NUMBER.exec(s);
196
+ if (!n)
197
+ return null;
198
+ const value = parseFloat(n[2].replace(/,/g, '')) * (MULTIPLIER[n[3]?.toLowerCase() ?? ''] ?? 1);
199
+ return n[1] && n[1] !== '+' ? -value : value;
200
+ }
201
+ /**
202
+ * The column's sort keys. A column is dates if every filled cell starts with a
203
+ * date, numbers if every one starts with a number, and text otherwise. One
204
+ * stray value makes the whole column text, which is better than sorting it half
205
+ * by one rule and half by another.
206
+ */
207
+ function columnKeys(cells) {
208
+ const blank = cells.map(c => BLANK.test(c));
209
+ for (const parse of [parseDate, parseNumber]) {
210
+ const keys = cells.map((c, i) => (blank[i] ? null : parse(c)));
211
+ if (keys.every((k, i) => k !== null || blank[i]))
212
+ return keys;
213
+ }
214
+ return cells.map((c, i) => (blank[i] ? null : c));
215
+ }
216
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
217
+ function compare(a, b, direction) {
218
+ if (a === null || b === null)
219
+ return a === b ? 0 : a === null ? 1 : -1;
220
+ const order = typeof a === 'number' && typeof b === 'number' ? a - b : collator.compare(String(a), String(b));
221
+ return direction === 'ascending' ? order : -order;
222
+ }
223
+ /**
224
+ * The row whose cells label the columns: the last row of a `<thead>`, or the
225
+ * first row when every cell in it is a `<th>`. The editor writes the second
226
+ * form. An infobox's label/value table has neither form, so it is left alone.
227
+ */
228
+ function headerRow(table) {
229
+ const head = table.tHead?.rows;
230
+ if (head?.length)
231
+ return head[head.length - 1];
232
+ const first = table.rows[0];
233
+ return first && Array.from(first.cells).every(c => c.tagName === 'TH') ? first : undefined;
234
+ }
235
+ /**
236
+ * Make every column-headed table under `root` sortable by its headers, once.
237
+ *
238
+ * Pressing a header sorts by that column. Text sorts A–Z first and numbers and
239
+ * dates largest first, the same as a React table sorted by `useTableSort`. A
240
+ * second press reverses the order and a third restores the author's order,
241
+ * which is often meaningful (chronological, or ranked) and otherwise could only
242
+ * be recovered by a reload.
243
+ *
244
+ * Tables stored in article HTML arrive as a string a `dangerouslySetInnerHTML`
245
+ * wrote, so React never sees their rows and cannot sort them — which is why
246
+ * this is a DOM pass beside `addCopyButtons` rather than a component. The
247
+ * markup it writes, `aria-sort` on the cell and a button inside it, is the
248
+ * markup a React sortable header should write too, so both kinds of table draw
249
+ * their arrows from one stylesheet rule.
250
+ *
251
+ * Skipped: tables with merged cells, where moving a row would break the grid;
252
+ * tables with fewer than two rows to sort; and rows that span several `<tbody>`s.
253
+ */
254
+ export function sortTables(root, options = {}) {
255
+ const { className = 'sort-header' } = options;
256
+ for (const table of Array.from(root.querySelectorAll('table:not([data-sort-init])'))) {
257
+ table.setAttribute('data-sort-init', '');
258
+ const head = headerRow(table);
259
+ if (!head)
260
+ continue;
261
+ const rows = Array.from(table.tBodies).flatMap(b => Array.from(b.rows)).filter(r => r !== head);
262
+ const body = rows[0]?.parentElement;
263
+ if (!body || rows.length < 2 || rows.some(r => r.parentElement !== body))
264
+ continue;
265
+ if (Array.from(table.querySelectorAll('th, td')).some(c => c.colSpan > 1 || c.rowSpan > 1))
266
+ continue;
267
+ const headers = Array.from(head.cells);
268
+ const sortBy = (col, th) => {
269
+ const keys = columnKeys(rows.map(r => r.cells[col]?.textContent?.trim() ?? ''));
270
+ const first = typeof keys.find(k => k !== null) === 'string' ? 'ascending' : 'descending';
271
+ const current = th.getAttribute('aria-sort');
272
+ const next = current === first ? (first === 'ascending' ? 'descending' : 'ascending') : current === 'none' ? first : null;
273
+ for (const h of headers)
274
+ if (h.hasAttribute('aria-sort'))
275
+ h.setAttribute('aria-sort', 'none');
276
+ if (next)
277
+ th.setAttribute('aria-sort', next);
278
+ const order = next
279
+ ? rows.map((row, i) => ({ row, key: keys[i] ?? null })).sort((a, b) => compare(a.key, b.key, next)).map(x => x.row)
280
+ : rows;
281
+ body.append(...order);
282
+ };
283
+ headers.forEach((th, col) => {
284
+ // A header with no label has nothing to press, and a link inside a
285
+ // button would fire both.
286
+ if (!th.textContent?.trim() || th.querySelector('a, button'))
287
+ return;
288
+ const button = document.createElement('button');
289
+ button.type = 'button';
290
+ button.className = className;
291
+ // The editor wraps every cell's text in a <p>, which a button cannot hold.
292
+ const label = th.children.length === 1 && th.firstElementChild?.tagName === 'P' ? th.firstElementChild : th;
293
+ button.append(...Array.from(label.childNodes));
294
+ button.onclick = () => sortBy(col, th);
295
+ th.replaceChildren(button);
296
+ th.scope ||= 'col';
297
+ th.setAttribute('aria-sort', 'none');
298
+ });
299
+ }
300
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -193,6 +193,7 @@
193
193
  "@types/node": "^22.0.0",
194
194
  "@types/react": "^19.0.0",
195
195
  "jose": "^6.2.10",
196
+ "jsdom": "^30.1.0",
196
197
  "react": "^19.2.4",
197
198
  "typescript": "^5.9.0"
198
199
  },
@@ -200,18 +201,18 @@
200
201
  "@radixdlt/rola": ">=2",
201
202
  "@tiptap/core": ">=2.10",
202
203
  "@tiptap/extension-code-block": ">=2.10",
203
- "@tiptap/extension-youtube": ">=2.10",
204
- "@tiptap/react": ">=2.10",
205
- "jose": ">=5",
206
- "react": ">=18",
207
- "@tiptap/starter-kit": ">=2.10",
208
- "@tiptap/extension-link": ">=2.10",
209
204
  "@tiptap/extension-image": ">=2.10",
205
+ "@tiptap/extension-link": ">=2.10",
206
+ "@tiptap/extension-placeholder": ">=2.10",
210
207
  "@tiptap/extension-table": ">=2.10",
211
- "@tiptap/extension-table-row": ">=2.10",
212
208
  "@tiptap/extension-table-cell": ">=2.10",
213
209
  "@tiptap/extension-table-header": ">=2.10",
214
- "@tiptap/extension-placeholder": ">=2.10"
210
+ "@tiptap/extension-table-row": ">=2.10",
211
+ "@tiptap/extension-youtube": ">=2.10",
212
+ "@tiptap/react": ">=2.10",
213
+ "@tiptap/starter-kit": ">=2.10",
214
+ "jose": ">=5",
215
+ "react": ">=18"
215
216
  },
216
217
  "peerDependenciesMeta": {
217
218
  "react": {