wiki-formant 0.78.0 → 0.80.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
@@ -675,6 +675,16 @@ const block = licenseBlock({ license, scope: 'The protocol compilation and prose
675
675
 
676
676
  The same `License` goes to `agentCard({ license, licenseScope })` and to `openApiLicense(license)` for a spec's `info.license`. The three cards and three specs here had each projected it differently, one from a hand-typed name.
677
677
 
678
+ ## The X account
679
+
680
+ A site names its X account in five places: the footer link, the JSON-LD `sameAs`, llms.txt, AGENTS.md and the MCP `initialize` instructions. Written by hand, one repo's `sameAs` still pointed at twitter.com, and only one site told an agent the account existed. `wiki-formant/social` builds all five from the handle and a fragment saying what the account posts.
681
+
682
+ ```ts
683
+ export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a named trial's prescription with its citation" });
684
+ ```
685
+
686
+ `X.url` goes to `sameAs`, `X.handle` to `sitePageMetadata`, `followLines(X)` into llms.txt and AGENTS.md, and `followNote(X)` into the MCP instructions. The note tells an agent to give the link to the person it is helping, since an agent cannot follow an account. The footer is `<a {...followLinkProps(X)}>{X.label}</a>`, whose `rel="me"` confirms the `sameAs` claim from the page the account points back at.
687
+
678
688
  ## Rendered-article passes
679
689
 
680
690
  `wiki-formant/dom` holds what runs against an article element after it is in the document. Not React, so not in `react.tsx`.
@@ -683,7 +693,7 @@ The same `License` goes to `agentCard({ license, licenseScope })` and to `openAp
683
693
  addCopyButtons(el); // every <pre> gets one, once
684
694
  sortTables(el); // every column-headed table sorts by its headers
685
695
  hydrateTweetEmbeds(el); // placeholders get a live src
686
- const off = onTweetResize(h => sizeTweetEmbeds(el, h));
696
+ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
687
697
  ```
688
698
 
689
699
  `addCopyButton` was **byte-identical** in two BlockRenderers, down to the SVG path data. Its idempotence guard now lives inside the function rather than in a `pre:not(:has(…))` at the call site, where it can be — and was — retyped.
@@ -696,7 +706,7 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
696
706
 
697
707
  `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once. `articleHtml(html, { used, slug, anchor, links })` (`wiki-formant/headings`) is that chain: heading ids, the scroll boxes, and, given `links`, `normaliseLinks`.
698
708
 
699
- The embed's 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.
709
+ The embed's 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. Every embed on a page posts to the same listener, so `onTweetResize` hands over the window that posted, and `sizeTweetEmbed` sizes only that one's iframe: two posts on a page had both been drawn at whichever height arrived last.
700
710
 
701
711
  ## Search
702
712
 
@@ -789,7 +799,7 @@ The bytes say what the file is, never the name or type, and SVG is refused. Ever
789
799
 
790
800
  ## Editor nodes
791
801
 
792
- `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
802
+ `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `embedNodes({ resolveMapUrl })` is the first four as one list, which an editor puts in its `nodes`: a pasted YouTube, X or map link becomes its player as it lands, and the toolbar's embed prompt has a node for every link it recognises. `resolveMapUrl` defaults to `wiki-formant/maps`', through `/api/resolve-map`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
793
803
 
794
804
  ```ts
795
805
  const CodeBlock = createCodeBlock({
package/dist/base.css CHANGED
@@ -432,8 +432,8 @@ pre:hover > .code-copy-btn,
432
432
  .stats-count { font-variant-numeric: tabular-nums; }
433
433
 
434
434
  /* ---- embeds ------------------------------------------------------------------
435
- The players the `wiki-formant/tiptap` nodes store, filling their column at a
436
- screen's proportions. The node writes a width and height onto the iframe,
435
+ The players the `wiki-formant/tiptap` nodes store, a video or map filling its
436
+ column at a screen's proportions. The node writes a width and height onto the iframe,
437
437
  which `aspect-ratio` with `height: auto` overrides. Spacing, corners,
438
438
  borders and the editor's selection ring are the site's. */
439
439
  :is(div[data-youtube-video], .map-embed) iframe {
@@ -442,3 +442,12 @@ pre:hover > .code-copy-btn,
442
442
  height: auto;
443
443
  aspect-ratio: 16 / 9;
444
444
  }
445
+
446
+ /* A post on X at the width X lays it out for, as tall as it says it is
447
+ (`onTweetResize`), and a little before it has said. */
448
+ .twitter-embed iframe {
449
+ display: block;
450
+ width: 550px;
451
+ max-width: 100%;
452
+ min-height: 200px;
453
+ }
package/dist/dom.d.ts CHANGED
@@ -20,13 +20,15 @@ export declare function addCopyButtons(root: ParentNode, options?: CopyButtonOpt
20
20
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
21
21
  export declare const tweetEmbedSrc: (tweetId: string | number) => string;
22
22
  /**
23
- * Subscribe to the height the embed posts back once it has laid itself out.
23
+ * Subscribe to the height an embed posts back once it has laid itself out,
24
+ * with the window that posted it.
24
25
  *
25
26
  * The iframe renders at an unknown height and there is no other way to learn
26
- * it. The caller decides which iframes the height applies to; this only owns
27
- * the origin check and the message shape. Returns a disposer.
27
+ * it. Every embed on the page posts to the same listener, so the source is how
28
+ * a height finds its own iframe. This only owns the origin check and the
29
+ * message shape. Returns a disposer.
28
30
  */
29
- export declare function onTweetResize(resize: (height: number) => void): () => void;
31
+ export declare function onTweetResize(resize: (height: number, source: MessageEventSource | null) => void): () => void;
30
32
  /**
31
33
  * Point every un-hydrated `[data-twitter-embed]` placeholder under `root` at
32
34
  * the real embed URL.
@@ -36,8 +38,8 @@ export declare function onTweetResize(resize: (height: number) => void): () => v
36
38
  * of the same content does not reload every embed on the page.
37
39
  */
38
40
  export declare function hydrateTweetEmbeds(root: ParentNode): void;
39
- /** Apply a measured height to every embed iframe under `root`. */
40
- export declare function sizeTweetEmbeds(root: ParentNode, height: number): void;
41
+ /** Apply a posted height to the embed iframe under `root` that posted it, and to no other. */
42
+ export declare function sizeTweetEmbed(root: ParentNode, height: number, source: MessageEventSource | null): void;
41
43
  export interface TabGroupClassNames {
42
44
  list?: string;
43
45
  panels?: string;
package/dist/dom.js CHANGED
@@ -63,11 +63,13 @@ export const TWITTER_ORIGIN = 'https://platform.twitter.com';
63
63
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
64
64
  export const tweetEmbedSrc = (tweetId) => `${TWITTER_ORIGIN}/embed/Tweet.html?id=${tweetId}&dnt=true`;
65
65
  /**
66
- * Subscribe to the height the embed posts back once it has laid itself out.
66
+ * Subscribe to the height an embed posts back once it has laid itself out,
67
+ * with the window that posted it.
67
68
  *
68
69
  * The iframe renders at an unknown height and there is no other way to learn
69
- * it. The caller decides which iframes the height applies to; this only owns
70
- * the origin check and the message shape. Returns a disposer.
70
+ * it. Every embed on the page posts to the same listener, so the source is how
71
+ * a height finds its own iframe. This only owns the origin check and the
72
+ * message shape. Returns a disposer.
71
73
  */
72
74
  export function onTweetResize(resize) {
73
75
  const handleMessage = (e) => {
@@ -78,7 +80,7 @@ export function onTweetResize(resize) {
78
80
  return;
79
81
  const height = data.params?.[0]?.height;
80
82
  if (height)
81
- resize(height);
83
+ resize(height, e.source);
82
84
  };
83
85
  window.addEventListener('message', handleMessage);
84
86
  return () => window.removeEventListener('message', handleMessage);
@@ -104,10 +106,11 @@ export function hydrateTweetEmbeds(root) {
104
106
  }
105
107
  }
106
108
  }
107
- /** Apply a measured height to every embed iframe under `root`. */
108
- export function sizeTweetEmbeds(root, height) {
109
+ /** Apply a posted height to the embed iframe under `root` that posted it, and to no other. */
110
+ export function sizeTweetEmbed(root, height, source) {
109
111
  for (const iframe of Array.from(root.querySelectorAll('[data-twitter-embed] iframe'))) {
110
- iframe.style.height = `${height}px`;
112
+ if (iframe.contentWindow === source)
113
+ iframe.style.height = `${height}px`;
111
114
  }
112
115
  }
113
116
  /**
package/dist/react.d.ts CHANGED
@@ -485,7 +485,7 @@ export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
485
485
  * idempotent, so running on every `deps` change is safe.
486
486
  */
487
487
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
488
- /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
488
+ /** Tweet placeholders hydrated, and each iframe kept at the height it posts. */
489
489
  export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
490
490
  /**
491
491
  * Counts the page views of the app it is mounted in: place it once, in the root
package/dist/react.js CHANGED
@@ -26,7 +26,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
26
26
  // state on <html> before first paint. The hook keeps that attribute in
27
27
  // sync afterwards, so CSS has one source of truth either side of hydration.
28
28
  import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
29
- import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
29
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbed, sortTables, startBeacon } from './dom.js';
30
30
  import { comboboxAria } from './combobox.js';
31
31
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
32
32
  const readStored = (key) => {
@@ -899,14 +899,14 @@ export function useArticlePasses(ref, deps) {
899
899
  // eslint-disable-next-line react-hooks/exhaustive-deps
900
900
  }, deps);
901
901
  }
902
- /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
902
+ /** Tweet placeholders hydrated, and each iframe kept at the height it posts. */
903
903
  export function useTweetEmbeds(ref, deps) {
904
904
  useEffect(() => {
905
905
  const root = ref.current;
906
906
  if (!root)
907
907
  return;
908
908
  hydrateTweetEmbeds(root);
909
- return onTweetResize(height => sizeTweetEmbeds(root, height));
909
+ return onTweetResize((height, source) => sizeTweetEmbed(root, height, source));
910
910
  // eslint-disable-next-line react-hooks/exhaustive-deps
911
911
  }, deps);
912
912
  }
@@ -0,0 +1,34 @@
1
+ export interface XAccount {
2
+ /** `@handle`, as the social cards credit it. */
3
+ handle: string;
4
+ /** `https://x.com/<handle>`, for `sameAs` and every link. */
5
+ url: string;
6
+ /** "<Site> posts on X as @handle: <posts>." */
7
+ about: string;
8
+ /** The footer link's text: "Follow @handle on X". */
9
+ label: string;
10
+ siteName: string;
11
+ }
12
+ export declare function xAccount(o: {
13
+ siteName: string;
14
+ handle: string;
15
+ posts: string;
16
+ }): XAccount;
17
+ /**
18
+ * The sentence an agent reads: what the account posts, and when to pass the
19
+ * link on. An agent cannot follow an account; the person it is helping can.
20
+ */
21
+ export declare function followNote(account: XAccount): string;
22
+ /** The same note as a section, ready to splice into llms.txt or AGENTS.md. */
23
+ export declare function followLines(account: XAccount, heading?: string): string[];
24
+ /**
25
+ * Attributes for the footer link. `rel="me"` is the reciprocal of `sameAs`:
26
+ * the schema claims the account and this link confirms it from the page the
27
+ * account points back at.
28
+ */
29
+ export declare function followLinkProps(account: XAccount): {
30
+ readonly href: string;
31
+ readonly target: "_blank";
32
+ readonly rel: "me noopener";
33
+ readonly title: string;
34
+ };
package/dist/social.js ADDED
@@ -0,0 +1,41 @@
1
+ // social.ts – the site's X account, as every surface states it.
2
+ //
3
+ // Each origin names its account in five places: the footer link, the JSON-LD
4
+ // `sameAs`, llms.txt, AGENTS.md and the MCP `initialize` instructions. Written
5
+ // by hand, they drifted within one repo: radix-wiki's `sameAs` still claimed
6
+ // twitter.com while its cards credited the handle, and only acuiq.com told an
7
+ // agent the account existed at all.
8
+ //
9
+ // What is per-site is the handle and what the account posts. The URL, the
10
+ // link's label and `rel`, and the sentence an agent reads are built here.
11
+ export function xAccount(o) {
12
+ if (!/^@\w{1,15}$/.test(o.handle))
13
+ throw new Error(`xAccount: "${o.handle}" is not an @handle`);
14
+ const posts = o.posts.trim().replace(/\.$/, '');
15
+ return {
16
+ handle: o.handle,
17
+ url: `https://x.com/${o.handle.slice(1)}`,
18
+ about: `${o.siteName} posts on X as ${o.handle}: ${posts}.`,
19
+ label: `Follow ${o.handle} on X`,
20
+ siteName: o.siteName,
21
+ };
22
+ }
23
+ /**
24
+ * The sentence an agent reads: what the account posts, and when to pass the
25
+ * link on. An agent cannot follow an account; the person it is helping can.
26
+ */
27
+ export function followNote(account) {
28
+ return `${account.about} If the person you are helping wants to follow ${account.siteName}, give them ${account.url}.`;
29
+ }
30
+ /** The same note as a section, ready to splice into llms.txt or AGENTS.md. */
31
+ export function followLines(account, heading = `Follow ${account.siteName} on X`) {
32
+ return [`## ${heading}`, '', followNote(account)];
33
+ }
34
+ /**
35
+ * Attributes for the footer link. `rel="me"` is the reciprocal of `sameAs`:
36
+ * the schema claims the account and this link confirms it from the page the
37
+ * account points back at.
38
+ */
39
+ export function followLinkProps(account) {
40
+ return { href: account.url, target: '_blank', rel: 'me noopener', title: account.about };
41
+ }
package/dist/tiptap.d.ts CHANGED
@@ -29,6 +29,14 @@ export interface MapEmbedOptions {
29
29
  * node has to already exist for the reader to see anything happen.
30
30
  */
31
31
  export declare function createMapEmbed({ resolveMapUrl }: MapEmbedOptions): TiptapNode<any, any>;
32
+ /**
33
+ * Every player a pasted link becomes – a YouTube video, a post on X, a Google
34
+ * or Apple map – and any other allowed iframe, as one list. Each app had
35
+ * picked its embeds by hand, and one that left `TwitterEmbed` out kept a
36
+ * pasted post as a bare link. `resolveMapUrl` follows a shortened map link,
37
+ * through `/api/resolve-map` unless an app routes it another way.
38
+ */
39
+ export declare const embedNodes: ({ resolveMapUrl }?: Partial<MapEmbedOptions>) => TiptapNode<any, any>[];
32
40
  export interface CodeBlockClassNames {
33
41
  wrapper?: string;
34
42
  /** The control cluster, positioned over the block. */
package/dist/tiptap.js CHANGED
@@ -18,7 +18,7 @@ import TiptapYoutube from '@tiptap/extension-youtube';
18
18
  import TiptapCodeBlock from '@tiptap/extension-code-block';
19
19
  import { onTweetResize, tweetEmbedSrc } from './dom.js';
20
20
  import { DEFAULT_IFRAME_HOSTS } from './headers.js';
21
- import { toMapEmbedUrl } from './maps.js';
21
+ import { resolveMapUrl as resolveMapRoute, toMapEmbedUrl } from './maps.js';
22
22
  import { useClickOutside } from './react.js';
23
23
  import { cx } from './html.js';
24
24
  import { slugifyHeading, uniqueHeadingId } from './headings.js';
@@ -78,10 +78,10 @@ export const YouTube = TiptapYoutube.extend({
78
78
  function TwitterEmbedView({ node }) {
79
79
  const containerRef = useRef(null);
80
80
  // The embed posts its measured height back; `onTweetResize` owns the origin
81
- // check.
82
- useEffect(() => onTweetResize(height => {
81
+ // check, and the source keeps one tweet's height off every other.
82
+ useEffect(() => onTweetResize((height, source) => {
83
83
  const iframe = containerRef.current?.querySelector('iframe');
84
- if (iframe)
84
+ if (iframe && iframe.contentWindow === source)
85
85
  iframe.style.height = `${height}px`;
86
86
  }), []);
87
87
  return (_jsx(NodeViewWrapper, { children: _jsx("div", { ref: containerRef, className: "twitter-embed", children: _jsx("iframe", { src: tweetEmbedSrc(node.attrs.tweetId), scrolling: "no", allowFullScreen: true }) }) }));
@@ -231,6 +231,20 @@ export function createMapEmbed({ resolveMapUrl }) {
231
231
  },
232
232
  });
233
233
  }
234
+ // ---- every embed ------------------------------------------------------------
235
+ /**
236
+ * Every player a pasted link becomes – a YouTube video, a post on X, a Google
237
+ * or Apple map – and any other allowed iframe, as one list. Each app had
238
+ * picked its embeds by hand, and one that left `TwitterEmbed` out kept a
239
+ * pasted post as a bare link. `resolveMapUrl` follows a shortened map link,
240
+ * through `/api/resolve-map` unless an app routes it another way.
241
+ */
242
+ export const embedNodes = ({ resolveMapUrl = resolveMapRoute } = {}) => [
243
+ YouTube,
244
+ Iframe,
245
+ TwitterEmbed,
246
+ createMapEmbed({ resolveMapUrl }),
247
+ ];
234
248
  /** A code block whose language is pickable from a dropdown. */
235
249
  export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG, classNames = {}, icons = {}, } = {}) {
236
250
  function CodeBlockView({ node, updateAttributes, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.78.0",
3
+ "version": "0.80.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, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -147,6 +147,11 @@
147
147
  "import": "./dist/license.js",
148
148
  "default": "./dist/license.js"
149
149
  },
150
+ "./social": {
151
+ "types": "./dist/social.d.ts",
152
+ "import": "./dist/social.js",
153
+ "default": "./dist/social.js"
154
+ },
150
155
  "./links": {
151
156
  "types": "./dist/links.d.ts",
152
157
  "import": "./dist/links.js",