feat(levelmark): 塊級分類 CSS 鉤子 data-jz-block-level+增量塊粒度契約(下游建議七/說明八)

建議七:新增 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。
This commit is contained in:
2026-07-16 16:42:56 +08:00
parent 2f04ea4085
commit 8ee9895247
8 changed files with 345 additions and 5 deletions
+86
View File
@@ -1522,3 +1522,89 @@ test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphanIV-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(
'<div id="m" lang="zh-Hant">容器直接文字。<p>子段文字。</p></div>'
+ "<ul><li>列表項文字。</li></ul>",
);
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("levelMarklevel.text 選擇器/data-jz-level 屬性 → 標 text;作者輸入屬性不被觸碰", () =>
{
const { document } = setupDom(
'<h1>標題文字</h1><p data-jz-level="text">說明文字。</p>'
+ '<div data-jz-level="text"><p id="n">內層段落。</p></div>',
);
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("levelMarkavoid 子樹與 pill 內部文本不計——僅含此類內容之塊不標", () =>
{
const { document } = setupDom(
"<div><code>onlyPill</code></div><p data-jz-skip>跳過內容。</p>",
);
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("levelMarkrevert 全數移除 data-jz-block-levelI6),root 自身亦清", () =>
{
const { document } = setupDom("直接文字。<p>段落文字。</p>");
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 C2class 變化令 level 翻轉 → 觀察自動刷新 resolved 標記", async () =>
{
const { document } = setupDom('<p lang="zh-Hant">正文文字,標點。</p>');
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 級不再跑段落級禁則(重分類一致)");
});