a5faec5abf
下游 Vite / Rollup / TypeScript 靜態 ESM 解析需要真正之 `export`
關鍵字;原 UMD-ish(module.exports + globalThis)對 ESM 消費端
仍須走副作用導入 + globalThis 取值之 workaround。
於檔尾既有 CJS / globalThis 兩塊後再加 `export { createCjkAutospace };
順序刻意如此,讓串接端(IIFE 函式體內不能有 module-level export)
只需以 `sed '/^export {/d'` 剝最後一行即安全。
four-form export 同時支援:ESM named import / CommonJS require /
globalThis / IIFE 串接(後者需剝 export 行)。README 同步補上。
583 lines
24 KiB
JavaScript
583 lines
24 KiB
JavaScript
"use strict";
|
||
|
||
/* cjk-autospace — Pangu.js v7.2.1 風格 CJK ↔ Latin 邊界自動間距 shim。
|
||
*
|
||
* 工廠模式:`createCjkAutospace(options).apply(root)`。配置由消費端注入;
|
||
* 預設值對應 HTML 模板 runtime 之原狀(pill = code, kbd;標籤三分類;
|
||
* marker class 為 .cjk-autospace;skip ancestor 為 mono / SVG /
|
||
* contentEditable / [data-md-key])。
|
||
*
|
||
* 標籤三分類:
|
||
* - STYLE_INLINES — 樣式標籤,shim 透明走過。跨樣式邊界(如
|
||
* <strong>是</strong> Neovim)由 Pass A 在剝離樣式後識別。
|
||
* - PILL — 行內塊(有獨立背景,如 <code>),邊界由 Pass B
|
||
* 處理。預設 'code, kbd',可由消費端擴充。
|
||
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
|
||
*
|
||
* 三 pass:
|
||
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
|
||
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
|
||
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker,
|
||
* 否則純插。actions 倒序執行確保前面偏移不被後面動作影響。
|
||
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
|
||
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
|
||
* marker;其餘跳過)。
|
||
* Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段
|
||
* 包進 <span lang="en">;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
|
||
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
|
||
* 不污染原文。預設關閉以保持向後相容。
|
||
*
|
||
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
|
||
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
|
||
* 換行 / justify 可拉伸);配 CSS user-select: none 讓選取與剪貼板跳
|
||
* 過該 span。textContent 不變(無視 span 內容)→ Copy / Paste / 序列
|
||
* 化都乾淨。
|
||
*/
|
||
|
||
function createCjkAutospace(options)
|
||
{
|
||
options = options || {};
|
||
|
||
/* ----- 預設配置 ----- */
|
||
const DEFAULT_STYLE_INLINES = [
|
||
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
|
||
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
|
||
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
|
||
];
|
||
const DEFAULT_BLOCK_TAGS = [
|
||
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
|
||
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
|
||
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
|
||
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
|
||
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
|
||
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
|
||
];
|
||
const DEFAULT_SKIP_TAGS = [
|
||
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
|
||
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
|
||
];
|
||
|
||
const STYLE_SET = new Set(options.styleInlines || DEFAULT_STYLE_INLINES);
|
||
const BLOCK_SET = new Set(options.blockTags || DEFAULT_BLOCK_TAGS);
|
||
const SKIP_SET = new Set(options.skipTags || DEFAULT_SKIP_TAGS);
|
||
const pillSelector = options.pillSelector || "code, kbd";
|
||
const autospaceCls = options.autospaceClass || "cjk-autospace";
|
||
const skipAttr = options.skipAttribute || "data-md-key";
|
||
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null;
|
||
|
||
/* Pass C 設定。預設啟用(minLength=6 / lang="en");顯式傳 false 才
|
||
關閉。可給 { minLength, lang } 局部自訂。
|
||
注意:斷點前後最少字符數由消費端 CSS `hyphenate-limit-chars` 控
|
||
制(建議 `6 3 3`),lib 不涉入。 */
|
||
const longWordCfg = (function ()
|
||
{
|
||
const v = options.longWordWrap;
|
||
if (v === false) { return null; }
|
||
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
|
||
return {
|
||
minLength: typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6,
|
||
lang: typeof v.lang === "string" && v.lang ? v.lang : "en",
|
||
};
|
||
}());
|
||
|
||
/* ----- Pangu 字符類與規則表 ----- */
|
||
const PANGU_CJK = "⺀-⼀-"
|
||
+ "-ゟ゠-ヺー-ヿ"
|
||
+ "-ㄯ㈀-㋿"
|
||
+ "㐀-䶿一-鿿豈-";
|
||
const AN_CHARS = "A-Za-z0-9";
|
||
const A_CHARS = "A-Za-z";
|
||
const OPS_HYPHEN = "\\+\\*=&\\-";
|
||
const OPS_GRADE = "\\+\\-\\*";
|
||
const QUOTES_CLS = "`\"״";
|
||
const LBR_BASIC = "\\(\\[\\{";
|
||
const RBR_BASIC = "\\)\\]\\}";
|
||
const LBR_EXT = "\\(\\[\\{<>";
|
||
const RBR_EXT = "\\)\\]\\}<>";
|
||
const ANS_CJK_AFTER = A_CHARS + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-✀-➿";
|
||
const ANS_BEFORE_CJK = A_CHARS + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-✀-➿";
|
||
|
||
const ANY_CJK = new RegExp("[" + PANGU_CJK + "]");
|
||
|
||
/* PUA marker chars. 不會撞到正常 prose 中之 glyph。 */
|
||
const MARK = "";
|
||
const PH_OPEN = "";
|
||
const PH_CLOSE = "";
|
||
|
||
const STRIP_CJK_SPACE_ANS = new RegExp("([" + PANGU_CJK + "]) ([" + ANS_CJK_AFTER + "])", "g");
|
||
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + PANGU_CJK + "])", "g");
|
||
|
||
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + PANGU_CJK + "])", "g");
|
||
const CJK_PUNCTUATION = new RegExp("([" + PANGU_CJK + "])([!;,\\?:]+)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
|
||
const AN_PUNCTUATION_CJK = new RegExp("([" + AN_CHARS + "])([!;,\\?]+)([" + PANGU_CJK + "])", "g");
|
||
const CJK_TILDE = new RegExp("([" + PANGU_CJK + "])(~+)(?!=)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
|
||
const CJK_TILDE_EQUALS = new RegExp("([" + PANGU_CJK + "])(~=)", "g");
|
||
const CJK_PERIOD = new RegExp("([" + PANGU_CJK + "])(\\.)(?![" + AN_CHARS + "\\./])(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
|
||
const AN_PERIOD_CJK = new RegExp("([" + AN_CHARS + "])(\\.)([" + PANGU_CJK + "])", "g");
|
||
const AN_COLON_CJK = new RegExp("([" + AN_CHARS + "])(:)([" + PANGU_CJK + "])", "g");
|
||
const CJK_QUOTE = new RegExp("([" + PANGU_CJK + "])([" + QUOTES_CLS + "])", "g");
|
||
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + PANGU_CJK + "])", "g");
|
||
const QUOTE_AN = new RegExp("([”])([" + AN_CHARS + "])", "g");
|
||
const CJK_QUOTE_AN = new RegExp("([" + PANGU_CJK + "])(\")([" + AN_CHARS + "])", "g");
|
||
const CJK_HASH = new RegExp("([" + PANGU_CJK + "])(#([^ ]))", "g");
|
||
const HASH_CJK = new RegExp("(([^ ])#)([" + PANGU_CJK + "])", "g");
|
||
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + A_CHARS + "])([" + OPS_GRADE + "])([" + PANGU_CJK + "])", "g");
|
||
const CJK_OPERATOR_ANS = new RegExp("([" + PANGU_CJK + "])([" + OPS_HYPHEN + "])([" + AN_CHARS + "])", "g");
|
||
const ANS_OPERATOR_CJK = new RegExp("([" + AN_CHARS + "])([" + OPS_HYPHEN + "])([" + PANGU_CJK + "])", "g");
|
||
const CJK_LESS_THAN = new RegExp("([" + PANGU_CJK + "])(<)([" + AN_CHARS + "])", "g");
|
||
const LESS_THAN_CJK = new RegExp("([" + AN_CHARS + "])(<)([" + PANGU_CJK + "])", "g");
|
||
const CJK_GREATER_THAN = new RegExp("([" + PANGU_CJK + "])(>)([" + AN_CHARS + "])", "g");
|
||
const GREATER_THAN_CJK = new RegExp("([" + AN_CHARS + "])(>)([" + PANGU_CJK + "])", "g");
|
||
const CJK_LEFT_BRACKET = new RegExp("([" + PANGU_CJK + "])([" + LBR_EXT + "])", "g");
|
||
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + PANGU_CJK + "])", "g");
|
||
const AN_LEFT_BRACKET = new RegExp("([" + AN_CHARS + "])(?<!\\.[" + AN_CHARS + "]*)([" + LBR_BASIC + "])", "g");
|
||
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + AN_CHARS + "])", "g");
|
||
const CJK_ANS = new RegExp("([" + PANGU_CJK + "])([" + ANS_CJK_AFTER + "])", "g");
|
||
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + PANGU_CJK + "])", "g");
|
||
const S_A = new RegExp("(%)([" + A_CHARS + "])", "g");
|
||
|
||
/* Compound words like GPT-4 / state-of-the-art mustn't get operator
|
||
spaces wedged into them. Mask before operator passes, restore
|
||
before bracket / CJK_ANS / ANS_CJK passes (so the boundary
|
||
between 是 and GPT-4 still gets spaced). */
|
||
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
|
||
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
|
||
|
||
function panguSpace(text)
|
||
{
|
||
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; }
|
||
|
||
let s = text;
|
||
|
||
/* Pre-pass:strip authored single spaces at CJK↔ANS boundaries so
|
||
the rest of the pipeline can treat the text as if it had been
|
||
written flush. */
|
||
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
|
||
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
|
||
|
||
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
|
||
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
|
||
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
|
||
s = s.replace(CJK_TILDE, "$1$2" + MARK);
|
||
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
|
||
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
|
||
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
|
||
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
|
||
|
||
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2");
|
||
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
|
||
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
|
||
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
|
||
|
||
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
|
||
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
|
||
|
||
const compounds = [];
|
||
s = s.replace(COMPOUND_WORD, function (m)
|
||
{
|
||
compounds.push(m);
|
||
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
|
||
});
|
||
|
||
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
|
||
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
|
||
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
|
||
|
||
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
|
||
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
|
||
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
|
||
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
|
||
|
||
/* Restore compound words BEFORE bracket / CJK_ANS / ANS_CJK
|
||
passes — otherwise their PUA placeholders look like neither
|
||
CJK nor AN and the boundaries silently skip. */
|
||
s = s.replace(PH_PATTERN, function (_m, idx) { return compounds[+idx] || ""; });
|
||
|
||
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2");
|
||
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
|
||
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2");
|
||
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2");
|
||
|
||
s = s.replace(CJK_ANS, "$1" + MARK + "$2");
|
||
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
|
||
s = s.replace(S_A, "$1" + MARK + "$2");
|
||
|
||
return s;
|
||
}
|
||
|
||
/* ----- skip ancestor 判定 ----- */
|
||
function isSkipped(node)
|
||
{
|
||
if (userIsSkipped && userIsSkipped(node)) { return true; }
|
||
let p = node.parentNode;
|
||
while (p && p.nodeType === 1)
|
||
{
|
||
if (SKIP_SET.has(p.nodeName)) { return true; }
|
||
if (p.namespaceURI === "http://www.w3.org/2000/svg") { return true; }
|
||
if (p.isContentEditable) { return true; }
|
||
if (skipAttr && p.hasAttribute && p.hasAttribute(skipAttr)) { return true; }
|
||
p = p.parentNode;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* ----- helpers ----- */
|
||
function insertSpanAt(parent, refNode)
|
||
{
|
||
const span = document.createElement("span");
|
||
span.className = autospaceCls;
|
||
span.setAttribute("aria-hidden", "true");
|
||
span.textContent = " ";
|
||
parent.insertBefore(span, refNode);
|
||
}
|
||
function insertMarkerAfterPosition(pos)
|
||
{
|
||
const tn = pos.textNode;
|
||
const afterIdx = pos.offset + 1;
|
||
if (afterIdx >= tn.data.length)
|
||
{
|
||
insertSpanAt(tn.parentNode, tn.nextSibling);
|
||
}
|
||
else
|
||
{
|
||
const split = tn.splitText(afterIdx);
|
||
insertSpanAt(split.parentNode, split);
|
||
}
|
||
}
|
||
function isAutospaceSpan(node)
|
||
{
|
||
return node && node.nodeType === 1
|
||
&& node.classList && node.classList.contains(autospaceCls);
|
||
}
|
||
|
||
/* ----- Pass A: per-block Pangu on tag-stripped text ----- */
|
||
function processBlockText(block)
|
||
{
|
||
const runs = [];
|
||
let curText = "";
|
||
let curMap = [];
|
||
|
||
function flushRun()
|
||
{
|
||
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
|
||
curText = "";
|
||
curMap = [];
|
||
}
|
||
|
||
function visit(node)
|
||
{
|
||
if (!node) { return; }
|
||
if (node.nodeType === 3)
|
||
{
|
||
if (isSkipped(node)) { return; }
|
||
const data = node.data;
|
||
for (let i = 0; i < data.length; i += 1)
|
||
{
|
||
curText += data.charAt(i);
|
||
curMap.push({ textNode: node, offset: i });
|
||
}
|
||
return;
|
||
}
|
||
if (node.nodeType !== 1) { return; }
|
||
if (isSkipped(node)) { return; }
|
||
if (isAutospaceSpan(node)) { flushRun(); return; }
|
||
const tag = node.nodeName;
|
||
/* PILL / BLOCK / SKIP_SET 三類都中斷 run(pill 與 block 邊界
|
||
另由 Pass B / 各自子 block 處理)。 */
|
||
if (SKIP_SET.has(tag) || BLOCK_SET.has(tag))
|
||
{
|
||
flushRun();
|
||
return;
|
||
}
|
||
if (node.matches && node.matches(pillSelector))
|
||
{
|
||
flushRun();
|
||
return;
|
||
}
|
||
/* 樣式標籤或未知元素:透明遞迴。 */
|
||
let c = node.firstChild;
|
||
while (c) { visit(c); c = c.nextSibling; }
|
||
}
|
||
|
||
let c = block.firstChild;
|
||
while (c) { visit(c); c = c.nextSibling; }
|
||
flushRun();
|
||
|
||
for (let r = 0; r < runs.length; r += 1)
|
||
{
|
||
const text = runs[r].text;
|
||
const map = runs[r].map;
|
||
const spaced = panguSpace(text);
|
||
if (spaced === text) { continue; }
|
||
|
||
/* Walk spaced + text in parallel. 每個 MARK 在 spaced 中要麼
|
||
(a) 取代了原文同位之 single space,要麼 (b) 是純插入。 */
|
||
const actions = [];
|
||
let origIdx = 0;
|
||
let spacedIdx = 0;
|
||
while (spacedIdx < spaced.length)
|
||
{
|
||
const sc = spaced.charAt(spacedIdx);
|
||
if (sc === MARK)
|
||
{
|
||
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
|
||
if (oc === " ")
|
||
{
|
||
actions.push({ before: map[origIdx - 1], space: map[origIdx] });
|
||
origIdx += 1;
|
||
}
|
||
else
|
||
{
|
||
actions.push({ before: map[origIdx - 1], space: null });
|
||
}
|
||
spacedIdx += 1;
|
||
continue;
|
||
}
|
||
origIdx += 1;
|
||
spacedIdx += 1;
|
||
}
|
||
|
||
/* 倒序執行,避免 text-node 修改影響前面動作之 offset。 */
|
||
for (let i = actions.length - 1; i >= 0; i -= 1)
|
||
{
|
||
const act = actions[i];
|
||
if (!act.before) { continue; }
|
||
if (act.space)
|
||
{
|
||
const tn = act.space.textNode;
|
||
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
|
||
}
|
||
insertMarkerAfterPosition(act.before);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ----- Pass B: pill boundary, style-inline aware ----- */
|
||
function adjacentLogicalChar(el, direction)
|
||
{
|
||
function pickEnd(tn)
|
||
{
|
||
if (direction === -1)
|
||
{
|
||
return { textNode: tn, offset: tn.data.length - 1, ch: tn.data.charAt(tn.data.length - 1) };
|
||
}
|
||
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
|
||
}
|
||
function descend(into)
|
||
{
|
||
let n = direction === -1 ? into.lastChild : into.firstChild;
|
||
while (n)
|
||
{
|
||
if (n.nodeType === 3 && n.data.length > 0) { return pickEnd(n); }
|
||
if (n.nodeType === 1 && STYLE_SET.has(n.nodeName) && !isAutospaceSpan(n))
|
||
{
|
||
const r = descend(n);
|
||
if (r) { return r; }
|
||
}
|
||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||
}
|
||
return null;
|
||
}
|
||
let n = direction === -1 ? el.previousSibling : el.nextSibling;
|
||
let p = el.parentNode;
|
||
while (true)
|
||
{
|
||
while (n)
|
||
{
|
||
if (n.nodeType === 3)
|
||
{
|
||
if (n.data.length > 0) { return pickEnd(n); }
|
||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||
continue;
|
||
}
|
||
if (n.nodeType === 1)
|
||
{
|
||
if (isSkipped(n)) { return null; }
|
||
if (isAutospaceSpan(n)) { return null; }
|
||
if (STYLE_SET.has(n.nodeName))
|
||
{
|
||
const r = descend(n);
|
||
if (r) { return r; }
|
||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||
continue;
|
||
}
|
||
/* Pill 或 block — 停。 */
|
||
return null;
|
||
}
|
||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||
}
|
||
if (!p || !STYLE_SET.has(p.nodeName)) { return null; }
|
||
n = direction === -1 ? p.previousSibling : p.nextSibling;
|
||
p = p.parentNode;
|
||
}
|
||
}
|
||
|
||
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + PANGU_CJK + "]");
|
||
|
||
function processPills(root)
|
||
{
|
||
root.querySelectorAll(pillSelector).forEach(function (pill)
|
||
{
|
||
if (isSkipped(pill)) { return; }
|
||
const parent = pill.parentNode;
|
||
if (!parent) { return; }
|
||
|
||
const prev = adjacentLogicalChar(pill, -1);
|
||
if (prev)
|
||
{
|
||
if (PILL_NEIGHBOR.test(prev.ch))
|
||
{
|
||
insertSpanAt(parent, pill);
|
||
}
|
||
else if (prev.ch === " " && prev.offset >= 1)
|
||
{
|
||
const tn = prev.textNode;
|
||
const bc = tn.data.charAt(prev.offset - 1);
|
||
if (bc !== " " && PILL_NEIGHBOR.test(bc))
|
||
{
|
||
tn.data = tn.data.slice(0, prev.offset) + tn.data.slice(prev.offset + 1);
|
||
insertSpanAt(parent, pill);
|
||
}
|
||
}
|
||
}
|
||
|
||
const next = adjacentLogicalChar(pill, +1);
|
||
if (next)
|
||
{
|
||
if (PILL_NEIGHBOR.test(next.ch))
|
||
{
|
||
insertSpanAt(parent, pill.nextSibling);
|
||
}
|
||
else if (next.ch === " " && next.textNode.data.length >= 2)
|
||
{
|
||
const ac = next.textNode.data.charAt(1);
|
||
if (ac !== " " && PILL_NEIGHBOR.test(ac))
|
||
{
|
||
next.textNode.data = next.textNode.data.slice(1);
|
||
insertSpanAt(parent, pill.nextSibling);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ----- Pass C: long English word wrap ----- */
|
||
/* 冪等性:parent 為 <span lang="…"> 直接跳過。已被本 pass 或作者標
|
||
語的英文段都會被 catch;e.g. <span lang="en-US">…</span> 雖然 lang
|
||
不等於 "en",但 caller 已意圖標語,重複包裝無實益。 */
|
||
function isInLangSpan(node)
|
||
{
|
||
const p = node.parentNode;
|
||
if (!p || p.nodeType !== 1) { return false; }
|
||
if (p.nodeName !== "SPAN") { return false; }
|
||
return p.hasAttribute && p.hasAttribute("lang");
|
||
}
|
||
|
||
function wrapLongWordsIn(textNode, cfg)
|
||
{
|
||
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
|
||
const data = textNode.data;
|
||
const matches = [];
|
||
let m;
|
||
while ((m = re.exec(data)) !== null)
|
||
{
|
||
matches.push({ start: m.index, end: m.index + m[0].length });
|
||
}
|
||
if (matches.length === 0) { return; }
|
||
|
||
const frag = document.createDocumentFragment();
|
||
let cursor = 0;
|
||
for (let i = 0; i < matches.length; i += 1)
|
||
{
|
||
const mm = matches[i];
|
||
if (mm.start > cursor)
|
||
{
|
||
frag.appendChild(document.createTextNode(data.slice(cursor, mm.start)));
|
||
}
|
||
const span = document.createElement("span");
|
||
span.setAttribute("lang", cfg.lang);
|
||
span.textContent = data.slice(mm.start, mm.end);
|
||
frag.appendChild(span);
|
||
cursor = mm.end;
|
||
}
|
||
if (cursor < data.length)
|
||
{
|
||
frag.appendChild(document.createTextNode(data.slice(cursor)));
|
||
}
|
||
textNode.parentNode.replaceChild(frag, textNode);
|
||
}
|
||
|
||
function processLongWords(root, cfg)
|
||
{
|
||
const reTest = new RegExp("[A-Za-z]{" + cfg.minLength + ",}");
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||
const targets = [];
|
||
let n = walker.nextNode();
|
||
while (n)
|
||
{
|
||
if (!isSkipped(n) && !isInLangSpan(n) && reTest.test(n.data))
|
||
{
|
||
targets.push(n);
|
||
}
|
||
n = walker.nextNode();
|
||
}
|
||
for (let i = 0; i < targets.length; i += 1)
|
||
{
|
||
wrapLongWordsIn(targets[i], cfg);
|
||
}
|
||
}
|
||
|
||
/* ----- 對外 API ----- */
|
||
function apply(root)
|
||
{
|
||
root = root || (typeof document !== "undefined" ? document.body : null);
|
||
if (!root) { return; }
|
||
|
||
/* Pass A:對每個 block 逐個處理。把 BLOCK_TAGS 轉成 selector
|
||
並對 root 之 descendants 查詢;若 root 本身是 block 也處理。 */
|
||
const blockSel = Array.from(BLOCK_SET).map(function (t) { return t.toLowerCase(); }).join(",");
|
||
if (blockSel)
|
||
{
|
||
root.querySelectorAll(blockSel).forEach(processBlockText);
|
||
}
|
||
if (root.nodeType === 1 && BLOCK_SET.has(root.nodeName))
|
||
{
|
||
processBlockText(root);
|
||
}
|
||
|
||
/* Pass B */
|
||
processPills(root);
|
||
|
||
/* Pass C(opt-in) */
|
||
if (longWordCfg) { processLongWords(root, longWordCfg); }
|
||
}
|
||
|
||
return { apply: apply };
|
||
}
|
||
|
||
/* ----- 多形 export -----
|
||
*
|
||
* 四種消費場景並存:
|
||
* ESM(Vite / Rollup / TypeScript):使用 `export { createCjkAutospace }`,
|
||
* 可 `import { createCjkAutospace } from "./cjk-autospace.js"`;
|
||
* CommonJS(Node `require`):使用 `module.exports`;
|
||
* Browser global / `<script>` 標籤:使用 `globalThis` 賦值;
|
||
* IIFE 串接(build.sh / Nvim peek hook 把本檔內容 cat 進單個閉包):
|
||
* `export` 語句屬 ESM 之 module-level 語法,串入函式體內會語法錯誤,
|
||
* 故串接端須剝掉 `export {` 起始之那行(單行 `sed '/^export {/d'`
|
||
* 即可)。其餘 module.exports / globalThis 兩塊在閉包內安全(typeof
|
||
* 檢查不會 throw、globalThis 賦值無副作用)。
|
||
*
|
||
* 順序:先 CJS / globalThis(不影響語法解析),最後 ESM `export`,
|
||
* 讓串接端只需剝最後一塊。
|
||
*/
|
||
if (typeof module !== "undefined" && module.exports)
|
||
{
|
||
module.exports = { createCjkAutospace: createCjkAutospace };
|
||
}
|
||
if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
|
||
{
|
||
globalThis.createCjkAutospace = createCjkAutospace;
|
||
}
|
||
export { createCjkAutospace };
|