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 +3 -0
- package/dist/dom.d.ts +24 -0
- package/dist/dom.js +135 -0
- package/package.json +10 -9
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.
|
|
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-
|
|
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": {
|