**alpha**
${content}`);
- return PH.CODE(codes.length - 1);
- });
- }
-
- // 2. LaTeX 수식
- const maths = [];
- if (SETTINGS.latex && typeof katex !== 'undefined') {
- html = html.replace(/(? {
- try {
- const clean = latex
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/&/g, '&');
- const rendered = katex.renderToString(clean, {
- throwOnError: true,
- output: 'html',
- displayMode: false
- });
- maths.push(`${rendered}`);
- return PH.MATH(maths.length - 1);
- } catch {
- return match;
- }
- });
- }
-
- // 3. 마크다운 서식
- if (SETTINGS.bold) html = html.replace(/\*\*(.*?)\*\*/g, '$1');
- if (SETTINGS.italic) html = html.replace(/(?$1');
- if (SETTINGS.strike) html = html.replace(/~~(.*?)~~/g, '"+rt(t[e].content)+"\n"},pt.fence=function(t,e,r,n,s){const i=t[e],o=i.info?X(i.info).trim():"";let u,c="",a="";if(o){const t=o.split(/(\s+)/g);c=t[0],a=t.slice(2).join("")}if(u=r.highlight&&r.highlight(i.content,c,a)||rt(i.content),0===u.indexOf("${u}\n`}return`${u}\n`},pt.image=function(t,e,r,n,s){const i=t[e];return i.attrs[i.attrIndex("alt")][1]=s.renderInlineAsText(i.children,r,n),s.renderToken(t,e,r)},pt.hardbreak=function(t,e,r){return r.xhtmlOut?"=p)return!1;for(a=i,u=t.md.helpers.parseLinkDestination(t.src,i,t.posMax),u.ok&&(l=t.md.normalizeLink(u.str),t.md.validateLink(l)?i=u.pos:l=""),a=i;i
=p||41!==t.src.charCodeAt(i))return t.pos=h,!1;i++}else{if(void 0===t.env.references)return!1;if(i
=0?s=t.src.slice(a,i++):i=d+1):i=d+1,s||(s=t.src.slice(f,d)),o=t.env.references[ct(s)],!o)return t.pos=h,!1;l=o.href,c=o.title}if(!e){n=t.src.slice(f,d);const e=[];t.md.inline.parse(n,t.md,t.env,e);const r=t.push("image","img",0),s=[["src",l],["alt",""]];r.attrs=s,r.children=e,r.content=n,c&&s.push(["title",c])}return t.pos=i,t.posMax=p,!0}],["autolink",function(t,e){let r=t.pos;if(60!==t.src.charCodeAt(r))return!1;const n=t.pos,s=t.posMax;for(;;){if(++r>=s)return!1;const e=t.src.charCodeAt(r);if(60===e)return!1;if(62===e)break}const i=t.src.slice(n+1,r);if(ee.test(i)){const r=t.md.normalizeLink(i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}if(te.test(i)){const r=t.md.normalizeLink("mailto:"+i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}return!1}],["html_inline",function(t,e){if(!t.md.options.html)return!1;const r=t.posMax,n=t.pos;if(60!==t.src.charCodeAt(n)||n+2>=r)return!1;const s=t.src.charCodeAt(n+1);if(33!==s&&63!==s&&47!==s&&!function(t){const e=32|t;return e>=97&&e<=122}(s))return!1;const i=t.src.slice(n).match(jt);if(!i)return!1;if(!e){const e=t.push("html_inline","",0);e.content=i[0],o=e.content,/^\s]/i.test(o)&&t.linkLevel++,function(t){return/^<\/a\s*>/i.test(t)}(e.content)&&t.linkLevel--}var o;return t.pos+=i[0].length,!0}],["entity",function(t,e){const r=t.pos,n=t.posMax;if(38!==t.src.charCodeAt(r))return!1;if(r+1>=n)return!1;if(35===t.src.charCodeAt(r+1)){const n=t.src.slice(r).match(re);if(n){if(!e){const e="x"===n[1][0].toLowerCase()?parseInt(n[1].slice(1),16):parseInt(n[1],10),r=t.push("text_special","",0);r.content=V(e)?G(e):G(65533),r.markup=n[0],r.info="entity"}return t.pos+=n[0].length,!0}}else{const n=t.src.slice(r).match(ne);if(n){const r=j(n[0]);if(r!==n[0]){if(!e){const e=t.push("text_special","",0);e.content=r,e.markup=n[0],e.info="entity"}return t.pos+=n[0].length,!0}}}return!1}]],oe=[["balance_pairs",function(t){const e=t.tokens_meta,r=t.tokens_meta.length;se(t.delimiters);for(let t=0;t **live answer** and $x^2$ **alpha** **outside** $native$ **code** **query** **edit** formatting does.
+ try {
+ const math = mathView(child.getAttribute('data-math'), !!child.querySelector('.katex-display'), document, katex);
+ const computed = global.getComputedStyle(child);
+ for (const [property, baseline] of [['display', 'inline'], ['visibility', 'visible'], ['opacity', '1']]) {
+ const value = computed.getPropertyValue(property);
+ if (property === 'display' ? value === 'none' : value !== baseline) math.style.setProperty(property, value);
+ }
+ fragment.appendChild(math);
+ } catch {
+ // A malformed final formula must not reset the rest of
+ // the paragraph or repeatedly switch display ownership.
+ appendLiteral(fragment, child.textContent || child.getAttribute('data-math'), document);
+ }
+ changed = true; return;
+ }
+ // Gemini can split one token across adjacent Text nodes/spans.
+ if (child.tagName === 'SPAN' && [...child.attributes].every(attribute =>
+ ['data-path-to-node', 'data-index-in-node'].includes(attribute.name) || attribute.name.startsWith('_ngcontent-'))) {
+ child.childNodes.forEach(visit); return;
+ }
+ flush();
+ const type = FORMATS[child.tagName];
+ const finalizedFormat = type && settings[type] && !child.hasAttribute('style') && !child.hasAttribute('class') &&
+ (child.hasAttribute('data-path-to-node') || child.hasAttribute('data-index-in-node'));
+ const tag = finalizedFormat ? { bold: 'b', italic: 'i', strike: 's', underline: 'u', code: 'code' }[type] : child.tagName.toLowerCase();
+ const copy = document.createElement(tag);
+ if (!finalizedFormat) {
+ const computed = global.getComputedStyle(child);
+ INLINE_STYLE.forEach(property => copy.style.setProperty(property, computed.getPropertyValue(property)));
+ if (child.tagName === 'CODE') CODE_BOX_STYLE.forEach(property => copy.style.setProperty(property, computed.getPropertyValue(property)));
+ }
+ // Completed Gemini tokens use our streaming presentation too;
+ // adopting native computed fonts here causes a visible final jump.
+ ['dir', 'lang'].forEach(name => { if (child.hasAttribute(name)) copy.setAttribute(name, child.getAttribute(name)); });
+ if (type && settings[type]) {
+ copy.classList.add(`gemini-parser-${type}`);
+ changed = true;
+ }
+ if (child.tagName === 'CODE') copy.textContent = child.textContent;
+ else copy.appendChild(childrenOf(child));
+ fragment.appendChild(copy);
+ }
+ node.childNodes.forEach(visit); flush(); return fragment;
+ }
+ const fragment = childrenOf(source);
+ return { fragment, changed };
+ }
+
+ function styleRules(colors, document) {
+ return COLOR_KEYS.map(key => {
+ const value = colors[key];
+ if (!value) return '';
+ const declaration = document.createElement('span').style;
+ declaration.cssText = value.customCss || '';
+ if (value.color) declaration.setProperty('color', value.color);
+ if (Number.isFinite(value.opacity)) declaration.setProperty('opacity', String(Math.max(0, Math.min(100, value.opacity)) / 100));
+ Array.from(declaration).forEach(property => declaration.setProperty(property, declaration.getPropertyValue(property), 'important'));
+ return `.gemini-parser-${key.slice(0, -5)} { ${declaration.cssText} }`;
+ }).join('\n');
+ }
+
+ function createEngine(options = {}) {
+ const document = options.document || global.document;
+ const markdown = tokenizer(options.markdownit || global.markdownit);
+ const settings = { ...DEFAULTS, ...options.settings };
+ const colors = Object.fromEntries(COLOR_KEYS.map(key => [key, options.settings?.[key] ?? null]));
+ const roots = new WeakMap();
+ const snapshots = new WeakMap();
+ const active = new Map();
+ const dirty = new Set();
+ let katexCss = options.katexCss || '';
+ let revision = 0;
+ let timer = null;
+ let scan = false;
+ let destroyed = false;
+ let observing = false;
+ let disclaimerStyle = null;
+ let fontStyle = null;
+ let containers = new Set();
+ let sharedSheet = null;
+ let sheetRevision = -1;
+ let styleText = '';
+ if (global.CSSStyleSheet?.prototype.replaceSync) sharedSheet = new global.CSSStyleSheet();
+ const counters = { flushes: 0, renderWrites: 0 };
+
+ function restore(element) {
+ const root = roots.get(element);
+ if (root) {
+ root.adoptedStyleSheets = [];
+ root.replaceChildren(document.createElement('slot'));
+ }
+ active.delete(element);
+ }
+
+ function styles() {
+ if (sheetRevision !== revision) {
+ const css = BASE_CSS + katexCss.replace(/@font-face\s*\{[^}]+\}/g, '') + '\n' + styleRules(colors, document);
+ if (css !== styleText) { styleText = css; sharedSheet?.replaceSync(css); }
+ sheetRevision = revision;
+ }
+ return styleText;
+ }
+
+ function projection(element) {
+ let root = roots.get(element);
+ if (!root) {
+ // Never take over another component's shadow root.
+ if (element.shadowRoot) return null;
+ try { root = element.attachShadow({ mode: 'open' }); }
+ catch { return null; }
+ roots.set(element, root);
+ }
+ const css = styles();
+ const style = sharedSheet ? null : document.createElement('style');
+ if (style) style.textContent = css;
+ else root.adoptedStyleSheets = [sharedSheet];
+ const original = document.createElement('span');
+ original.className = 'gp-original'; original.setAttribute('aria-hidden', 'true');
+ original.appendChild(document.createElement('slot'));
+ const view = document.createElement('span'); view.className = 'gp-view';
+ // The clipped slot keeps light-DOM innerText usable by native copy
+ // handlers, but is excluded from selection/accessibility. Only the
+ // preview takes space in normal flow. Original children never move.
+ root.replaceChildren(...(style ? [style, original, view] : [original, view]));
+ const entry = { root, style, view };
+ active.set(element, entry); return entry;
+ }
+
+ function process(element) {
+ if (!safeBlock(element)) { restore(element); snapshots.delete(element); return; }
+ if (element.shadowRoot && element.shadowRoot !== roots.get(element)) return;
+ // Keep native formulas visible until their projection stylesheet is
+ // ready; otherwise startup would briefly show unstyled math/text.
+ if (!katexCss && element.querySelector('.math-inline[data-math]')) { restore(element); snapshots.delete(element); return; }
+ const snapshot = element.innerHTML; // Read only; excludes the shadow tree.
+ const previous = snapshots.get(element);
+ if (previous?.source === snapshot && previous.revision === revision) return;
+ snapshots.set(element, { source: snapshot, revision });
+ try {
+ const parsed = renderBlock(element, settings, colors, document, katexCss ? options.katex : null, markdown);
+ if (!parsed.changed) { if (active.has(element)) restore(element); return; }
+ const entry = active.get(element) || projection(element);
+ if (!entry) return;
+ const css = styles();
+ if (entry.style && entry.style.textContent !== css) entry.style.textContent = css;
+ if (reconcile(entry.view, parsed.fragment)) counters.renderWrites++;
+ } catch (error) {
+ restore(element);
+ console.warn('[Gemini Parser] Kept native content after rendering error:', error);
+ }
+ }
+
+ function flush() {
+ timer = null;
+ if (destroyed || !settings.enabled) return;
+ counters.flushes++;
+ for (const element of active.keys()) {
+ if (!element.isConnected || !element.closest('.chat-container')) { restore(element); snapshots.delete(element); }
+ }
+ if (scan) {
+ containers = new Set(document.querySelectorAll('.chat-container'));
+ containers.forEach(container => container.querySelectorAll(BLOCKS).forEach(element => dirty.add(element)));
+ scan = false;
+ }
+ const batch = [...dirty]; dirty.clear();
+ batch.forEach(process);
+ }
+
+ function schedule(fullScan = false) {
+ if (destroyed || !settings.enabled) return;
+ scan ||= fullScan;
+ // Fixed coalescing window: continuous token arrival cannot postpone it.
+ if (timer === null) timer = global.setTimeout(flush, options.interval ?? 40);
+ }
+
+ function flushNow(fullScan = false) {
+ if (timer !== null) global.clearTimeout(timer);
+ timer = null; scan ||= fullScan; flush();
+ }
+
+ const observer = new global.MutationObserver(records => {
+ let fullScan = false;
+ let structuralChange = false;
+ for (const record of records) {
+ const target = record.target.nodeType === 1 ? record.target : record.target.parentElement;
+ const block = target?.closest(BLOCKS);
+ if (record.type === 'childList') {
+ // A new/replaced host otherwise shows raw content until the
+ // 40 ms token timer fires. Mount it in this microtask, before
+ // the next paint. Plain Text updates still use coalescing.
+ if (block && active.has(block) && [...record.addedNodes, ...record.removedNodes].some(node => node.nodeType === 1)) structuralChange = true;
+ if (Array.from(record.addedNodes).some(node => node.nodeType === 1 &&
+ (node.matches(BLOCKS) || node.querySelector(BLOCKS)) &&
+ (target?.closest('.chat-container') || node.matches('.chat-container') || node.querySelector('.chat-container')))) structuralChange = true;
+ }
+ if (block && (block.closest('.chat-container') || active.has(block))) {
+ dirty.add(block);
+ if (record.type === 'attributes') snapshots.delete(block);
+ } else if (record.type === 'childList' || target?.closest('.chat-container') || containers.has(target)) fullScan = true;
+ // Inline styles copied into the preview must follow ancestor
+ // theme changes too, even though the source HTML is unchanged.
+ if (record.type === 'attributes' && ['class', 'style'].includes(record.attributeName) &&
+ [...containers].some(container => target === container || target?.contains(container) ||
+ (container.contains(target) && target.querySelector(BLOCKS)))) {
+ revision++; fullScan = true;
+ }
+ }
+ if (fullScan || dirty.size) {
+ if (structuralChange) flushNow(fullScan);
+ else schedule(fullScan);
+ }
+ });
+
+ function start() {
+ if (destroyed || !settings.enabled || observing) return;
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
+ observing = true;
+ disclaimerStyle = document.createElement('style');
+ disclaimerStyle.textContent = 'hallucination-disclaimer { display: block !important; visibility: hidden !important; height: 16px !important; overflow: hidden !important; }';
+ document.head.appendChild(disclaimerStyle);
+ updateFonts();
+ scan = true; flush();
+ }
+
+ function stop() {
+ observer.disconnect(); observing = false;
+ if (timer !== null) global.clearTimeout(timer);
+ timer = null; dirty.clear(); scan = false;
+ for (const element of active.keys()) restore(element);
+ disclaimerStyle?.remove(); disclaimerStyle = null;
+ fontStyle?.remove(); fontStyle = null; containers.clear();
+ }
+
+ function updateFonts() {
+ if (!settings.enabled || !katexCss) return;
+ // Chromium does not register @font-face rules defined only inside a
+ // shadow root. Register just the font faces in the document scope.
+ if (!fontStyle) { fontStyle = document.createElement('style'); document.head.appendChild(fontStyle); }
+ fontStyle.textContent = (katexCss.match(/@font-face\s*\{[^}]+\}/g) || []).join('\n');
+ }
+
+ function updateSettings(values = {}) {
+ if (destroyed) return;
+ let changed = false;
+ for (const key of Object.keys(DEFAULTS)) {
+ if (typeof values[key] === 'boolean' && values[key] !== settings[key]) { settings[key] = values[key]; changed = true; }
+ }
+ for (const key of COLOR_KEYS) {
+ if (Object.hasOwn(values, key) && JSON.stringify(values[key]) !== JSON.stringify(colors[key])) {
+ colors[key] = values[key]; changed = true;
+ }
+ }
+ if (!changed) return;
+ revision++;
+ if (!settings.enabled) stop();
+ else if (!observing) start();
+ else schedule(true);
+ }
+
+ const api = {
+ updateSettings,
+ setKatexCss(css) { if (!destroyed && css !== katexCss) { katexCss = css; revision++; updateFonts(); schedule(true); } },
+ refresh() { revision++; schedule(true); },
+ destroy() { stop(); destroyed = true; },
+ diagnostics() { return { ...counters, enabled: settings.enabled, activeProjections: active.size, pendingBlocks: dirty.size }; }
+ };
+ start();
+ return api;
+ }
+
+ global.GeminiParser = { createEngine, DEFAULTS, COLOR_KEYS };
+})(globalThis);
diff --git a/scripts/build-userscript.cjs b/scripts/build-userscript.cjs
new file mode 100644
index 0000000..cd45aae
--- /dev/null
+++ b/scripts/build-userscript.cjs
@@ -0,0 +1,29 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const root = path.resolve(__dirname, '..');
+const version = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')).version;
+const header = `// ==UserScript==
+// @name Fix Gemini Parser
+// @namespace http://tampermonkey.net/
+// @version ${version}
+// @description Gemini의 스트리밍 마크다운과 수식을 원본 DOM을 유지하며 렌더링합니다.
+// @author kangjoseph90
+// @match https://gemini.google.com/*
+// @grant GM_getResourceText
+// @grant GM_getValue
+// @grant GM_setValue
+// @grant GM_registerMenuCommand
+// @require https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js
+// @require https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js
+// @resource KATEX_CSS https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css
+// ==/UserScript==
+
+// Generated by scripts/build-userscript.cjs. Edit parser-engine.js or userscript-bootstrap.js.
+`;
+const output = header + ['parser-engine.js', 'userscript-bootstrap.js'].map(name => fs.readFileSync(path.join(root, name), 'utf8')).join('\n');
+const destination = path.join(root, 'user-script.js');
+if (process.argv.includes('--check')) {
+ if (fs.readFileSync(destination, 'utf8') !== output) {
+ console.error('user-script.js is out of date. Run npm run build:userscript.'); process.exitCode = 1;
+ }
+} else fs.writeFileSync(destination, output);
diff --git a/tests/extension.test.cjs b/tests/extension.test.cjs
new file mode 100644
index 0000000..ae11299
--- /dev/null
+++ b/tests/extension.test.cjs
@@ -0,0 +1,51 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+const root = path.resolve(__dirname, '..');
+
+test('installed MV3 extension renders and toggles through the real popup and storage APIs', async t => {
+ const results = path.join(root, 'test-results'); fs.mkdirSync(results, { recursive: true });
+ const profile = fs.mkdtempSync(path.join(results, 'extension-profile-'));
+ const context = await chromium.launchPersistentContext(profile, {
+ headless: true, executablePath: process.env.EXTENSION_CHROME_PATH || chromium.executablePath(),
+ args: [`--disable-extensions-except=${root}`, `--load-extension=${root}`]
+ });
+ t.after(async () => { await context.close(); fs.rmSync(profile, { recursive: true, force: true }); });
+ await context.route('https://gemini.google.com/**', route => route.fulfill({
+ contentType: 'text/html',
+ headers: { 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' chrome-extension:; require-trusted-types-for 'script'; trusted-types 'none'" },
+ body: '}$ tail
');
+ });
+ await page.waitForTimeout(70);
+ assert.deepEqual(await page.evaluate(() => ['answer', 'math', 'pre', 'user', 'editor', 'cell', 'outside'].map(id => !!document.getElementById(id).shadowRoot)), Array(7).fill(false));
+});
+
+test('another component shadow root is never overwritten', async t => {
+ const page = await fixture(t, { text: 'plain' });
+ await page.evaluate(() => {
+ const shadow = source.attachShadow({ mode: 'open' }); const child = document.createElement('span');
+ child.textContent = 'native component'; shadow.append(child); originalText.data = '**changed**';
+ });
+ await page.waitForTimeout(70);
+ assert.equal(await page.evaluate(() => source.shadowRoot.textContent), 'native component');
+});
+
+test('class-only completion is observed and identical content does not rewrite the view', async t => {
+ const page = await fixture(t);
+ const before = await page.evaluate(() => engine.diagnostics());
+ await page.evaluate(() => document.querySelector('footer').classList.remove('animated'));
+ await page.waitForFunction(count => engine.diagnostics().flushes > count, before.flushes);
+ assert.equal(await page.evaluate(() => engine.diagnostics().renderWrites), before.renderWrites);
+});
+
+test('500 synchronous updates coalesce and a sustained stream renders before its final token', async t => {
+ const page = await fixture(t);
+ const before = await page.evaluate(() => engine.diagnostics().renderWrites);
+ await page.evaluate(() => { for (let i = 0; i < 500; i++) originalText.data = `**token ${i}**`; });
+ await rendered(page, 'token 499');
+ assert.equal(await page.evaluate(count => engine.diagnostics().renderWrites - count, before), 1);
+ assert.equal(await page.evaluate(async () => {
+ const before = engine.diagnostics().renderWrites; let mid = 0;
+ for (let i = 0; i < 14; i++) {
+ originalText.data = `**live ${i}**`; await new Promise(resolve => setTimeout(resolve, 12));
+ if (i === 6) mid = engine.diagnostics().renderWrites - before;
+ }
+ return mid > 0;
+ }), true);
+ await rendered(page, 'live 13');
+});
+
+test('shadow writes do not retrigger the native observer and destroy cancels pending updates', async t => {
+ const page = await fixture(t);
+ await page.waitForTimeout(80);
+ const before = await page.evaluate(() => engine.diagnostics().flushes);
+ await page.waitForTimeout(80);
+ assert.equal(await page.evaluate(() => engine.diagnostics().flushes), before);
+ await page.evaluate(() => { originalText.data = '**queued**'; engine.destroy(); });
+ await page.waitForTimeout(80);
+ assert.equal(await page.evaluate(() => view()), null);
+});
+
+test('nested emphasis, strikethrough, literal underline and code remain correctly separated', async t => {
+ const page = await fixture(t, { text: '**bold *italic*** ~~gone~~ under `**code** $x$`' });
+ await rendered(page, 'bold italic gone under **code** $x$');
+ assert.deepEqual(await page.evaluate(() => [view().querySelector('b i').textContent,
+ view().querySelector('s').textContent, view().querySelector('u').textContent,
+ view().querySelector('code').textContent, view().querySelectorAll('.katex').length]), ['italic', 'gone', 'under', '**code** $x$', 0]);
+});
+
+test('escaped delimiters and disabled code do not leak formatting into code contents', async t => {
+ const page = await fixture(t, { text: '\\*literal\\* **yes** `**code** $x$`', settings: { code: false } });
+ await rendered(page, '*literal* yes `**code** $x$`');
+ assert.deepEqual(await page.evaluate(() => [view().querySelectorAll('i').length, view().querySelectorAll('b').length,
+ view().querySelectorAll('.katex').length]), [0, 1, 0]);
+});
+
+test('disabled math stays opaque to emphasis, and a long literal dollar run is not parsed as math', async t => {
+ const page = await fixture(t, { text: '$a*b*c$ **yes**', settings: { latex: false } });
+ await rendered(page, '$a*b*c$ yes');
+ assert.equal(await page.evaluate(() => view().querySelectorAll('i,.katex').length), 0);
+ await page.evaluate(() => { originalText.data = '$'.repeat(10000) + ' **after**'; });
+ await rendered(page, '$'.repeat(10000) + ' after');
+});
+
+test('adjacent code inside emphasis cannot close the outer emphasis', async t => {
+ const page = await fixture(t, { text: '**before `**code**` after**' });
+ await rendered(page, 'before **code** after');
+ assert.equal(await page.evaluate(() => view().querySelector('b code').textContent), '**code**');
+});
+
+test('a token split across native text nodes and neutral spans is parsed once', async t => {
+ const page = await fixture(t, { html: '**bold**' });
+ await rendered(page, 'bold');
+ assert.equal(await page.evaluate(() => source.childNodes.length), 2);
+ assert.equal(await page.evaluate(() => view().querySelectorAll('b').length), 1);
+});
+
+test('native formatting and opaque native inline code survive independent feature toggles', async t => {
+ const page = await fixture(t, { html: 'native **fixed** **table** **literal**' });
+ await rendered(page, 'native fixed **literal**');
+ assert.equal(await page.evaluate(() => getComputedStyle(view().querySelector('strong')).color), 'rgb(100, 0, 0)');
+ await page.evaluate(() => engine.updateSettings({ bold: false }));
+ await rendered(page, 'native **fixed** **literal**');
+ assert.equal(await page.evaluate(() => view().querySelector('strong').textContent), 'native');
+ assert.equal(await page.evaluate(() => source.innerHTML), 'native **fixed** **literal**');
+});
+
+test('completion class and identical snapshots retain displayed nodes', async t => {
+ const page = await fixture(t, { text: '**steady** $x^2$' });
+ await page.evaluate(() => {
+ window.displayedBold = view().querySelector('b'); window.displayedMath = view().querySelector('.gemini-parser-math');
+ document.querySelector('#response').classList.add('complete');
+ document.querySelector('footer').classList.remove('animated');
+ source.setAttribute('data-path-to-node', '0');
+ });
+ await page.waitForTimeout(70);
+ assert.deepEqual(await page.evaluate(() => [view().querySelector('b') === displayedBold,
+ view().querySelector('.gemini-parser-math') === displayedMath]), [true, true]);
+});
+
+test('native final formatting and math metadata keep the existing projection and math tree before paint', async t => {
+ const page = await fixture(t, { text: '**steady** and $x^2$' });
+ assert.deepEqual(await page.evaluate(async () => {
+ const oldView = view(); const oldBold = oldView.querySelector('b'); const oldMath = oldView.querySelector('.gemini-parser-math');
+ const bold = document.createElement('b'); bold.textContent = 'steady';
+ bold.setAttribute('data-path-to-node', '0,0'); bold.setAttribute('data-index-in-node', '0');
+ const math = document.createElement('span'); math.className = 'math-inline';
+ math.setAttribute('data-math', 'x^2'); math.setAttribute('data-index-in-node', '2');
+ katex.render('x^2', math, { output: 'html' });
+ source.replaceChildren(bold, document.createTextNode(' and '), math);
+ await new Promise(requestAnimationFrame);
+ return [view() === oldView, view().querySelector('b') === oldBold,
+ view().querySelector('.gemini-parser-math') === oldMath, source.firstChild === bold,
+ view().querySelectorAll('.gemini-parser-math').length];
+ }), [true, true, true, true, 1]);
+});
+
+test('replacement paragraph gets its projection before the first animation frame', async t => {
+ const page = await fixture(t);
+ assert.equal(await page.evaluate(async () => {
+ const replacement = document.createElement('p'); replacement.id = 'answer'; replacement.textContent = '**finished**';
+ source.replaceWith(replacement); source = replacement;
+ await new Promise(requestAnimationFrame);
+ return view()?.querySelector('b')?.textContent;
+ }), 'finished');
+});
+
+test('completed metadata tokens retain streaming fonts, styles, and inline node identity', async t => {
+ const page = await fixture(t, { text: '**steady** `code`' });
+ assert.deepEqual(await page.evaluate(async () => {
+ const style = document.createElement('style'); style.textContent = 'b{font-weight:500}code{font:30px serif;color:red}';
+ document.head.appendChild(style);
+ const bold = view().querySelector('b'); const code = view().querySelector('code');
+ const before = [getComputedStyle(bold).fontWeight, getComputedStyle(code).fontFamily, getComputedStyle(code).fontSize];
+ const nativeBold = document.createElement('b'); nativeBold.textContent = 'steady'; nativeBold.setAttribute('data-path-to-node', '0,0');
+ const nativeCode = document.createElement('code'); nativeCode.textContent = 'code'; nativeCode.setAttribute('data-path-to-node', '0,2');
+ source.replaceChildren(nativeBold, document.createTextNode(' '), nativeCode);
+ await new Promise(requestAnimationFrame);
+ const after = [getComputedStyle(view().querySelector('b')).fontWeight,
+ getComputedStyle(view().querySelector('code')).fontFamily, getComputedStyle(view().querySelector('code')).fontSize];
+ return [JSON.stringify(before) === JSON.stringify(after), view().querySelector('b') === bold, view().querySelector('code') === code];
+ }), [true, true, true]);
+});
+
+test('appended streaming text retains the equal formula prefix and its text node', async t => {
+ const page = await fixture(t, { text: '$x^2$ tail' });
+ await page.evaluate(() => {
+ window.oldFormula = view().querySelector('.gemini-parser-math');
+ window.oldTail = view().lastChild;
+ originalText.data = '$x^2$ tail grows';
+ });
+ await page.waitForFunction(() => view().lastChild.textContent === ' tail grows');
+ assert.deepEqual(await page.evaluate(() => [view().querySelector('.gemini-parser-math') === oldFormula,
+ view().lastChild === oldTail, source.firstChild === originalText]), [true, true, true]);
+});
+
+test('inline code keeps its visible themed chip during streaming, completion, and toggles', async t => {
+ const page = await fixture(t, { text: 'inline `const x = "$math$ **literal**";`' });
+ assert.deepEqual(await page.evaluate(async () => {
+ source.style.setProperty('--lumi-sys-color--code-background', '#171717');
+ source.style.setProperty('--lumi-sys-color--on-surface-variant', 'rgba(255,255,255,.55)');
+ const code = view().querySelector('code');
+ const box = node => {
+ const style = getComputedStyle(node);
+ return ['backgroundColor', 'color', 'display', 'padding', 'borderRadius', 'fontFamily', 'fontSize'].map(key => style[key]);
+ };
+ const before = box(code);
+ const native = document.createElement('code'); native.textContent = code.textContent;
+ native.setAttribute('data-path-to-node', '0,1');
+ source.replaceChildren(document.createTextNode('inline '), native);
+ await new Promise(requestAnimationFrame);
+ const stable = view().querySelector('code') === code && JSON.stringify(box(code)) === JSON.stringify(before);
+ engine.updateSettings({ enabled: false });
+ const restored = source.shadowRoot.firstChild.tagName === 'SLOT' && source.lastChild === native;
+ engine.updateSettings({ enabled: true });
+ return [before, stable, restored, JSON.stringify(box(view().querySelector('code'))) === JSON.stringify(before),
+ view().querySelector('code').textContent, view().querySelectorAll('.katex,b').length];
+ }), [['rgb(23, 23, 23)', 'rgba(255, 255, 255, 0.55)', 'inline-block', '4px 6px', '9999px',
+ '"Google Sans Code", monospace', '15px'], true, true, true, 'const x = "$math$ **literal**";', 0]);
+});
+
+test('code theme changes and user overrides apply without replacing the code node', async t => {
+ const page = await fixture(t, { text: '`code`' });
+ const themes = await page.evaluate(() => {
+ const code = view().querySelector('code');
+ window.originalCode = code;
+ const background = () => getComputedStyle(code).backgroundColor;
+ const fallback = background();
+ source.style.setProperty('--lumi-sys-color--code-background', '#eeeeee');
+ const light = background();
+ source.style.setProperty('--lumi-sys-color--code-background', '#171717');
+ const dark = background();
+ engine.updateSettings({ codeColor: { color: '#ff0000', opacity: 75, customCss: 'border-radius: 4px; padding: 2px;' } });
+ return [fallback !== 'rgba(0, 0, 0, 0)', light, dark];
+ });
+ assert.deepEqual(themes, [true, 'rgb(238, 238, 238)', 'rgb(23, 23, 23)']);
+ await page.waitForFunction(() => getComputedStyle(view().querySelector('code')).color === 'rgb(255, 0, 0)');
+ assert.deepEqual(await page.evaluate(() => {
+ const code = view().querySelector('code'); const style = getComputedStyle(code);
+ return [code === originalCode, style.opacity, style.borderRadius, style.padding];
+ }), [true, '0.75', '4px', '2px']);
+});
+
+test('disabling code repair preserves the native code box inside other repaired formatting', async t => {
+ const page = await fixture(t, { html: '**bold** native', settings: { code: false } });
+ assert.deepEqual(await page.evaluate(() => {
+ const native = source.querySelector('code'); const code = view().querySelector('code');
+ const box = node => { const style = getComputedStyle(node); return ['backgroundColor', 'display', 'padding', 'borderRadius'].map(key => style[key]); };
+ return [JSON.stringify(box(native)) === JSON.stringify(box(code)), code.textContent,
+ code.classList.contains('gemini-parser-code'), view().querySelector('b').textContent];
+ }), [true, 'native', false, 'bold']);
+});
+
+test('quoted Korean emphasis and strike repair without loosening identifier underscores', async t => {
+ const page = await fixture(t, { text: '**"중요"**입니다 **(괄호)**도 *"기울임"*은 ~~"취소"~~입니다 변수__이름__입니다' });
+ await rendered(page, '"중요"입니다 (괄호)도 "기울임"은 "취소"입니다 변수__이름__입니다');
+ assert.deepEqual(await page.evaluate(() => [Array.from(view().querySelectorAll('b')).map(b => b.textContent),
+ view().querySelector('i').textContent, view().querySelector('s').textContent]), [['"중요"', '(괄호)'], '"기울임"', '"취소"']);
+});
+
+test('inline code backslashes are literal and closing ticks still end the code', async t => {
+ const page = await fixture(t, { text: '`C:\\` **after**' });
+ await rendered(page, 'C:\\ after');
+ assert.deepEqual(await page.evaluate(() => [view().querySelector('code').textContent,
+ view().querySelector('b').textContent]), ['C:\\', 'after']);
+});
+
+test('code whitespace normalization matches finalized native tokens', async t => {
+ const page = await fixture(t, { text: '`` code ` x `` and `a\nb` and ` `' });
+ assert.deepEqual(await page.evaluate(() => Array.from(view().querySelectorAll('code')).map(c => c.textContent)), ['code ` x', 'a b', ' ']);
+ assert.equal(await page.evaluate(async () => {
+ const codes = Array.from(view().querySelectorAll('code'));
+ source.replaceChildren(...Array.from(view().childNodes).map(node => {
+ if (node.nodeType === 3) return node.cloneNode();
+ const code = document.createElement('code'); code.textContent = node.textContent; code.setAttribute('data-path-to-node', '0'); return code;
+ }));
+ await new Promise(requestAnimationFrame);
+ return codes.every((code, i) => view().querySelectorAll('code')[i] === code);
+ }), true);
+});
+
+test('literal HTML entity spellings are not decoded a second time from native text', async t => {
+ const page = await fixture(t, { text: '© & * **yes**' });
+ await rendered(page, '© & * yes');
+ assert.equal(await page.evaluate(() => source.textContent), '© & * **yes**');
+});
+
+test('bracket LaTeX delimiters stay opaque and render instead of losing backslashes', async t => {
+ const page = await fixture(t, { text: '\\(x^2\\) **bold** \\[\\frac{a}{b}\\]' });
+ assert.deepEqual(await page.evaluate(() => [view().querySelectorAll('.katex').length,
+ view().querySelectorAll('.katex-display').length, view().querySelector('b').textContent]), [2, 1, 'bold']);
+ await page.evaluate(() => engine.updateSettings({ latex: false }));
+ await rendered(page, '\\(x^2\\) bold \\[\\frac{a}{b}\\]');
+});
+
+test('hidden and translucent native inline content keeps its presentation in the shadow view', async t => {
+ const page = await fixture(t, { html: '**fixed** hidden bold' });
+ assert.deepEqual(await page.evaluate(() => [getComputedStyle(view().querySelector('span')).display,
+ getComputedStyle(view().querySelectorAll('span')[1]).opacity, getComputedStyle(view().querySelectorAll('span')[1]).visibility,
+ getComputedStyle(view().querySelectorAll('b')[1]).display]), ['none', '0.2', 'hidden', 'none']);
+});
+
+test('class changes on ancestors inside the chat refresh copied inline styles', async t => {
+ const page = await fixture(t, { html: 'native **fixed**' });
+ await page.evaluate(() => {
+ const style = document.createElement('style');style.textContent = '.theme .native{color:rgb(0,80,120)}';document.head.append(style);
+ document.querySelector('#response').classList.add('theme');
+ });
+ await page.waitForFunction(() => getComputedStyle(view().querySelector('span')).color === 'rgb(0, 80, 120)', null, { timeout: 1000 });
+});
+
+test('prices do not become formulas or swallow following emphasis', async t => {
+ const page = await fixture(t, { text: '가격 $20이고 **중요**합니다. cost $20 and $30; **bold**. 수식 $20$과 $2+x$' });
+ assert.deepEqual(await page.evaluate(() => [Array.from(view().querySelectorAll('b')).map(b => b.textContent),
+ Array.from(view().querySelectorAll('.gemini-parser-math')).map(m => m.getAttribute('data-math'))]), [['중요', 'bold'], ['20', '2+x']]);
+ assert.ok(await page.evaluate(() => view().textContent.includes('cost $20 and $30;')));
+});
+
+test('metadata-only spans can split inline delimiters without blocking repair', async t => {
+ const page = await fixture(t, { html: '**bold**' });
+ await rendered(page, 'bold');
+ assert.deepEqual(await page.evaluate(() => [view().querySelectorAll('b').length, source.innerHTML === originalMarkup]), [1, true]);
+});
+
+test('nested underline pairs ignore tag spellings inside opaque code and math', async t => {
+ const page = await fixture(t, { text: 'outer inner `` $\\text{
formatting does.
+ try {
+ const math = mathView(child.getAttribute('data-math'), !!child.querySelector('.katex-display'), document, katex);
+ const computed = global.getComputedStyle(child);
+ for (const [property, baseline] of [['display', 'inline'], ['visibility', 'visible'], ['opacity', '1']]) {
+ const value = computed.getPropertyValue(property);
+ if (property === 'display' ? value === 'none' : value !== baseline) math.style.setProperty(property, value);
+ }
+ fragment.appendChild(math);
+ } catch {
+ // A malformed final formula must not reset the rest of
+ // the paragraph or repeatedly switch display ownership.
+ appendLiteral(fragment, child.textContent || child.getAttribute('data-math'), document);
+ }
+ changed = true; return;
+ }
+ // Gemini can split one token across adjacent Text nodes/spans.
+ if (child.tagName === 'SPAN' && [...child.attributes].every(attribute =>
+ ['data-path-to-node', 'data-index-in-node'].includes(attribute.name) || attribute.name.startsWith('_ngcontent-'))) {
+ child.childNodes.forEach(visit); return;
+ }
+ flush();
+ const type = FORMATS[child.tagName];
+ const finalizedFormat = type && settings[type] && !child.hasAttribute('style') && !child.hasAttribute('class') &&
+ (child.hasAttribute('data-path-to-node') || child.hasAttribute('data-index-in-node'));
+ const tag = finalizedFormat ? { bold: 'b', italic: 'i', strike: 's', underline: 'u', code: 'code' }[type] : child.tagName.toLowerCase();
+ const copy = document.createElement(tag);
+ if (!finalizedFormat) {
+ const computed = global.getComputedStyle(child);
+ INLINE_STYLE.forEach(property => copy.style.setProperty(property, computed.getPropertyValue(property)));
+ if (child.tagName === 'CODE') CODE_BOX_STYLE.forEach(property => copy.style.setProperty(property, computed.getPropertyValue(property)));
}
- return children;
+ // Completed Gemini tokens use our streaming presentation too;
+ // adopting native computed fonts here causes a visible final jump.
+ ['dir', 'lang'].forEach(name => { if (child.hasAttribute(name)) copy.setAttribute(name, child.getAttribute(name)); });
+ if (type && settings[type]) {
+ copy.classList.add(`gemini-parser-${type}`);
+ changed = true;
+ }
+ if (child.tagName === 'CODE') copy.textContent = child.textContent;
+ else copy.appendChild(childrenOf(child));
+ fragment.appendChild(copy);
+ }
+ node.childNodes.forEach(visit); flush(); return fragment;
}
+ const fragment = childrenOf(source);
+ return { fragment, changed };
}
- // ==================================
- // Markdown → HTML 변환
- // ==================================
- function render(text, preserved = []) {
- // HTML 이스케이프
- let html = text
- .replace(/&/g, '&')
- .replace(//g, '>');
+ function styleRules(colors, document) {
+ return COLOR_KEYS.map(key => {
+ const value = colors[key];
+ if (!value) return '';
+ const declaration = document.createElement('span').style;
+ declaration.cssText = value.customCss || '';
+ if (value.color) declaration.setProperty('color', value.color);
+ if (Number.isFinite(value.opacity)) declaration.setProperty('opacity', String(Math.max(0, Math.min(100, value.opacity)) / 100));
+ Array.from(declaration).forEach(property => declaration.setProperty(property, declaration.getPropertyValue(property), 'important'));
+ return `.gemini-parser-${key.slice(0, -5)} { ${declaration.cssText} }`;
+ }).join('\n');
+ }
- // 1. 인라인 코드 보호
- const codes = [];
- if (SETTINGS.code) {
- html = html.replace(/(`+)(.*?)\1/g, (_, tick, content) => {
- codes.push(`${content}`);
- return PH.CODE(codes.length - 1);
- });
+ function createEngine(options = {}) {
+ const document = options.document || global.document;
+ const markdown = tokenizer(options.markdownit || global.markdownit);
+ const settings = { ...DEFAULTS, ...options.settings };
+ const colors = Object.fromEntries(COLOR_KEYS.map(key => [key, options.settings?.[key] ?? null]));
+ const roots = new WeakMap();
+ const snapshots = new WeakMap();
+ const active = new Map();
+ const dirty = new Set();
+ let katexCss = options.katexCss || '';
+ let revision = 0;
+ let timer = null;
+ let scan = false;
+ let destroyed = false;
+ let observing = false;
+ let disclaimerStyle = null;
+ let fontStyle = null;
+ let containers = new Set();
+ let sharedSheet = null;
+ let sheetRevision = -1;
+ let styleText = '';
+ if (global.CSSStyleSheet?.prototype.replaceSync) sharedSheet = new global.CSSStyleSheet();
+ const counters = { flushes: 0, renderWrites: 0 };
+
+ function restore(element) {
+ const root = roots.get(element);
+ if (root) {
+ root.adoptedStyleSheets = [];
+ root.replaceChildren(document.createElement('slot'));
+ }
+ active.delete(element);
}
- // 2. LaTeX 수식
- const maths = [];
- if (SETTINGS.latex && typeof katex !== 'undefined') {
- html = html.replace(/(? {
- try {
- const clean = latex
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/&/g, '&');
- const rendered = katex.renderToString(clean, {
- throwOnError: true,
- output: 'html',
- displayMode: false
- });
- maths.push(`${rendered}`);
- return PH.MATH(maths.length - 1);
- } catch {
- return match;
- }
- });
+ function styles() {
+ if (sheetRevision !== revision) {
+ const css = BASE_CSS + katexCss.replace(/@font-face\s*\{[^}]+\}/g, '') + '\n' + styleRules(colors, document);
+ if (css !== styleText) { styleText = css; sharedSheet?.replaceSync(css); }
+ sheetRevision = revision;
+ }
+ return styleText;
}
- // 3. 마크다운 서식
- if (SETTINGS.bold) html = html.replace(/\*\*(.*?)\*\*/g, '$1');
- if (SETTINGS.italic) html = html.replace(/(?$1');
- if (SETTINGS.strike) html = html.replace(/~~(.*?)~~/g, '$1');
- if (SETTINGS.underline) html = html.replace(/<u>(.*?)<\/u>/g, '$1');
-
- // 4. 코드 복구
- if (SETTINGS.code) {
- html = html.replace(PH.REGEX.CODE, (_, i) => codes[i]);
+ function projection(element) {
+ let root = roots.get(element);
+ if (!root) {
+ // Never take over another component's shadow root.
+ if (element.shadowRoot) return null;
+ try { root = element.attachShadow({ mode: 'open' }); }
+ catch { return null; }
+ roots.set(element, root);
+ }
+ const css = styles();
+ const style = sharedSheet ? null : document.createElement('style');
+ if (style) style.textContent = css;
+ else root.adoptedStyleSheets = [sharedSheet];
+ const original = document.createElement('span');
+ original.className = 'gp-original'; original.setAttribute('aria-hidden', 'true');
+ original.appendChild(document.createElement('slot'));
+ const view = document.createElement('span'); view.className = 'gp-view';
+ // The clipped slot keeps light-DOM innerText usable by native copy
+ // handlers, but is excluded from selection/accessibility. Only the
+ // preview takes space in normal flow. Original children never move.
+ root.replaceChildren(...(style ? [style, original, view] : [original, view]));
+ const entry = { root, style, view };
+ active.set(element, entry); return entry;
}
- // 5. 줄바꿈
- html = html.replace(/\n/g, '
');
-
- // 6. 수식 복구
- if (SETTINGS.latex) {
- html = html.replace(PH.REGEX.MATH, (_, i) => maths[i]);
+ function process(element) {
+ if (!safeBlock(element)) { restore(element); snapshots.delete(element); return; }
+ if (element.shadowRoot && element.shadowRoot !== roots.get(element)) return;
+ // Keep native formulas visible until their projection stylesheet is
+ // ready; otherwise startup would briefly show unstyled math/text.
+ if (!katexCss && element.querySelector('.math-inline[data-math]')) { restore(element); snapshots.delete(element); return; }
+ const snapshot = element.innerHTML; // Read only; excludes the shadow tree.
+ const previous = snapshots.get(element);
+ if (previous?.source === snapshot && previous.revision === revision) return;
+ snapshots.set(element, { source: snapshot, revision });
+ try {
+ const parsed = renderBlock(element, settings, colors, document, katexCss ? options.katex : null, markdown);
+ if (!parsed.changed) { if (active.has(element)) restore(element); return; }
+ const entry = active.get(element) || projection(element);
+ if (!entry) return;
+ const css = styles();
+ if (entry.style && entry.style.textContent !== css) entry.style.textContent = css;
+ if (reconcile(entry.view, parsed.fragment)) counters.renderWrites++;
+ } catch (error) {
+ restore(element);
+ console.warn('[Gemini Parser] Kept native content after rendering error:', error);
+ }
}
- // 7. 보존된 요소 복구
- html = html.replace(PH.REGEX.ELEM, (_, i) => preserved[i]);
+ function flush() {
+ timer = null;
+ if (destroyed || !settings.enabled) return;
+ counters.flushes++;
+ for (const element of active.keys()) {
+ if (!element.isConnected || !element.closest('.chat-container')) { restore(element); snapshots.delete(element); }
+ }
+ if (scan) {
+ containers = new Set(document.querySelectorAll('.chat-container'));
+ containers.forEach(container => container.querySelectorAll(BLOCKS).forEach(element => dirty.add(element)));
+ scan = false;
+ }
+ const batch = [...dirty]; dirty.clear();
+ batch.forEach(process);
+ }
- return html;
- }
+ function schedule(fullScan = false) {
+ if (destroyed || !settings.enabled) return;
+ scan ||= fullScan;
+ // Fixed coalescing window: continuous token arrival cannot postpone it.
+ if (timer === null) timer = global.setTimeout(flush, options.interval ?? 40);
+ }
- // ==================================
- // 처리 필요 여부 체크
- // ==================================
- function needsProcessing(text) {
- if (!text.trim()) return false;
- return /\*|\$|`|~~|/.test(text);
- }
+ function flushNow(fullScan = false) {
+ if (timer !== null) global.clearTimeout(timer);
+ timer = null; scan ||= fullScan; flush();
+ }
- // ==================================
- // 면책 조항 제거
- // ==================================
- function removeDisclaimers() {
- // hallucination-disclaimer 요소를 높이 16px 빈 공간으로 변경
- document.querySelectorAll('hallucination-disclaimer').forEach(el => {
- el.style.height = '16px';
- el.style.display = 'block';
- el.style.visibility = 'hidden';
- el.innerHTML = '';
+ const observer = new global.MutationObserver(records => {
+ let fullScan = false;
+ let structuralChange = false;
+ for (const record of records) {
+ const target = record.target.nodeType === 1 ? record.target : record.target.parentElement;
+ const block = target?.closest(BLOCKS);
+ if (record.type === 'childList') {
+ // A new/replaced host otherwise shows raw content until the
+ // 40 ms token timer fires. Mount it in this microtask, before
+ // the next paint. Plain Text updates still use coalescing.
+ if (block && active.has(block) && [...record.addedNodes, ...record.removedNodes].some(node => node.nodeType === 1)) structuralChange = true;
+ if (Array.from(record.addedNodes).some(node => node.nodeType === 1 &&
+ (node.matches(BLOCKS) || node.querySelector(BLOCKS)) &&
+ (target?.closest('.chat-container') || node.matches('.chat-container') || node.querySelector('.chat-container')))) structuralChange = true;
+ }
+ if (block && (block.closest('.chat-container') || active.has(block))) {
+ dirty.add(block);
+ if (record.type === 'attributes') snapshots.delete(block);
+ } else if (record.type === 'childList' || target?.closest('.chat-container') || containers.has(target)) fullScan = true;
+ // Inline styles copied into the preview must follow ancestor
+ // theme changes too, even though the source HTML is unchanged.
+ if (record.type === 'attributes' && ['class', 'style'].includes(record.attributeName) &&
+ [...containers].some(container => target === container || target?.contains(container) ||
+ (container.contains(target) && target.querySelector(BLOCKS)))) {
+ revision++; fullScan = true;
+ }
+ }
+ if (fullScan || dirty.size) {
+ if (structuralChange) flushNow(fullScan);
+ else schedule(fullScan);
+ }
});
- }
-
- // ==================================
- // 메인 렌더링
- // ==================================
- const SELECTOR = 'p:not([data-rendered]), h1:not([data-rendered]), h2:not([data-rendered]), h3:not([data-rendered]), h4:not([data-rendered]), td:not([data-rendered]), th:not([data-rendered])';
- function reRender() {
- if (!SETTINGS.enabled) return;
-
- const container = document.querySelector('.chat-container');
- if (!container) return;
+ function start() {
+ if (destroyed || !settings.enabled || observing) return;
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
+ observing = true;
+ disclaimerStyle = document.createElement('style');
+ disclaimerStyle.textContent = 'hallucination-disclaimer { display: block !important; visibility: hidden !important; height: 16px !important; overflow: hidden !important; }';
+ document.head.appendChild(disclaimerStyle);
+ updateFonts();
+ scan = true; flush();
+ }
- // 스트리밍 중이면 건너뛰기
- if (container.querySelector('.response-footer.animated')) return;
+ function stop() {
+ observer.disconnect(); observing = false;
+ if (timer !== null) global.clearTimeout(timer);
+ timer = null; dirty.clear(); scan = false;
+ for (const element of active.keys()) restore(element);
+ disclaimerStyle?.remove(); disclaimerStyle = null;
+ fontStyle?.remove(); fontStyle = null; containers.clear();
+ }
- container.querySelectorAll(SELECTOR).forEach(el => {
- const ctx = { preserved: [] };
- const markdown = serialize(el, ctx);
+ function updateFonts() {
+ if (!settings.enabled || !katexCss) return;
+ // Chromium does not register @font-face rules defined only inside a
+ // shadow root. Register just the font faces in the document scope.
+ if (!fontStyle) { fontStyle = document.createElement('style'); document.head.appendChild(fontStyle); }
+ fontStyle.textContent = (katexCss.match(/@font-face\s*\{[^}]+\}/g) || []).join('\n');
+ }
- if (!needsProcessing(markdown)) {
- el.setAttribute('data-rendered', '');
- return;
+ function updateSettings(values = {}) {
+ if (destroyed) return;
+ let changed = false;
+ for (const key of Object.keys(DEFAULTS)) {
+ if (typeof values[key] === 'boolean' && values[key] !== settings[key]) { settings[key] = values[key]; changed = true; }
}
-
- const newHtml = render(markdown, ctx.preserved);
- if (el.innerHTML !== newHtml) {
- el.innerHTML = htmlPolicy.createHTML(newHtml);
+ for (const key of COLOR_KEYS) {
+ if (Object.hasOwn(values, key) && JSON.stringify(values[key]) !== JSON.stringify(colors[key])) {
+ colors[key] = values[key]; changed = true;
+ }
}
- el.setAttribute('data-rendered', '');
- });
- }
+ if (!changed) return;
+ revision++;
+ if (!settings.enabled) stop();
+ else if (!observing) start();
+ else schedule(true);
+ }
- // ==================================
- // 초기화
- // ==================================
- // 커스텀 스타일 주입
- injectCustomStyles();
+ const api = {
+ updateSettings,
+ setKatexCss(css) { if (!destroyed && css !== katexCss) { katexCss = css; revision++; updateFonts(); schedule(true); } },
+ refresh() { revision++; schedule(true); },
+ destroy() { stop(); destroyed = true; },
+ diagnostics() { return { ...counters, enabled: settings.enabled, activeProjections: active.size, pendingBlocks: dirty.size }; }
+ };
+ start();
+ return api;
+ }
- // 면책 조항 제거
- removeDisclaimers();
+ global.GeminiParser = { createEngine, DEFAULTS, COLOR_KEYS };
+})(globalThis);
- // DOM 변경 감지
- new MutationObserver(() => {
- reRender();
- removeDisclaimers();
- }).observe(document.body, {
- childList: true,
- subtree: true
+/* Editable userscript defaults. Run npm run build:userscript after changes. */
+(function () {
+ 'use strict';
+ const SETTINGS = { ...GeminiParser.DEFAULTS,
+ enabled: GM_getValue('enabled', true),
+ boldColor: null, italicColor: null, strikeColor: null,
+ underlineColor: null, codeColor: null, mathColor: null
+ };
+ const cssURL = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css';
+ const css = GM_getResourceText('KATEX_CSS').replace(/url\(([^)]+)\)/g, (_, url) =>
+ `url("${new URL(url.trim().replace(/^['"]|['"]$/g, ''), cssURL).href}")`);
+ const engine = GeminiParser.createEngine({ settings: SETTINGS, katex, katexCss: css });
+ GM_registerMenuCommand('Fix Gemini Parser: Toggle', () => {
+ SETTINGS.enabled = !SETTINGS.enabled;
+ GM_setValue('enabled', SETTINGS.enabled);
+ engine.updateSettings({ enabled: SETTINGS.enabled });
});
-
- reRender();
- console.log('[Gemini Parser] Started');
-
-})();
\ No newline at end of file
+})();
diff --git a/userscript-bootstrap.js b/userscript-bootstrap.js
new file mode 100644
index 0000000..c7e0732
--- /dev/null
+++ b/userscript-bootstrap.js
@@ -0,0 +1,18 @@
+/* Editable userscript defaults. Run npm run build:userscript after changes. */
+(function () {
+ 'use strict';
+ const SETTINGS = { ...GeminiParser.DEFAULTS,
+ enabled: GM_getValue('enabled', true),
+ boldColor: null, italicColor: null, strikeColor: null,
+ underlineColor: null, codeColor: null, mathColor: null
+ };
+ const cssURL = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css';
+ const css = GM_getResourceText('KATEX_CSS').replace(/url\(([^)]+)\)/g, (_, url) =>
+ `url("${new URL(url.trim().replace(/^['"]|['"]$/g, ''), cssURL).href}")`);
+ const engine = GeminiParser.createEngine({ settings: SETTINGS, katex, katexCss: css });
+ GM_registerMenuCommand('Fix Gemini Parser: Toggle', () => {
+ SETTINGS.enabled = !SETTINGS.enabled;
+ GM_setValue('enabled', SETTINGS.enabled);
+ engine.updateSettings({ enabled: SETTINGS.enabled });
+ });
+})();