"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 透明走過。跨樣式邊界(如
* 是 Neovim)由 Pass A 在剝離樣式後識別。
* - PILL — 行內塊(有獨立背景,如 ),邊界由 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,} 之長英文段
* 包進 ;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
* 不污染原文。預設關閉以保持向後相容。
*
* Marker = (空白)。
* 內含真實 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 + "])(?= 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 為 直接跳過。已被本 pass 或作者標
語的英文段都會被 catch;e.g. … 雖然 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 / `