starmark 1.0.0

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.
package/src/server.js ADDED
@@ -0,0 +1,1056 @@
1
+ import express from "express";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { execFile } from "child_process";
5
+ import { promisify } from "util";
6
+ import { fileURLToPath } from "url";
7
+ import { buildInitialFileContent } from "./content-config.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const PACKAGE_ROOT = path.join(__dirname, "..");
12
+ const PUBLIC_DIR = path.join(PACKAGE_ROOT, "public");
13
+
14
+ const app = express();
15
+ const PORT = process.env.PORT || 5748;
16
+
17
+ function getUserIniPath() {
18
+ return path.join(process.cwd(), ".starmark", "user.ini");
19
+ }
20
+
21
+ const SKIP_DIRS = new Set([
22
+ "node_modules",
23
+ ".git",
24
+ "dist",
25
+ ".astro",
26
+ ".vercel",
27
+ ".netlify",
28
+ ]);
29
+
30
+ app.use(express.json());
31
+ app.use(express.static(PUBLIC_DIR));
32
+
33
+ const TOOLS_DIR = path.join(PUBLIC_DIR, "tools");
34
+
35
+ async function listToolbarTools() {
36
+ let entries;
37
+ try {
38
+ entries = await fs.readdir(TOOLS_DIR, { withFileTypes: true });
39
+ } catch {
40
+ return [];
41
+ }
42
+
43
+ return entries
44
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
45
+ .map((entry) => entry.name.replace(/\.js$/, ""))
46
+ .sort((a, b) => a.localeCompare(b));
47
+ }
48
+
49
+ app.get("/api/tools", async (_req, res) => {
50
+ const tools = await listToolbarTools();
51
+ res.json({ tools });
52
+ });
53
+
54
+ async function pickFolderNative() {
55
+ if (process.platform === "darwin") {
56
+ const { stdout } = await execFileAsync("osascript", [
57
+ "-e",
58
+ 'POSIX path of (choose folder with prompt "Select Astro project folder")',
59
+ ]);
60
+ return stdout.trim();
61
+ }
62
+
63
+ if (process.platform === "win32") {
64
+ const script = `
65
+ Add-Type -AssemblyName System.Windows.Forms
66
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
67
+ $dialog.Description = "Select Astro project folder"
68
+ if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
69
+ Write-Output $dialog.SelectedPath
70
+ }
71
+ `;
72
+ const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-Command", script]);
73
+ return stdout.trim();
74
+ }
75
+
76
+ const { stdout } = await execFileAsync("zenity", [
77
+ "--file-selection",
78
+ "--directory",
79
+ "--title=Select Astro project folder",
80
+ ]);
81
+ return stdout.trim();
82
+ }
83
+
84
+ async function isDirectory(dir) {
85
+ try {
86
+ const stat = await fs.stat(dir);
87
+ return stat.isDirectory();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ async function findMarkdownFiles(dir, { source, pathPrefix, baseDir = dir } = {}) {
94
+ const files = [];
95
+
96
+ let entries;
97
+ try {
98
+ entries = await fs.readdir(dir, { withFileTypes: true });
99
+ } catch {
100
+ return files;
101
+ }
102
+
103
+ for (const entry of entries) {
104
+ const fullPath = path.join(dir, entry.name);
105
+
106
+ if (entry.isDirectory()) {
107
+ if (SKIP_DIRS.has(entry.name)) continue;
108
+ files.push(
109
+ ...(await findMarkdownFiles(fullPath, { source, pathPrefix, baseDir })),
110
+ );
111
+ continue;
112
+ }
113
+
114
+ if (!entry.isFile()) continue;
115
+
116
+ const ext = path.extname(entry.name).toLowerCase();
117
+ if (ext !== ".md" && ext !== ".mdx") continue;
118
+
119
+ const relativeWithinRoot = path.relative(baseDir, fullPath);
120
+ const navOrder = await readFileNavOrder(fullPath);
121
+ files.push({
122
+ name: entry.name,
123
+ relativePath: pathPrefix
124
+ ? path.join(pathPrefix, relativeWithinRoot)
125
+ : relativeWithinRoot,
126
+ absolutePath: fullPath,
127
+ extension: ext.slice(1),
128
+ source: source ?? "project",
129
+ navOrder,
130
+ });
131
+ }
132
+
133
+ return files;
134
+ }
135
+
136
+ async function findDirectories(dir, { pathPrefix, baseDir = dir } = {}) {
137
+ const directories = [];
138
+
139
+ let entries;
140
+ try {
141
+ entries = await fs.readdir(dir, { withFileTypes: true });
142
+ } catch {
143
+ return directories;
144
+ }
145
+
146
+ for (const entry of entries) {
147
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
148
+
149
+ const fullPath = path.join(dir, entry.name);
150
+ const relativeWithinRoot = path.relative(baseDir, fullPath);
151
+ const relativePath = pathPrefix
152
+ ? path.join(pathPrefix, relativeWithinRoot)
153
+ : relativeWithinRoot;
154
+
155
+ directories.push(relativePath.replace(/\\/g, "/"));
156
+ directories.push(
157
+ ...(await findDirectories(fullPath, { pathPrefix, baseDir })),
158
+ );
159
+ }
160
+
161
+ return directories;
162
+ }
163
+
164
+ function resolveProjectSubpath(projectPath, relativePath = "") {
165
+ const resolvedProject = path.resolve(projectPath);
166
+ const normalized = String(relativePath).replace(/^\/+/, "").replace(/\\/g, "/");
167
+ const target = path.resolve(resolvedProject, normalized || ".");
168
+
169
+ if (target !== resolvedProject && !target.startsWith(`${resolvedProject}${path.sep}`)) {
170
+ return null;
171
+ }
172
+
173
+ return {
174
+ projectPath: resolvedProject,
175
+ target,
176
+ relativePath: normalized,
177
+ };
178
+ }
179
+
180
+ function normalizeRelativePath(relativePath) {
181
+ return String(relativePath).replace(/\\/g, "/").replace(/^\/+/, "");
182
+ }
183
+
184
+ function inferSourceFromRelativePath(relativePath, scanTargets) {
185
+ const normalized = normalizeRelativePath(relativePath);
186
+
187
+ for (const target of scanTargets) {
188
+ const prefix = normalizeRelativePath(target.pathPrefix);
189
+ if (!prefix) {
190
+ if (target.source === "project") {
191
+ return "project";
192
+ }
193
+ continue;
194
+ }
195
+
196
+ if (normalized === prefix || normalized.startsWith(`${prefix}/`)) {
197
+ return target.source;
198
+ }
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ function isPathUnderScanTargets(relativePath, scanTargets) {
205
+ return inferSourceFromRelativePath(relativePath, scanTargets) !== null;
206
+ }
207
+
208
+ function isProtectedScanRoot(relativePath, scanTargets) {
209
+ const normalized = normalizeRelativePath(relativePath);
210
+
211
+ for (const target of scanTargets) {
212
+ const prefix = normalizeRelativePath(target.pathPrefix);
213
+ if (prefix && normalized === prefix) {
214
+ return true;
215
+ }
216
+ }
217
+
218
+ return false;
219
+ }
220
+
221
+ function isMarkdownFileName(name) {
222
+ const ext = path.extname(name).toLowerCase();
223
+ return ext === ".md" || ext === ".mdx";
224
+ }
225
+
226
+ function validateEntryName(name) {
227
+ const trimmed = String(name).trim();
228
+
229
+ if (!trimmed) {
230
+ return { error: "A name is required" };
231
+ }
232
+
233
+ if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
234
+ return { error: "Name cannot contain path separators" };
235
+ }
236
+
237
+ return { name: trimmed };
238
+ }
239
+
240
+ async function resolveScanTargets(projectPath) {
241
+ const targets = [];
242
+ const contentDir = path.join(projectPath, "src", "content");
243
+ const pagesDir = path.join(projectPath, "src", "pages");
244
+
245
+ if (await isDirectory(contentDir)) {
246
+ targets.push({
247
+ source: "content",
248
+ scanRoot: contentDir,
249
+ pathPrefix: "src/content",
250
+ });
251
+ }
252
+
253
+ if (await isDirectory(pagesDir)) {
254
+ targets.push({
255
+ source: "pages",
256
+ scanRoot: pagesDir,
257
+ pathPrefix: "src/pages",
258
+ });
259
+ }
260
+
261
+ if (targets.length === 0) {
262
+ targets.push({
263
+ source: "project",
264
+ scanRoot: projectPath,
265
+ pathPrefix: "",
266
+ });
267
+ }
268
+
269
+ return targets;
270
+ }
271
+
272
+ const SOURCE_ORDER = { content: 0, pages: 1, project: 2 };
273
+
274
+ function sortFiles(files) {
275
+ return files.sort((a, b) => {
276
+ const sourceDiff = SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
277
+ if (sourceDiff !== 0) return sourceDiff;
278
+ return a.relativePath.localeCompare(b.relativePath);
279
+ });
280
+ }
281
+
282
+ function splitFrontmatter(content) {
283
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
284
+ if (!match) {
285
+ return { frontmatter: null, body: content };
286
+ }
287
+
288
+ return {
289
+ frontmatter: match[1],
290
+ body: content.slice(match[0].length),
291
+ };
292
+ }
293
+
294
+ function parseNavOrder(frontmatter) {
295
+ if (!frontmatter) {
296
+ return null;
297
+ }
298
+
299
+ const match = frontmatter.match(/^navOrder:\s*(.+)$/m);
300
+ if (!match) {
301
+ return null;
302
+ }
303
+
304
+ let value = match[1].trim();
305
+ if (
306
+ (value.startsWith('"') && value.endsWith('"')) ||
307
+ (value.startsWith("'") && value.endsWith("'"))
308
+ ) {
309
+ value = value.slice(1, -1);
310
+ }
311
+
312
+ const parsed = Number(value);
313
+ return Number.isFinite(parsed) ? parsed : null;
314
+ }
315
+
316
+ async function readFileNavOrder(filePath) {
317
+ try {
318
+ const content = await fs.readFile(filePath, "utf8");
319
+ const { frontmatter } = splitFrontmatter(content);
320
+ return parseNavOrder(frontmatter);
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ async function readSavedProjects() {
327
+ try {
328
+ const contents = await fs.readFile(getUserIniPath(), "utf8");
329
+ const paths = [];
330
+
331
+ for (const line of contents.split("\n")) {
332
+ const trimmed = line.trim();
333
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("[")) continue;
334
+
335
+ const match = trimmed.match(/^(?:path|folder)=(.+)$/);
336
+ if (match) paths.push(match[1].trim());
337
+ }
338
+
339
+ const seen = new Set();
340
+ const projects = [];
341
+
342
+ for (const projectPath of paths) {
343
+ const resolved = path.resolve(projectPath);
344
+ if (seen.has(resolved)) continue;
345
+ seen.add(resolved);
346
+
347
+ if (!(await isDirectory(resolved))) continue;
348
+
349
+ projects.push({
350
+ path: resolved,
351
+ name: path.basename(resolved),
352
+ });
353
+ }
354
+
355
+ return projects;
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ async function saveProject(folderPath) {
362
+ const resolved = path.resolve(folderPath);
363
+ const projects = (await readSavedProjects()).filter(
364
+ (project) => project.path !== resolved,
365
+ );
366
+
367
+ projects.unshift({
368
+ path: resolved,
369
+ name: path.basename(resolved),
370
+ });
371
+
372
+ const lines = ["[projects]", ...projects.map((project) => `path=${project.path}`)];
373
+
374
+ const userIniPath = getUserIniPath();
375
+ await fs.mkdir(path.dirname(userIniPath), { recursive: true });
376
+ await fs.writeFile(userIniPath, `${lines.join("\n")}\n`, "utf8");
377
+ }
378
+
379
+ app.get("/api/config", (_req, res) => {
380
+ res.json({
381
+ defaultProjectPath: process.cwd(),
382
+ });
383
+ });
384
+
385
+ app.get("/api/projects", async (_req, res) => {
386
+ const projects = await readSavedProjects();
387
+ res.json({ projects });
388
+ });
389
+
390
+ app.post("/api/browse", async (_req, res) => {
391
+ try {
392
+ const folderPath = await pickFolderNative();
393
+ if (!folderPath) {
394
+ return res.status(400).json({ error: "No folder selected" });
395
+ }
396
+
397
+ const stat = await fs.stat(folderPath);
398
+ if (!stat.isDirectory()) {
399
+ return res.status(400).json({ error: "Selected path is not a directory" });
400
+ }
401
+
402
+ res.json({ path: folderPath });
403
+ } catch (err) {
404
+ if (err.killed || err.code === 1) {
405
+ return res.status(400).json({ error: "Folder selection cancelled" });
406
+ }
407
+ console.error(err);
408
+ res.status(500).json({ error: "Could not open folder picker" });
409
+ }
410
+ });
411
+
412
+ app.post("/api/scan", async (req, res) => {
413
+ const { path: projectPath } = req.body ?? {};
414
+
415
+ if (!projectPath || typeof projectPath !== "string") {
416
+ return res.status(400).json({ error: "A folder path is required" });
417
+ }
418
+
419
+ const resolved = path.resolve(projectPath);
420
+
421
+ try {
422
+ const stat = await fs.stat(resolved);
423
+ if (!stat.isDirectory()) {
424
+ return res.status(400).json({ error: "Path is not a directory" });
425
+ }
426
+ } catch {
427
+ return res.status(400).json({ error: "Folder does not exist or is not accessible" });
428
+ }
429
+
430
+ const scanTargets = await resolveScanTargets(resolved);
431
+ const fileGroups = await Promise.all(
432
+ scanTargets.map(async (target) => ({
433
+ ...target,
434
+ files: await findMarkdownFiles(target.scanRoot, {
435
+ source: target.source,
436
+ pathPrefix: target.pathPrefix,
437
+ }),
438
+ directories: await findDirectories(target.scanRoot, {
439
+ pathPrefix: target.pathPrefix,
440
+ }),
441
+ })),
442
+ );
443
+ const files = sortFiles(fileGroups.flatMap((group) => group.files));
444
+ const directories = [
445
+ ...new Set(
446
+ fileGroups
447
+ .flatMap((group) => group.directories)
448
+ .map((dirPath) => dirPath.replace(/\\/g, "/")),
449
+ ),
450
+ ].sort((a, b) => a.localeCompare(b));
451
+
452
+ if (files.length > 0) {
453
+ await saveProject(resolved);
454
+ }
455
+
456
+ res.json({
457
+ projectPath: resolved,
458
+ scanTargets: fileGroups.map(({ source, scanRoot, pathPrefix, files: groupFiles }) => ({
459
+ source,
460
+ scanRoot,
461
+ pathPrefix,
462
+ fileCount: groupFiles.length,
463
+ })),
464
+ files,
465
+ directories,
466
+ });
467
+ });
468
+
469
+ app.post("/api/entry", async (req, res) => {
470
+ const { projectPath, parentPath = "", name } = req.body ?? {};
471
+
472
+ if (!projectPath || typeof projectPath !== "string") {
473
+ return res.status(400).json({ error: "A project path is required" });
474
+ }
475
+
476
+ const nameResult = validateEntryName(name);
477
+ if (nameResult.error) {
478
+ return res.status(400).json({ error: nameResult.error });
479
+ }
480
+
481
+ const resolvedProject = path.resolve(projectPath);
482
+
483
+ try {
484
+ const stat = await fs.stat(resolvedProject);
485
+ if (!stat.isDirectory()) {
486
+ return res.status(400).json({ error: "Project path is not a directory" });
487
+ }
488
+ } catch {
489
+ return res.status(404).json({ error: "Project does not exist or is not accessible" });
490
+ }
491
+
492
+ const normalizedParentPath = normalizeRelativePath(parentPath);
493
+ const parentResolved = resolveProjectSubpath(resolvedProject, normalizedParentPath);
494
+ if (!parentResolved) {
495
+ return res.status(400).json({ error: "Invalid parent path" });
496
+ }
497
+
498
+ try {
499
+ const parentStat = await fs.stat(parentResolved.target);
500
+ if (!parentStat.isDirectory()) {
501
+ return res.status(400).json({ error: "Parent path is not a directory" });
502
+ }
503
+ } catch {
504
+ return res.status(404).json({ error: "Parent folder does not exist or is not accessible" });
505
+ }
506
+
507
+ const scanTargets = await resolveScanTargets(resolvedProject);
508
+ if (!isPathUnderScanTargets(normalizedParentPath, scanTargets)) {
509
+ return res.status(400).json({ error: "Parent path is outside the project content area" });
510
+ }
511
+
512
+ const targetRelativePath = normalizedParentPath
513
+ ? `${normalizedParentPath}/${nameResult.name}`
514
+ : nameResult.name;
515
+ const targetResolved = resolveProjectSubpath(resolvedProject, targetRelativePath);
516
+ if (!targetResolved) {
517
+ return res.status(400).json({ error: "Invalid target path" });
518
+ }
519
+
520
+ try {
521
+ const stat = await fs.stat(targetResolved.target);
522
+ if (stat.isDirectory()) {
523
+ return res.status(409).json({ error: "A folder with that name already exists" });
524
+ }
525
+ return res.status(409).json({ error: "A file with that name already exists" });
526
+ } catch {
527
+ // Target does not exist yet.
528
+ }
529
+
530
+ const source = inferSourceFromRelativePath(targetRelativePath, scanTargets) ?? "project";
531
+
532
+ try {
533
+ if (isMarkdownFileName(nameResult.name)) {
534
+ const initialContent = await buildInitialFileContent(
535
+ resolvedProject,
536
+ targetRelativePath.replace(/\\/g, "/"),
537
+ source,
538
+ );
539
+ await fs.writeFile(targetResolved.target, initialContent, "utf8");
540
+ const ext = path.extname(nameResult.name).toLowerCase().slice(1);
541
+
542
+ return res.json({
543
+ type: "file",
544
+ path: targetResolved.target,
545
+ relativePath: targetRelativePath.replace(/\\/g, "/"),
546
+ name: nameResult.name,
547
+ absolutePath: targetResolved.target,
548
+ extension: ext,
549
+ source,
550
+ });
551
+ }
552
+
553
+ await fs.mkdir(targetResolved.target, { recursive: false });
554
+
555
+ return res.json({
556
+ type: "folder",
557
+ path: targetResolved.target,
558
+ relativePath: targetRelativePath.replace(/\\/g, "/"),
559
+ name: nameResult.name,
560
+ source,
561
+ });
562
+ } catch {
563
+ return res.status(500).json({ error: "Could not create entry" });
564
+ }
565
+ });
566
+
567
+ app.delete("/api/entry", async (req, res) => {
568
+ const { projectPath, relativePath } = req.body ?? {};
569
+
570
+ if (!projectPath || typeof projectPath !== "string") {
571
+ return res.status(400).json({ error: "A project path is required" });
572
+ }
573
+
574
+ if (!relativePath || typeof relativePath !== "string") {
575
+ return res.status(400).json({ error: "A relative path is required" });
576
+ }
577
+
578
+ const resolvedProject = path.resolve(projectPath);
579
+
580
+ try {
581
+ const stat = await fs.stat(resolvedProject);
582
+ if (!stat.isDirectory()) {
583
+ return res.status(400).json({ error: "Project path is not a directory" });
584
+ }
585
+ } catch {
586
+ return res.status(404).json({ error: "Project does not exist or is not accessible" });
587
+ }
588
+
589
+ const normalizedRelativePath = normalizeRelativePath(relativePath);
590
+ const targetResolved = resolveProjectSubpath(resolvedProject, normalizedRelativePath);
591
+ if (!targetResolved) {
592
+ return res.status(400).json({ error: "Invalid path" });
593
+ }
594
+
595
+ const scanTargets = await resolveScanTargets(resolvedProject);
596
+ if (!isPathUnderScanTargets(normalizedRelativePath, scanTargets)) {
597
+ return res.status(400).json({ error: "Path is outside the project content area" });
598
+ }
599
+
600
+ if (isProtectedScanRoot(normalizedRelativePath, scanTargets)) {
601
+ return res.status(400).json({ error: "This folder cannot be deleted" });
602
+ }
603
+
604
+ let entryType;
605
+ try {
606
+ const stat = await fs.stat(targetResolved.target);
607
+ entryType = stat.isDirectory() ? "folder" : "file";
608
+ } catch {
609
+ return res.status(404).json({ error: "Entry does not exist or is not accessible" });
610
+ }
611
+
612
+ try {
613
+ if (entryType === "folder") {
614
+ await fs.rm(targetResolved.target, { recursive: true, force: true });
615
+ } else {
616
+ await fs.unlink(targetResolved.target);
617
+ }
618
+
619
+ return res.json({
620
+ type: entryType,
621
+ relativePath: normalizedRelativePath,
622
+ });
623
+ } catch {
624
+ return res.status(500).json({ error: "Could not delete entry" });
625
+ }
626
+ });
627
+
628
+ app.get("/api/file", async (req, res) => {
629
+ const { path: filePath } = req.query;
630
+
631
+ if (!filePath || typeof filePath !== "string") {
632
+ return res.status(400).json({ error: "A file path is required" });
633
+ }
634
+
635
+ const resolved = path.resolve(filePath);
636
+
637
+ try {
638
+ const stat = await fs.stat(resolved);
639
+ if (!stat.isFile()) {
640
+ return res.status(400).json({ error: "Path is not a file" });
641
+ }
642
+ } catch {
643
+ return res.status(404).json({ error: "File does not exist or is not accessible" });
644
+ }
645
+
646
+ const ext = path.extname(resolved).toLowerCase();
647
+ if (ext !== ".md" && ext !== ".mdx") {
648
+ return res.status(400).json({ error: "Only .md and .mdx files are supported" });
649
+ }
650
+
651
+ try {
652
+ const raw = await fs.readFile(resolved, "utf8");
653
+ const { frontmatter, body } = splitFrontmatter(raw);
654
+ res.json({
655
+ path: resolved,
656
+ name: path.basename(resolved),
657
+ content: body,
658
+ frontmatter,
659
+ });
660
+ } catch {
661
+ res.status(500).json({ error: "Could not read file" });
662
+ }
663
+ });
664
+
665
+ app.post("/api/file", async (req, res) => {
666
+ const { path: filePath, content } = req.body ?? {};
667
+
668
+ if (!filePath || typeof filePath !== "string") {
669
+ return res.status(400).json({ error: "A file path is required" });
670
+ }
671
+
672
+ if (typeof content !== "string") {
673
+ return res.status(400).json({ error: "File content is required" });
674
+ }
675
+
676
+ const resolved = path.resolve(filePath);
677
+
678
+ try {
679
+ const stat = await fs.stat(resolved);
680
+ if (!stat.isFile()) {
681
+ return res.status(400).json({ error: "Path is not a file" });
682
+ }
683
+ } catch {
684
+ return res.status(404).json({ error: "File does not exist or is not accessible" });
685
+ }
686
+
687
+ const ext = path.extname(resolved).toLowerCase();
688
+ if (ext !== ".md" && ext !== ".mdx") {
689
+ return res.status(400).json({ error: "Only .md and .mdx files are supported" });
690
+ }
691
+
692
+ try {
693
+ await fs.writeFile(resolved, content, "utf8");
694
+ res.json({ path: resolved, saved: true });
695
+ } catch {
696
+ res.status(500).json({ error: "Could not save file" });
697
+ }
698
+ });
699
+
700
+ const IMAGE_EXTENSIONS = new Set([
701
+ ".png",
702
+ ".jpg",
703
+ ".jpeg",
704
+ ".gif",
705
+ ".webp",
706
+ ".svg",
707
+ ".avif",
708
+ ".ico",
709
+ ]);
710
+
711
+ function resolvePublicSubpath(projectPath, relativePath = "") {
712
+ const publicDir = path.resolve(projectPath, "public");
713
+ const normalized = String(relativePath).replace(/^\/+/, "").replace(/\\/g, "/");
714
+ const target = path.resolve(publicDir, normalized || ".");
715
+
716
+ if (target !== publicDir && !target.startsWith(`${publicDir}${path.sep}`)) {
717
+ return null;
718
+ }
719
+
720
+ return {
721
+ publicDir,
722
+ target,
723
+ relativePath: normalized,
724
+ };
725
+ }
726
+
727
+ function toWebPath(relativeToPublic) {
728
+ const normalized = relativeToPublic.replace(/\\/g, "/");
729
+ return normalized ? `/${normalized}` : "/";
730
+ }
731
+
732
+ async function listMediaDirectory(projectPath, relativePath = "img") {
733
+ const resolved = resolvePublicSubpath(projectPath, relativePath);
734
+ if (!resolved) {
735
+ return { error: "Invalid media path" };
736
+ }
737
+
738
+ const { publicDir, target, relativePath: currentDir } = resolved;
739
+
740
+ if (!(await isDirectory(publicDir))) {
741
+ return { error: "This project has no public folder" };
742
+ }
743
+
744
+ if (!(await isDirectory(target))) {
745
+ return { error: "Folder does not exist" };
746
+ }
747
+
748
+ let entries;
749
+ try {
750
+ entries = await fs.readdir(target, { withFileTypes: true });
751
+ } catch {
752
+ return { error: "Could not read folder" };
753
+ }
754
+
755
+ const folders = [];
756
+ const images = [];
757
+
758
+ for (const entry of entries) {
759
+ if (entry.name.startsWith(".")) continue;
760
+
761
+ const entryRelativePath = currentDir
762
+ ? path.posix.join(currentDir, entry.name)
763
+ : entry.name;
764
+
765
+ if (entry.isDirectory()) {
766
+ folders.push({
767
+ name: entry.name,
768
+ dir: entryRelativePath,
769
+ });
770
+ continue;
771
+ }
772
+
773
+ if (!entry.isFile()) continue;
774
+
775
+ const ext = path.extname(entry.name).toLowerCase();
776
+ if (!IMAGE_EXTENSIONS.has(ext)) continue;
777
+
778
+ images.push({
779
+ name: entry.name,
780
+ dir: currentDir,
781
+ webPath: toWebPath(entryRelativePath),
782
+ });
783
+ }
784
+
785
+ folders.sort((a, b) => a.name.localeCompare(b.name));
786
+ images.sort((a, b) => a.name.localeCompare(b.name));
787
+
788
+ return {
789
+ currentDir,
790
+ parentDir: getMediaParentDir(currentDir),
791
+ folders,
792
+ images,
793
+ };
794
+ }
795
+
796
+ function getMediaParentDir(currentDir) {
797
+ if (currentDir === "") {
798
+ return null;
799
+ }
800
+
801
+ const parentDir = path.posix.dirname(currentDir);
802
+ return parentDir === "." ? "" : parentDir;
803
+ }
804
+
805
+ async function walkMediaImages(dirPath, relativeDir, query, images) {
806
+ let entries;
807
+
808
+ try {
809
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
810
+ } catch {
811
+ return;
812
+ }
813
+
814
+ for (const entry of entries) {
815
+ if (entry.name.startsWith(".")) continue;
816
+
817
+ const entryRelativePath = relativeDir
818
+ ? path.posix.join(relativeDir, entry.name)
819
+ : entry.name;
820
+
821
+ if (entry.isDirectory()) {
822
+ await walkMediaImages(
823
+ path.join(dirPath, entry.name),
824
+ entryRelativePath,
825
+ query,
826
+ images,
827
+ );
828
+ continue;
829
+ }
830
+
831
+ if (!entry.isFile()) continue;
832
+
833
+ const ext = path.extname(entry.name).toLowerCase();
834
+ if (!IMAGE_EXTENSIONS.has(ext)) continue;
835
+
836
+ if (entry.name.toLowerCase().includes(query)) {
837
+ images.push({
838
+ name: entry.name,
839
+ dir: relativeDir,
840
+ webPath: toWebPath(entryRelativePath),
841
+ });
842
+ }
843
+ }
844
+ }
845
+
846
+ async function searchMediaImages(projectPath, relativePath, query) {
847
+ const resolved = resolvePublicSubpath(projectPath, relativePath);
848
+ if (!resolved) {
849
+ return { error: "Invalid media path" };
850
+ }
851
+
852
+ const { publicDir, target, relativePath: currentDir } = resolved;
853
+
854
+ if (!(await isDirectory(publicDir))) {
855
+ return { error: "This project has no public folder" };
856
+ }
857
+
858
+ if (!(await isDirectory(target))) {
859
+ return { error: "Folder does not exist" };
860
+ }
861
+
862
+ const normalizedQuery = query.trim().toLowerCase();
863
+ if (!normalizedQuery) {
864
+ return listMediaDirectory(projectPath, relativePath);
865
+ }
866
+
867
+ const images = [];
868
+ await walkMediaImages(target, currentDir, normalizedQuery, images);
869
+ images.sort((a, b) => a.name.localeCompare(b.name));
870
+
871
+ return {
872
+ currentDir,
873
+ parentDir: getMediaParentDir(currentDir),
874
+ folders: [],
875
+ images,
876
+ searchQuery: query.trim(),
877
+ };
878
+ }
879
+
880
+ app.get("/api/media", async (req, res) => {
881
+ const { project: projectPath, dir = "img", q = "" } = req.query;
882
+
883
+ if (!projectPath || typeof projectPath !== "string") {
884
+ return res.status(400).json({ error: "A project path is required" });
885
+ }
886
+
887
+ const resolvedProject = path.resolve(projectPath);
888
+
889
+ try {
890
+ const stat = await fs.stat(resolvedProject);
891
+ if (!stat.isDirectory()) {
892
+ return res.status(400).json({ error: "Project path is not a directory" });
893
+ }
894
+ } catch {
895
+ return res.status(404).json({ error: "Project does not exist or is not accessible" });
896
+ }
897
+
898
+ const relativeDir = typeof dir === "string" ? dir : "img";
899
+ const searchQuery = typeof q === "string" ? q.trim() : "";
900
+ const result = searchQuery
901
+ ? await searchMediaImages(resolvedProject, relativeDir, searchQuery)
902
+ : await listMediaDirectory(resolvedProject, relativeDir);
903
+
904
+ if (result.error) {
905
+ return res.status(400).json({ error: result.error });
906
+ }
907
+
908
+ res.json({
909
+ projectPath: resolvedProject,
910
+ ...result,
911
+ });
912
+ });
913
+
914
+ async function getUniqueImageFilename(dirPath, filename) {
915
+ const sanitized = path.basename(filename.replace(/\\/g, "/"));
916
+ const ext = path.extname(sanitized).toLowerCase();
917
+ const base = path.basename(sanitized, path.extname(sanitized));
918
+ let candidate = sanitized;
919
+ let counter = 1;
920
+
921
+ while (true) {
922
+ try {
923
+ await fs.stat(path.join(dirPath, candidate));
924
+ candidate = `${base}-${counter}${ext}`;
925
+ counter += 1;
926
+ } catch {
927
+ return candidate;
928
+ }
929
+ }
930
+ }
931
+
932
+ app.post(
933
+ "/api/media/upload",
934
+ express.raw({ type: () => true, limit: "25mb" }),
935
+ async (req, res) => {
936
+ const { project: projectPath, dir = "img", filename } = req.query;
937
+
938
+ if (!projectPath || typeof projectPath !== "string") {
939
+ return res.status(400).json({ error: "A project path is required" });
940
+ }
941
+
942
+ if (!filename || typeof filename !== "string") {
943
+ return res.status(400).json({ error: "A filename is required" });
944
+ }
945
+
946
+ const sanitizedFilename = path.basename(filename.replace(/\\/g, "/"));
947
+ const ext = path.extname(sanitizedFilename).toLowerCase();
948
+ if (!IMAGE_EXTENSIONS.has(ext)) {
949
+ return res.status(400).json({ error: "Only image files are supported" });
950
+ }
951
+
952
+ if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) {
953
+ return res.status(400).json({ error: "File content is required" });
954
+ }
955
+
956
+ const resolvedProject = path.resolve(projectPath);
957
+
958
+ try {
959
+ const stat = await fs.stat(resolvedProject);
960
+ if (!stat.isDirectory()) {
961
+ return res.status(400).json({ error: "Project path is not a directory" });
962
+ }
963
+ } catch {
964
+ return res.status(404).json({ error: "Project does not exist or is not accessible" });
965
+ }
966
+
967
+ const relativeDir = typeof dir === "string" ? dir : "img";
968
+ const resolved = resolvePublicSubpath(resolvedProject, relativeDir);
969
+ if (!resolved) {
970
+ return res.status(400).json({ error: "Invalid media path" });
971
+ }
972
+
973
+ const { publicDir, target, relativePath: currentDir } = resolved;
974
+
975
+ if (!(await isDirectory(publicDir))) {
976
+ return res.status(400).json({ error: "This project has no public folder" });
977
+ }
978
+
979
+ if (!(await isDirectory(target))) {
980
+ return res.status(400).json({ error: "Folder does not exist" });
981
+ }
982
+
983
+ const uniqueFilename = await getUniqueImageFilename(target, sanitizedFilename);
984
+ const filePath = path.join(target, uniqueFilename);
985
+ const entryRelativePath = currentDir
986
+ ? path.posix.join(currentDir, uniqueFilename)
987
+ : uniqueFilename;
988
+
989
+ try {
990
+ await fs.writeFile(filePath, req.body);
991
+ res.json({
992
+ name: uniqueFilename,
993
+ dir: currentDir,
994
+ webPath: toWebPath(entryRelativePath),
995
+ });
996
+ } catch {
997
+ res.status(500).json({ error: "Could not save image" });
998
+ }
999
+ },
1000
+ );
1001
+
1002
+ app.get("/api/media/file", async (req, res) => {
1003
+ const { project: projectPath, path: mediaPath } = req.query;
1004
+
1005
+ if (!projectPath || typeof projectPath !== "string") {
1006
+ return res.status(400).json({ error: "A project path is required" });
1007
+ }
1008
+
1009
+ if (!mediaPath || typeof mediaPath !== "string") {
1010
+ return res.status(400).json({ error: "A media path is required" });
1011
+ }
1012
+
1013
+ const resolvedProject = path.resolve(projectPath);
1014
+ const resolved = resolvePublicSubpath(resolvedProject, mediaPath);
1015
+ if (!resolved) {
1016
+ return res.status(400).json({ error: "Invalid media path" });
1017
+ }
1018
+
1019
+ try {
1020
+ const stat = await fs.stat(resolved.target);
1021
+ if (!stat.isFile()) {
1022
+ return res.status(400).json({ error: "Path is not a file" });
1023
+ }
1024
+ } catch {
1025
+ return res.status(404).json({ error: "File does not exist or is not accessible" });
1026
+ }
1027
+
1028
+ const ext = path.extname(resolved.target).toLowerCase();
1029
+ if (!IMAGE_EXTENSIONS.has(ext)) {
1030
+ return res.status(400).json({ error: "Only image files are supported" });
1031
+ }
1032
+
1033
+ res.sendFile(resolved.target);
1034
+ });
1035
+
1036
+ export function startServer(options = {}) {
1037
+ const port = options.port ?? PORT;
1038
+
1039
+ const server = app.listen(port, () => {
1040
+ console.log(`Starmark running at http://localhost:${port}`);
1041
+ console.log(`Project folder: ${process.cwd()}`);
1042
+ });
1043
+
1044
+ server.on("error", (err) => {
1045
+ if (err.code === "EADDRINUSE") {
1046
+ console.error(
1047
+ `Port ${port} is already in use. Stop the other process or run with PORT=<number> starmark`,
1048
+ );
1049
+ process.exit(1);
1050
+ }
1051
+
1052
+ throw err;
1053
+ });
1054
+
1055
+ return server;
1056
+ }