Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
release_packages:
description: Optional comma-separated allowlist of packages already selected by release calculation
required: false
type: string

concurrency: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha || github.sha }}

Expand Down Expand Up @@ -71,15 +76,29 @@ jobs:
# commit, so its first parent is the exact already-released baseline.
RELEASE_BASE_SHA: ${{ steps.base.outputs.sha }}

- name: Apply the optional manual package allowlist
id: selected
env:
CALCULATED_PACKAGES: ${{ steps.versions.outputs.publishedPackages }}
RELEASE_PACKAGE_ALLOWLIST: ${{ inputs.release_packages }}
run: |
selected="$CALCULATED_PACKAGES"
if [[ -n "$RELEASE_PACKAGE_ALLOWLIST" ]]; then
requested=$(jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))' <<<"$RELEASE_PACKAGE_ALLOWLIST")
jq -e --argjson requested "$requested" '. as $packages | all($requested[]; . as $wanted | any($packages[]; .name == $wanted))' <<<"$CALCULATED_PACKAGES" >/dev/null
selected=$(jq -c --argjson requested "$requested" '[.[] | select(.name as $name | any($requested[]; . == $name))]' <<<"$CALCULATED_PACKAGES")
fi
echo "publishedPackages=$selected" >> "$GITHUB_OUTPUT"

- name: Publish next patch versions
id: publish
run: node nodejs/scripts/publish-versioned-packages.mjs
env:
RELEASE_PACKAGES: ${{ steps.versions.outputs.publishedPackages }}
RELEASE_PACKAGES: ${{ steps.selected.outputs.publishedPackages }}

- name: Create GitHub Releases with downloadable tarballs
if: steps.publish.outputs.published == 'true'
run: node nodejs/scripts/create-github-releases.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PUBLISHED_PACKAGES: ${{ steps.versions.outputs.publishedPackages }}
PUBLISHED_PACKAGES: ${{ steps.selected.outputs.publishedPackages }}
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@ flags:

