feat: 功能分級(文本級 vs 段落級)+ data-jz-level 標記(§6.5.0a)

整體計劃 §6.5 之前置基礎設施。把已實作功能切為兩級:
- 文本級(spacing 中西間隙、jiya 標點擠壓/鄰接):任意文本片段皆適用。
- 段落級(jinze 禁則、longWord 斷詞、lineEdge 行端、gapTrim):需行/段布局。

標記 API:
- 預設塊級元素 → 段落級(全功能)。
- 元素屬性 data-jz-level="text"|"paragraph"(最近祖先勝)。
- 選項 level:{ text: "選擇器" }(如標題/按鈕一鍵降為文本級)。

實作:
- 每個 pass 加 level 元數據;Finder.levelFor/levelAllows(含快取)。
- charify 段落級 pass(longWord)於共享走訪受閘;standalone 段落級 pass
  (jinze/lineEdge/gapTrim)逐元素受閘。文本級不受限。
- spacing 補單元素 root 支援:root 為行內/單一非塊元素時亦處理(標題等
  之中西間隙、標點半形生效)。

6 項新單元測試(text-level 跳段落級、選擇器標記、巢狀最近勝、單元素 spacing、
預設段落級回歸)。共 76 測試全綠。
This commit is contained in:
2026-06-09 16:52:00 +08:00
parent a00b35577e
commit c1fbabf5b0
15 changed files with 309 additions and 7 deletions
+65 -1
View File
@@ -9,9 +9,11 @@ var Finder = class {
this.pillSelector = opts.finder.pillSelector;
this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped;
this.levelTextSelector = opts.levelText;
this.avoidSelector = opts.scope.avoid;
this.includeSelector = opts.scope.include;
this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
pillMatches(el) {
@@ -231,6 +233,48 @@ var Finder = class {
p = pe.parentNode;
}
}
/**
* 節點之功能級別(§6.5.0a)。自 node 向上找**最近**之顯式信號:元素屬性
* `data-jz-level="text|paragraph"`(最優先),或命中 `level.text` 選擇器(→ text);
* 皆無則預設 paragraph(全功能)。結果以解析後之元素為鍵快取。
*/
levelFor(node) {
const start = node.nodeType === 1 ? node : node.parentElement;
if (!start) {
return "paragraph";
}
const cached = this.levelCache.get(start);
if (cached) {
return cached;
}
let el = start;
let result = "paragraph";
while (el) {
const attr = el.getAttribute ? el.getAttribute("data-jz-level") : null;
if (attr === "text" || attr === "paragraph") {
result = attr;
break;
}
if (this.levelTextSelector && el.matches && el.matches(this.levelTextSelector)) {
result = "text";
break;
}
el = el.parentElement;
}
this.levelCache.set(start, result);
return result;
}
/**
* 某級別之 pass 是否可作用於該節點(§6.5.0a gate)。文本級 pass 不受限(恆 true);
* 段落級 pass 僅作用於段落級元素(text-level 子樹跳過)。與 featureEnabledFor 疊加:
* level 為粗粒度語義組(先判),blocks/屬性為細粒度逐功能(後調)。
*/
levelAllows(node, passLevel) {
if (passLevel === "text") {
return true;
}
return this.levelFor(node) === "paragraph";
}
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
@@ -303,6 +347,9 @@ function runCharify(passes, ctx) {
ctx.finder.eachTextNode(ctx.root, (node) => {
let requests = [];
for (const p of passes) {
if (!ctx.finder.levelAllows(node, p.level)) {
continue;
}
const got = p.collect(node, ctx);
if (got.length > 0) {
requests = requests.concat(got);
@@ -637,6 +684,7 @@ var jiyaPass = {
name: PASS,
kind: "charify",
order: 20,
level: "text",
enabled: (o) => o.features.jiya,
collect(node, ctx) {
if (inJzChar(node)) {
@@ -690,6 +738,7 @@ var jiyaAdjacencyPass = {
name: "jiyaAdjacency",
kind: "standalone",
order: 25,
level: "text",
enabled: (o) => o.features.jiya,
render(ctx) {
const { finder, options } = ctx;
@@ -862,11 +911,15 @@ var jinzePass = {
name: PASS2,
kind: "standalone",
order: 40,
level: "paragraph",
enabled: (o) => o.features.jinze,
render(ctx) {
const anyCjk = new RegExp("[" + ctx.options.ruleset.cjk + "]");
const parents = /* @__PURE__ */ new Set();
ctx.root.querySelectorAll("jz-char").forEach((c) => {
if (!ctx.finder.levelAllows(c, "paragraph")) {
return;
}
if (c.parentNode) {
parents.add(c.parentNode);
}
@@ -976,6 +1029,9 @@ function relayout(root, finder) {
(c) => finder.inScope(c) && !finder.isAvoided(c)
);
for (const el of chars) {
if (!finder.levelAllows(el, "paragraph")) {
continue;
}
if (!finder.featureEnabledFor(el, "jiya")) {
continue;
}
@@ -1004,6 +1060,7 @@ var lineEdgePass = {
name: "jiyaLineEdge",
kind: "standalone",
order: 90,
level: "paragraph",
enabled: (o) => o.features.jiya,
render(ctx) {
if (!hasLayout()) {
@@ -1019,7 +1076,7 @@ var lineEdgePass = {
var TRIM = "jz-hws-trim";
function trimGaps(root, finder) {
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g)
(g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph")
);
if (!gaps.length) {
return;
@@ -1038,6 +1095,7 @@ var gapTrimPass = {
name: "spacingGapTrim",
kind: "standalone",
order: 92,
level: "paragraph",
enabled: (o) => o.features.spacing,
render(ctx) {
if (!hasLayout()) {
@@ -1068,6 +1126,7 @@ var longWordPass = {
name: PASS3,
kind: "charify",
order: 60,
level: "paragraph",
enabled: (o) => o.features.longWord,
collect(node, ctx) {
if (inLangSpan(node)) {
@@ -1319,6 +1378,7 @@ var spacingPass = {
name: PASS4,
kind: "standalone",
order: 50,
level: "text",
enabled: (o) => o.features.spacing,
render(ctx) {
const { finder, options } = ctx;
@@ -1329,6 +1389,9 @@ var spacingPass = {
}
processBlock(block, finder, R);
});
if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS4) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) {
processBlock(ctx.root, finder, R);
}
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
},
revert(ctx) {
@@ -1523,6 +1586,7 @@ function normalizeOptions(opts = {}) {
ruleset: resolveRuleset(opts.charClass),
autospaceClass: opts.autospaceClass || "",
justifyAtoms: opts.justifyAtoms === false ? false : true,
levelText: opts.level && opts.level.text || null,
scope: {
root: scope.root || null,
include: scope.include || null,