forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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", "@awesome.me/webawesome/*"],
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 // nanoid ships a browser entry (index.browser.js) but esbuild resolves
105 // the default condition (index.js) which uses Buffer.allocUnsafe.
106 {
107 name: "nanoid-browser",
108 setup(build) {
109 build.onLoad(
110 { filter: /nanoid\/index\.js$/ },
111 async (args) => {
112 const browserPath = args.path.replace(
113 "index.js",
114 "index.browser.js",
115 );
116 const contents = await Deno.readTextFile(browserPath);
117 return { contents, loader: "js" };
118 },
119 );
120 },
121 },
122 nodeModulesPolyfillPlugin({
123 fallback: "empty",
124 modules: [],
125 }),
126 wasmLoader(),
127 ],
128 splitting: true,
129 target: "esnext",
130 },
131}));
132
133site.add([".js"]);
134
135// *.inline.js files are inlined into their companion HTML at build/serve time.
136// Exclude them from the regular build so esbuild doesn't try to bundle them.
137site.ignore((p) => p.endsWith(".inline.js"));
138
139////////////////////////////////////////////
140// CSS
141////////////////////////////////////////////
142
143site.use(postcss({
144 plugins: [
145 autoprefixer(),
146 cssnano({
147 preset: "default",
148 }),
149 ],
150}));
151
152site.add([".css"]);
153
154site.remoteFile(
155 "vendor/98.css",
156 import.meta.resolve("./node_modules/98.css/dist/98.css"),
157);
158
159////////////////////////////////////////////
160// BINARY ASSETS
161////////////////////////////////////////////
162
163site.add("/favicons", "/");
164site.add("/fonts");
165site.add("/images");
166site.add("/testing");
167site.add([".woff2"]);
168
169site.remoteFile(
170 "vendor/ms_sans_serif.woff2",
171 import.meta.resolve(
172 "./node_modules/98.css/fonts/converted/ms_sans_serif.woff2",
173 ),
174);
175
176site.remoteFile(
177 "vendor/ms_sans_serif_bold.woff2",
178 import.meta.resolve(
179 "./node_modules/98.css/fonts/converted/ms_sans_serif_bold.woff2",
180 ),
181);
182
183site.remoteFile(
184 "fonts/98.css/ms_sans_serif.woff2",
185 import.meta.resolve(
186 "./node_modules/98.css/fonts/converted/ms_sans_serif.woff2",
187 ),
188);
189
190site.remoteFile(
191 "fonts/98.css/ms_sans_serif_bold.woff2",
192 import.meta.resolve(
193 "./node_modules/98.css/fonts/converted/ms_sans_serif_bold.woff2",
194 ),
195);
196
197////////////////////////////////////////////
198// DEFINITIONS
199////////////////////////////////////////////
200
201site.add("/definitions");
202
203// HELPERS
204
205site.filter("facetURI", (text) => {
206 if (text.includes("://")) {
207 return text;
208 } else {
209 return `diffuse://${text}`;
210 }
211});
212
213site.filter("facetLoaderURL", (text) => {
214 let key = "path";
215
216 if (text.includes("://")) {
217 key = "uri";
218 }
219
220 return `l/?${key}=${encodeURIComponent(text)}`;
221});
222
223////////////////////////////////////////////
224// PHOSPHOR ICONS
225////////////////////////////////////////////
226
227function phosphor(path: string) {
228 site.remoteFile(
229 `vendor/@phosphor-icons/web/${path}`,
230 import.meta.resolve(`./node_modules/@phosphor-icons/web/src/${path}`),
231 );
232
233 site.add(`vendor/@phosphor-icons/web/${path}`);
234}
235
236["bold", "duotone", "fill", "light", "regular", "light"].forEach((v) => {
237 const f = v === "regular" ? "" : `-${v[0].toUpperCase()}${v.slice(1)}`;
238 phosphor(`${v}/selection.json`);
239 phosphor(`${v}/style.css`);
240 phosphor(`${v}/Phosphor${f}.svg`);
241 phosphor(`${v}/Phosphor${f}.ttf`);
242 phosphor(`${v}/Phosphor${f}.woff`);
243 phosphor(`${v}/Phosphor${f}.woff2`);
244});
245
246////////////////////////////////////////////
247// WEB AWESOME
248////////////////////////////////////////////
249
250for (
251 const f of walkSync("./node_modules/@awesome.me/webawesome/dist-cdn/", {
252 includeDirs: false,
253 })
254) {
255 const relativePath = f.path.replace(
256 /^node_modules\/@awesome\.me\/webawesome\/dist-cdn\//,
257 "",
258 );
259
260 const destPath = `vendor/@awesome.me/webawesome/${relativePath}`;
261
262 site.remoteFile(
263 destPath,
264 import.meta.resolve(
265 `./node_modules/@awesome.me/webawesome/dist-cdn/${relativePath}`,
266 ),
267 );
268
269 site.copy(destPath);
270}
271
272////////////////////////////////////////////
273// MISC
274////////////////////////////////////////////
275
276site.add([".html"]);
277site.add([".json"]);
278site.add([".webmanifest"]);
279
280site.remoteFile(
281 "architecture.txt",
282 import.meta.resolve("./docs/ARCHITECTURE.md"),
283);
284
285site.add("architecture.txt");
286
287site.use(brotli());
288site.use(sourceMaps());
289
290site.script("copy-type-defs", () => {
291 for (
292 const f of walkSync(
293 "./src/",
294 { includeDirs: false, exts: [".d.ts"] },
295 )
296 ) {
297 const dest = "dist/" + f.path.replace(/^src\//, "");
298 const dir = path.dirname(dest);
299 ensureDirSync(dir);
300 Deno.copyFileSync(f.path, dest);
301 }
302});
303
304site.addEventListener("afterBuild", () => {
305 // site.run("copy-type-defs");
306});
307
308////////////////////////////////////////////
309// MIDDLEWARE
310////////////////////////////////////////////
311
312// Facet HTML files are HTML fragments fetched via JS, not full pages.
313// Serving them as text/plain prevents Lume's dev server from injecting
314// its live-reload <script> tag into the fetched content.
315//
316// Also inlines any <script type="module" src="./foo.inline.js"> references so
317// that forked facets contain readable JS rather than an external file reference.
318async function facetHtmlMiddleware(
319 request: Request,
320 next: RequestHandler,
321): Promise<Response> {
322 const { pathname } = new URL(request.url);
323 const isFacetHtml = pathname.endsWith(".html") &&
324 !pathname.startsWith("/testing/");
325 const response = await next(request);
326
327 if (!isFacetHtml || !response.headers.get("content-type")?.includes("html")) {
328 return response;
329 }
330
331 let content = await response.text();
332 content = await inlineScriptSrc(content);
333
334 const headers = new Headers(response.headers);
335 headers.set("content-type", "text/plain; charset=utf-8");
336 return new Response(content, {
337 status: response.status,
338 statusText: response.statusText,
339 headers,
340 });
341}
342
343const SCRIPT_SRC_RE =
344 /<script type="module" src="([^"]+\.inline\.js)"><\/script>/;
345
346async function inlineScriptSrc(content: string): Promise<string> {
347 const match = SCRIPT_SRC_RE.exec(content);
348 if (!match) return content;
349
350 const jsPath = path.join("src", match[1]);
351 try {
352 return htmlWithInlineJs({ content, jsPath, match: match[0] });
353 } catch {
354 return content;
355 }
356}
357
358site.addEventListener("afterBuild", async () => {
359 for (
360 const f of walkSync("./dist/", { includeDirs: false, exts: [".html"] })
361 ) {
362 const content = Deno.readTextFileSync(f.path);
363 const match = SCRIPT_SRC_RE.exec(content);
364 if (!match) continue;
365
366 const jsPath = path.join("src", match[1]);
367
368 try {
369 const newContent = htmlWithInlineJs({ content, jsPath, match: match[0] });
370 Deno.writeTextFileSync(f.path, newContent);
371 } catch {
372 // leave as-is if the source file can't be read
373 }
374 }
375});
376
377site.addEventListener("afterBuild", async () => {
378 const RAW = 0x55;
379
380 async function buildFileTree(
381 dir: string,
382 prefix = "",
383 ): Promise<Record<string, string>> {
384 const tree: Record<string, string> = {};
385
386 for (const entry of Deno.readDirSync(dir)) {
387 const entryPath = path.join(dir, entry.name);
388 const entryKey = prefix ? `${prefix}/${entry.name}` : entry.name;
389 if (entry.isDirectory) {
390 Object.assign(tree, await buildFileTree(entryPath, entryKey));
391 } else {
392 const data = Deno.readFileSync(entryPath);
393 tree[entryKey] = await createCID(RAW, data);
394 }
395 }
396
397 return tree;
398 }
399
400 const tree = await buildFileTree("dist/");
401 const sorted = Object.fromEntries(
402 Object.keys(tree).sort().map((k) => [k, tree[k]]),
403 );
404
405 Deno.writeTextFileSync(
406 "./dist/file-tree.json",
407 JSON.stringify(sorted, null, 2),
408 );
409});
410
411function htmlWithInlineJs({ content, match, jsPath }: {
412 content: string;
413 match: string;
414 jsPath: string;
415}): string {
416 const js =
417 Deno.readTextFileSync(jsPath).split("\n").map((line) => ` ${line}`).join(
418 "\n",
419 ).trimEnd() + "\n";
420 return content.replace(match, `<script type="module">\n${js}</script>`);
421}