From b137776381c3f72324bb453c815d230bd8b02228 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 12:25:06 +0300 Subject: [PATCH] fix: bundle the content script to stop 'Unsafe attempt to load URL' errors Dynamic import() of extension modules from a content script makes Chrome log 'Unsafe attempt to load URL chrome-extension://...' on YouTube pages, which fills the extension's error list even though the modules load (HTTP 200). The content script is now one classic file built by 'npm run build'; web_accessible_resources is no longer needed and is removed. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 10 +- .../plans/2026-10-25-vidiq-rework.md | 18 ++ .../specs/2026-10-25-outlier-saver-design.md | 6 +- manifest.json | 6 - package.json | 1 + src/content.js | 295 +++++++++++++++--- src/content/main.js | 63 ++++ test/bundle.test.js | 68 ++++ tools/build-content.js | 55 ++++ 9 files changed, 461 insertions(+), 61 deletions(-) create mode 100644 src/content/main.js create mode 100644 test/bundle.test.js create mode 100644 tools/build-content.js diff --git a/README.md b/README.md index 2b74f50..0bb3475 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,18 @@ ## Разработка -Сборки нет. Тесты (Node 22, без зависимостей; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден): +Зависимостей нет, сборка только одна (content script, см. ниже). Тесты (Node 22; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден): ``` npm test ``` +Content script собирается в один файл: после правки `src/content/main.js` или модулей из `src/lib/`, которые он использует (`card`, `numbers`, `vidiq`, `video-id`), выполните + +``` +npm run build +``` + +Собранный `src/content.js` лежит в репозитории, тест `bundle` падает, если он отстал от исходников. Динамический `import()` в content script не используется: Chrome оставляет из-за него ошибки в `chrome://extensions`. + После изменения кода нажмите «Обновить» у расширения на `chrome://extensions`. diff --git a/docs/superpowers/plans/2026-10-25-vidiq-rework.md b/docs/superpowers/plans/2026-10-25-vidiq-rework.md index 7a6f162..2495944 100644 --- a/docs/superpowers/plans/2026-10-25-vidiq-rework.md +++ b/docs/superpowers/plans/2026-10-25-vidiq-rework.md @@ -1594,3 +1594,21 @@ Expected: все проверки проходят. git add README.md git commit -m "docs: describe the VidIQ-based workflow" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` + +--- + +### Task 10 (добавлена после проверки в браузере): один обычный файл для content script + +**Почему:** в `chrome://extensions` у расширения копились ошибки «Unsafe attempt to load URL chrome-extension://…/src/lib/video-id.js from frame with URL https://www.youtube.com/…». Разбор показал: Chrome пишет это сообщение при динамическом `import()` модулей из content script (недетерминированно, воспроизводится даже на минимальном примере из двух файлов), хотя запросы завершаются статусом 200 и расширение работает. Чтобы не копить ошибки, content script собирается в один обычный файл без `import()`. + +**Files:** +- Create: `tools/build-content.js` (функция `bundle(entry)` и запуск `npm run build`), `src/content/main.js` (исходник content script на статических `import`), `test/bundle.test.js` +- Generate: `src/content.js` (`npm run build`, коммитится) +- Modify: `manifest.json` (убрать `web_accessible_resources`), `package.json` (скрипт `build`), `README.md`, спецификация + +**Что проверено:** +- [x] `test/bundle.test.js` (6 тестов): порядок модулей, общий модуль включён один раз, отказ при совпадающих именах и неподдерживаемом `export`, закоммиченный `src/content.js` совпадает со свежей сборкой, в нём нет `import()`, в манифесте нет `web_accessible_resources`. +- [x] Три прогона воспроизведения на главной, поиске, просмотре и канале: сообщений «Unsafe attempt» нет (раньше не меньше четырёх на страницу). +- [x] Сквозная проверка в Chrome (26 проверок) проходит на собранном файле. + +**Правило разработки:** после правки `src/content/main.js` или модулей из `src/lib/`, которые он использует, выполните `npm run build`; без этого тест `bundle.test.js` падает. diff --git a/docs/superpowers/specs/2026-10-25-outlier-saver-design.md b/docs/superpowers/specs/2026-10-25-outlier-saver-design.md index 25835a7..37f51b7 100644 --- a/docs/superpowers/specs/2026-10-25-outlier-saver-design.md +++ b/docs/superpowers/specs/2026-10-25-outlier-saver-design.md @@ -18,7 +18,7 @@ | Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup | | Страницы | Главная, страница просмотра (боковые карточки), поиск, страница канала | | Языки интерфейса | Русский и английский | -| Сборка | Нет. Чистый JS, ES-модули, загрузка как «распакованное расширение» | +| Сборка | Только для content script: `npm run build` склеивает `src/content/main.js` и его модули в один обычный файл `src/content.js` (он закоммичен, тест следит, чтобы он не отставал). Причина: динамический `import()` в content script оставляет в `chrome://extensions` ошибки «Unsafe attempt to load URL chrome-extension://…», хотя модули грузятся. Остальной код без сборки: чистый JS, ES-модули, загрузка как «распакованное расширение» | ## Откуда берётся каждое поле @@ -38,7 +38,8 @@ | Часть | Файл | Ответственность | |---|---|---| -| Content script | `src/content.js` | Наблюдает за DOM на `youtube.com`, разбирает карточки, отправляет в background только карточки, где VidIQ уже вывел score, и только когда данные изменились | +| Content script | `src/content/main.js` → `src/content.js` | Наблюдает за DOM на `youtube.com`, разбирает карточки, отправляет в background только карточки, где VidIQ уже вывел score, и только когда данные изменились. В манифесте подключён собранный `src/content.js`, исходник — `src/content/main.js` | +| Сборка content script | `tools/build-content.js` | Склеивает исходник и импортируемые модули в один обычный скрипт (поддерживает только однострочные `import` и `export` перед `const/let/function/class`, имена верхнего уровня должны быть уникальны) | | Service worker | `src/background.js` | Принимает карточки, подтягивает настройки и ранее сохранённые записи, пишет результат в IndexedDB. Записи обрабатываются строго по очереди | | Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки | | Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` | @@ -101,6 +102,7 @@ CSV содержит эти же поля в том же порядке. ## Тестирование - **Автотесты** на встроенном `node:test`, без зависимостей: `numbers.js`, `vidiq.js` (на подставных объектах), `record.js`, `video-id.js`, `export.js`, `storage.js`. +- **Тест сборки** (`test/bundle.test.js`): сборщик на временных файлах, проверка, что закоммиченный `src/content.js` совпадает со свежей сборкой, что в нём нет `import()` и что в манифесте нет `web_accessible_resources`. - **Тест разбора карточки в Chrome** (`test/card.dom.test.js`): запускает headless Chrome, загружает фикстуры и проверяет `scrapeCard`. Фикстуры: настоящая карточка с VidIQ с главной, плюс упрощённые карточки поиска и боковой панели. Если Chrome не найден, тест пропускается. - **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup. diff --git a/manifest.json b/manifest.json index adc56e1..bb4c499 100644 --- a/manifest.json +++ b/manifest.json @@ -13,11 +13,5 @@ "js": ["src/content.js"], "run_at": "document_idle" } - ], - "web_accessible_resources": [ - { - "resources": ["src/lib/*.js"], - "matches": ["https://www.youtube.com/*"] - } ] } diff --git a/package.json b/package.json index 62b9e25..ae654bf 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "build": "node tools/build-content.js", "test": "node --test" } } diff --git a/src/content.js b/src/content.js index 794041b..34a8497 100644 --- a/src/content.js +++ b/src/content.js @@ -1,65 +1,256 @@ -(async () => { - const { pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js')); - const { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } = await import(chrome.runtime.getURL('src/lib/card.js')); +// GENERATED by tools/build-content.js from src/content/main.js and the modules it imports. +// Edit those files and run `npm run build`; do not edit this file by hand. +(() => { +'use strict'; - const SCAN_INTERVAL_MS = 500; - const MAX_ITEMS_PER_MESSAGE = 200; +// ---- numbers.js ---- +const UNITS = new Map([ + ['k', 1e3], ['тыс', 1e3], ['тысяча', 1e3], ['тысячи', 1e3], ['тысяч', 1e3], ['thousand', 1e3], + ['m', 1e6], ['млн', 1e6], ['миллион', 1e6], ['миллиона', 1e6], ['миллионов', 1e6], ['million', 1e6], + ['b', 1e9], ['млрд', 1e9], ['миллиард', 1e9], ['миллиарда', 1e9], ['миллиардов', 1e9], ['billion', 1e9], +]); - // videoId -> "outlierScore|views|subscribers" of the last message that carried the video. - // VidIQ may draw or update its numbers after the card appears, so a changed signature is sent again. - let sent = new Map(); - let scanTimer = null; +// "просмотров", "подписчиков", "views", "subscribers" and their other forms at the end of the text. +const COUNT_WORDS = /\s*(просмотров|просмотра|просмотр|подписчиков|подписчика|подписчики|подписчик|views?|subscribers?)$/; +const GROUPED_INTEGER = /^\d{1,3}(?:[ ,.]\d{3})+$/; +const DECIMAL = /^\d+(?:[.,]\d+)?$/; - // Cards on a channel page do not name the channel, so take it from the page itself. - function channelContext() { - const [kind, name] = location.pathname.split('/').filter(Boolean); - const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null; - return { - channelUrl: path ? `https://www.youtube.com/${path}` : null, - channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null, - }; +// Reads "59 млн", "571 тыс.", "1.2M", "12 345" and the like; the whole text must be a count. +function parseCount(text) { + if (typeof text !== 'string') return null; + const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase().replace(COUNT_WORDS, '').trim(); + const match = /^(\d[\d ,.]*?)(?: ?([a-zа-яё]+)\.?)?$/.exec(normalized); + if (!match) return null; + const [, number, unit] = match; + + if (unit === undefined) { + if (/^\d+$/.test(number)) return Number(number); + return GROUPED_INTEGER.test(number) ? Number(number.replace(/\D/g, '')) : null; } + const multiplier = UNITS.get(unit); + if (multiplier === undefined || !DECIMAL.test(number)) return null; + return Math.round(Number(number.replace(',', '.')) * multiplier); +} - function scan() { - scanTimer = null; - const page = pageFromPath(location.pathname); - if (!page) return; - const context = page === 'channel' ? channelContext() : null; +function parseViews(text) { + if (typeof text !== 'string') return null; + if (/^\s*(no|нет) (views|просмотров)\s*$/i.test(text)) return 0; + return parseCount(text); +} - const found = new Map(); - const visited = new Set(); - for (const link of document.querySelectorAll(LINK_SELECTOR)) { - const card = link.closest(CARD_SELECTOR); - if (!card || visited.has(card)) continue; - visited.add(card); - const item = scrapeCard(card, link, context); - if (!item || item.outlierScore === null || item.views === null) continue; - const signature = `${item.outlierScore}|${item.views}|${item.subscribers}`; - if (sent.get(item.videoId) === signature) continue; - found.set(item.videoId, { item: { ...item, page }, signature }); +// ---- video-id.js ---- +const ID_RE = /^[A-Za-z0-9_-]{11}$/; +const YOUTUBE_HOSTS = new Set(['www.youtube.com', 'youtube.com', 'm.youtube.com']); + +function isVideoId(value) { + return typeof value === 'string' && ID_RE.test(value); +} + +// Returns { videoId, isShort } for a link to a single video, or null for anything else. +function parseVideoLink(href, base = 'https://www.youtube.com') { + if (!href) return null; + let url; + try { + url = new URL(href, base); + } catch { + return null; + } + if (!YOUTUBE_HOSTS.has(url.hostname)) return null; + + if (url.pathname === '/watch') { + const id = url.searchParams.get('v'); + return isVideoId(id) ? { videoId: id, isShort: false } : null; + } + const shorts = url.pathname.match(/^\/shorts\/([^/]+)/); + if (shorts && isVideoId(shorts[1])) return { videoId: shorts[1], isShort: true }; + return null; +} + +function pageFromPath(pathname) { + if (pathname === '/') return 'home'; + if (pathname === '/watch') return 'watch'; + if (pathname === '/results') return 'search'; + if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/.test(pathname)) return 'channel'; + return null; +} + +// ---- vidiq.js ---- +// VidIQ's own class names are generated (vidiq-c-xxxx) and change between builds; these hooks are +// data attributes it sets deliberately. +const OUTLIER_SELECTOR = '[data-hover-popover="OutlierPopover"]'; +const SUBSCRIBERS_SELECTOR = '[data-vidiq-subscriber-count]'; + +function parseVidiqScore(text) { + const match = /(\d+(?:[.,]\d+)?)/.exec(text ?? ''); + return match ? Number(match[1].replace(',', '.')) : null; +} + +// The badge link carries the exact value (e.g. 146.19) while the badge text is capped (">100x"). +function outlierScoreFromHref(href) { + if (!href) return null; + let raw; + try { + raw = new URL(href, 'https://app.vidiq.com').searchParams.get('outlier_score'); + } catch { + return null; + } + if (raw === null || raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? Math.round(value * 100) / 100 : null; +} + +function readVidiqData(card) { + const outlier = card.querySelector(OUTLIER_SELECTOR); + const href = outlier?.querySelector('a')?.getAttribute('href'); + const outlierScore = outlier ? (outlierScoreFromHref(href) ?? parseVidiqScore(outlier.textContent)) : null; + const subscribersNode = card.querySelector(SUBSCRIBERS_SELECTOR); + return { outlierScore, subscribers: subscribersNode ? parseCount(subscribersNode.textContent) : null }; +} + +// ---- card.js ---- +const LINK_SELECTOR = 'a[href*="/watch?v="], a[href*="/shorts/"]'; +const CARD_SELECTOR = [ + 'ytd-rich-item-renderer', + 'ytd-video-renderer', + 'ytd-compact-video-renderer', + 'ytd-grid-video-renderer', + 'yt-lockup-view-model', + 'ytm-shorts-lockup-view-model', + 'ytd-reel-item-renderer', +].join(','); + +// Spans that hold the channel name, the view count and the age in the new and old card layouts. +const METADATA_TEXT = '.ytContentMetadataViewModelMetadataText, .inline-metadata-item, #metadata-line span'; +const CHANNEL_LINK = 'a[href^="/@"], a[href^="/channel/"], a[href^="/c/"], a[href^="/user/"]'; +const RELATIVE_TIME = /назад|(^|\s)ago(\s|$)/i; + +// VidIQ puts its own text (subscribers, VPH, score) inside `.vidiq-scope`; it must not be read as YouTube's. +const outsideVidiq = (element) => !element.closest('.vidiq-scope'); + +function readTitle(card) { + const heading = card.querySelector('h3[title]')?.getAttribute('title')?.trim(); + if (heading) return heading; + const oldTitle = card.querySelector('#video-title'); + const oldText = (oldTitle?.getAttribute('title') || oldTitle?.textContent || '').trim(); + if (oldText) return oldText; + return card.querySelector('.ytLockupMetadataViewModelTitle')?.textContent.trim() || null; +} + +function readThumbnail(card, videoId) { + const image = card.querySelector('img[src^="https://i.ytimg.com/vi/"], img[src^="https://i.ytimg.com/vi_webp/"]'); + return image ? image.src : `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; +} + +function readViews(card) { + for (const element of card.querySelectorAll(METADATA_TEXT)) { + if (!outsideVidiq(element)) continue; + const views = parseViews(element.textContent) ?? parseViews(element.getAttribute('aria-label')); + if (views !== null) return views; + } + return null; +} + +function absoluteChannelUrl(href) { + try { + const url = new URL(href, 'https://www.youtube.com'); + return url.origin + url.pathname; + } catch { + return null; + } +} + +function readChannel(card, context) { + const link = [...card.querySelectorAll(CHANNEL_LINK)].find((a) => outsideVidiq(a) && a.textContent.trim()); + if (link) return { channelTitle: link.textContent.trim(), channelUrl: absoluteChannelUrl(link.getAttribute('href')) }; + + // New layouts show the channel name as plain text: the first metadata text that is neither a count nor an age. + for (const element of card.querySelectorAll(METADATA_TEXT)) { + const text = element.textContent.trim(); + if (outsideVidiq(element) && text && parseViews(text) === null && !RELATIVE_TIME.test(text)) { + return { channelTitle: text, channelUrl: context?.channelUrl ?? null }; } - if (found.size === 0) return; - - const batch = [...found.values()].slice(0, MAX_ITEMS_PER_MESSAGE); - for (const { item, signature } of batch) sent.set(item.videoId, signature); - try { - chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {}); - } catch { - observer.disconnect(); // the extension was reloaded; this script is orphaned - return; - } - if (found.size > batch.length) scheduleScan(); } + return { channelTitle: context?.channelTitle ?? null, channelUrl: context?.channelUrl ?? null }; +} - function scheduleScan() { - if (scanTimer === null) scanTimer = setTimeout(scan, SCAN_INTERVAL_MS); +// Reads one video card. `context` supplies the channel on channel pages, where cards do not name it. +function scrapeCard(card, link, context = null) { + const parsed = parseVideoLink(link.getAttribute('href')); + if (!parsed) return null; + const { outlierScore, subscribers } = readVidiqData(card); + const { channelTitle, channelUrl } = readChannel(card, context); + return { + videoId: parsed.videoId, + isShort: parsed.isShort, + title: readTitle(card), + thumbnailUrl: readThumbnail(card, parsed.videoId), + channelTitle, + channelUrl, + subscribers, + views: readViews(card), + outlierScore, + }; +} + +// ---- main.js ---- +const SCAN_INTERVAL_MS = 500; +const MAX_ITEMS_PER_MESSAGE = 200; + +// videoId -> "outlierScore|views|subscribers" of the last message that carried the video. +// VidIQ may draw or update its numbers after the card appears, so a changed signature is sent again. +let sent = new Map(); +let scanTimer = null; + +// Cards on a channel page do not name the channel, so take it from the page itself. +function channelContext() { + const [kind, name] = location.pathname.split('/').filter(Boolean); + const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null; + return { + channelUrl: path ? `https://www.youtube.com/${path}` : null, + channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null, + }; +} + +function scan() { + scanTimer = null; + const page = pageFromPath(location.pathname); + if (!page) return; + const context = page === 'channel' ? channelContext() : null; + + const found = new Map(); + const visited = new Set(); + for (const link of document.querySelectorAll(LINK_SELECTOR)) { + const card = link.closest(CARD_SELECTOR); + if (!card || visited.has(card)) continue; + visited.add(card); + const item = scrapeCard(card, link, context); + if (!item || item.outlierScore === null || item.views === null) continue; + const signature = `${item.outlierScore}|${item.views}|${item.subscribers}`; + if (sent.get(item.videoId) === signature) continue; + found.set(item.videoId, { item: { ...item, page }, signature }); } + if (found.size === 0) return; - const observer = new MutationObserver(scheduleScan); - observer.observe(document.documentElement, { childList: true, subtree: true }); - document.addEventListener('yt-navigate-finish', () => { - sent = new Map(); - scheduleScan(); - }); + const batch = [...found.values()].slice(0, MAX_ITEMS_PER_MESSAGE); + for (const { item, signature } of batch) sent.set(item.videoId, signature); + try { + chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {}); + } catch { + observer.disconnect(); // the extension was reloaded; this script is orphaned + return; + } + if (found.size > batch.length) scheduleScan(); +} + +function scheduleScan() { + if (scanTimer === null) scanTimer = setTimeout(scan, SCAN_INTERVAL_MS); +} + +const observer = new MutationObserver(scheduleScan); +observer.observe(document.documentElement, { childList: true, subtree: true }); +document.addEventListener('yt-navigate-finish', () => { + sent = new Map(); scheduleScan(); +}); +scheduleScan(); })(); diff --git a/src/content/main.js b/src/content/main.js new file mode 100644 index 0000000..6b168ec --- /dev/null +++ b/src/content/main.js @@ -0,0 +1,63 @@ +import { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } from '../lib/card.js'; +import { pageFromPath } from '../lib/video-id.js'; + +const SCAN_INTERVAL_MS = 500; +const MAX_ITEMS_PER_MESSAGE = 200; + +// videoId -> "outlierScore|views|subscribers" of the last message that carried the video. +// VidIQ may draw or update its numbers after the card appears, so a changed signature is sent again. +let sent = new Map(); +let scanTimer = null; + +// Cards on a channel page do not name the channel, so take it from the page itself. +function channelContext() { + const [kind, name] = location.pathname.split('/').filter(Boolean); + const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null; + return { + channelUrl: path ? `https://www.youtube.com/${path}` : null, + channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null, + }; +} + +function scan() { + scanTimer = null; + const page = pageFromPath(location.pathname); + if (!page) return; + const context = page === 'channel' ? channelContext() : null; + + const found = new Map(); + const visited = new Set(); + for (const link of document.querySelectorAll(LINK_SELECTOR)) { + const card = link.closest(CARD_SELECTOR); + if (!card || visited.has(card)) continue; + visited.add(card); + const item = scrapeCard(card, link, context); + if (!item || item.outlierScore === null || item.views === null) continue; + const signature = `${item.outlierScore}|${item.views}|${item.subscribers}`; + if (sent.get(item.videoId) === signature) continue; + found.set(item.videoId, { item: { ...item, page }, signature }); + } + if (found.size === 0) return; + + const batch = [...found.values()].slice(0, MAX_ITEMS_PER_MESSAGE); + for (const { item, signature } of batch) sent.set(item.videoId, signature); + try { + chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {}); + } catch { + observer.disconnect(); // the extension was reloaded; this script is orphaned + return; + } + if (found.size > batch.length) scheduleScan(); +} + +function scheduleScan() { + if (scanTimer === null) scanTimer = setTimeout(scan, SCAN_INTERVAL_MS); +} + +const observer = new MutationObserver(scheduleScan); +observer.observe(document.documentElement, { childList: true, subtree: true }); +document.addEventListener('yt-navigate-finish', () => { + sent = new Map(); + scheduleScan(); +}); +scheduleScan(); diff --git a/test/bundle.test.js b/test/bundle.test.js new file mode 100644 index 0000000..a53542e --- /dev/null +++ b/test/bundle.test.js @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bundle } from '../tools/build-content.js'; + +const root = fileURLToPath(new URL('..', import.meta.url)); + +function project(files) { + const dir = mkdtempSync(join(tmpdir(), 'outlier-saver-bundle-')); + for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); + return dir; +} + +test('bundle puts dependencies before their users and leaves no import/export syntax', () => { + const dir = project({ + 'a.js': 'export const A = 1;\n', + 'b.js': "import { A } from './a.js';\nexport function b() { return A + 1; }\n", + 'main.js': "import { b } from './b.js';\nglobalThis.result = b();\n", + }); + const output = bundle(join(dir, 'main.js')); + assert.ok(!/^\s*(import|export)\b/m.test(output), output); + const context = {}; + new Function('globalThis', output)(context); + assert.equal(context.result, 2); +}); + +test('bundle includes a shared dependency only once', () => { + const dir = project({ + 'shared.js': 'export const SHARED = 7;\n', + 'left.js': "import { SHARED } from './shared.js';\nexport const left = () => SHARED;\n", + 'right.js': "import { SHARED } from './shared.js';\nexport const right = () => SHARED * 2;\n", + 'main.js': "import { left } from './left.js';\nimport { right } from './right.js';\nglobalThis.sum = left() + right();\n", + }); + const output = bundle(join(dir, 'main.js')); + assert.equal(output.match(/const SHARED/g).length, 1); + const context = {}; + new Function('globalThis', output)(context); + assert.equal(context.sum, 21); +}); + +test('bundle refuses two modules that declare the same top-level name', () => { + const dir = project({ + 'a.js': 'export const X = 1;\n', + 'b.js': 'export const X = 2;\n', + 'main.js': "import { X } from './a.js';\nimport './b.js';\n", + }); + assert.throws(() => bundle(join(dir, 'main.js')), /Duplicate top-level name "X"/); +}); + +test('bundle refuses export forms it cannot rewrite', () => { + for (const line of ['export default 1;', 'const a = 1;\nexport { a };', "export * from './x.js';"]) { + const dir = project({ 'main.js': `${line}\n` }); + assert.throws(() => bundle(join(dir, 'main.js')), /only "export const/, line); + } +}); + +test('the committed src/content.js is up to date (run `npm run build` if this fails)', () => { + const built = bundle(join(root, 'src/content/main.js')); + assert.equal(readFileSync(join(root, 'src/content.js'), 'utf8'), built); +}); + +test('the content script has no dynamic import and the manifest has no web_accessible_resources', () => { + assert.ok(!/\bimport\s*\(/.test(readFileSync(join(root, 'src/content.js'), 'utf8'))); + assert.equal('web_accessible_resources' in JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')), false); +}); diff --git a/tools/build-content.js b/tools/build-content.js new file mode 100644 index 0000000..3d5d912 --- /dev/null +++ b/tools/build-content.js @@ -0,0 +1,55 @@ +// Bundles the content script and the modules it imports into one classic script. +// +// Content scripts cannot be ES modules, and loading modules with a dynamic import() makes Chrome log +// "Unsafe attempt to load URL chrome-extension://..." on every YouTube page, which piles up in the +// extension's error list. A single classic file avoids both. +// +// Supported source style: one-line `import ... from './x.js';`, and `export` only before +// const / let / function / class. Top-level names must be unique across the bundled modules. +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const IMPORT = /^import\s+(?:[^'"]*?\s+from\s+)?'([^']+)';?[ \t]*\r?\n/gm; +const EXPORT_DECLARATION = /^export\s+(?=(?:async\s+function|function|const|let|class)\b)/gm; +const UNSUPPORTED_EXPORT = /^export\s+(?!(?:async\s+function|function|const|let|class)\b)/m; +const TOP_LEVEL_NAME = /^(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/gm; + +const HEADER = + '// GENERATED by tools/build-content.js from src/content/main.js and the modules it imports.\n' + + '// Edit those files and run `npm run build`; do not edit this file by hand.\n'; + +export function bundle(entry) { + const order = []; + const seen = new Set(); + const visit = (file) => { + if (seen.has(file)) return; + seen.add(file); + for (const [, specifier] of readFileSync(file, 'utf8').matchAll(IMPORT)) visit(resolve(dirname(file), specifier)); + order.push(file); + }; + visit(resolve(entry)); + + const owner = new Map(); + const parts = order.map((file) => { + const source = readFileSync(file, 'utf8'); + if (UNSUPPORTED_EXPORT.test(source)) { + throw new Error(`${file}: only "export const/let/function/class" declarations are supported`); + } + const code = source.replace(IMPORT, '').replace(EXPORT_DECLARATION, '').trim(); + for (const [, name] of code.matchAll(TOP_LEVEL_NAME)) { + if (owner.has(name)) throw new Error(`Duplicate top-level name "${name}" in ${file} and ${owner.get(name)}`); + owner.set(name, file); + } + return `// ---- ${basename(file)} ----\n${code}\n`; + }); + + return `${HEADER}(() => {\n'use strict';\n\n${parts.join('\n')}})();\n`; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + writeFileSync(resolve(root, 'src/content.js'), bundle(resolve(root, 'src/content/main.js'))); + console.log('Wrote src/content.js'); +}