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:
IgorVolochay
2026-10-25 12:25:06 +03:00
co-authored by Claude Sonnet 5.5
parent 38dabd9fa2
commit b137776381
9 changed files with 461 additions and 61 deletions
+9 -1
View File
@@ -24,10 +24,18 @@
## Разработка ## Разработка
Сборки нет. Тесты (Node 22, без зависимостей; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден): Зависимостей нет, сборка только одна (content script, см. ниже). Тесты (Node 22; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден):
``` ```
npm test 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`. После изменения кода нажмите «Обновить» у расширения на `chrome://extensions`.
@@ -1594,3 +1594,21 @@ Expected: все проверки проходят.
git add README.md git add README.md
git commit -m "docs: describe the VidIQ-based workflow" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>" 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 | | Хранение | 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. Записи обрабатываются строго по очереди | | Service worker | `src/background.js` | Принимает карточки, подтягивает настройки и ранее сохранённые записи, пишет результат в IndexedDB. Записи обрабатываются строго по очереди |
| Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки | | Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки |
| Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` | | Данные 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`. - **Автотесты** на встроенном `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** (`test/card.dom.test.js`): запускает headless Chrome, загружает фикстуры и проверяет `scrapeCard`. Фикстуры: настоящая карточка с VidIQ с главной, плюс упрощённые карточки поиска и боковой панели. Если Chrome не найден, тест пропускается.
- **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup. - **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup.
-6
View File
@@ -13,11 +13,5 @@
"js": ["src/content.js"], "js": ["src/content.js"],
"run_at": "document_idle" "run_at": "document_idle"
} }
],
"web_accessible_resources": [
{
"resources": ["src/lib/*.js"],
"matches": ["https://www.youtube.com/*"]
}
] ]
} }
+1
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node tools/build-content.js",
"test": "node --test" "test": "node --test"
} }
} }
+194 -3
View File
@@ -1,7 +1,198 @@
(async () => { // GENERATED by tools/build-content.js from src/content/main.js and the modules it imports.
const { pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js')); // Edit those files and run `npm run build`; do not edit this file by hand.
const { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } = await import(chrome.runtime.getURL('src/lib/card.js')); (() => {
'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 SCAN_INTERVAL_MS = 500;
const MAX_ITEMS_PER_MESSAGE = 200; const MAX_ITEMS_PER_MESSAGE = 200;
+63
View File
@@ -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();
+68
View File
@@ -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);
});
+55
View File
@@ -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');
}