wiki-formant 0.79.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
@@ -693,7 +693,7 @@ export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a name
693
693
  addCopyButtons(el); // every <pre> gets one, once
694
694
  sortTables(el); // every column-headed table sorts by its headers
695
695
  hydrateTweetEmbeds(el); // placeholders get a live src
696
- const off = onTweetResize(h => sizeTweetEmbeds(el, h));
696
+ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
697
697
  ```
698
698
 
699
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.
@@ -706,7 +706,7 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
706
706
 
707
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`.
708
708
 
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.
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.
710
710
 
711
711
  ## Search
712
712
 
@@ -799,7 +799,7 @@ The bytes say what the file is, never the name or type, and SVG is refused. Ever
799
799
 
800
800
  ## Editor nodes
801
801
 
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`. `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.
803
803
 
804
804
  ```ts
805
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
  }
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.79.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",