├── .gitignore ├── index.d.ts ├── prettierrc.json ├── package.json ├── LICENSE ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .DS_Store/ 3 | /.DS_Store 4 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from '@sveltejs/kit'; 2 | 3 | interface AdapterOptions { 4 | pages?: string; 5 | assets?: string; 6 | fallback?: string; 7 | precompress?: boolean; 8 | manifest?: string; 9 | emptyOutDir?: boolean; 10 | } 11 | 12 | declare function plugin(options?: AdapterOptions): Adapter; 13 | export = plugin; 14 | -------------------------------------------------------------------------------- /prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "endOfLine": "lf", 7 | "htmlWhitespaceSensitivity": "css", 8 | "insertPragma": false, 9 | "jsxBracketSameLine": false, 10 | "jsxSingleQuote": false, 11 | "printWidth": 80, 12 | "proseWrap": "preserve", 13 | "quoteProps": "as-needed", 14 | "requirePragma": false, 15 | "semi": true, 16 | "singleQuote": false, 17 | "tabWidth": 2, 18 | "trailingComma": "es5", 19 | "useTabs": false, 20 | "vueIndentScriptAndStyle": false 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-adapter-chrome-extension", 3 | "version": "2.0.1", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/michmich112/sveltekit-adapter-chrome-extension" 7 | }, 8 | "license": "MIT", 9 | "type": "module", 10 | "main": "index.js", 11 | "exports": { 12 | ".": { 13 | "import": "./index.js" 14 | }, 15 | "./package.json": "./package.json" 16 | }, 17 | "types": "index.d.ts", 18 | "scripts": { 19 | "lint": "eslint --ignore-path .gitignore \"**/*.{ts,js,svelte}\" && npm run check-format", 20 | "check": "tsc", 21 | "format": "npm run check-format -- --write", 22 | "check-format": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore", 23 | "test": "uvu test test.js" 24 | }, 25 | "dependencies": { 26 | "cheerio": "^1.0.0-rc.10", 27 | "tiny-glob": "^0.2.9" 28 | }, 29 | "peerDependencies": { 30 | "@sveltejs/adapter-static": "^3.0.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sveltekit-adapter-chrome-extension 2 | 3 | [Adapter](https://kit.svelte.dev/docs#adapters) for SvelteKit apps that prerenders your site as a collection of static files and removes inline scripts to comply with content source policies of Chrome extensions using manifest v3. 4 | 5 | > Based on [@sveltekit/adapter-static](https://github.com/sveltejs/kit/blob/master/packages/adapter-static). Credit goes to [these people](https://github.com/sveltejs/kit/graphs/contributors) for their hard work to make Svelte so great 6 | 7 | > 🚧 If you are using SvelteKit v1.0.0+, make sure to set your `prerender=true` for every page reference by your extension so SvelteKit generates the HTML files. (c.f. Issue #27) 8 | 9 | ## Usage 10 | 11 | Install with `npm i -D sveltekit-adapter-chrome-extension`, then add the adapter to your `svelte.config.js`: 12 | 13 | ```js 14 | // svelte.config.js 15 | import adapter from "sveltekit-adapter-chrome-extension"; 16 | 17 | export default { 18 | kit: { 19 | adapter: adapter({ 20 | // default options are shown 21 | pages: "build", 22 | assets: "build", 23 | fallback: null, 24 | precompress: false, 25 | manifest: "manifest.json", 26 | }), 27 | appDir: "app", 28 | }, 29 | }; 30 | ``` 31 | 32 | ## Options 33 | 34 | ### pages 35 | 36 | The directory to write prerendered pages to. It defaults to `build`. 37 | 38 | ### assets 39 | 40 | The directory to write static assets (the contents of `static`, plus client-side JS and CSS generated by SvelteKit) to. Ordinarily this should be the same as `pages`, and it will default to whatever the value of `pages` is, but in rare circumstances you might need to output pages and assets to separate locations. 41 | 42 | ### fallback 43 | 44 | Specify a fallback page for SPA mode, e.g. `index.html` or `200.html` or `404.html`. 45 | 46 | ### precompress 47 | 48 | If `true`, precompresses files with brotli and gzip. This will generate `.br` and `.gz` files. 49 | 50 | ### manifest 51 | 52 | Specify manifest file name if you want different manifests for different target platforms, e.g. `chrome_manifest.json`, `firefox_manifest.json`. 53 | This file name must match one that is present in the `static` directory (the dir containing your static files). The selected target file will be renamed to `manifest.json` in the build directory, and all other `.json` files with `manifest` in the name won't be copied. 54 | 55 | ## License 56 | 57 | [MIT](LICENSE) 58 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import staticAdapter from "@sveltejs/adapter-static"; 2 | import { load } from "cheerio"; 3 | import { 4 | createReadStream, 5 | createWriteStream, 6 | existsSync, 7 | readFileSync, 8 | statSync, 9 | unlinkSync, 10 | writeFileSync, 11 | } from "fs"; 12 | import { dirname, join } from "path"; 13 | import { pipeline } from "stream"; 14 | import glob from "tiny-glob"; 15 | import { promisify } from "util"; 16 | import zlib from "zlib"; 17 | 18 | const pipe = promisify(pipeline); 19 | 20 | /** @type {import('.')} */ 21 | export default function(options) { 22 | return { 23 | name: "sveltekit-adapter-chrome-extension", 24 | 25 | async adapt(builder) { 26 | staticAdapter(options).adapt(builder); 27 | 28 | /* extension */ 29 | const pages = options?.pages ?? "build"; 30 | const assets = options?.assets ?? pages; 31 | const manifest = options?.manifest ?? "manifest.json"; 32 | 33 | await removeInlineScripts(assets, builder.log); 34 | 35 | await removeAppManifest(assets, builder.config.kit.appDir, builder.log); 36 | await removeAppManifest(".", assets, builder.log); 37 | 38 | // operation required since generated app manifest will overwrite the static extension manifest.json 39 | reWriteExtensionManifest(assets, manifest, builder); 40 | }, 41 | }; 42 | } 43 | 44 | /** 45 | * Hash using djb2 46 | * @param {import('types/hooks').StrictBody} value 47 | */ 48 | function hash(value) { 49 | let hash = 5381; 50 | let i = value.length; 51 | 52 | if (typeof value === "string") { 53 | while (i) hash = (hash * 33) ^ value.charCodeAt(--i); 54 | } else { 55 | while (i) hash = (hash * 33) ^ value[--i]; 56 | } 57 | 58 | return (hash >>> 0).toString(36); 59 | } 60 | 61 | async function removeAppManifest(directory, appDir, log) { 62 | log("Removing App Manifest"); 63 | const files = await glob(`**/${appDir}/*manifest*.json`, { 64 | cwd: directory, 65 | dot: true, 66 | absolute: true, 67 | filesOnly: true, 68 | }); 69 | 70 | files.forEach((path) => { 71 | try { 72 | unlinkSync(path); 73 | log.success(`Removed app manifest file at path: ${path}`); 74 | } catch (err) { 75 | log.warn( 76 | `Error removing app manifest file at path: ${path}. You may have to delete it manually before submitting you extension.\nError: ${err}` 77 | ); 78 | } 79 | }); 80 | } 81 | 82 | async function removeInlineScripts(directory, log) { 83 | log("Removing Inline Scripts"); 84 | const files = await glob("**/*.{html}", { 85 | cwd: directory, 86 | dot: true, 87 | aboslute: true, 88 | filesOnly: true, 89 | }); 90 | 91 | files 92 | .map((f) => join(directory, f)) 93 | .forEach((file) => { 94 | log.minor(`file: ${file}`); 95 | const f = readFileSync(file); 96 | const $ = load(f.toString()); 97 | const node = $("script").get()[0]; 98 | 99 | if (!node) return; 100 | if (Object.keys(node.attribs).includes("src")) return; // if there is a src, it's not an inline script 101 | 102 | const attribs = Object.keys(node.attribs).reduce( 103 | (a, c) => a + `${c}="${node.attribs[c]}" `, 104 | "" 105 | ); 106 | const innerScript = node.children[0].data; 107 | const fullTag = $("script").toString(); 108 | //get new filename 109 | const hashedName = `script-${hash(innerScript)}.js`; 110 | //remove from orig html file and replace with new script tag 111 | const newHtml = f 112 | .toString() 113 | .replace(fullTag, ``); 114 | writeFileSync(file, newHtml); 115 | log.minor(`Rewrote ${file}`); 116 | 117 | const p = `${dirname(file)}/${hashedName}`; 118 | writeFileSync(p, innerScript); 119 | log.success(`Inline script extracted and saved at: ${p}`); 120 | }); 121 | } 122 | 123 | function reWriteExtensionManifest(directory, manifest, builder) { 124 | const { log, getStaticDirectory, getClientDirectory, copy } = builder; 125 | log("Re-writing extension manifest"); 126 | let sourceFilePath; 127 | if (typeof getStaticDirectory !== "undefined") { 128 | sourceFilePath = join(getStaticDirectory(), manifest); 129 | } else { 130 | sourceFilePath = join(getClientDirectory(), manifest); 131 | } 132 | if (existsSync(sourceFilePath)) { 133 | log.info("Extension manifest found"); 134 | const res = copy(sourceFilePath, join(directory, "manifest.json")); 135 | log.success("Successfully re-wrote extension manifest"); 136 | } else { 137 | log.error( 138 | `Extension manifest not found. Make sure you've added your extension manifest in your statics directory with the name ${manifest}` 139 | ); 140 | } 141 | } 142 | 143 | /** 144 | * @param {string} directory 145 | */ 146 | async function compress(directory) { 147 | const files = await glob("**/*.{html,js,json,css,svg,xml}", { 148 | cwd: directory, 149 | dot: true, 150 | absolute: true, 151 | filesOnly: true, 152 | }); 153 | 154 | await Promise.all( 155 | files.map((file) => 156 | Promise.all([compress_file(file, "gz"), compress_file(file, "br")]) 157 | ) 158 | ); 159 | } 160 | 161 | /** 162 | * @param {string} file 163 | * @param {'gz' | 'br'} format 164 | */ 165 | async function compress_file(file, format = "gz") { 166 | const compress = 167 | format == "br" 168 | ? zlib.createBrotliCompress({ 169 | params: { 170 | [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, 171 | [zlib.constants.BROTLI_PARAM_QUALITY]: 172 | zlib.constants.BROTLI_MAX_QUALITY, 173 | [zlib.constants.BROTLI_PARAM_SIZE_HINT]: statSync(file).size, 174 | }, 175 | }) 176 | : zlib.createGzip({ level: zlib.constants.Z_BEST_COMPRESSION }); 177 | 178 | const source = createReadStream(file); 179 | const destination = createWriteStream(`${file}.${format}`); 180 | 181 | await pipe(source, compress, destination); 182 | } 183 | --------------------------------------------------------------------------------