1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-23 14:52:59 +00:00

Reworking post-svelte build process for the extension

This commit is contained in:
2024-05-08 01:47:35 +04:00
parent 2ffef5951d
commit 571b8dd575
7 changed files with 351 additions and 146 deletions

View File

@@ -0,0 +1,125 @@
import {build} from "vite";
import {createHash} from "crypto";
import path from "path";
import fs from "fs";
/**
* Create the result base file name for the file.
* @param {string} inputPath Path to the original filename.
* @return {string} Result base file name without extension. Contains original filename + hash suffix.
*/
function createOutputBaseName(inputPath) {
const hashSuffix = createHash('sha256')
.update(
// Yes, suffix is only dependent on the entry file, dependencies are not included.
fs.readFileSync(inputPath, 'utf8')
)
.digest('base64url')
.substring(0, 8);
const baseName = path.basename(inputPath, path.extname(inputPath));
return `${baseName}-${hashSuffix}`;
}
/**
* Small workaround plugin to cover each individual content script into IIFE. This is pretty much mandatory to use,
* otherwise helper functions made by Vite will collide with each other. Only include this plugin into config with
* script!
* @return {import('vite').Plugin}
*/
function wrapScriptIntoIIFE() {
return {
name: 'wrap-scripts-into-iife',
generateBundle(outputBundles, bundle) {
Object.keys(bundle).forEach(fileName => {
const file = bundle[fileName];
file.code = `(() => {\n${file.code}})();`
});
}
}
}
/**
* Default aliases used inside popup app.
* @param {string} rootDir Root directory of the repo for building paths.
* @return {Record<string, string>} Aliases to include into the config object.
*/
function makeAliases(rootDir) {
return {
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),
}
}
/**
* Build the selected script separately.
* @param {AssetBuildOptions} buildOptions Building options for the script.
* @return {Promise<string>} Result file path.
*/
export async function buildScript(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js'
}
},
emptyOutDir: false,
},
resolve: {
alias: makeAliases(buildOptions.rootDir)
},
plugins: [
wrapScriptIntoIIFE()
]
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
}
/**
* Build the selected stylesheet.
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
* @return {Promise<string>} Result file path.
*/
export async function buildStyle(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js',
assetFileNames: '[name].[ext]',
}
},
emptyOutDir: false,
}
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
}
/**
* @typedef {Object} AssetBuildOptions
* @property {string} input Full path to the input file to build.
* @property {string} outputDir Destination folder for the script.
* @property {string} rootDir Root directory of the repository.
*/

46
.vite/lib/index-file.js Normal file
View File

@@ -0,0 +1,46 @@
import fs from "fs";
import {createHash} from "crypto";
import {load} from "cheerio";
import path from "path";
/**
* Find and extract all inline scripts injected into index file by the SvelteKit builder. This needs to be done due to
* ManifestV3 restrictions on inline/loaded scripts usage. The only way to run scripts in popup is by specifying
* `<script>` tag with the path. Thanks, ManifestV3!
*
* @param {string} indexFilePath Path to the index.html file. This file will be overridden and all the inline scripts
* found inside it will be placed in the same directory.
*/
export function extractInlineScriptsFromIndex(indexFilePath) {
const directory = path.dirname(indexFilePath);
const html = fs.readFileSync(indexFilePath, 'utf8');
const ch = load(html);
ch('script').each((index, scriptElement) => {
const $script = ch(scriptElement);
const scriptContent = $script.text();
const contentsHash = createHash('sha256')
.update(scriptContent)
.digest('base64url')
.substring(0, 8);
const fileName = `init.${contentsHash}.js`;
const filePath = path.resolve(directory, fileName);
const publicPath = `./${fileName}`;
fs.writeFileSync(
filePath,
// This will work for minifying index script of the SvelteKit, but might cause some issues if any other scripts
// will appear. Good for now.
scriptContent
.replaceAll("\t", "")
.replaceAll("\n", "")
);
$script.attr('src', publicPath);
$script.text('');
});
fs.writeFileSync(indexFilePath, ch.html());
}

103
.vite/lib/manifest.js Normal file
View File

