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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
38dabd9fa2
commit
b137776381
@@ -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`.
|
||||
|
||||
@@ -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 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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` падает.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -13,11 +13,5 @@
|
||||
"js": ["src/content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["src/lib/*.js"],
|
||||
"matches": ["https://www.youtube.com/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node tools/build-content.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
|
||||
+194
-3
@@ -1,7 +1,198 @@
|
||||
(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';
|
||||
|
||||
// ---- 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],
|
||||
]);
|
||||
|
||||
// "просмотров", "подписчиков", "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+)?$/;
|
||||
|
||||
// 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 parseViews(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
if (/^\s*(no|нет) (views|просмотров)\s*$/i.test(text)) return 0;
|
||||
return parseCount(text);
|
||||
}
|
||||
|
||||
// ---- 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 };
|
||||
}
|
||||
}
|
||||
return { channelTitle: context?.channelTitle ?? null, channelUrl: context?.channelUrl ?? null };
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user