A music player that connects to your cloud/distributed storage.
at v4 409 lines 11 kB view raw
1import type { RequestHandler } from "lume/core/server.ts"; 2 3import { dotenvRun } from "@dotenv-run/esbuild"; 4import lume from "lume/mod.ts"; 5 6import brotli from "lume/plugins/brotli.ts"; 7import esbuild from "lume/plugins/esbuild.ts"; 8import postcss from "lume/plugins/postcss.ts"; 9import sourceMaps from "lume/plugins/source_maps.ts"; 10 11import * as path from "@std/path"; 12import { ensureDirSync } from "@std/fs/ensure-dir"; 13import { walkSync } from "@std/fs/walk"; 14import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; 15import { wasmLoader } from "esbuild-plugin-wasm"; 16import autoprefixer from "autoprefixer"; 17import cssnano from "cssnano"; 18 19import { create as createCID } from "~/common/cid.js"; 20 21const site = lume({ 22 dest: "./dist", 23 src: "./src", 24 server: { 25 debugBar: false, 26 middlewares: [facetHtmlMiddleware], 27 }, 28}); 29 30export default site; 31 32//////////////////////////////////////////// 33// JS 34//////////////////////////////////////////// 35 36site.use(esbuild({ 37 extensions: [".js"], 38 options: { 39 alias: { 40 "@automerge/automerge": "https://esm.sh/@automerge/automerge@^3.2.3", 41 }, 42 bundle: true, 43 format: "esm", 44 minify: true, 45 external: ["./file-tree.json"], 46 platform: "browser", 47 plugins: [ 48 // @ts-ignore 49 dotenvRun({ 50 files: [".env"], 51 }), 52 // Force @atcute/uint8array to use the browser entry (dist/index.js) 53 // instead of the Node entry (dist/index.node.js) which imports from 54 // node:crypto. The @deno/loader Workspace defaults to platform "node", 55 // causing the "node" export condition to match before "default". 56 { 57 name: "atcute-uint8array-browser", 58 setup(build) { 59 build.onLoad( 60 { filter: /@atcute\+uint8array.*index\.node\.js$/ }, 61 async (args) => { 62 const browserPath = args.path.replace( 63 "index.node.js", 64 "index.js", 65 ); 66 const contents = await Deno.readTextFile(browserPath); 67 return { contents, loader: "js" }; 68 }, 69 ); 70 }, 71 }, 72 { 73 name: "atcute-tid-browser", 74 setup(build) { 75 build.onLoad( 76 { filter: /@atcute\+tid.*random-node\.js$/ }, 77 async (args) => { 78 const browserPath = args.path.replace( 79 "random-node.js", 80 "random-web.js", 81 ); 82 const contents = await Deno.readTextFile(browserPath); 83 return { contents, loader: "js" }; 84 }, 85 ); 86 }, 87 }, 88 { 89 name: "atcute-multibase-browser", 90 setup(build) { 91 build.onLoad( 92 { filter: /@atcute\+multibase.*-node\.js$/ }, 93 async (args) => { 94 const browserPath = args.path.replace( 95 "-node.js", 96 "-web.js", 97 ); 98 const contents = await Deno.readTextFile(browserPath); 99 return { contents, loader: "js" }; 100 }, 101 ); 102 }, 103 }, 104 nodeModulesPolyfillPlugin({ 105 fallback: "empty", 106 modules: [], 107 }), 108 wasmLoader(), 109 ], 110 splitting: true, 111 target: "esnext", 112 }, 113})); 114 115site.add([".js"]); 116 117//////////////////////////////////////////// 118// CSS 119//////////////////////////////////////////// 120 121site.use(postcss({ 122 plugins: [ 123 autoprefixer(), 124 cssnano({ 125 preset: "default", 126 }), 127 ], 128})); 129 130site.add([".css"]); 131 132site.remoteFile( 133 "vendor/98.css", 134 import.meta.resolve("./node_modules/98.css/dist/98.css"), 135); 136 137//////////////////////////////////////////// 138// BINARY ASSETS 139//////////////////////////////////////////// 140 141site.add("/favicons", "/"); 142site.add("/fonts"); 143site.add("/images"); 144site.add([".woff2"]); 145 146site.remoteFile( 147 "vendor/ms_sans_serif.woff2", 148 import.meta.resolve( 149 "./node_modules/98.css/fonts/converted/ms_sans_serif.woff2", 150 ), 151); 152 153site.remoteFile( 154 "vendor/ms_sans_serif_bold.woff2", 155 import.meta.resolve( 156 "./node_modules/98.css/fonts/converted/ms_sans_serif_bold.woff2", 157 ), 158); 159 160site.remoteFile( 161 "fonts/98.css/ms_sans_serif.woff2", 162 import.meta.resolve( 163 "./node_modules/98.css/fonts/converted/ms_sans_serif.woff2", 164 ), 165); 166 167site.remoteFile( 168 "fonts/98.css/ms_sans_serif_bold.woff2", 169 import.meta.resolve( 170 "./node_modules/98.css/fonts/converted/ms_sans_serif_bold.woff2", 171 ), 172); 173 174//////////////////////////////////////////// 175// DEFINITIONS 176//////////////////////////////////////////// 177 178site.add("/definitions"); 179 180// HELPERS 181 182site.filter("facetOrThemeURI", (text) => { 183 if (text.includes("://")) { 184 return text; 185 } else { 186 return `diffuse://${text}`; 187 } 188}); 189 190site.filter("facetLoaderURL", (text) => { 191 let key = "path"; 192 193 if (text.includes("://")) { 194 key = "uri"; 195 } 196 197 return `facets/l/?${key}=${encodeURIComponent(text)}`; 198}); 199 200site.filter("themeLoaderURL", (text) => { 201 let key = "path"; 202 203 if (text.includes("://")) { 204 key = "uri"; 205 } 206 207 return `themes/l/?${key}=${encodeURIComponent(text)}`; 208}); 209 210//////////////////////////////////////////// 211// PHOSPHOR ICONS 212//////////////////////////////////////////// 213 214function phosphor(path: string) { 215 site.remoteFile( 216 `vendor/@phosphor-icons/${path}`, 217 import.meta.resolve(`./node_modules/@phosphor-icons/web/src/${path}`), 218 ); 219 220 site.add(`vendor/@phosphor-icons/${path}`); 221} 222 223phosphor("fill/style.css"); 224phosphor("fill/Phosphor-Fill.svg"); 225phosphor("fill/Phosphor-Fill.ttf"); 226phosphor("fill/Phosphor-Fill.woff"); 227phosphor("fill/Phosphor-Fill.woff2"); 228phosphor("bold/style.css"); 229phosphor("bold/Phosphor-Bold.svg"); 230phosphor("bold/Phosphor-Bold.ttf"); 231phosphor("bold/Phosphor-Bold.woff"); 232phosphor("bold/Phosphor-Bold.woff2"); 233 234//////////////////////////////////////////// 235// WEB AWESOME 236//////////////////////////////////////////// 237 238for ( 239 const f of walkSync("./node_modules/@awesome.me/webawesome/dist-cdn/", { 240 includeDirs: false, 241 }) 242) { 243 const relativePath = f.path.replace( 244 /^node_modules\/@awesome\.me\/webawesome\/dist-cdn\//, 245 "", 246 ); 247 248 const destPath = `vendor/@awesome.me/webawesome/${relativePath}`; 249 250 site.remoteFile( 251 destPath, 252 import.meta.resolve( 253 `./node_modules/@awesome.me/webawesome/dist-cdn/${relativePath}`, 254 ), 255 ); 256 257 site.copy(destPath); 258} 259 260//////////////////////////////////////////// 261// MISC 262//////////////////////////////////////////// 263 264site.add([".html"]); 265site.add([".json"]); 266 267site.use(brotli()); 268site.use(sourceMaps()); 269 270// *.inline.js files are inlined into their companion HTML at build/serve time. 271// Exclude them from the regular build so esbuild doesn't try to bundle them. 272site.ignore((p) => p.endsWith(".inline.js")); 273 274site.script("copy-type-defs", () => { 275 for ( 276 const f of walkSync( 277 "./src/", 278 { includeDirs: false, exts: [".d.ts"] }, 279 ) 280 ) { 281 const dest = "dist/" + f.path.replace(/^src\//, ""); 282 const dir = path.dirname(dest); 283 ensureDirSync(dir); 284 Deno.copyFileSync(f.path, dest); 285 } 286}); 287 288site.addEventListener("afterBuild", () => { 289 // site.run("copy-type-defs"); 290}); 291 292//////////////////////////////////////////// 293// MIDDLEWARE 294//////////////////////////////////////////// 295 296// Facet HTML files are HTML fragments fetched via JS, not full pages. 297// Serving them as text/plain prevents Lume's dev server from injecting 298// its live-reload <script> tag into the fetched content. 299// 300// Also inlines any <script type="module" src="./foo.inline.js"> references so 301// that forked facets contain readable JS rather than an external file reference. 302async function facetHtmlMiddleware( 303 request: Request, 304 next: RequestHandler, 305): Promise<Response> { 306 const { pathname } = new URL(request.url); 307 const isFacetHtml = pathname.endsWith(".html") && 308 !pathname.startsWith("/testing/"); 309 const response = await next(request); 310 311 if (!isFacetHtml || !response.headers.get("content-type")?.includes("html")) { 312 return response; 313 } 314 315 let content = await response.text(); 316 content = await inlineScriptSrc( 317 content, 318 path.join("./src", path.dirname(pathname)), 319 ); 320 321 const headers = new Headers(response.headers); 322 headers.set("content-type", "text/plain; charset=utf-8"); 323 return new Response(content, { 324 status: response.status, 325 statusText: response.statusText, 326 headers, 327 }); 328} 329 330const SCRIPT_SRC_RE = 331 /<script type="module" src="\.\/([^"]+\.inline\.js)"><\/script>/; 332 333async function inlineScriptSrc(content: string, dir: string): Promise<string> { 334 const match = SCRIPT_SRC_RE.exec(content); 335 if (!match) return content; 336 337 const jsPath = path.join(dir, match[1]); 338 try { 339 return htmlWithInlineJs({ content, jsPath, match: match[0] }); 340 } catch { 341 return content; 342 } 343} 344 345site.addEventListener("afterBuild", async () => { 346 for ( 347 const f of walkSync("./dist/", { includeDirs: false, exts: [".html"] }) 348 ) { 349 const content = Deno.readTextFileSync(f.path); 350 const match = SCRIPT_SRC_RE.exec(content); 351 if (!match) continue; 352 353 const srcDir = path.dirname(f.path).replace(/^dist\//, "src/"); 354 const jsPath = path.join(srcDir, match[1]); 355 356 try { 357 const newContent = htmlWithInlineJs({ content, jsPath, match: match[0] }); 358 Deno.writeTextFileSync(f.path, newContent); 359 } catch { 360 // leave as-is if the source file can't be read 361 } 362 } 363}); 364 365site.addEventListener("afterBuild", async () => { 366 const RAW = 0x55; 367 368 async function buildFileTree( 369 dir: string, 370 prefix = "", 371 ): Promise<Record<string, string>> { 372 const tree: Record<string, string> = {}; 373 374 for (const entry of Deno.readDirSync(dir)) { 375 const entryPath = path.join(dir, entry.name); 376 const entryKey = prefix ? `${prefix}/${entry.name}` : entry.name; 377 if (entry.isDirectory) { 378 Object.assign(tree, await buildFileTree(entryPath, entryKey)); 379 } else { 380 const data = Deno.readFileSync(entryPath); 381 tree[entryKey] = await createCID(RAW, data); 382 } 383 } 384 385 return tree; 386 } 387 388 const tree = await buildFileTree("dist/"); 389 const sorted = Object.fromEntries( 390 Object.keys(tree).sort().map((k) => [k, tree[k]]), 391 ); 392 393 Deno.writeTextFileSync( 394 "./dist/file-tree.json", 395 JSON.stringify(sorted, null, 2), 396 ); 397}); 398 399function htmlWithInlineJs({ content, match, jsPath }: { 400 content: string; 401 match: string; 402 jsPath: string; 403}): string { 404 const js = 405 Deno.readTextFileSync(jsPath).split("\n").map((line) => ` ${line}`).join( 406 "\n", 407 ).trimEnd() + "\n"; 408 return content.replace(match, `<script type="module">\n${js}</script>`); 409}