@@ -0,0 +1,103 @@
import fs from "fs";
/**
* Helper class for processing and using manifest for packing the extension.
*/
class ManifestProcessor {
/**
* Current state of the manifest object.
* @type {Manifest}
*/
#manifestObject;
/**
* @param {Manifest} parsedManifest Original manifest contents parsed as JSON object.
*/
constructor(parsedManifest) {
this.#manifestObject = parsedManifest;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
*
* @param {function(ContentScriptsEntry): Promise<ContentScriptsEntry>} mapCallback Processing function to call on
* every entry. Entries should be modified and returned. Function should be asynchronous.
*
* @return {Promise<void>}
*/
async mapContentScripts(mapCallback) {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to map over.');
return;
}
for (let entryIndex = 0; entryIndex < contentScripts.length; entryIndex++) {
contentScripts[entryIndex] = await mapCallback(contentScripts[entryIndex]);
}
}
/**
* Pass the version of the plugin from following package.json file.
*
* @param {string} packageFilePath Path to the JSON file to parse and extract the version from. If version is not
* found, original version will be kept.
*/
passVersionFromPackage(packageFilePath) {
/** @type {PackageObject} */
const packageObject = JSON.parse(fs.readFileSync(packageFilePath, 'utf8'));
if (packageObject.version) {
this.#manifestObject.version = packageObject.version;
}
}
/**
* Save the current state of the manifest into the selected file.
*
* @param {string} manifestFilePath File to write the resulting manifest to. Should be called after all the
* modifications.
*/
saveTo(manifestFilePath) {
fs.writeFileSync(
manifestFilePath,
JSON.stringify(this.#manifestObject, null, 2),
{
encoding: 'utf8'
}
);
}
}
/**
* Load the manifest and create a processor object.
*
* @param {string} filePath Path to the original manifest file.
*
* @return {ManifestProcessor} Object for manipulating manifest file.
*/
export function loadManifest(filePath) {
const manifest = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
return new ManifestProcessor(manifest);
}
/**
* @typedef {Object} Manifest
* @property {string} version
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} mathces
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/
/**
* @typedef {Object} PackageObject
* @property {string|undefined} version
*/

65
.vite/pack-extension.js Normal file
View File

@@ -0,0 +1,65 @@
import {loadManifest} from "./lib/manifest.js";
import path from "path";
import {buildScript, buildStyle} from "./lib/content-scripts.js";
import {normalizePath} from "vite";
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
/**
* Build addition assets required for the extension and pack it into the directory.
* @param {PackExtensionSettings} settings Build settings.
*/
export async function packExtension(settings) {
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
await manifest.mapContentScripts(async (entry) => {
if (entry.js) {
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
const builtScriptFilePath = await buildScript({
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
entry.js[scriptIndex] = normalizePath(
path.relative(
settings.exportDir,
builtScriptFilePath
)
);
}
}
if (entry.css) {
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
const builtStylesheetFilePath = await buildStyle({
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir
});
entry.css[styleIndex] = normalizePath(
path.relative(
settings.exportDir,
builtStylesheetFilePath
)
);
}
}
return entry;
});
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
extractInlineScriptsFromIndex(path.resolve(settings.exportDir, 'index.html'));
}
/**
* @typedef {Object} PackExtensionSettings
* @property {string} rootDir Root directory of the repository. Required for properly fetching source files.
* @property {string} exportDir Directory of the built extension.
* @property {string} contentScriptsDir Directory specifically for content scripts entries.
*/

11
build-extension.js Normal file
View File

@@ -0,0 +1,11 @@
import {packExtension} from "./.vite/pack-extension.js";
import path from "path";
import {fileURLToPath} from "url";
const __dirname = fileURLToPath(new URL('.', import.meta.url));
void packExtension({
rootDir: __dirname,
exportDir: path.resolve(__dirname, 'build'),
contentScriptsDir: path.resolve(__dirname, 'build', 'content')
});

View File

@@ -5,7 +5,7 @@
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:popup": "vite build",
"build:extension": "vite build --config vite.config.extension.js",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
},

View File

