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"],
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}