From 8ee98952476256fc88f75b109b7f716a95a71adf Mon Sep 17 00:00:00 2001 From: commilitia Date: Thu, 16 Jul 2026 16:42:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(levelmark):=20=E5=A1=8A=E7=B4=9A=E5=88=86?= =?UTF-8?q?=E9=A1=9E=20CSS=20=E9=89=A4=E5=AD=90=20data-jz-block-level?= =?UTF-8?q?=EF=BC=8B=E5=A2=9E=E9=87=8F=E5=A1=8A=E7=B2=92=E5=BA=A6=E5=A5=91?= =?UTF-8?q?=E7=B4=84=EF=BC=88=E4=B8=8B=E6=B8=B8=E5=BB=BA=E8=AD=B0=E4=B8=83?= =?UTF-8?q?=EF=BC=8F=E8=AA=AA=E6=98=8E=E5=85=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建議七:新增 levelMark pass(standalone,order 10,恆開)——render 時把 resolved 功能級別寫回「直接承載可處理文本」之塊級元素:data-jz-block-level="paragraph|text", revert 全數移除(I6 之 data-jz-* 清單涵蓋)。消費端段落級 CSS(首要 text-align: justify)自此與 jinze/orphan 共用單一真相源,免另立必然漂移之選擇器白名單。 - 僅標直接承載文本之塊:純容器(body/ul/table…,直接文本僅空白)不標——否則 body 恆判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass 實際 作用之塊集。avoid 子樹/pill 內部文本不計,未被處理之塊一致不標。 - 輸入/輸出分離:作者輸入 data-jz-level 只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 OBSERVE_ATTRS 白名單(外部改動不觸發);增量路徑 revert 即清、render 即重標。 - 實現:一次 eachTextNode 走訪+短程上攀定塊(per-run 快取),O(文本);levelFor 已記憶化。上攀止於 ctx.root,不越界外標。 說明八:README/ARCHITECTURE §3.1 明載增量粒度契約——增量須以塊為粒度(換塊 內容、增刪塊、改分類屬性皆與全量重繪一致);不支持對聚珍已切分文本節點之外科式 characterData 直改(charify 以 fragment 替換原節點,舊引用已脫離文檔)——後處理 排版器之固有限制、非缺陷,以塊為單位替換內容即可。 測試 140→145(標記/純容器不標、text 解析三路(選擇器/屬性/祖先)、avoid/ pill 不標、revert 全清含 root、C2 class 翻轉自動刷新);tsc 聲明構建通過; headless Chrome 整管線煙測零報錯、段落屬性實際落 DOM。 --- ARCHITECTURE.md | 20 +++++++++ README.md | 38 ++++++++++++++++ dist/juzhen.iife.js | 60 +++++++++++++++++++++++++- dist/juzhen.mjs | 60 +++++++++++++++++++++++++- dist/typeset/levelmark.d.ts | 2 + src/index.ts | 7 +-- src/typeset/levelmark.ts | 77 +++++++++++++++++++++++++++++++++ test/juzhen.test.mjs | 86 +++++++++++++++++++++++++++++++++++++ 8 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 dist/typeset/levelmark.d.ts create mode 100644 src/typeset/levelmark.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17fb5fb..16f57cd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,6 +92,14 @@ jz.disconnect(); // 停止觀察 **暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數 百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。 +**增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/ +`innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍 +已切分之文本節點做外科式 `characterData` 直改(如持有 render **前**之 `p.firstChild` 引用、 +事後直改其 `.data`):charify 以 fragment **替換**原文本節點(§3.4),舊引用已脫離文檔或僅指 +碎片,直改令 `revert` 合併出之內容偏離作者意圖。此為後處理排版器之固有限制、非缺陷——聚珍對 +當前 DOM 之處理始終自洽;外科式文本 patch 之框架(vdom 等)須以塊為單位替換,或對受管區域 +`revert` 後再 patch。 + `options` 形如: ``` @@ -654,6 +662,18 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j **與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging 註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。 +**Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要 +`text-align:justify`)語義上與段落級 pass 應作用於**同一集合**,但 level 判定原僅存內部快取, +消費端只能另立一份選擇器白名單、兩份分類必然漂移(實例:`.callout` 綁了避頭尾卻不在 justify +名單)。`levelMark` pass(standalone,order 10,恆開)於 render 時把 resolved 級別寫回塊級元素: +`data-jz-block-level="paragraph|text"`,`revert` 全數移除(I6)。**僅標「直接承載可處理文本」 +之塊**(存在非空白文本節點、其最近塊祖先即該塊、非 avoid/pill 內/域外)——純容器(body/ul +/table…)不標,否則 body 恆被判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass +實際作用之塊集(`collectRuns` 對純容器收不到 run)。**輸入/輸出分離**:作者輸入 `data-jz-level` +只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 `OBSERVE_ATTRS` 白名單(外部改動不觸發重處理); +增量路徑(rerender/observe)revert 即清、render 即重標,天然刷新。本庫只供分類、不介入樣式 +(justify 非段落之純函數——窄欄邊注可 opt-out,由消費端 CSS 決定)。 + #### 6.5.0 四機制之分工與邊界(核心約定,實作前必讀) | 機制 | 作用層 | 唯一職責 | 手段 | 狀態 | diff --git a/README.md b/README.md index bdc94b8..3724722 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,13 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復 > 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。 > 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。 +> **增量之粒度為「塊」**:換塊內容(`textContent`/`innerHTML`)、增刪塊、改分類屬性——皆與 +> 全量重繪一致。**不支持**對聚珍已切分之文本節點做外科式 `characterData` 直改(如持有 render +> **前**之 `p.firstChild` 引用、事後直改其 `.data`):render 後原文本節點已被 `jz-*` 切分/ +> 替換,該引用已非原整段,直改單一碎片會令 `revert` 合併出之內容偏離作者意圖。此為後處理排版 +> 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前 +> DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。 + > **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯 > 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、 > CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義 @@ -311,6 +318,37 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔 createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記 ``` +#### CSS 可查之 resolved 分類:`data-jz-block-level`(輸出屬性) + +`render` 時,聚珍把 **resolved** 級別寫回每個「直接承載可處理文本」之塊級元素: + +```html +
+