ignore:
- nodejs/**/dist/
# Playwright executes this packed-consumer fixture in three browsers; it is
# not production package source and the browser run does not emit V8 coverage.
- "nodejs/browser-test/**/*"
- nodejs/docs/
- python/
11 changes: 11 additions & 0 deletions nodejs/browser-test/src.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async function run(): Promise<void> {
const remarkOutput = processor.stringify(transformed);

document.querySelector("#result")!.textContent = JSON.stringify({
stylesheetMutations: await testStylesheetMutations(),
warichuBrowser: await testWarichuBrowser(),
warichu: layoutMdiWarichu([{ type: "text", value: "一二三四五" }]),
irVersion: parsed.irVersion,
Expand All @@ -87,6 +88,16 @@ async function run(): Promise<void> {
});
}

async function testStylesheetMutations() {
const host=document.createElement('div');document.body.append(host);const adapter=attachMdiWarichuLayout(host);await adapter.settled();
const layoutReady=()=> (window as unknown as {__mdiWarichuLayoutReady:Promise<void>}).__mdiWarichuLayoutReady;const deliver=async()=>{await new Promise<void>(resolve=>setTimeout(resolve,0));};
try {
for(const currentRel of ['icon','preload']) {const link=document.createElement('link');link.rel=currentRel;link.setAttribute('media','stylesheet');document.head.append(link);await deliver();adapter.configure();await adapter.settled();const unchanged=layoutReady();link.setAttribute('media','print');await deliver();if(layoutReady()!==unchanged) throw new Error(`${currentRel} media mutation invalidated warichu`);link.remove();await deliver();}
for(const order of ['rel-remove','remove-rel']) {const link=document.createElement('link');link.rel='stylesheet';document.head.append(link);await deliver();adapter.configure();await adapter.settled();const before=layoutReady();if(order==='rel-remove') {link.rel='preload';link.remove();} else {link.remove();link.rel='preload';}await deliver();if(layoutReady()===before) throw new Error(`Stylesheet to preload removal (${order}) did not invalidate warichu`);await adapter.settled();}
return true;
} finally {adapter.dispose();host.remove();}
}

function canonicalProvenance(document: MdiMdastDocument): unknown {
return {
frontmatter: document.frontmatter?.mdiProvenance ?? null,
Expand Down
8 changes: 7 additions & 1 deletion nodejs/packages/mdi/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# @illusions-lab/mdi

## Unreleased
## 2.0.27

### Patch Changes

- Invalidate automatic warichu layout when stylesheet links change or leave the document head, while ignoring unrelated favicon and preload attribute mutations.

## 2.0.26

- Fix long notes clipping or shrinking during PDF output by measuring the physical printable page and actual glyph advances, including inherited tracking and indentation.

Expand Down
64 changes: 60 additions & 4 deletions nodejs/packages/mdi/src/warichu-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ import {expect,it,vi} from 'vitest';
import {measureMdiWarichu,applyMdiWarichu,attachMdiWarichuLayout} from './warichu-browser.js';
import {layoutMdiWarichu} from './index.js';
function fixture() {
const listeners=new Map<string,()=>void>();const observed=new Set<unknown>();
const listeners=new Map<string,()=>void>();const documentListeners=new Map<string,(event:any)=>void>();const observed=new Set<unknown>();
let observerOptions:any;
let mutation:(records:any[])=>void=()=>{},resize:(entries:any[])=>void=()=>{};
const style={writingMode:'horizontal-tb',direction:'ltr',fontSize:'20px',paddingInlineStart:'0',paddingInlineEnd:'0'};
const note:any={dataset:{mdiWarichuSource:JSON.stringify([{type:'text',value:'一二三四五六'}])},innerHTML:'',querySelectorAll:()=>[],closest:()=>paragraph};
const paragraph:any={clientWidth:200,clientHeight:300,getBoundingClientRect:()=>({left:0,right:200,bottom:300}),contains:(n:unknown)=>n===note,closest:(selector:string)=>selector.startsWith('[data')?null:paragraph,nodeType:1};
let rects:any[]=[{left:0,right:120,bottom:40}];
const win:any={getComputedStyle:()=>style,requestAnimationFrame:(cb:()=>void)=>setTimeout(cb,0),addEventListener:vi.fn(),removeEventListener:vi.fn(),visualViewport:{addEventListener:vi.fn(),removeEventListener:vi.fn()},MutationObserver:class{constructor(cb:any){mutation=cb}observe(){}disconnect(){}},ResizeObserver:class{constructor(cb:any){resize=cb}observe(element:unknown){observed.add(element)}unobserve(element:unknown){observed.delete(element)}disconnect(){observed.clear()}}};
const win:any={getComputedStyle:()=>style,requestAnimationFrame:(cb:()=>void)=>setTimeout(cb,0),addEventListener:vi.fn(),removeEventListener:vi.fn(),visualViewport:{addEventListener:vi.fn(),removeEventListener:vi.fn()},MutationObserver:class{constructor(cb:any){mutation=cb}observe(_target:unknown,options:unknown){observerOptions=options}disconnect(){}},ResizeObserver:class{constructor(cb:any){resize=cb}observe(element:unknown){observed.add(element)}unobserve(element:unknown){observed.delete(element)}disconnect(){observed.clear()}}};
const fonts={ready:Promise.resolve(),addEventListener:(name:string,cb:()=>void)=>listeners.set(name,cb),removeEventListener:vi.fn()};
const doc:any={defaultView:win,fonts,documentElement:{},createRange:()=>({selectNodeContents(){},setEndBefore(){},getClientRects:()=>rects})};
const headTargets=new Set<unknown>();
const head:any={nodeType:1,closest:()=>null,contains:(target:unknown)=>headTargets.has(target)};
const doc:any={defaultView:win,fonts,documentElement:{},head,addEventListener:(name:string,cb:(event:any)=>void)=>documentListeners.set(name,cb),removeEventListener:vi.fn((name:string)=>documentListeners.delete(name)),createRange:()=>({selectNodeContents(){},setEndBefore(){},getClientRects:()=>rects})};
const paragraphs=[paragraph];
const container:any={ownerDocument:doc,querySelectorAll:(selector:string)=>selector.startsWith('[data')?[note]:paragraphs,contains:(n:unknown)=>n===note||n===paragraph};
note.ownerDocument=doc;note.parentElement=paragraph;
return {container,note,paragraph,paragraphs,observed,style,win,fonts,rects:(value:any[])=>rects=value,mutation:(records:any[])=>mutation(records),resize:(entries:any[])=>resize(entries),listeners};
return {container,note,paragraph,paragraphs,observed,style,win,fonts,head,headTargets,rects:(value:any[])=>rects=value,mutation:(records:any[])=>mutation(records),resize:(entries:any[])=>resize(entries),listeners,documentListeners,observerOptions:()=>observerOptions};
}
it('measures remaining capacity in the containing realm and writing direction',()=>{
const f=fixture();
Expand Down Expand Up @@ -43,6 +46,59 @@ it('settles queued mutations, resize, fonts and disposal without touching source
f.listeners.get('loadingdone')!();await adapter.settled();
adapter.dispose();adapter.configure();expect(f.fonts.removeEventListener).toHaveBeenCalled();
});
it('invalidates head styles, stylesheet tree changes, link attributes and stylesheet loads',async()=>{
const f=fixture();const adapter=attachMdiWarichuLayout(f.container);await adapter.settled();
const expectFreshReady=async(record:any)=>{const previous=f.win.__mdiWarichuLayoutReady;f.mutation([record]);expect(f.win.__mdiWarichuLayoutReady).not.toBe(previous);await adapter.settled();};
const style:any={nodeType:1,tagName:'STYLE',parentElement:null,closest:()=>style,getAttribute:()=>null,querySelectorAll:()=>[]};f.headTargets.add(style);
await expectFreshReady({type:'characterData',target:{nodeType:3,parentElement:style},addedNodes:[],removedNodes:[]});
const link:any={nodeType:1,tagName:'LINK',rel:'stylesheet',parentElement:null,closest:()=>link,getAttribute:(name:string)=>name==='rel'?link.rel:null,querySelectorAll:()=>[],matches:(selector:string)=>selector==='link[rel~="stylesheet"]'};
await expectFreshReady({type:'childList',target:f.head,addedNodes:[link],removedNodes:[]});
await expectFreshReady({type:'childList',target:f.head,addedNodes:[],removedNodes:[link]});
f.headTargets.add(link);
for(const attributeName of ['href','media','disabled']) await expectFreshReady({type:'attributes',target:link,attributeName,oldValue:null,addedNodes:[],removedNodes:[]});
let previous=f.win.__mdiWarichuLayoutReady;f.documentListeners.get('load')!({target:link});expect(f.win.__mdiWarichuLayoutReady).not.toBe(previous);await adapter.settled();
adapter.dispose();expect(f.container.ownerDocument.removeEventListener).toHaveBeenCalledWith('load',expect.any(Function),true);
});
it('associates nested removed head trees and tolerates unrelated mutation shapes',async()=>{
const f=fixture();const adapter=attachMdiWarichuLayout(f.container);await adapter.settled();
const descendant:any={nodeType:1,tagName:'LINK',getAttribute:(name:string)=>name==='rel'?'preload STYLESHEET':null};
const linkWithoutRel:any={nodeType:1,tagName:'LINK',getAttribute:()=>null,querySelectorAll:()=>[]};
const textNode:any={nodeType:3};
const wrapper:any={nodeType:1,tagName:'DIV',parentElement:null,closest:()=>null,getAttribute:()=>null,querySelectorAll:()=>[descendant],contains:(node:unknown)=>node===descendant};
const previous=f.win.__mdiWarichuLayoutReady;
f.mutation([
{type:'childList',target:{nodeType:1,contains:()=>false},addedNodes:[],removedNodes:[]},
{type:'childList',target:wrapper,addedNodes:[descendant],removedNodes:[]},
{type:'childList',target:f.head,addedNodes:[textNode,linkWithoutRel],removedNodes:[wrapper]},
]);
expect(f.win.__mdiWarichuLayoutReady).not.toBe(previous);await adapter.settled();
f.mutation([{type:'attributes',target:{nodeType:1,contains:(node:unknown)=>node===f.container,closest:()=>null},attributeName:'class',oldValue:null,addedNodes:[],removedNodes:[]}]);await adapter.settled();
const unchanged=f.win.__mdiWarichuLayoutReady;f.documentListeners.get('load')!({target:{matches:()=>false}});expect(f.win.__mdiWarichuLayoutReady).toBe(unchanged);
adapter.dispose();
});
it('invalidates stylesheet mutations in head, including rel and removal records in either order',async()=>{
const f=fixture();const adapter=attachMdiWarichuLayout(f.container);await adapter.settled();
const makeLink=(rel:string)=>{const link:any={nodeType:1,tagName:'LINK',rel,parentElement:null,closest:()=>link,getAttribute:(name:string)=>name==='rel'?link.rel:null,querySelectorAll:()=>[]};return link;};
for(const order of ['rel-remove','remove-rel']) {
const link=makeLink(order==='rel-remove'?'preload':'stylesheet');
const rel={type:'attributes',target:link,attributeName:'rel',oldValue:order==='rel-remove'?'stylesheet':'preload',addedNodes:[],removedNodes:[]};
const removal={type:'childList',target:f.head,addedNodes:[],removedNodes:[link]};
const previous=f.win.__mdiWarichuLayoutReady;f.mutation(order==='rel-remove'?[rel,removal]:[removal,rel]);
expect(f.win.__mdiWarichuLayoutReady).not.toBe(previous);await adapter.settled();
}
expect(f.observerOptions()).toEqual(expect.objectContaining({attributeOldValue:true}));
adapter.dispose();
});
it('ignores favicon and preload non-rel attributes even when oldValue is stylesheet',async()=>{
const f=fixture();const adapter=attachMdiWarichuLayout(f.container);await adapter.settled();
for(const currentRel of ['icon','preload']) {
const link:any={nodeType:1,tagName:'LINK',parentElement:null,closest:()=>link,getAttribute:(name:string)=>name==='rel'?currentRel:null,querySelectorAll:()=>[]};f.headTargets.add(link);
const ready=f.win.__mdiWarichuLayoutReady;
for(const attributeName of ['href','media','disabled']) f.mutation([{type:'attributes',target:link,attributeName,oldValue:'stylesheet',addedNodes:[],removedNodes:[]}]);
expect(f.win.__mdiWarichuLayoutReady).toBe(ready);
}
adapter.dispose();
});
it('settled rejects pending font timeout and cancellation',async()=>{
const f=fixture();f.fonts.ready=new Promise(()=>{});const adapter=attachMdiWarichuLayout(f.container);
await expect(adapter.settled({timeoutMs:2})).rejects.toThrow('timed out');
Expand Down
41 changes: 38 additions & 3 deletions nodejs/packages/mdi/src/warichu-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,44 @@ export function attachMdiWarichuLayout(container:HTMLElement):MdiWarichuLayoutCo
try {await Promise.race([new Promise<void>(resolve=>{id=win.requestAnimationFrame(()=>resolve());}),disposal]);}
finally {win.cancelAnimationFrame?.(id);}
};
const observe = () => mutation.observe(container.ownerDocument.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['style','class','dir','data-mdi-warichu-source']});
const observe = () => mutation.observe(container.ownerDocument.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeOldValue:true,attributeFilter:['style','class','dir','data-mdi-warichu-source','href','rel','media','disabled']});
const mutation = new realm.MutationObserver(records=>{
const head=container.ownerDocument.head;
const headTrees=new Set<Node>();
const isInTree=(node:Node|null|undefined,tree:Node)=>node===tree || (tree.nodeType===1 && typeof (tree as Element).contains==='function' && (tree as Element).contains(node ?? null));
// Attribute records can point at a node which has already left <head>. Associate
// every added or removed tree with its head mutation before inspecting records,
// independently of the order chosen by the browser for this delivery.
let found=true;
while(found) {
found=false;
for(const record of records) {
if(record.type!=='childList') continue;
const targetInHead=record.target===head || head?.contains(record.target) || Array.from(headTrees).some(tree=>isInTree(record.target,tree));
if(!targetInHead) continue;
for(const node of [...Array.from(record.addedNodes),...Array.from(record.removedNodes)]) if(!headTrees.has(node)) {headTrees.add(node);found=true;}
}
}
const isHeadMutation=(node:Node|null|undefined)=>Boolean(node && (node===head || head?.contains(node) || Array.from(headTrees).some(tree=>isInTree(node,tree))));
const stylesheetRel=(rel:string|null|undefined)=>rel?.split(/\s+/).some(value=>value.toLowerCase()==='stylesheet') ?? false;
const stylesheetInTree=(node:Node)=>{
const elements=node.nodeType===1 ? [node as Element,...Array.from((node as Element).querySelectorAll?.('style,link') ?? [])] : [];
return elements.some(element=>element.tagName==='STYLE' || (element.tagName==='LINK' && stylesheetRel(element.getAttribute('rel'))));
};
for(const record of records) {
if(!container.contains(record.target) && !record.target.contains(container)) continue;
const element=record.target.nodeType===1?record.target as HTMLElement:record.target.parentElement;
const candidate=element?.closest?.('style,link');
const stylesheetMutation=(isHeadMutation(record.target) || isHeadMutation(element)) && (
candidate?.tagName==='STYLE' ||
(record.type==='attributes' && candidate?.tagName==='LINK' && (
record.attributeName==='rel'
? stylesheetRel(candidate.getAttribute('rel')) || stylesheetRel(record.oldValue)
: ['href','media','disabled'].includes(record.attributeName ?? '') && stylesheetRel(candidate.getAttribute('rel'))
)) ||
(record.type==='childList' && [...Array.from(record.addedNodes),...Array.from(record.removedNodes)].some(stylesheetInTree))
);
if(stylesheetMutation) {invalidate();continue;}
if(!container.contains(record.target) && !(typeof record.target.contains==='function' && record.target.contains(container))) continue;
const paragraph=element?.closest<HTMLElement>('p,li,td,th,h1,h2,h3,h4,h5,h6');
invalidate(paragraph && container.contains(paragraph)?paragraph:undefined);
}
Expand Down Expand Up @@ -137,6 +170,8 @@ export function attachMdiWarichuLayout(container:HTMLElement):MdiWarichuLayoutCo
syncObservedParagraphs();
observe();
container.ownerDocument.fonts.addEventListener('loadingdone',configure);
const stylesheetLoad=(event:Event)=>{const target=event.target as Element|null;if(target?.matches?.('link[rel~="stylesheet"]')) configure();};
container.ownerDocument.addEventListener('load',stylesheetLoad,true);
win.addEventListener('resize',configure);
win.visualViewport?.addEventListener('resize',configure);
configure();
Expand All @@ -148,5 +183,5 @@ export function attachMdiWarichuLayout(container:HTMLElement):MdiWarichuLayoutCo
signal?.addEventListener('abort',abort,{once:true});
const latest=async()=>{let current;do {current=ready;await Promise.race([current,disposal]);} while(current!==ready);};
latest().then(()=>finish(),finish);
}),dispose:()=>{disposed=true;rejectDisposal(new Error('Warichu layout disposed'));resize.disconnect();mutation.disconnect();container.ownerDocument.fonts.removeEventListener('loadingdone',configure);win.removeEventListener('resize',configure);win.visualViewport?.removeEventListener('resize',configure);}};
}),dispose:()=>{disposed=true;rejectDisposal(new Error('Warichu layout disposed'));resize.disconnect();mutation.disconnect();container.ownerDocument.fonts.removeEventListener('loadingdone',configure);container.ownerDocument.removeEventListener('load',stylesheetLoad,true);win.removeEventListener('resize',configure);win.visualViewport?.removeEventListener('resize',configure);}};
}
1 change: 1 addition & 0 deletions nodejs/scripts/test-browser-wasm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ async function testPage(browserName, page, url, getNodeProjection, parseNodeMdas
assert.equal(result.error, undefined, `${browserName}: ${result.error}`);
assert.equal(result.irVersion, "1.0", browserName);
assert.equal(result.projectionVersion, "1.0", browserName);
assert.equal(result.stylesheetMutations, true, `${browserName}: stylesheet mutation invalidation`);
assert.deepEqual(result.warichu[0].widths, [6, 4], browserName);
const geometry=result.warichuBrowser;
assert(geometry.preserved && geometry.wraps && geometry.resized, `${browserName}: adaptive warichu preserves source through wrapping and resizing`);
Expand Down
Loading