@freqhole/playlistz 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,184 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import { FreqholePlaylistzSchema, generatePlaylistzJs, type FreqholePlaylist, type FreqholePlaylistz } from "../utils/standaloneTemplates.js";
5
+ import { parseM3U, serializeM3U } from "../utils/m3u.js";
6
+
7
+ // deterministic uuid v5 from a string (dns namespace)
8
+ function uuidv5(name: string): string {
9
+ const ns = Buffer.from("6ba7b8109dad11d180b400c04fd430c8", "hex");
10
+ const hash = crypto.createHash("sha1").update(Buffer.concat([ns, Buffer.from(name)])).digest();
11
+ hash[6] = (hash[6]! & 0x0f) | 0x50;
12
+ hash[8] = (hash[8]! & 0x3f) | 0x80;
13
+ const h = hash.toString("hex");
14
+ return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20,32)}`;
15
+ }
16
+
17
+ function extOf(filePath: string): string {
18
+ return path.extname(filePath).toLowerCase();
19
+ }
20
+
21
+ function mimeForExt(ext: string): string {
22
+ const map: Record<string, string> = {
23
+ ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav",
24
+ ".flac": "audio/flac", ".ogg": "audio/ogg", ".webm": "audio/webm",
25
+ };
26
+ return map[ext] ?? "audio/mpeg";
27
+ }
28
+
29
+ // load existing playlistz.js if present, return parsed array or empty
30
+ function loadExistingPlaylistz(playlistzPath: string): FreqholePlaylistz {
31
+ if (!fs.existsSync(playlistzPath)) return [];
32
+ try {
33
+ const src = fs.readFileSync(playlistzPath, "utf-8");
34
+ const attrMatch = src.match(/setAttribute\s*\(\s*'data-playlistz'\s*,\s*("(?:[^"\\]|\\.)*")\s*\)/);
35
+ if (!attrMatch) {
36
+ console.warn("existing playlistz.js has unrecognised format - treating as empty");
37
+ return [];
38
+ }
39
+ const raw = JSON.parse(JSON.parse(attrMatch[1]!));
40
+ const parsed = FreqholePlaylistzSchema.safeParse(raw);
41
+ if (parsed.success) return parsed.data;
42
+ console.warn("existing playlistz.js failed schema validation - treating as empty");
43
+ return [];
44
+ } catch {
45
+ console.warn("could not parse existing playlistz.js - treating as empty");
46
+ return [];
47
+ }
48
+ }
49
+
50
+ export function generateData(dir: string): void {
51
+ const resolved = path.resolve(dir);
52
+ if (!fs.existsSync(resolved)) {
53
+ console.error(`directory not found: ${resolved}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ // find .m3u8 files in dir or data/ subdir
58
+ const candidates = [
59
+ ...fs.readdirSync(resolved).filter((f) => f.endsWith(".m3u8")).map((f) => path.join(resolved, f)),
60
+ ...(fs.existsSync(path.join(resolved, "data"))
61
+ ? fs.readdirSync(path.join(resolved, "data")).filter((f) => f.endsWith(".m3u8")).map((f) => path.join(resolved, "data", f))
62
+ : []),
63
+ ];
64
+
65
+ if (candidates.length === 0) {
66
+ console.error(`no .m3u8 files found in ${resolved} or ${resolved}/data/`);
67
+ process.exit(1);
68
+ }
69
+
70
+ const playlistzPath = path.join(resolved, "playlistz.js");
71
+ const existing = loadExistingPlaylistz(playlistzPath);
72
+
73
+ let updated = 0;
74
+
75
+ for (const m3uPath of candidates) {
76
+ console.log(`processing: ${path.relative(resolved, m3uPath)}`);
77
+
78
+ const content = fs.readFileSync(m3uPath, "utf-8");
79
+ const parsed = parseM3U(content);
80
+ const m3uDir = path.dirname(m3uPath);
81
+
82
+ if (!parsed.title) {
83
+ console.warn(` skipping: no # Playlist: header found`);
84
+ continue;
85
+ }
86
+
87
+ // assign stable id from title if not present
88
+ const isNew = !parsed.id;
89
+ if (isNew) {
90
+ parsed.id = uuidv5(parsed.title);
91
+ parsed.rev = 0;
92
+ console.log(` new playlist - assigned id: ${parsed.id}`);
93
+ } else {
94
+ // increment rev on each generate run to signal standaloneService to re-check
95
+ parsed.rev = (parsed.rev ?? 0) + 1;
96
+ console.log(` existing playlist - id: ${parsed.id}, rev: ${parsed.rev}`);
97
+ }
98
+
99
+ // write id + rev back to m3u8
100
+ fs.writeFileSync(m3uPath, serializeM3U(parsed), "utf-8");
101
+ console.log(` updated: ${path.relative(resolved, m3uPath)}`);
102
+
103
+ // resolve playlist cover image metadata
104
+ const coverExt = parsed.playlistImageFile ? extOf(parsed.playlistImageFile) : undefined;
105
+ const coverMime = coverExt === ".gif" ? "image/gif"
106
+ : coverExt === ".png" ? "image/png"
107
+ : coverExt === ".webp" ? "image/webp"
108
+ : coverExt ? "image/jpeg"
109
+ : undefined;
110
+
111
+ // build songs array
112
+ const songs = parsed.songs.map((s, i) => {
113
+ // audio file path is relative to the m3u8 dir, e.g. "data/01-song.mp3"
114
+ const audioFilename = path.basename(s.audioFile);
115
+ const audioExt = extOf(audioFilename);
116
+
117
+ // image: strip "data/" prefix and figure out extension
118
+ const imageBasename = s.imageFile ? path.basename(s.imageFile) : undefined;
119
+ const imageExt = imageBasename ? extOf(imageBasename) : undefined;
120
+ const imageMime = !imageExt ? undefined
121
+ : imageExt === ".gif" ? "image/gif"
122
+ : imageExt === ".png" ? "image/png"
123
+ : imageExt === ".webp" ? "image/webp"
124
+ : "image/jpeg";
125
+
126
+ // check file exists on disk
127
+ const audioPath = path.join(m3uDir, audioFilename);
128
+ if (!fs.existsSync(audioPath)) {
129
+ console.warn(` warn: audio file not found: ${audioFilename}`);
130
+ }
131
+
132
+ return {
133
+ id: uuidv5(`${parsed.id}:${i}:${audioFilename}`),
134
+ title: s.title || audioFilename,
135
+ artist: s.artist || "",
136
+ album: s.album || "",
137
+ duration: s.duration,
138
+ originalFilename: audioFilename,
139
+ filePath: `data/${audioFilename}`,
140
+ safeFilename: audioFilename,
141
+ fileSize: fs.existsSync(audioPath) ? fs.statSync(audioPath).size : 0,
142
+ mimeType: mimeForExt(audioExt),
143
+ imageExtension: imageExt ?? undefined,
144
+ imageMimeType: imageMime,
145
+ };
146
+ });
147
+
148
+ const entry: FreqholePlaylist = {
149
+ playlist: {
150
+ // parsed.id is always assigned above (uuidv5 from title when new)
151
+ id: parsed.id!,
152
+ title: parsed.title,
153
+ description: parsed.description || undefined,
154
+ rev: parsed.rev,
155
+ imageExtension: coverExt ?? undefined,
156
+ imageMimeType: coverMime,
157
+ },
158
+ songs,
159
+ };
160
+
161
+ // upsert into existing data: replace by id or append
162
+ const idx = existing.findIndex((e) => e.playlist.id === parsed.id);
163
+ if (idx >= 0) {
164
+ existing[idx] = entry;
165
+ console.log(` updated entry in playlistz.js`);
166
+ } else {
167
+ existing.push(entry);
168
+ console.log(` added new entry to playlistz.js`);
169
+ }
170
+
171
+ updated++;
172
+ }
173
+
174
+ if (updated === 0) {
175
+ console.log("no playlists were updated");
176
+ return;
177
+ }
178
+
179
+ // write playlistz.js (new attribute-based format)
180
+ const output = generatePlaylistzJs(existing);
181
+ fs.writeFileSync(playlistzPath, output, "utf-8");
182
+ console.log(`\nwrote: ${playlistzPath}`);
183
+ console.log(` ${existing.length} playlist(s) total`);
184
+ }
@@ -0,0 +1,88 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as http from "node:http";
4
+
5
+ const MIME: Record<string, string> = {
6
+ ".html": "text/html",
7
+ ".js": "application/javascript",
8
+ ".css": "text/css",
9
+ ".json": "application/json",
10
+ ".mp3": "audio/mpeg",
11
+ ".m4a": "audio/mp4",
12
+ ".wav": "audio/wav",
13
+ ".flac": "audio/flac",
14
+ ".ogg": "audio/ogg",
15
+ ".webm": "audio/webm",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".png": "image/png",
19
+ ".gif": "image/gif",
20
+ ".webp": "image/webp",
21
+ ".map": "application/json",
22
+ };
23
+
24
+ function mimeFor(filePath: string): string {
25
+ return MIME[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
26
+ }
27
+
28
+ export function serveHttp(dir: string): void {
29
+ const root = path.resolve(dir);
30
+ if (!fs.existsSync(root)) {
31
+ console.error(`directory not found: ${root}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ const server = http.createServer((req, res) => {
36
+ const rawPath = req.url?.split("?")[0] ?? "/";
37
+ const urlPath = decodeURIComponent(rawPath);
38
+ const filePath = path.join(root, urlPath === "/" ? "index.html" : urlPath);
39
+
40
+ // prevent path traversal
41
+ if (!filePath.startsWith(root + path.sep) && filePath !== root) {
42
+ res.writeHead(403);
43
+ res.end("forbidden");
44
+ return;
45
+ }
46
+
47
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
48
+ res.writeHead(404);
49
+ res.end("not found");
50
+ return;
51
+ }
52
+
53
+ const stat = fs.statSync(filePath);
54
+ const total = stat.size;
55
+ const mime = mimeFor(filePath);
56
+ const rangeHeader = req.headers["range"];
57
+
58
+ if (rangeHeader) {
59
+ const [, rangeStr] = rangeHeader.split("=");
60
+ const [startStr, endStr] = (rangeStr ?? "").split("-");
61
+ const start = parseInt(startStr ?? "0", 10);
62
+ const end = endStr ? parseInt(endStr, 10) : total - 1;
63
+ const chunkSize = end - start + 1;
64
+
65
+ res.writeHead(206, {
66
+ "Content-Range": `bytes ${start}-${end}/${total}`,
67
+ "Accept-Ranges": "bytes",
68
+ "Content-Length": chunkSize,
69
+ "Content-Type": mime,
70
+ });
71
+ fs.createReadStream(filePath, { start, end }).pipe(res);
72
+ } else {
73
+ res.writeHead(200, {
74
+ "Content-Length": total,
75
+ "Content-Type": mime,
76
+ "Accept-Ranges": "bytes",
77
+ });
78
+ fs.createReadStream(filePath).pipe(res);
79
+ }
80
+ });
81
+
82
+ const port = parseInt(process.env["PORT"] ?? "8080", 10);
83
+ server.listen(port, () => {
84
+ console.log(`serving ${root}`);
85
+ console.log(`http://localhost:${port}`);
86
+ console.log("ctrl+c to stop");
87
+ });
88
+ }
@@ -0,0 +1,65 @@
1
+ import { serveHttp } from "./http.js";
2
+ import { checkFile } from "./check.js";
3
+ import { initDir } from "./init.js";
4
+ import { generateData } from "./generate.js";
5
+
6
+ const HELP = `
7
+ freqhole-playlistz cli
8
+
9
+ usage:
10
+ freqhole-playlistz --http [dir] serve dir over http with range requests (default: .)
11
+ freqhole-playlistz --check [file] validate playlistz.js structure + check files on disk (default: ./playlistz.js)
12
+ freqhole-playlistz --init <dir> write index.html + sw.js to dir
13
+ freqhole-playlistz --generate-data <dir> parse .m3u8 file(s) in dir, generate/update playlistz.js
14
+ freqhole-playlistz --help show this help
15
+
16
+ env:
17
+ PORT port for --http server (default: 8080)
18
+ `.trim();
19
+
20
+ export function runCli(argv: string[]): void {
21
+ const args = argv.slice(2);
22
+ const cmd = args[0];
23
+
24
+ if (!cmd || cmd === "--help" || cmd === "-h") {
25
+ console.log(HELP);
26
+ return;
27
+ }
28
+
29
+ if (cmd === "--http") {
30
+ serveHttp(args[1] ?? ".");
31
+ return;
32
+ }
33
+
34
+ if (cmd === "--check") {
35
+ checkFile(args[1] ?? "./playlistz.js");
36
+ return;
37
+ }
38
+
39
+ if (cmd === "--init") {
40
+ const dir = args[1];
41
+ if (!dir) {
42
+ console.error("usage: freqhole-playlistz --init <dir>");
43
+ process.exit(1);
44
+ }
45
+ initDir(dir);
46
+ return;
47
+ }
48
+
49
+ if (cmd === "--generate-data") {
50
+ const dir = args[1];
51
+ if (!dir) {
52
+ console.error("usage: freqhole-playlistz --generate-data <dir>");
53
+ process.exit(1);
54
+ }
55
+ generateData(dir);
56
+ return;
57
+ }
58
+
59
+ console.error(`unknown command: ${cmd}`);
60
+ console.error("run with --help for usage");
61
+ process.exit(1);
62
+ }
63
+
64
+ // self-executing entrypoint when run directly via node
65
+ runCli(process.argv);
@@ -0,0 +1,18 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ // these placeholder strings are replaced at build time by build-component.js
5
+ // with the actual file contents read from the source tree
6
+ const INDEX_HTML = "__INDEX_HTML__";
7
+ const SW_JS = "__SW_JS__";
8
+
9
+ export function initDir(dir: string): void {
10
+ const resolved = path.resolve(dir);
11
+ fs.mkdirSync(resolved, { recursive: true });
12
+
13
+ fs.writeFileSync(path.join(resolved, "index.html"), INDEX_HTML, "utf-8");
14
+ console.log(`wrote ${path.join(resolved, "index.html")}`);
15
+
16
+ fs.writeFileSync(path.join(resolved, "sw.js"), SW_JS, "utf-8");
17
+ console.log(`wrote ${path.join(resolved, "sw.js")}`);
18
+ }