+ +``` + +消費端之**段落級 CSS**(首要:CJK 兩端對齊)從此與聚珍的段落級 pass(禁則/垂懸)共用 +**單一真相源**,免另行維護一份必然漂移之選擇器白名單: + +```css +[data-jz-block-level="paragraph"] { text-align: justify; } +.marginalia[data-jz-block-level="paragraph"] { text-align: start; } /* 窄欄 opt-out */ +``` + +規則: + +- **僅標直接承載文本之塊**——純容器(`body`/`ul`/`table`…,直接文本僅空白)不標, + 否則 `body` 恆被判段落級,justify 鉤子經繼承污染全頁。此集合恰為段落級 pass 實際 + 作用之塊集。avoid 子樹/pill 內部之文本不計,故未被處理之塊一致不標。 +- **輸出專用、輸入輸出分離**:作者輸入屬性 `data-jz-level` 只讀不寫;本屬性由聚珍寫入、 + 聚珍**絕不讀取**(消費端手寫無效),`revert` 時全數移除。增量模式(`observe`)下分類 + 屬性變化自動刷新。 +- 是否對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out);聚珍 + 只供分類、不介入樣式。 + ### 標點懸掛(`hanging`)— 已移除 原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與 diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index ac7dd14..4056358 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -1413,6 +1413,63 @@ var Juzhen = (() => { } }; + // src/typeset/levelmark.ts + var ATTR = "data-jz-block-level"; + var levelMarkPass = { + name: "levelMark", + kind: "standalone", + order: 10, + level: "text", + // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出) + enabled: () => true, + // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響) + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const nearest = /* @__PURE__ */ new Map(); + const nearestBlock = (p) => { + const cached = nearest.get(p); + if (cached !== void 0) { + return cached; + } + let e = p; + let block = null; + while (e) { + if (blockSet.has(e.nodeName)) { + block = e; + break; + } + if (e === ctx.root) { + break; + } + e = e.parentElement; + } + nearest.set(p, block); + return block; + }; + finder.eachTextNode(ctx.root, (t) => { + if (!/\S/.test(t.data)) { + return; + } + const p = t.parentElement; + const block = p ? nearestBlock(p) : null; + if (!block) { + return; + } + const level = finder.levelFor(block); + if (block.getAttribute(ATTR) !== level) { + block.setAttribute(ATTR, level); + } + }); + }, + revert(ctx) { + if (ctx.root.hasAttribute(ATTR)) { + ctx.root.removeAttribute(ATTR); + } + ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR)); + } + }; + // src/typeset/lineedge.ts var LE = "jz-half-le"; var ALL_EDGE = [LE]; @@ -2156,7 +2213,8 @@ var Juzhen = (() => { jinzePass, orphanPass, lineEdgePass, - gapTrimPass + gapTrimPass, + levelMarkPass ]; function feature(v, dflt) { return v === void 0 ? dflt : !!v; diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 2be70d3..0c7145f 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -1386,6 +1386,63 @@ var jinzePass = { } }; +// src/typeset/levelmark.ts +var ATTR = "data-jz-block-level"; +var levelMarkPass = { + name: "levelMark", + kind: "standalone", + order: 10, + level: "text", + // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出) + enabled: () => true, + // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響) + render(ctx) { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + const nearest = /* @__PURE__ */ new Map(); + const nearestBlock = (p) => { + const cached = nearest.get(p); + if (cached !== void 0) { + return cached; + } + let e = p; + let block = null; + while (e) { + if (blockSet.has(e.nodeName)) { + block = e; + break; + } + if (e === ctx.root) { + break; + } + e = e.parentElement; + } + nearest.set(p, block); + return block; + }; + finder.eachTextNode(ctx.root, (t) => { + if (!/\S/.test(t.data)) { + return; + } + const p = t.parentElement; + const block = p ? nearestBlock(p) : null; + if (!block) { + return; + } + const level = finder.levelFor(block); + if (block.getAttribute(ATTR) !== level) { + block.setAttribute(ATTR, level); + } + }); + }, + revert(ctx) { + if (ctx.root.hasAttribute(ATTR)) { + ctx.root.removeAttribute(ATTR); + } + ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR)); + } +}; + // src/typeset/lineedge.ts var LE = "jz-half-le"; var ALL_EDGE = [LE]; @@ -2129,7 +2186,8 @@ var PASSES = [ jinzePass, orphanPass, lineEdgePass, - gapTrimPass + gapTrimPass, + levelMarkPass ]; function feature(v, dflt) { return v === void 0 ? dflt : !!v; diff --git a/dist/typeset/levelmark.d.ts b/dist/typeset/levelmark.d.ts new file mode 100644 index 0000000..a1689be --- /dev/null +++ b/dist/typeset/levelmark.d.ts @@ -0,0 +1,2 @@ +import type { StandalonePass } from "../types.js"; +export declare const levelMarkPass: StandalonePass; diff --git a/src/index.ts b/src/index.ts index 88b3a2d..459c5ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { Finder } from "./core/finder.js"; import { runPasses, revertPasses } from "./core/pass.js"; import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js"; import { jinzePass } from "./typeset/jinze.js"; +import { levelMarkPass } from "./typeset/levelmark.js"; import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js"; import { longWordPass } from "./typeset/longword.js"; import { orphanPass } from "./typeset/orphan.js"; @@ -45,11 +46,11 @@ const DEFAULT_SKIP_TAGS = [ // 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、 // 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組, -// 真實序為 order:jiya(20)→jiyaAdjacency(25)→jinze(40)→spacing(50)→longWord(60)→ -// orphan(70)→lineEdge(90)→gapTrim(92)。 +// 真實序:charify 群 jiya(20)→longWord(60)(共享單次 P1 走訪),再 standalone 群 +// levelMark(10)→jiyaAdjacency(25)→jinze(40)→spacing(50)→orphan(70)→lineEdge(90)→gapTrim(92)。 const PASSES: Pass[] = [ jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass, - orphanPass, lineEdgePass, gapTrimPass, + orphanPass, lineEdgePass, gapTrimPass, levelMarkPass, ]; function feature(v: unknown, dflt: boolean): boolean diff --git a/src/typeset/levelmark.ts b/src/typeset/levelmark.ts new file mode 100644 index 0000000..0467ae9 --- /dev/null +++ b/src/typeset/levelmark.ts @@ -0,0 +1,77 @@ +// 塊級分類鉤子(下游建議七):把 resolved 功能級別寫回 DOM,供消費端 CSS 查詢。 +// +// 消費端的段落級樣式(首要:CJK 兩端對齊 text-align:justify)語義上應與本庫的 +// 段落級 pass(jinze 避頭尾/orphan 垂懸)作用於同一集合;但 level 判定原本只存 +// 於內部快取、不寫回 DOM,消費端只能另行維護一份 CSS 白名單,兩份分類必然漂移 +// (下游實例:.callout 綁了避頭尾卻不在 justify 名單,避頭尾與兩端對齊不一致)。 +// 本 pass 把單一真相源暴露出去: +// +// render → 對每個「直接承載可處理文本」之塊寫 data-jz-block-level="paragraph|text" +// revert → 全數移除(I6) +// +// **僅標直接承載文本之塊**:純容器(body/ul/table…——直接文本僅空白)不標。 +// 否則 body 恆被判 paragraph,消費端 `[data-jz-block-level="paragraph"]` 之 +// justify 經繼承污染全頁,鉤子即廢。此集合恰為段落級 pass 實際作用之塊集 +// (collectRuns 對純容器收不到 run)。avoid 子樹/pill 內部/域外文本不計 +// (eachTextNode 同口徑),故未被處理之塊一致不標。 +// +// 輸入/輸出分離:作者輸入屬性 data-jz-level 只讀不寫;本屬性為 resolved 輸出、 +// 本庫絕不讀之(亦不在 C2 OBSERVE_ATTRS 白名單,外部改動不觸發重處理)。是否 +// 對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out),本庫 +// 只供分類、不介入樣式。 +// +// order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內 +// 元素,不改變「文本之最近塊祖先」,故時點無影響)。 + +import type { RenderContext, StandalonePass } from "../types.js"; + +const ATTR = "data-jz-block-level"; + +export const levelMarkPass: StandalonePass = { + name: "levelMark", + kind: "standalone", + order: 10, + level: "text", // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出) + enabled: () => true, // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響) + render(ctx: RenderContext): void + { + const { finder, options } = ctx; + const blockSet = options.finder.blockTags; + // 近塊定位之 per-run 快取(兄弟文本節點共用 parentElement,免重複上攀)。 + // 上攀止於 ctx.root:eachTextNode 之文本必在 root 內、鏈上元素亦然,故命中 + // 之塊恆在 render 作用域內,revert(root) 必能移除;root 非塊時不越界外標。 + const nearest = new Map(); + const nearestBlock = ( + (p: Element): Element | null => + { + const cached = nearest.get(p); + if (cached !== undefined) { return cached; } + let e: Element | null = p; + let block: Element | null = null; + while (e) + { + if (blockSet.has(e.nodeName)) { block = e; break; } + if (e === ctx.root) { break; } + e = e.parentElement; + } + nearest.set(p, block); + return block; + } + ); + finder.eachTextNode(ctx.root, (t) => + { + if (!/\S/.test(t.data)) { return; } // 純空白(縮排/換行)不算承載文本 + const p = t.parentElement; + const block = p ? nearestBlock(p) : null; + if (!block) { return; } + const level = finder.levelFor(block); + if (block.getAttribute(ATTR) !== level) { block.setAttribute(ATTR, level); } + }); + }, + revert(ctx: RenderContext): void + { + if (ctx.root.hasAttribute(ATTR)) { ctx.root.removeAttribute(ATTR); } + ctx.root.querySelectorAll("[" + ATTR + "]") + .forEach((el) => el.removeAttribute(ATTR)); + }, +}; diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index d333b95..be3a56b 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -1522,3 +1522,89 @@ test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3 assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除"); assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常"); }); + +// ----- 塊級分類鉤子 data-jz-block-level(下游建議七)----- + +test("levelMark:直接承載文本之塊標 data-jz-block-level,純容器不標", () => +{ + const { document } = setupDom( + '
容器直接文字。

子段文字。

' + + "", + ); + createJuzhen().render(document.body); + const m = document.getElementById("m"); + assert.equal(m.getAttribute("data-jz-block-level"), "paragraph", "混合塊有直接文本 → 標"); + assert.equal(m.querySelector("p").getAttribute("data-jz-block-level"), "paragraph"); + assert.equal(document.querySelector("li").getAttribute("data-jz-block-level"), "paragraph"); + assert.equal( + document.querySelector("ul").hasAttribute("data-jz-block-level"), false, + "純容器(直接文本僅空白)不標", + ); + assert.equal( + document.body.hasAttribute("data-jz-block-level"), false, + "body 無直接文本不標(justify 鉤子不經繼承污染全頁)", + ); +}); + +test("levelMark:level.text 選擇器/data-jz-level 屬性 → 標 text;作者輸入屬性不被觸碰", () => +{ + const { document } = setupDom( + '

標題文字

說明文字。

' + + '

內層段落。

', + ); + createJuzhen({ level: { text: "h1" } }).render(document.body); + assert.equal(document.querySelector("h1").getAttribute("data-jz-block-level"), "text", "選擇器命中"); + const p = document.querySelector("p[data-jz-level]"); + assert.equal(p.getAttribute("data-jz-block-level"), "text", "屬性命中"); + assert.equal(p.getAttribute("data-jz-level"), "text", "作者輸入 data-jz-level 原樣保留"); + assert.equal( + document.getElementById("n").getAttribute("data-jz-block-level"), "text", + "祖先 data-jz-level=text → 後代塊 resolved 為 text", + ); +}); + +test("levelMark:avoid 子樹與 pill 內部文本不計——僅含此類內容之塊不標", () => +{ + const { document } = setupDom( + "
onlyPill

跳過內容。

", + ); + createJuzhen().render(document.body); + assert.equal( + document.querySelector("div").hasAttribute("data-jz-block-level"), false, + "pill 內部不可見 → 塊不標", + ); + assert.equal( + document.querySelector("p").hasAttribute("data-jz-block-level"), false, + "avoid 塊不標(與「未被處理」一致)", + ); +}); + +test("levelMark:revert 全數移除 data-jz-block-level(I6),root 自身亦清", () => +{ + const { document } = setupDom("直接文字。

段落文字。

"); + const jz = createJuzhen(); + jz.render(document.body); + assert.equal( + document.body.getAttribute("data-jz-block-level"), "paragraph", + "body 有直接文本 → root 自身被標", + ); + assert.ok(document.querySelector("p[data-jz-block-level]"), "段落已標"); + jz.revert(document.body); + assert.equal(document.querySelectorAll("[data-jz-block-level]").length, 0, "子樹全清"); + assert.equal(document.body.hasAttribute("data-jz-block-level"), false, "root 自身已清"); +}); + +test("levelMark C2:class 變化令 level 翻轉 → 觀察自動刷新 resolved 標記", async () => +{ + const { document } = setupDom('

正文文字,標點。

'); + const jz = createJuzhen({ level: { text: ".chrome" } }); + jz.render(document.body); + jz.observe(document.body); + const p = document.querySelector("p"); + assert.equal(p.getAttribute("data-jz-block-level"), "paragraph"); + p.classList.add("chrome"); + await tick(); + jz.disconnect(); + assert.equal(p.getAttribute("data-jz-block-level"), "text", "翻轉後 resolved 標記已刷新"); + assert.equal(countTag(p, "jz-jinze"), 0, "text 級不再跑段落級禁則(重分類一致)"); +});