From ad9745ab7520dd61fb9ad955f140e6d2e38bd55b Mon Sep 17 00:00:00 2001 From: e-mon Date: Thu, 12 Mar 2026 19:39:02 +0900 Subject: [PATCH 01/10] docs: add build-essential to WSL/Linux prerequisites node-llama-cpp (QMD dependency) requires native compilation tools. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c7764c9..5593d95 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ curl -fsSL https://bun.sh/install | bash **Ubuntu / Debian / WSL2:** ```bash curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs tmux +sudo apt-get install -y nodejs tmux build-essential curl -fsSL https://bun.sh/install | bash # Docker Engine From 7f2f6fa7909a2bf79b1a65ad646d22cba1b457bd Mon Sep 17 00:00:00 2001 From: e-mon Date: Thu, 12 Mar 2026 20:11:14 +0900 Subject: [PATCH 02/10] fix(preview): CSSOM-based CSS capture, revert CSP, unify block toggle The translate-preview skill failed to capture CSS on sites that SSR with localhost URLs (e.g. Next.js on Vercel). The fetch-based CSS inlining silently failed and removed all tags, producing unstyled preview HTML. CSS capture: - Primary: extract CSS from CSSOM (browser-loaded, URL-independent) - Fallback: rewrite localhost URLs to page origin before fetching - Absolutize relative url() in CSSOM CSS using each sheet's href - Remove localhost preload/prefetch links from saved HTML Block toggle (unify with twitter-render.js): - Wrap block pairs in .tp-block container with data-state attribute - Replace !important + body class + style.display hack with data-state driven CSS (no specificity conflicts with site CSS) - Simplify interaction script: 3-line tpToggle vs getComputedStyle hack CSP: revert PR#1's over-relaxation (http:, *, blob:) back to the https:-only policy from ffb8356. The localhost errors were ERR_CONNECTION_REFUSED, not CSP violations. Co-Authored-By: Claude Opus 4.6 --- src/serve.ts | 2 +- src/skills/inject-translations.test.ts | 70 ++++---- .../references/inject-translations.js | 166 ++++++++++++------ 3 files changed, 147 insertions(+), 91 deletions(-) diff --git a/src/serve.ts b/src/serve.ts index 766b845..3462615 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -370,7 +370,7 @@ export function createPreviewServer( previewApp.use((_req, res, next) => { res.setHeader( 'Content-Security-Policy', - "default-src 'none'; script-src 'self' 'unsafe-inline' http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline' http: https:; font-src * data:; frame-src http: https:; media-src * data: blob:", + "default-src 'none'; script-src 'unsafe-inline'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https:; frame-src https:", ); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); diff --git a/src/skills/inject-translations.test.ts b/src/skills/inject-translations.test.ts index c5b59e8..29d7088 100644 --- a/src/skills/inject-translations.test.ts +++ b/src/skills/inject-translations.test.ts @@ -244,10 +244,15 @@ describe('basic translation injection', () => { } }); - it('does not create any tp-block wrapper divs', () => { + it('wraps block pairs in .tp-block containers with data-state', () => { const dom = setup(); const blocks = dom.window.document.querySelectorAll('.tp-block'); - expect(blocks.length).toBe(0); + expect(blocks.length).toBe(2); // h1 + p (code is skipped) + blocks.forEach((block) => { + expect(block.getAttribute('data-state')).toBe('translated'); + expect(block.querySelector('.tp-translated')).toBeTruthy(); + expect(block.querySelector('.tp-original')).toBeTruthy(); + }); }); it('sets translated text content', () => { @@ -264,13 +269,13 @@ describe('basic translation injection', () => { expect(originals[1].textContent).toBe('This is a test paragraph.'); }); - it('pairs translated and original via data-tp-pair / data-tp-id', () => { + it('pairs translated and original inside same .tp-block wrapper', () => { const dom = setup(); - const translated = dom.window.document.querySelectorAll('.tp-translated'); - translated.forEach((el) => { - const pairId = el.getAttribute('data-tp-pair'); - expect(pairId).toBeTruthy(); - const original = dom.window.document.querySelector(`[data-tp-id="${pairId}"]`); + const blocks = dom.window.document.querySelectorAll('.tp-block'); + blocks.forEach((block) => { + const translated = block.querySelector('.tp-translated'); + const original = block.querySelector('.tp-original'); + expect(translated).toBeTruthy(); expect(original).toBeTruthy(); expect(original?.classList.contains('tp-original')).toBe(true); }); @@ -347,16 +352,15 @@ describe('injected styles and scripts', () => { return dom; } - it('injects style tag with body-class-based visibility rules', () => { + it('injects style tag with data-state-based visibility rules', () => { const dom = setup(); const styles = dom.window.document.querySelectorAll('style[data-tp]'); expect(styles.length).toBe(1); const css = styles[0].textContent || ''; - // Should use body class approach, not .tp-block wrapper - expect(css).toContain('.tp-original'); - expect(css).toContain('body.tp-mode-original'); - expect(css).toContain('body.tp-mode-both'); - expect(css).not.toContain('.tp-block'); + expect(css).toContain('.tp-block[data-state="translated"]'); + expect(css).toContain('.tp-block[data-state="original"]'); + expect(css).toContain('.tp-block[data-state="both"]'); + expect(css).toContain('display: contents'); }); it('injects script tag with data-tp attribute', () => { @@ -364,7 +368,7 @@ describe('injected styles and scripts', () => { const scripts = dom.window.document.querySelectorAll('script[data-tp]'); expect(scripts.length).toBe(1); expect(scripts[0].textContent).toContain('data-tp-mode'); - expect(scripts[0].textContent).toContain('tpSetMode'); + expect(scripts[0].textContent).toContain('tpToggle'); }); }); @@ -372,7 +376,7 @@ describe('injected styles and scripts', () => { // Nested structures // ============================================================ describe('nested structures', () => { - it('li elements remain direct children of ul (valid HTML)', () => { + it('li elements are wrapped in .tp-block inside ul', () => { const dom = createMarkedDom(`
  • Item one
  • @@ -387,20 +391,18 @@ describe('nested structures', () => { }; injectTranslations(dom.window.document as unknown as Document, data); - // No wrapper divs + // .tp-block wrappers with display:contents preserve list layout const wrappers = dom.window.document.querySelectorAll('.tp-block'); - expect(wrappers.length).toBe(0); + expect(wrappers.length).toBe(2); - // All li elements (translated + original) are direct children of ul - const ul = dom.window.document.querySelector('ul'); - expect(ul).toBeTruthy(); - const children = Array.from(ul?.children); - expect(children.every((c) => c.tagName === 'LI')).toBe(true); - // 2 translated + 2 original = 4 li elements - expect(children.length).toBe(4); + // Each wrapper contains translated + original li + wrappers.forEach((w) => { + expect(w.querySelector('.tp-translated')?.tagName).toBe('LI'); + expect(w.querySelector('.tp-original')?.tagName).toBe('LI'); + }); }); - it('flex parent children are not disrupted', () => { + it('flex parent children wrapped in .tp-block with display:contents', () => { const dom = createMarkedDom(`

    Flex child 1

    @@ -415,17 +417,15 @@ describe('nested structures', () => { }; injectTranslations(dom.window.document as unknown as Document, data); - // No wrapper divs inserted + // .tp-block wrappers inserted (display:contents avoids layout disruption) const wrappers = dom.window.document.querySelectorAll('.tp-block'); - expect(wrappers.length).toBe(0); + expect(wrappers.length).toBe(2); - // All children of flex container are

    tags (same tag type preserved) - const flex = dom.window.document.querySelector('div[style]'); - expect(flex).toBeTruthy(); - const children = Array.from(flex?.children); - expect(children.every((c) => c.tagName === 'P')).toBe(true); - // 2 translated + 2 original = 4 - expect(children.length).toBe(4); + // Each wrapper contains translated + original

    tags + wrappers.forEach((w) => { + expect(w.querySelector('.tp-translated')?.tagName).toBe('P'); + expect(w.querySelector('.tp-original')?.tagName).toBe('P'); + }); }); it('inline spans: no wrapper, text swap in place', () => { diff --git a/templates/.gemini/skills/translate-preview/references/inject-translations.js b/templates/.gemini/skills/translate-preview/references/inject-translations.js index ea78264..20979bb 100644 --- a/templates/.gemini/skills/translate-preview/references/inject-translations.js +++ b/templates/.gemini/skills/translate-preview/references/inject-translations.js @@ -41,19 +41,21 @@ function injectTranslations(doc, data) { node.className = (node.className ? node.className + ' ' : '') + 'tp-inline'; node.setAttribute('data-tp-state', 'translated'); } else { - // Block nodes: adjacent sibling approach (no wrapper div) - // Clone original tag for translated text — preserves CSS inheritance + // Block nodes: wrap in .tp-block container with data-state toggle + // (same structure as twitter-render.js for consistent interaction) var translated = node.cloneNode(false); translated.textContent = cleanText; translated.className = (translated.className ? translated.className + ' ' : '') + 'tp-translated'; translated.removeAttribute('data-tp-id'); - translated.setAttribute('data-tp-pair', String(block.id)); - // Mark original: hide by default, keep full DOM structure node.className = (node.className ? node.className + ' ' : '') + 'tp-original'; - // Insert translated clone immediately before the original - node.parentNode.insertBefore(translated, node); + var wrapper = doc.createElement('div'); + wrapper.className = 'tp-block'; + wrapper.setAttribute('data-state', 'translated'); + node.parentNode.insertBefore(wrapper, node); + wrapper.appendChild(translated); + wrapper.appendChild(node); } } @@ -115,14 +117,11 @@ function _tp_injectStyles(doc) { '.tp-btn-active { background: #3b82f6; color: #fff; border-color: #3b82f6; }' + '.tp-btn-active:hover { background: #2563eb; }' + - /* Bilingual blocks — default: translated mode (original hidden) */ - '.tp-original { display: none !important; }' + - /* Original mode: hide translated, show original */ - 'body.tp-mode-original .tp-translated { display: none !important; }' + - 'body.tp-mode-original .tp-original { display: initial !important; }' + - /* Both mode: show both, muted original */ - 'body.tp-mode-both .tp-original { ' + - 'display: initial !important; ' + + /* Bilingual blocks — data-state driven (matches twitter-render.js) */ + '.tp-block { display: contents; }' + + '.tp-block[data-state="translated"] .tp-original { display: none; }' + + '.tp-block[data-state="original"] .tp-translated { display: none; }' + + '.tp-block[data-state="both"] .tp-original { ' + 'color: #6b7280; font-size: 0.85em; ' + 'border-left: 3px solid rgba(59,130,246,0.3); ' + 'padding-left: 8px; margin-top: 4px; }' + @@ -136,7 +135,7 @@ function _tp_injectStyles(doc) { '.tp-btn { border-color: #4b5563; color: #d1d5db; }' + '.tp-btn:hover { background: #374151; }' + '.tp-btn-active { background: #3b82f6; color: #fff; border-color: #3b82f6; }' + - 'body.tp-mode-both .tp-original { color: #9ca3af; }' + + '.tp-block[data-state="both"] .tp-original { color: #9ca3af; }' + '}'; (doc.head || doc.documentElement).appendChild(style); @@ -147,10 +146,19 @@ function _tp_injectInteractionScript(doc) { script.setAttribute('data-tp', 'true'); script.textContent = '(function(){' + - // Global mode switch via header buttons (body class approach) - 'function tpSetMode(mode){' + - 'document.body.classList.remove("tp-mode-translated","tp-mode-original","tp-mode-both");' + - 'if(mode!=="translated")document.body.classList.add("tp-mode-"+mode);' + + // Toggle a single .tp-block through translated→both→original→translated + 'function tpToggle(block){' + + 'var s=block.getAttribute("data-state");' + + 'block.setAttribute("data-state",s==="translated"?"both":s==="both"?"original":"translated");' + + '}' + + + // Global mode switch via header buttons + 'document.addEventListener("click",function(e){' + + 'var btn=e.target.closest("[data-tp-mode]");' + + 'if(!btn)return;' + + 'var mode=btn.getAttribute("data-tp-mode");' + + // Set all blocks + 'document.querySelectorAll(".tp-block").forEach(function(b){b.setAttribute("data-state",mode);});' + // Inline elements: swap text content 'document.querySelectorAll(".tp-inline").forEach(function(el){' + 'var orig=el.getAttribute("data-tp-original")||"";' + @@ -160,48 +168,26 @@ function _tp_injectInteractionScript(doc) { 'else el.textContent=trans;' + 'el.setAttribute("data-tp-state",mode);' + '});' + - '}' + - 'document.addEventListener("click",function(e){' + - 'var btn=e.target.closest("[data-tp-mode]");' + - 'if(!btn)return;' + - 'var mode=btn.getAttribute("data-tp-mode");' + - 'tpSetMode(mode);' + 'document.querySelectorAll(".tp-btn").forEach(function(b){b.classList.remove("tp-btn-active");});' + 'btn.classList.add("tp-btn-active");' + '});' + - // Long-press toggle on individual pair (500ms) - 'var _tpTimer=null,_tpMoved=false;' + - 'function tpTogglePair(el){' + - 'var pair=el.closest("[data-tp-pair]");' + - 'if(!pair)pair=el.closest("[data-tp-id]");' + - 'if(!pair)return;' + - 'var id=pair.getAttribute("data-tp-pair")||pair.getAttribute("data-tp-id");' + - 'var t=document.querySelector("[data-tp-pair=\\""+id+"\\"]");' + - 'var o=document.querySelector("[data-tp-id=\\""+id+"\\"]");' + - 'if(!t||!o)return;' + - // Cycle: translated→both→original→translated - 'var tHidden=getComputedStyle(t).display==="none";' + - 'var oHidden=getComputedStyle(o).display==="none";' + - 'if(!tHidden&&oHidden){t.style.display="";o.style.display="initial";}' + // translated→both - 'else if(!tHidden&&!oHidden){t.style.display="none";o.style.display="initial";}' + // both→original - 'else{t.style.display="";o.style.display="none";}' + // original→translated - '}' + + // Long-press toggle on individual block (500ms) + 'var _tpTimer=null;' + 'document.addEventListener("touchstart",function(e){' + - 'var pair=e.target.closest("[data-tp-pair]")||e.target.closest("[data-tp-id]");' + - 'if(!pair)return;' + - '_tpMoved=false;' + - '_tpTimer=setTimeout(function(){tpTogglePair(pair);},500);' + + 'var block=e.target.closest(".tp-block");' + + 'if(!block)return;' + + '_tpTimer=setTimeout(function(){tpToggle(block);},500);' + '},{passive:true});' + - 'document.addEventListener("touchmove",function(){_tpMoved=true;if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + + 'document.addEventListener("touchmove",function(){if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + 'document.addEventListener("touchend",function(){if(_tpTimer){clearTimeout(_tpTimer);_tpTimer=null;}},{passive:true});' + // Right-click toggle on desktop 'document.addEventListener("contextmenu",function(e){' + - 'var pair=e.target.closest("[data-tp-pair]")||e.target.closest("[data-tp-id]");' + - 'if(!pair)return;' + + 'var block=e.target.closest(".tp-block");' + + 'if(!block)return;' + 'e.preventDefault();' + - 'tpTogglePair(pair);' + + 'tpToggle(block);' + '});' + '})();'; @@ -214,17 +200,70 @@ function _tp_escapeHtml(str) { // ── Capture: inline CSS, absolutize URLs, strip scripts ── +/** + * Rewrite localhost URLs to the page's actual origin. + * Some sites (e.g. Next.js on Vercel) SSR with localhost URLs that the CDN + * normally rewrites. When agent-browser fetches the page, these survive and + * cause resource loads to fail. + */ +function _tp_rewriteLocalhost(href, base) { + if (!href) return href; + var m = href.match(/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/.*)/); + if (!m) return href; + try { + var origin = new URL(base).origin; + return origin + m[1]; + } catch (_) { + return href; + } +} + function _tp_capturePage(doc) { var base = doc.baseURI || ''; - // 1. Inline external stylesheets + // 1a. Capture CSS from CSSOM — gets whatever the browser actually loaded, + // regardless of whether the URLs were correct or rewritten by JS at runtime. + // Absolutize relative url() references using each sheet's href as base. + var cssomTexts = []; + for (var s = 0; s < doc.styleSheets.length; s++) { + try { + var sheet = doc.styleSheets[s]; + if (sheet.ownerNode && sheet.ownerNode.getAttribute && sheet.ownerNode.getAttribute('data-tp')) continue; + var rules = sheet.cssRules || sheet.rules; + if (!rules || !rules.length) continue; + var sheetBase = sheet.href || base; + var css = ''; + for (var r = 0; r < rules.length; r++) css += rules[r].cssText + '\n'; + // Resolve relative url() in CSS to absolute using the sheet's origin + if (css && sheetBase) { + css = css.replace(/url\(["']?([^"')]+)["']?\)/g, function(match, u) { + if (u.startsWith('data:') || u.startsWith('http://') || u.startsWith('https://')) return match; + try { return 'url("' + new URL(u, sheetBase).href + '")'; } catch (_) { return match; } + }); + } + if (css) cssomTexts.push(css); + } catch (_) { + // Cross-origin stylesheets throw SecurityError — handled in 1b + } + } + + // 1b. For tags whose CSS wasn't captured via CSSOM + // (cross-origin or failed to load), fetch with localhost rewriting. var linkPromises = []; var links = doc.querySelectorAll('link[rel="stylesheet"]'); for (var i = 0; i < links.length; i++) { (function(link) { var href = link.href; if (!href) { link.remove(); return; } - var p = fetch(href).then(function(res) { + // Check if this sheet was already captured via CSSOM + try { + var rules = link.sheet && (link.sheet.cssRules || link.sheet.rules); + if (rules && rules.length > 0) { link.remove(); return; } + } catch (_) { /* cross-origin — need to fetch */ } + // Rewrite localhost URLs to the page's real origin before fetching + var fetchUrl = _tp_rewriteLocalhost(href, base); + var p = fetch(fetchUrl).then(function(res) { + if (!res.ok) throw new Error(res.status); return res.text(); }).then(function(css) { var style = doc.createElement('style'); @@ -239,22 +278,39 @@ function _tp_capturePage(doc) { } return Promise.all(linkPromises).then(function() { + // 1c. Inject CSSOM-captured CSS as inlined