"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 / `