@@ -1,145 +0,0 @@
import {defineConfig, normalizePath} from 'vite';
import path from "path";
import fs from "fs";
import {load} from "cheerio";
import crypto from "crypto";
const packageJsonPath = path.resolve(__dirname, 'package.json');
const manifestJsonPath = path.resolve(__dirname, 'manifest.json');
const buildDirectoryPath = path.resolve(__dirname, 'build');
const contentScriptsDirectoryPath = path.resolve(buildDirectoryPath, 'assets', 'content');
if (!fs.existsSync(manifestJsonPath)) {
throw new Error(
`The manifest.json file is missing from the root of the project.`
);
}
if (!fs.existsSync(packageJsonPath)) {
throw new Error(
`The package.json file is missing from the root of the project.`
);
}
const packageInformation = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const manifestInformation = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf8'));
/** @type {import('vite').RollupOptions} */
const rollupOptions = {
input: {},
output: {
dir: contentScriptsDirectoryPath,
entryFileNames: '[name].js',
assetFileNames: '[name].[ext]',
}
};
function hashFilePath(filePath) {
return crypto
.createHash('sha256')
.update(filePath)
.digest('base64url')
.slice(0, 8);
}
// This is somewhat hacky, but it works for now. This code goes over the list of content scripts, adding them to the
// rollupOptions.input object, and then modifying the content_scripts array to point to the built file names.
// TODO: This fragment should probably somehow be moved into a plugin, together with the code that copies the source
// mainifest.json file to the build directory.
if (manifestInformation?.['content_scripts']) {
manifestInformation['content_scripts'] = manifestInformation['content_scripts'].map(entry => {
if (entry.js) {
entry.js = entry.js.map(filePath => {
const fileName = path.basename(filePath);
const baseName = fileName.split('.').slice(0, -1).join('.');
const outputBaseName = `${baseName}-${hashFilePath(filePath)}`;
rollupOptions.input[outputBaseName] = filePath;
return normalizePath(
path.relative(
buildDirectoryPath,
path.resolve(contentScriptsDirectoryPath, outputBaseName + '.js')
)
);
});
}
if (entry.css) {
entry.css = entry.css.map(filePath => {
const fileName = path.basename(filePath);
const baseName = fileName.split('.').slice(0, -1).join('.');
const outputBaseName = `${baseName}-${hashFilePath(filePath)}`;
rollupOptions.input[outputBaseName] = filePath;
return normalizePath(
path.relative(
buildDirectoryPath,
path.resolve(contentScriptsDirectoryPath, outputBaseName + '.css')
)
);
})
}
return entry;
});
}
export default defineConfig({
build: {
rollupOptions,
emptyOutDir: false,
},
resolve: {
alias: {
"$lib": path.resolve(__dirname, 'src/lib'),
"$entities": path.resolve(__dirname, 'src/lib/extension/entities'),
"$styles": path.resolve(__dirname, 'src/styles'),
}
},
plugins: [
{
name: 'extract-inline-js',
async buildEnd() {
const buildPath = path.resolve(__dirname, 'build');
const indexFilePath = path.resolve(buildPath, 'index.html');
const indexHtml = fs.readFileSync(indexFilePath, 'utf8');
const ch = load(indexHtml);
ch('script').each((index, scriptElement) => {
const $script = ch(scriptElement);
const scriptContent = $script.text();
const entryHash = crypto.createHash('sha256')
.update(scriptContent)
.digest('base64url');
const scriptName = `init.${entryHash.slice(0, 8)}.js`;
const scriptFilePath = path.resolve(buildPath, scriptName);
const scriptPublicPath = `./${scriptName}`;
fs.writeFileSync(scriptFilePath, scriptContent);
$script.attr('src', scriptPublicPath);
$script.text('');
});
fs.writeFileSync(indexFilePath, ch.html());
}
},
{
name: "bypass-manifest-extension",
async buildEnd() {
manifestInformation.version = packageInformation.version;
fs.writeFileSync(
path.resolve(__dirname, 'build', 'manifest.json'),
JSON.stringify(manifestInformation, null, 2)
);
}
}
]
});