prev-cli 0.22.3 → 0.23.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/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
- import path9 from "path";
5
+ import path10 from "path";
6
6
  import { existsSync as existsSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, rmSync as rmSync3, readFileSync as readFileSync5 } from "fs";
7
7
  import { fileURLToPath as fileURLToPath3 } from "url";
8
8
 
@@ -15,7 +15,7 @@ import react from "@vitejs/plugin-react";
15
15
  import mdx from "@mdx-js/rollup";
16
16
  import remarkGfm from "remark-gfm";
17
17
  import rehypeHighlight from "rehype-highlight";
18
- import path7 from "path";
18
+ import path8 from "path";
19
19
  import { fileURLToPath as fileURLToPath2 } from "url";
20
20
  import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
21
21
 
@@ -70,6 +70,9 @@ async function ensureCacheDir(rootDir) {
70
70
  return cacheDir;
71
71
  }
72
72
 
73
+ // src/vite/plugins/pages-plugin.ts
74
+ import path3 from "path";
75
+
73
76
  // src/vite/pages.ts
74
77
  import fg from "fast-glob";
75
78
  import { readFile } from "fs/promises";
@@ -127,13 +130,18 @@ function isIndexFile(basename) {
127
130
  const lower = basename.toLowerCase();
128
131
  return lower === "index" || lower === "readme";
129
132
  }
133
+ var CONTENT_ROOT_DIRS = ["docs", "documentation", "content", "pages"];
130
134
  function fileToRoute(file) {
131
- const normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
135
+ let normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
136
+ const firstDir = normalizedFile.split("/")[0]?.toLowerCase();
137
+ if (CONTENT_ROOT_DIRS.includes(firstDir)) {
138
+ normalizedFile = normalizedFile.slice(firstDir.length + 1) || normalizedFile;
139
+ }
132
140
  const withoutExt = normalizedFile.replace(/\.mdx?$/, "");
133
141
  const basename = path2.basename(withoutExt).toLowerCase();
134
142
  if (basename === "index" || basename === "readme") {
135
143
  const dir = path2.dirname(withoutExt);
136
- if (dir === ".") {
144
+ if (dir === "." || dir === "") {
137
145
  return "/";
138
146
  }
139
147
  return "/" + dir;
@@ -254,45 +262,82 @@ function buildSidebarTree(pages) {
254
262
  // src/vite/plugins/pages-plugin.ts
255
263
  var VIRTUAL_MODULE_ID = "virtual:prev-pages";
256
264
  var RESOLVED_VIRTUAL_MODULE_ID = "\x00" + VIRTUAL_MODULE_ID;
265
+ var VIRTUAL_MODULES_ID = "virtual:prev-page-modules";
266
+ var RESOLVED_VIRTUAL_MODULES_ID = "\x00" + VIRTUAL_MODULES_ID;
257
267
  function pagesPlugin(rootDir, options = {}) {
258
268
  const { include } = options;
269
+ let cachedPages = null;
270
+ async function getPages() {
271
+ if (!cachedPages) {
272
+ cachedPages = await scanPages(rootDir, { include });
273
+ }
274
+ return cachedPages;
275
+ }
259
276
  return {
260
277
  name: "prev-pages",
261
278
  resolveId(id) {
262
279
  if (id === VIRTUAL_MODULE_ID) {
263
280
  return RESOLVED_VIRTUAL_MODULE_ID;
264
281
  }
282
+ if (id === VIRTUAL_MODULES_ID) {
283
+ return RESOLVED_VIRTUAL_MODULES_ID;
284
+ }
265
285
  },
266
286
  async load(id) {
267
287
  if (id === RESOLVED_VIRTUAL_MODULE_ID) {
268
- const pages = await scanPages(rootDir, { include });
288
+ const pages = await getPages();
269
289
  const sidebar = buildSidebarTree(pages);
270
290
  return `
271
291
  export const pages = ${JSON.stringify(pages)};
272
292
  export const sidebar = ${JSON.stringify(sidebar)};
273
293
  `;
274
294
  }
295
+ if (id === RESOLVED_VIRTUAL_MODULES_ID) {
296
+ const pages = await getPages();
297
+ const imports = pages.map((page, i) => {
298
+ const absolutePath = path3.join(rootDir, page.file);
299
+ return `import * as _page${i} from ${JSON.stringify(absolutePath)};`;
300
+ }).join(`
301
+ `);
302
+ const entries = pages.map((page, i) => {
303
+ return ` ${JSON.stringify("/" + page.file)}: _page${i}`;
304
+ }).join(`,
305
+ `);
306
+ return `${imports}
307
+
308
+ export const pageModules = {
309
+ ${entries}
310
+ };`;
311
+ }
275
312
  },
276
313
  handleHotUpdate({ file, server }) {
277
314
  if (file.endsWith(".mdx") || file.endsWith(".md")) {
315
+ cachedPages = null;
278
316
  const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
317
+ const modulesMod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULES_ID);
318
+ const mods = [];
279
319
  if (mod) {
280
320
  server.moduleGraph.invalidateModule(mod);
281
- return [mod];
321
+ mods.push(mod);
322
+ }
323
+ if (modulesMod) {
324
+ server.moduleGraph.invalidateModule(modulesMod);
325
+ mods.push(modulesMod);
282
326
  }
327
+ return mods.length > 0 ? mods : undefined;
283
328
  }
284
329
  }
285
330
  };
286
331
  }
287
332
 
288
333
  // src/vite/plugins/entry-plugin.ts
289
- import path3 from "path";
334
+ import path4 from "path";
290
335
  import { fileURLToPath } from "url";
291
336
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
292
337
  function findCliRoot() {
293
- let dir = path3.dirname(fileURLToPath(import.meta.url));
338
+ let dir = path4.dirname(fileURLToPath(import.meta.url));
294
339
  for (let i = 0;i < 10; i++) {
295
- const pkgPath = path3.join(dir, "package.json");
340
+ const pkgPath = path4.join(dir, "package.json");
296
341
  if (existsSync(pkgPath)) {
297
342
  try {
298
343
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
@@ -301,15 +346,15 @@ function findCliRoot() {
301
346
  }
302
347
  } catch {}
303
348
  }
304
- const parent = path3.dirname(dir);
349
+ const parent = path4.dirname(dir);
305
350
  if (parent === dir)
306
351
  break;
307
352
  dir = parent;
308
353
  }
309
- return path3.dirname(path3.dirname(fileURLToPath(import.meta.url)));
354
+ return path4.dirname(path4.dirname(fileURLToPath(import.meta.url)));
310
355
  }
311
356
  var cliRoot = findCliRoot();
312
- var srcRoot = path3.join(cliRoot, "src");
357
+ var srcRoot = path4.join(cliRoot, "src");
313
358
  function getHtml(entryPath, forBuild = false) {
314
359
  const scriptSrc = forBuild ? entryPath : `/@fs${entryPath}`;
315
360
  return `<!DOCTYPE html>
@@ -332,13 +377,13 @@ function getHtml(entryPath, forBuild = false) {
332
377
  </html>`;
333
378
  }
334
379
  function entryPlugin(rootDir) {
335
- const entryPath = path3.join(srcRoot, "theme/entry.tsx");
380
+ const entryPath = path4.join(srcRoot, "theme/entry.tsx");
336
381
  let tempHtmlPath = null;
337
382
  return {
338
383
  name: "prev-entry",
339
384
  config(config, { command }) {
340
385
  if (command === "build" && rootDir) {
341
- tempHtmlPath = path3.join(rootDir, "index.html");
386
+ tempHtmlPath = path4.join(rootDir, "index.html");
342
387
  writeFileSync(tempHtmlPath, getHtml(entryPath, true));
343
388
  const existingInput = config.build?.rollupOptions?.input || {};
344
389
  const inputObj = typeof existingInput === "string" ? { _original: existingInput } : Array.isArray(existingInput) ? Object.fromEntries(existingInput.map((f, i) => [`entry${i}`, f])) : existingInput;
@@ -385,10 +430,10 @@ function entryPlugin(rootDir) {
385
430
 
386
431
  // src/vite/previews.ts
387
432
  import fg2 from "fast-glob";
388
- import path4 from "path";
433
+ import path5 from "path";
389
434
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
390
435
  async function scanPreviews(rootDir) {
391
- const previewsDir = path4.join(rootDir, "previews");
436
+ const previewsDir = path5.join(rootDir, "previews");
392
437
  if (!existsSync2(previewsDir)) {
393
438
  return [];
394
439
  }
@@ -398,17 +443,17 @@ async function scanPreviews(rootDir) {
398
443
  });
399
444
  const previewDirs = new Map;
400
445
  for (const file of entryFiles) {
401
- const dir = path4.dirname(file);
446
+ const dir = path5.dirname(file);
402
447
  if (!previewDirs.has(dir)) {
403
448
  previewDirs.set(dir, file);
404
449
  }
405
450
  }
406
451
  return Array.from(previewDirs.entries()).map(([dir, file]) => {
407
- const name = dir === "." ? path4.basename(previewsDir) : dir;
452
+ const name = dir === "." ? path5.basename(previewsDir) : dir;
408
453
  return {
409
454
  name,
410
455
  route: `/_preview/${name}`,
411
- htmlPath: path4.join(previewsDir, file)
456
+ htmlPath: path5.join(previewsDir, file)
412
457
  };
413
458
  });
414
459
  }
@@ -418,8 +463,8 @@ async function scanPreviewFiles(previewDir) {
418
463
  ignore: ["node_modules/**", "dist/**"]
419
464
  });
420
465
  return files.map((file) => {
421
- const content = readFileSync2(path4.join(previewDir, file), "utf-8");
422
- const ext = path4.extname(file).slice(1);
466
+ const content = readFileSync2(path5.join(previewDir, file), "utf-8");
467
+ const ext = path5.extname(file).slice(1);
423
468
  return {
424
469
  path: file,
425
470
  content,
@@ -563,7 +608,7 @@ async function buildPreviewHtml(config) {
563
608
 
564
609
  // src/vite/plugins/previews-plugin.ts
565
610
  import { existsSync as existsSync3, mkdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
566
- import path5 from "path";
611
+ import path6 from "path";
567
612
  var VIRTUAL_MODULE_ID2 = "virtual:prev-previews";
568
613
  var RESOLVED_VIRTUAL_MODULE_ID2 = "\x00" + VIRTUAL_MODULE_ID2;
569
614
  function previewsPlugin(rootDir) {
@@ -596,10 +641,10 @@ function previewsPlugin(rootDir) {
596
641
  async closeBundle() {
597
642
  if (!isBuild)
598
643
  return;
599
- const distDir = path5.join(rootDir, "dist");
600
- const targetDir = path5.join(distDir, "_preview");
601
- const previewsDir = path5.join(rootDir, "previews");
602
- const oldPreviewsDir = path5.join(distDir, "previews");
644
+ const distDir = path6.join(rootDir, "dist");
645
+ const targetDir = path6.join(distDir, "_preview");
646
+ const previewsDir = path6.join(rootDir, "previews");
647
+ const oldPreviewsDir = path6.join(distDir, "previews");
603
648
  if (existsSync3(oldPreviewsDir)) {
604
649
  rmSync(oldPreviewsDir, { recursive: true });
605
650
  }
@@ -612,7 +657,7 @@ function previewsPlugin(rootDir) {
612
657
  console.log(`
613
658
  Building ${previews.length} preview(s)...`);
614
659
  for (const preview of previews) {
615
- const previewDir = path5.join(previewsDir, preview.name);
660
+ const previewDir = path6.join(previewsDir, preview.name);
616
661
  try {
617
662
  const config = await buildPreviewConfig(previewDir);
618
663
  const result = await buildPreviewHtml(config);
@@ -620,9 +665,9 @@ function previewsPlugin(rootDir) {
620
665
  console.error(` ✗ ${preview.name}: ${result.error}`);
621
666
  continue;
622
667
  }
623
- const outputDir = path5.join(targetDir, preview.name);
668
+ const outputDir = path6.join(targetDir, preview.name);
624
669
  mkdirSync(outputDir, { recursive: true });
625
- writeFileSync2(path5.join(outputDir, "index.html"), result.html);
670
+ writeFileSync2(path6.join(outputDir, "index.html"), result.html);
626
671
  console.log(` ✓ ${preview.name}`);
627
672
  } catch (err) {
628
673
  console.error(` ✗ ${preview.name}: ${err}`);
@@ -693,11 +738,11 @@ function validateConfig(raw) {
693
738
  }
694
739
  // src/config/loader.ts
695
740
  import { readFileSync as readFileSync3, existsSync as existsSync4, writeFileSync as writeFileSync3 } from "fs";
696
- import path6 from "path";
741
+ import path7 from "path";
697
742
  import yaml from "js-yaml";
698
743
  function findConfigFile(rootDir) {
699
- const yamlPath = path6.join(rootDir, ".prev.yaml");
700
- const ymlPath = path6.join(rootDir, ".prev.yml");
744
+ const yamlPath = path7.join(rootDir, ".prev.yaml");
745
+ const ymlPath = path7.join(rootDir, ".prev.yml");
701
746
  if (existsSync4(yamlPath))
702
747
  return yamlPath;
703
748
  if (existsSync4(ymlPath))
@@ -719,7 +764,7 @@ function loadConfig(rootDir) {
719
764
  }
720
765
  }
721
766
  function saveConfig(rootDir, config) {
722
- const configPath = findConfigFile(rootDir) || path6.join(rootDir, ".prev.yaml");
767
+ const configPath = findConfigFile(rootDir) || path7.join(rootDir, ".prev.yaml");
723
768
  const content = yaml.dump(config, {
724
769
  indent: 2,
725
770
  lineWidth: -1,
@@ -788,9 +833,9 @@ function createFriendlyLogger() {
788
833
  };
789
834
  }
790
835
  function findCliRoot2() {
791
- let dir = path7.dirname(fileURLToPath2(import.meta.url));
836
+ let dir = path8.dirname(fileURLToPath2(import.meta.url));
792
837
  for (let i = 0;i < 10; i++) {
793
- const pkgPath = path7.join(dir, "package.json");
838
+ const pkgPath = path8.join(dir, "package.json");
794
839
  if (existsSync5(pkgPath)) {
795
840
  try {
796
841
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
@@ -799,24 +844,24 @@ function findCliRoot2() {
799
844
  }
800
845
  } catch {}
801
846
  }
802
- const parent = path7.dirname(dir);
847
+ const parent = path8.dirname(dir);
803
848
  if (parent === dir)
804
849
  break;
805
850
  dir = parent;
806
851
  }
807
- return path7.dirname(path7.dirname(fileURLToPath2(import.meta.url)));
852
+ return path8.dirname(path8.dirname(fileURLToPath2(import.meta.url)));
808
853
  }
809
854
  function findNodeModules(cliRoot2) {
810
- const localNodeModules = path7.join(cliRoot2, "node_modules");
811
- if (existsSync5(path7.join(localNodeModules, "react"))) {
855
+ const localNodeModules = path8.join(cliRoot2, "node_modules");
856
+ if (existsSync5(path8.join(localNodeModules, "react"))) {
812
857
  return localNodeModules;
813
858
  }
814
859
  let dir = cliRoot2;
815
860
  for (let i = 0;i < 10; i++) {
816
- const parent = path7.dirname(dir);
861
+ const parent = path8.dirname(dir);
817
862
  if (parent === dir)
818
863
  break;
819
- if (path7.basename(parent) === "node_modules" && existsSync5(path7.join(parent, "react"))) {
864
+ if (path8.basename(parent) === "node_modules" && existsSync5(path8.join(parent, "react"))) {
820
865
  return parent;
821
866
  }
822
867
  dir = parent;
@@ -825,15 +870,16 @@ function findNodeModules(cliRoot2) {
825
870
  }
826
871
  var cliRoot2 = findCliRoot2();
827
872
  var cliNodeModules = findNodeModules(cliRoot2);
828
- var srcRoot2 = path7.join(cliRoot2, "src");
873
+ var srcRoot2 = path8.join(cliRoot2, "src");
829
874
  async function createViteConfig(options) {
830
- const { rootDir, mode, port, include } = options;
875
+ const { rootDir, mode, port, include, base } = options;
831
876
  const cacheDir = await ensureCacheDir(rootDir);
832
877
  const config = loadConfig(rootDir);
833
878
  return {
834
879
  root: rootDir,
835
880
  mode,
836
881
  cacheDir,
882
+ base: base || "/",
837
883
  customLogger: createFriendlyLogger(),
838
884
  logLevel: mode === "production" ? "silent" : "info",
839
885
  plugins: [
@@ -883,7 +929,7 @@ async function createViteConfig(options) {
883
929
  if (urlPath.startsWith("/__") || urlPath.startsWith("/@") || urlPath.startsWith("/node_modules") || urlPath.includes(".")) {
884
930
  return next();
885
931
  }
886
- const indexPath = path7.join(srcRoot2, "theme/index.html");
932
+ const indexPath = path8.join(srcRoot2, "theme/index.html");
887
933
  if (existsSync5(indexPath)) {
888
934
  server.transformIndexHtml(req.url, readFileSync4(indexPath, "utf-8")).then((html) => {
889
935
  res.setHeader("Content-Type", "text/html");
@@ -901,8 +947,8 @@ async function createViteConfig(options) {
901
947
  resolveId(id) {
902
948
  if (id.startsWith("/_preview/")) {
903
949
  const relativePath = id.slice("/_preview/".length);
904
- const previewsDir = path7.join(rootDir, "previews");
905
- const resolved = path7.resolve(previewsDir, relativePath);
950
+ const previewsDir = path8.join(rootDir, "previews");
951
+ const resolved = path8.resolve(previewsDir, relativePath);
906
952
  if (resolved.startsWith(previewsDir)) {
907
953
  return resolved;
908
954
  }
@@ -912,7 +958,7 @@ async function createViteConfig(options) {
912
958
  server.middlewares.use(async (req, res, next) => {
913
959
  const urlPath = req.url?.split("?")[0] || "";
914
960
  if (urlPath === "/_preview-runtime") {
915
- const templatePath = path7.join(srcRoot2, "preview-runtime/template.html");
961
+ const templatePath = path8.join(srcRoot2, "preview-runtime/template.html");
916
962
  if (existsSync5(templatePath)) {
917
963
  const html = readFileSync4(templatePath, "utf-8");
918
964
  res.setHeader("Content-Type", "text/html");
@@ -922,8 +968,8 @@ async function createViteConfig(options) {
922
968
  }
923
969
  if (urlPath.startsWith("/_preview-config/")) {
924
970
  const previewName = decodeURIComponent(urlPath.slice("/_preview-config/".length));
925
- const previewsDir = path7.join(rootDir, "previews");
926
- const previewDir = path7.resolve(previewsDir, previewName);
971
+ const previewsDir = path8.join(rootDir, "previews");
972
+ const previewDir = path8.resolve(previewsDir, previewName);
927
973
  if (!previewDir.startsWith(previewsDir)) {
928
974
  res.statusCode = 403;
929
975
  res.end("Forbidden");
@@ -944,11 +990,11 @@ async function createViteConfig(options) {
944
990
  }
945
991
  }
946
992
  if (urlPath.startsWith("/_preview/")) {
947
- const isHtmlRequest = !path7.extname(urlPath) || urlPath.endsWith("/");
993
+ const isHtmlRequest = !path8.extname(urlPath) || urlPath.endsWith("/");
948
994
  if (isHtmlRequest) {
949
995
  const previewName = decodeURIComponent(urlPath.slice("/_preview/".length).replace(/\/$/, ""));
950
- const previewsDir = path7.join(rootDir, "previews");
951
- const htmlPath = path7.resolve(previewsDir, previewName, "index.html");
996
+ const previewsDir = path8.join(rootDir, "previews");
997
+ const htmlPath = path8.resolve(previewsDir, previewName, "index.html");
952
998
  if (!htmlPath.startsWith(previewsDir)) {
953
999
  return next();
954
1000
  }
@@ -975,15 +1021,15 @@ async function createViteConfig(options) {
975
1021
  ],
976
1022
  resolve: {
977
1023
  alias: {
978
- "@prev/ui": path7.join(srcRoot2, "ui"),
979
- "@prev/theme": path7.join(srcRoot2, "theme"),
980
- react: path7.join(cliNodeModules, "react"),
981
- "react-dom": path7.join(cliNodeModules, "react-dom"),
982
- "@tanstack/react-router": path7.join(cliNodeModules, "@tanstack/react-router"),
983
- "@mdx-js/react": path7.join(cliNodeModules, "@mdx-js/react"),
984
- mermaid: path7.join(cliNodeModules, "mermaid"),
985
- dayjs: path7.join(cliNodeModules, "dayjs"),
986
- "@terrastruct/d2": path7.join(cliNodeModules, "@terrastruct/d2")
1024
+ "@prev/ui": path8.join(srcRoot2, "ui"),
1025
+ "@prev/theme": path8.join(srcRoot2, "theme"),
1026
+ react: path8.join(cliNodeModules, "react"),
1027
+ "react-dom": path8.join(cliNodeModules, "react-dom"),
1028
+ "@tanstack/react-router": path8.join(cliNodeModules, "@tanstack/react-router"),
1029
+ "@mdx-js/react": path8.join(cliNodeModules, "@mdx-js/react"),
1030
+ mermaid: path8.join(cliNodeModules, "mermaid"),
1031
+ dayjs: path8.join(cliNodeModules, "dayjs"),
1032
+ "@terrastruct/d2": path8.join(cliNodeModules, "@terrastruct/d2")
987
1033
  },
988
1034
  dedupe: [
989
1035
  "react",
@@ -1008,6 +1054,7 @@ async function createViteConfig(options) {
1008
1054
  "virtual:prev-config",
1009
1055
  "virtual:prev-previews",
1010
1056
  "virtual:prev-pages",
1057
+ "virtual:prev-page-modules",
1011
1058
  "@prev/theme"
1012
1059
  ]
1013
1060
  },
@@ -1022,8 +1069,8 @@ async function createViteConfig(options) {
1022
1069
  },
1023
1070
  warmup: {
1024
1071
  clientFiles: [
1025
- path7.join(srcRoot2, "theme/entry.tsx"),
1026
- path7.join(srcRoot2, "theme/styles.css")
1072
+ path8.join(srcRoot2, "theme/entry.tsx"),
1073
+ path8.join(srcRoot2, "theme/styles.css")
1027
1074
  ]
1028
1075
  }
1029
1076
  },
@@ -1032,12 +1079,12 @@ async function createViteConfig(options) {
1032
1079
  strictPort: false
1033
1080
  },
1034
1081
  build: {
1035
- outDir: path7.join(rootDir, "dist"),
1082
+ outDir: path8.join(rootDir, "dist"),
1036
1083
  reportCompressedSize: false,
1037
1084
  chunkSizeWarningLimit: 1e4,
1038
1085
  rollupOptions: {
1039
1086
  input: {
1040
- main: path7.join(srcRoot2, "theme/index.html")
1087
+ main: path8.join(srcRoot2, "theme/index.html")
1041
1088
  }
1042
1089
  }
1043
1090
  }
@@ -1077,8 +1124,8 @@ async function findAvailablePort(minPort, maxPort) {
1077
1124
 
1078
1125
  // src/vite/start.ts
1079
1126
  import { exec as exec2 } from "child_process";
1080
- import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
1081
- import path8 from "path";
1127
+ import { existsSync as existsSync6, rmSync as rmSync2, copyFileSync } from "fs";
1128
+ import path9 from "path";
1082
1129
  function printWelcome(type) {
1083
1130
  console.log();
1084
1131
  console.log(" ✨ prev");
@@ -1111,8 +1158,8 @@ function openBrowser(url) {
1111
1158
  console.log(` ↗ Opened ${url}`);
1112
1159
  }
1113
1160
  function clearCache(rootDir) {
1114
- const viteCacheDir = path8.join(rootDir, ".vite");
1115
- const nodeModulesVite = path8.join(rootDir, "node_modules", ".vite");
1161
+ const viteCacheDir = path9.join(rootDir, ".vite");
1162
+ const nodeModulesVite = path9.join(rootDir, "node_modules", ".vite");
1116
1163
  let cleared = 0;
1117
1164
  if (existsSync6(viteCacheDir)) {
1118
1165
  rmSync2(viteCacheDir, { recursive: true });
@@ -1183,9 +1230,16 @@ async function buildSite(rootDir, options = {}) {
1183
1230
  const config = await createViteConfig({
1184
1231
  rootDir,
1185
1232
  mode: "production",
1186
- include: options.include
1233
+ include: options.include,
1234
+ base: options.base
1187
1235
  });
1188
1236
  await build2(config);
1237
+ const distDir = path9.join(rootDir, "dist");
1238
+ const indexPath = path9.join(distDir, "index.html");
1239
+ const notFoundPath = path9.join(distDir, "404.html");
1240
+ if (existsSync6(indexPath)) {
1241
+ copyFileSync(indexPath, notFoundPath);
1242
+ }
1189
1243
  console.log();
1190
1244
  console.log(" Done! Your site is ready in ./dist");
1191
1245
  console.log(" You can deploy this folder anywhere.");
@@ -1212,15 +1266,15 @@ async function previewSite(rootDir, options = {}) {
1212
1266
  import yaml2 from "js-yaml";
1213
1267
  function getVersion() {
1214
1268
  try {
1215
- let dir = path9.dirname(fileURLToPath3(import.meta.url));
1269
+ let dir = path10.dirname(fileURLToPath3(import.meta.url));
1216
1270
  for (let i = 0;i < 5; i++) {
1217
- const pkgPath = path9.join(dir, "package.json");
1271
+ const pkgPath = path10.join(dir, "package.json");
1218
1272
  if (existsSync7(pkgPath)) {
1219
1273
  const pkg = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1220
1274
  if (pkg.name === "prev-cli")
1221
1275
  return pkg.version;
1222
1276
  }
1223
- dir = path9.dirname(dir);
1277
+ dir = path10.dirname(dir);
1224
1278
  }
1225
1279
  } catch {}
1226
1280
  return "unknown";
@@ -1231,13 +1285,14 @@ var { values, positionals } = parseArgs({
1231
1285
  port: { type: "string", short: "p" },
1232
1286
  days: { type: "string", short: "d" },
1233
1287
  cwd: { type: "string", short: "c" },
1288
+ base: { type: "string", short: "b" },
1234
1289
  help: { type: "boolean", short: "h" },
1235
1290
  version: { type: "boolean", short: "v" }
1236
1291
  },
1237
1292
  allowPositionals: true
1238
1293
  });
1239
1294
  var command = positionals[0] || "dev";
1240
- var rootDir = path9.resolve(values.cwd || (command === "config" || command === "create" ? "." : positionals[1]) || ".");
1295
+ var rootDir = path10.resolve(values.cwd || (command === "config" || command === "create" ? "." : positionals[1]) || ".");
1241
1296
  function printHelp() {
1242
1297
  console.log(`
1243
1298
  prev - Zero-config documentation site generator
@@ -1260,6 +1315,7 @@ Config subcommands:
1260
1315
  Options:
1261
1316
  -c, --cwd <path> Set working directory
1262
1317
  -p, --port <port> Specify port (dev/preview)
1318
+ -b, --base <path> Base path for deployment (e.g., /repo-name/ for GitHub Pages)
1263
1319
  -d, --days <days> Cache age threshold for clean (default: 30)
1264
1320
  -h, --help Show this help message
1265
1321
  -v, --version Show version number
@@ -1345,8 +1401,8 @@ async function clearViteCache(rootDir2) {
1345
1401
  console.log(` ✓ Removed ${prevCacheDir}`);
1346
1402
  }
1347
1403
  } catch {}
1348
- const viteCacheDir = path9.join(rootDir2, ".vite");
1349
- const nodeModulesVite = path9.join(rootDir2, "node_modules", ".vite");
1404
+ const viteCacheDir = path10.join(rootDir2, ".vite");
1405
+ const nodeModulesVite = path10.join(rootDir2, "node_modules", ".vite");
1350
1406
  if (existsSync7(viteCacheDir)) {
1351
1407
  rmSync3(viteCacheDir, { recursive: true });
1352
1408
  cleared++;
@@ -1398,7 +1454,7 @@ function handleConfig(rootDir2, subcommand) {
1398
1454
  break;
1399
1455
  }
1400
1456
  case "init": {
1401
- const targetPath = path9.join(rootDir2, ".prev.yaml");
1457
+ const targetPath = path10.join(rootDir2, ".prev.yaml");
1402
1458
  if (configPath) {
1403
1459
  console.log(`
1404
1460
  Config already exists: ${configPath}
@@ -1457,7 +1513,7 @@ Available subcommands: show, init, path`);
1457
1513
  }
1458
1514
  }
1459
1515
  function createPreview(rootDir2, name) {
1460
- const previewDir = path9.join(rootDir2, "previews", name);
1516
+ const previewDir = path10.join(rootDir2, "previews", name);
1461
1517
  if (existsSync7(previewDir)) {
1462
1518
  console.error(`Preview "${name}" already exists at: ${previewDir}`);
1463
1519
  process.exit(1);
@@ -1583,8 +1639,8 @@ export default function App() {
1583
1639
  .dark\\:text-white { color: #fff; }
1584
1640
  }
1585
1641
  `;
1586
- writeFileSync4(path9.join(previewDir, "App.tsx"), appTsx);
1587
- writeFileSync4(path9.join(previewDir, "styles.css"), stylesCss);
1642
+ writeFileSync4(path10.join(previewDir, "App.tsx"), appTsx);
1643
+ writeFileSync4(path10.join(previewDir, "styles.css"), stylesCss);
1588
1644
  console.log(`
1589
1645
  ✨ Created preview: previews/${name}/
1590
1646
 
@@ -1619,7 +1675,7 @@ async function main() {
1619
1675
  await startDev(rootDir, { port, include });
1620
1676
  break;
1621
1677
  case "build":
1622
- await buildSite(rootDir, { include });
1678
+ await buildSite(rootDir, { include, base: values.base });
1623
1679
  break;
1624
1680
  case "preview":
1625
1681
  await previewSite(rootDir, { port, include });
@@ -4,5 +4,6 @@ export interface ConfigOptions {
4
4
  mode: 'development' | 'production';
5
5
  port?: number;
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function createViteConfig(options: ConfigOptions): Promise<InlineConfig>;
@@ -4,6 +4,7 @@ export interface DevOptions {
4
4
  }
5
5
  export interface BuildOptions {
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function startDev(rootDir: string, options?: DevOptions): Promise<import("vite").ViteDevServer>;
9
10
  export declare function buildSite(rootDir: string, options?: BuildOptions): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "bun build src/cli.ts --outdir dist --target node --packages external && tsc --emitDeclarationOnly",
38
+ "build:docs": "bun run build && bun ./dist/cli.js build",
38
39
  "dev": "tsc --watch",
39
40
  "test": "bun test src",
40
41
  "test:integration": "bun run build && bun test test/integration.test.ts",
@@ -41,8 +41,11 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
41
41
  const isDev = import.meta.env?.DEV ?? false
42
42
  const effectiveMode = isDev ? mode : 'legacy'
43
43
 
44
+ // Get base URL for proper subpath deployment support
45
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
46
+
44
47
  // URL depends on mode - wasm mode needs src param, legacy uses pre-built files
45
- const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `/_preview/${src}/`
48
+ const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `${baseUrl}/_preview/${src}/`
46
49
  const displayTitle = title || src
47
50
 
48
51
  // Calculate current width
@@ -120,8 +120,20 @@
120
120
  position: fixed;
121
121
  inset: 0;
122
122
  z-index: 9998;
123
- background: rgba(0, 0, 0, 0.5);
124
- backdrop-filter: blur(4px);
123
+ background: rgba(0, 0, 0, 0.4);
124
+ backdrop-filter: blur(8px);
125
+ -webkit-backdrop-filter: blur(8px);
126
+ animation: fade-in-overlay 0.2s ease;
127
+ }
128
+
129
+ @keyframes fade-in-overlay {
130
+ from { opacity: 0; }
131
+ to { opacity: 1; }
132
+ }
133
+
134
+ @keyframes slide-up-sheet {
135
+ from { transform: translateY(100%); }
136
+ to { transform: translateY(0); }
125
137
  }
126
138
 
127
139
  .toc-overlay-content {
@@ -129,15 +141,31 @@
129
141
  bottom: 0;
130
142
  left: 0;
131
143
  right: 0;
132
- max-height: 80vh;
144
+ max-height: 70vh;
133
145
  background: var(--fd-background);
134
- border-radius: 1rem 1rem 0 0;
146
+ border-radius: 1.25rem 1.25rem 0 0;
135
147
  overflow: hidden;
148
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
149
+ animation: slide-up-sheet 0.25s ease;
150
+ /* Bottom safe area for toolbar overlap prevention */
151
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 72px);
152
+ }
153
+
154
+ /* Bottom sheet drag indicator */
155
+ .toc-overlay-content::before {
156
+ content: '';
157
+ display: block;
158
+ width: 36px;
159
+ height: 4px;
160
+ background: var(--fd-border);
161
+ border-radius: 2px;
162
+ margin: 8px auto;
136
163
  }
137
164
 
138
165
  .toc-overlay-content .toc-nav {
139
- max-height: calc(80vh - 60px);
140
- padding: 1rem;
166
+ max-height: calc(70vh - 120px);
167
+ padding: 0.5rem 1rem 1rem;
168
+ overscroll-behavior: contain;
141
169
  }
142
170
 
143
171
  @media (max-width: 768px) {
@@ -11,12 +11,31 @@
11
11
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
12
12
  cursor: grab;
13
13
  user-select: none;
14
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
14
15
  }
15
16
 
16
17
  .prev-toolbar:active {
17
18
  cursor: grabbing;
18
19
  }
19
20
 
21
+ /* Mobile: center toolbar at bottom with safe area */
22
+ @media (max-width: 768px) {
23
+ .prev-toolbar {
24
+ left: 50% !important;
25
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 16px) !important;
26
+ top: auto !important;
27
+ transform: translateX(-50%);
28
+ cursor: default;
29
+ padding: 0.5rem 0.75rem;
30
+ gap: 0.375rem;
31
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
32
+ }
33
+
34
+ .prev-toolbar:active {
35
+ cursor: default;
36
+ }
37
+ }
38
+
20
39
  .toolbar-btn {
21
40
  display: flex;
22
41
  align-items: center;
@@ -30,6 +49,7 @@
30
49
  cursor: pointer;
31
50
  transition: all 0.15s ease;
32
51
  text-decoration: none;
52
+ -webkit-tap-highlight-color: transparent;
33
53
  }
34
54
 
35
55
  .toolbar-btn:hover {
@@ -37,11 +57,23 @@
37
57
  color: var(--fd-foreground);
38
58
  }
39
59
 
60
+ .toolbar-btn:active {
61
+ transform: scale(0.92);
62
+ }
63
+
40
64
  .toolbar-btn.active {
41
65
  background: var(--fd-accent);
42
66
  color: var(--fd-accent-foreground);
43
67
  }
44
68
 
69
+ /* Mobile: larger touch targets */
70
+ @media (max-width: 768px) {
71
+ .toolbar-btn {
72
+ width: 40px;
73
+ height: 40px;
74
+ }
75
+ }
76
+
45
77
  .toolbar-devtools-slot {
46
78
  display: flex;
47
79
  align-items: center;
@@ -95,4 +127,16 @@
95
127
  .toolbar-btn.desktop-only {
96
128
  display: none;
97
129
  }
130
+
131
+ /* Simplify devtools on mobile - hide separator and some controls */
132
+ .toolbar-devtools-slot {
133
+ gap: 0.25rem;
134
+ margin-left: 0.375rem;
135
+ padding-left: 0.375rem;
136
+ }
137
+
138
+ /* Hide custom width slider button on mobile */
139
+ .toolbar-devtools-slot > div:has(button[title="Custom width"]) {
140
+ display: none;
141
+ }
98
142
  }
@@ -19,20 +19,30 @@ interface ToolbarProps {
19
19
  export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidth, onTocToggle, tocOpen }: ToolbarProps) {
20
20
  const [position, setPosition] = useState({ x: 20, y: typeof window !== 'undefined' ? window.innerHeight - 80 : 600 })
21
21
  const [dragging, setDragging] = useState(false)
22
+ const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' ? window.innerWidth <= 768 : false)
22
23
  const dragStart = useRef({ x: 0, y: 0 })
23
24
  const toolbarRef = useRef<HTMLDivElement>(null)
24
25
  const location = useLocation()
25
26
  const isOnPreviews = location.pathname.startsWith('/previews')
26
27
  const { devToolsContent } = useDevTools()
27
28
 
29
+ // Track mobile state
30
+ useEffect(() => {
31
+ const handleResize = () => setIsMobile(window.innerWidth <= 768)
32
+ window.addEventListener('resize', handleResize)
33
+ return () => window.removeEventListener('resize', handleResize)
34
+ }, [])
35
+
28
36
  const handleMouseDown = (e: React.MouseEvent) => {
37
+ // Disable dragging on mobile
38
+ if (isMobile) return
29
39
  if ((e.target as HTMLElement).closest('button, a')) return
30
40
  setDragging(true)
31
41
  dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }
32
42
  }
33
43
 
34
44
  useEffect(() => {
35
- if (!dragging) return
45
+ if (!dragging || isMobile) return
36
46
 
37
47
  const handleMouseMove = (e: MouseEvent) => {
38
48
  setPosition({
@@ -49,13 +59,13 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
49
59
  document.removeEventListener('mousemove', handleMouseMove)
50
60
  document.removeEventListener('mouseup', handleMouseUp)
51
61
  }
52
- }, [dragging])
62
+ }, [dragging, isMobile])
53
63
 
54
64
  return (
55
65
  <div
56
66
  ref={toolbarRef}
57
67
  className="prev-toolbar"
58
- style={{ left: position.x, top: position.y }}
68
+ style={isMobile ? undefined : { left: position.x, top: position.y }}
59
69
  onMouseDown={handleMouseDown}
60
70
  >
61
71
  <button
@@ -14,6 +14,7 @@ import {
14
14
  } from '@tanstack/react-router'
15
15
  import { MDXProvider } from '@mdx-js/react'
16
16
  import { pages, sidebar } from 'virtual:prev-pages'
17
+ import { pageModules } from 'virtual:prev-page-modules'
17
18
  import { previews } from 'virtual:prev-previews'
18
19
  import { Preview } from './Preview'
19
20
  import { useDiagrams } from './diagrams'
@@ -64,11 +65,10 @@ function convertToPageTree(items: any[]): PageTree.Root {
64
65
  }
65
66
  }
66
67
 
67
- // Dynamic imports for MDX pages (include dot directories for --include flag)
68
- const pageModules = import.meta.glob(['/**/*.{md,mdx}', '/.*/**/*.{md,mdx}'], { eager: true })
69
-
68
+ // Page modules are dynamically generated by the pages plugin
69
+ // This ensures all MDX files are included regardless of dot-prefixed directories
70
70
  function getPageComponent(file: string): React.ComponentType | null {
71
- const mod = pageModules[`/${file}`] as { default: React.ComponentType } | undefined
71
+ const mod = pageModules[`/${file}`]
72
72
  return mod?.default || null
73
73
  }
74
74
 
@@ -99,15 +99,15 @@ function PreviewsCatalog() {
99
99
  <h1 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
100
100
  No Previews Found
101
101
  </h1>
102
- <p style={{ color: '#666', marginBottom: '24px' }}>
102
+ <p style={{ color: 'var(--fd-muted-foreground)', marginBottom: '24px' }}>
103
103
  Create your first preview with:
104
104
  </p>
105
105
  <code style={{
106
106
  display: 'inline-block',
107
107
  padding: '12px 20px',
108
- backgroundColor: '#f4f4f5',
108
+ backgroundColor: 'var(--fd-muted)',
109
109
  borderRadius: '8px',
110
- fontFamily: 'monospace',
110
+ fontFamily: 'var(--fd-font-mono)',
111
111
  }}>
112
112
  prev create my-demo
113
113
  </code>
@@ -116,37 +116,33 @@ function PreviewsCatalog() {
116
116
  }
117
117
 
118
118
  return (
119
- <div style={{ padding: '20px' }}>
120
- <div style={{ marginBottom: '32px' }}>
119
+ <div className="previews-catalog">
120
+ <div style={{ marginBottom: '24px' }}>
121
121
  <h1 style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '8px' }}>
122
122
  Previews
123
123
  </h1>
124
- <p style={{ color: '#666' }}>
124
+ <p style={{ color: 'var(--fd-muted-foreground)', margin: 0 }}>
125
125
  {previews.length} component preview{previews.length !== 1 ? 's' : ''} available.
126
126
  Click any preview to open it.
127
127
  </p>
128
128
  </div>
129
129
 
130
- <div style={{
131
- display: 'grid',
132
- gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
133
- gap: '20px',
134
- }}>
130
+ <div className="previews-grid">
135
131
  {previews.map((preview: { name: string; route: string }) => (
136
132
  <PreviewCard key={preview.name} name={preview.name} />
137
133
  ))}
138
134
  </div>
139
135
 
140
136
  <div style={{
141
- marginTop: '40px',
142
- padding: '16px',
137
+ marginTop: '32px',
138
+ padding: '14px 16px',
143
139
  backgroundColor: 'var(--fd-muted)',
144
140
  border: '1px solid var(--fd-border)',
145
- borderRadius: '8px',
141
+ borderRadius: '10px',
146
142
  }}>
147
143
  <p style={{ margin: 0, fontSize: '14px', color: 'var(--fd-muted-foreground)' }}>
148
- <strong>Tip:</strong> Embed any preview in your MDX docs with{' '}
149
- <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px' }}>
144
+ <strong style={{ color: 'var(--fd-foreground)' }}>Tip:</strong> Embed any preview in your MDX docs with{' '}
145
+ <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px', fontFamily: 'var(--fd-font-mono)' }}>
150
146
  {'<Preview src="name" />'}
151
147
  </code>
152
148
  </p>
@@ -161,10 +157,23 @@ import type { PreviewConfig, PreviewMessage } from '../preview-runtime/types'
161
157
  function PreviewCard({ name }: { name: string }) {
162
158
  const iframeRef = React.useRef<HTMLIFrameElement>(null)
163
159
  const [isLoaded, setIsLoaded] = React.useState(false)
160
+ const [loadError, setLoadError] = React.useState(false)
164
161
 
165
162
  // In production, use pre-built static files; in dev, use WASM runtime
166
163
  const isDev = import.meta.env?.DEV ?? false
167
- const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `/_preview/${name}/`
164
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
165
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
166
+
167
+ // Timeout for loading - show placeholder if too slow
168
+ React.useEffect(() => {
169
+ const timeout = setTimeout(() => {
170
+ if (!isLoaded) {
171
+ setLoadError(true)
172
+ }
173
+ }, 5000) // 5 second timeout
174
+
175
+ return () => clearTimeout(timeout)
176
+ }, [isLoaded])
168
177
 
169
178
  // Set up WASM preview communication for thumbnail (dev mode only)
170
179
  React.useEffect(() => {
@@ -173,6 +182,7 @@ function PreviewCard({ name }: { name: string }) {
173
182
  const iframe = iframeRef.current
174
183
  if (iframe) {
175
184
  iframe.onload = () => setIsLoaded(true)
185
+ iframe.onerror = () => setLoadError(true)
176
186
  }
177
187
  return
178
188
  }
@@ -194,13 +204,17 @@ function PreviewCard({ name }: { name: string }) {
194
204
  iframe.contentWindow?.postMessage({ type: 'init', config } as PreviewMessage, '*')
195
205
  })
196
206
  .catch(() => {
197
- // Silently fail for thumbnails
207
+ setLoadError(true)
198
208
  })
199
209
  }
200
210
 
201
211
  if (msg.type === 'built') {
202
212
  setIsLoaded(true)
203
213
  }
214
+
215
+ if (msg.type === 'error') {
216
+ setLoadError(true)
217
+ }
204
218
  }
205
219
 
206
220
  window.addEventListener('message', handleMessage)
@@ -211,87 +225,38 @@ function PreviewCard({ name }: { name: string }) {
211
225
  }, [name, isDev])
212
226
 
213
227
  return (
214
- <Link
215
- to={`/previews/${name}`}
216
- style={{
217
- display: 'block',
218
- border: '1px solid var(--fd-border)',
219
- borderRadius: '12px',
220
- overflow: 'hidden',
221
- backgroundColor: 'var(--fd-background)',
222
- textDecoration: 'none',
223
- color: 'inherit',
224
- transition: 'box-shadow 0.2s, transform 0.2s',
225
- }}
226
- onMouseOver={(e) => {
227
- e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)'
228
- e.currentTarget.style.transform = 'translateY(-2px)'
229
- }}
230
- onMouseOut={(e) => {
231
- e.currentTarget.style.boxShadow = ''
232
- e.currentTarget.style.transform = ''
233
- }}
234
- >
228
+ <Link to={`/previews/${name}`} className="preview-card">
235
229
  {/* Thumbnail preview */}
236
- <div style={{
237
- height: '180px',
238
- overflow: 'hidden',
239
- position: 'relative',
240
- backgroundColor: 'var(--fd-muted)',
241
- pointerEvents: 'none',
242
- }}>
243
- {/* Loading spinner */}
244
- {!isLoaded && (
245
- <div style={{
246
- position: 'absolute',
247
- inset: 0,
248
- display: 'flex',
249
- alignItems: 'center',
250
- justifyContent: 'center',
251
- backgroundColor: 'var(--fd-muted)',
252
- zIndex: 1,
253
- }}>
254
- <div style={{
255
- width: '24px',
256
- height: '24px',
257
- border: '2px solid var(--fd-border)',
258
- borderTopColor: 'var(--fd-primary)',
259
- borderRadius: '50%',
260
- animation: 'spin 1s linear infinite',
261
- }} />
230
+ <div className="preview-card-thumbnail">
231
+ {/* Loading state */}
232
+ {!isLoaded && !loadError && (
233
+ <div className="preview-card-loading">
234
+ <div className="preview-card-spinner" />
235
+ </div>
236
+ )}
237
+ {/* Error/timeout placeholder */}
238
+ {loadError && (
239
+ <div className="preview-card-placeholder">
240
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
241
+ <rect x="3" y="3" width="18" height="18" rx="2" />
242
+ <path d="M9 9l6 6m0-6l-6 6" />
243
+ </svg>
244
+ <span>Preview</span>
262
245
  </div>
263
246
  )}
264
247
  <iframe
265
248
  ref={iframeRef}
266
249
  src={previewUrl}
267
- style={{
268
- border: 'none',
269
- transform: 'scale(0.5)',
270
- transformOrigin: 'top left',
271
- width: '200%',
272
- height: '200%',
273
- opacity: isLoaded ? 1 : 0,
274
- transition: 'opacity 0.3s',
275
- }}
250
+ className="preview-card-iframe"
251
+ style={{ opacity: isLoaded && !loadError ? 1 : 0 }}
276
252
  title={name}
253
+ loading="lazy"
277
254
  />
278
255
  </div>
279
256
  {/* Card footer */}
280
- <div style={{
281
- padding: '12px 16px',
282
- borderTop: '1px solid var(--fd-border)',
283
- backgroundColor: 'var(--fd-card)',
284
- }}>
285
- <h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>
286
- {name}
287
- </h3>
288
- <code style={{
289
- fontSize: '11px',
290
- color: 'var(--fd-muted-foreground)',
291
- fontFamily: 'monospace',
292
- }}>
293
- previews/{name}/
294
- </code>
257
+ <div className="preview-card-footer">
258
+ <h3 className="preview-card-title">{name}</h3>
259
+ <code className="preview-card-path">previews/{name}/</code>
295
260
  </div>
296
261
  </Link>
297
262
  )
@@ -408,8 +373,12 @@ const routeTree = rootRoute.addChildren([
408
373
  ...(indexRedirectRoute ? [indexRedirectRoute] : []),
409
374
  ...pageRoutes,
410
375
  ])
376
+ // Get base path for subpath deployments (e.g., GitHub Pages)
377
+ const basepath = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '') || '/'
378
+
411
379
  const router = createRouter({
412
380
  routeTree,
381
+ basepath,
413
382
  defaultNotFoundComponent: NotFoundPage,
414
383
  })
415
384
 
@@ -1,5 +1,79 @@
1
+ import React from 'react'
2
+ import { Link } from '@tanstack/react-router'
1
3
  import { Preview } from './Preview'
4
+ import { pages } from 'virtual:prev-pages'
5
+
6
+ // Get valid routes from pages
7
+ const validRoutes = new Set(pages.map((p: { route: string }) => p.route))
8
+
9
+ // Also add /previews routes
10
+ validRoutes.add('/previews')
11
+
12
+ // Check if a path is an internal link
13
+ function isInternalLink(href: string): boolean {
14
+ if (!href) return false
15
+ // External links start with http://, https://, mailto:, tel:, etc.
16
+ if (/^(https?:|mailto:|tel:|#)/.test(href)) return false
17
+ // Relative or absolute internal paths
18
+ return href.startsWith('/') || !href.includes(':')
19
+ }
20
+
21
+ // Check if an internal route exists
22
+ function routeExists(href: string): boolean {
23
+ if (!href) return true
24
+ // Remove hash and query string
25
+ const path = href.split(/[?#]/)[0]
26
+ // Check exact match or if it's a valid preview route
27
+ if (validRoutes.has(path)) return true
28
+ // Check if it starts with /previews/ (dynamic preview routes)
29
+ if (path.startsWith('/previews/')) return true
30
+ return false
31
+ }
32
+
33
+ // Custom link component that validates internal links and uses router
34
+ function MdxLink({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
35
+ const isInternal = isInternalLink(href || '')
36
+ const isDev = import.meta.env?.DEV ?? false
37
+
38
+ // For internal links, use TanStack Router's Link
39
+ if (isInternal && href) {
40
+ const exists = routeExists(href)
41
+
42
+ // In dev mode, show warning for dead links
43
+ if (isDev && !exists) {
44
+ return (
45
+ <span
46
+ className="dead-link"
47
+ title={`Dead link: "${href}" does not match any known route`}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <span className="dead-link-icon" aria-label="Dead link">⚠️</span>
52
+ </span>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <Link to={href} {...props}>
58
+ {children}
59
+ </Link>
60
+ )
61
+ }
62
+
63
+ // External links open in new tab
64
+ return (
65
+ <a
66
+ href={href}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ {...props}
70
+ >
71
+ {children}
72
+ </a>
73
+ )
74
+ }
2
75
 
3
76
  export const mdxComponents = {
4
77
  Preview,
78
+ a: MdxLink,
5
79
  }
@@ -567,3 +567,185 @@ body {
567
567
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
568
568
  }
569
569
  }
570
+
571
+ /* ===========================================
572
+ Previews Catalog - Mobile-First Grid
573
+ =========================================== */
574
+
575
+ .previews-catalog {
576
+ padding: 16px;
577
+ }
578
+
579
+ @media (min-width: 640px) {
580
+ .previews-catalog {
581
+ padding: 20px;
582
+ }
583
+ }
584
+
585
+ .previews-grid {
586
+ display: grid;
587
+ grid-template-columns: 1fr;
588
+ gap: 16px;
589
+ }
590
+
591
+ @media (min-width: 480px) {
592
+ .previews-grid {
593
+ grid-template-columns: repeat(2, 1fr);
594
+ }
595
+ }
596
+
597
+ @media (min-width: 768px) {
598
+ .previews-grid {
599
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
600
+ gap: 20px;
601
+ }
602
+ }
603
+
604
+ /* Preview card enhancements */
605
+ .previews-grid a {
606
+ display: block;
607
+ border: 1px solid var(--fd-border);
608
+ border-radius: 12px;
609
+ overflow: hidden;
610
+ background-color: var(--fd-background);
611
+ text-decoration: none;
612
+ color: inherit;
613
+ transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
614
+ }
615
+
616
+ .previews-grid a:hover {
617
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
618
+ transform: translateY(-2px);
619
+ border-color: var(--fd-primary);
620
+ }
621
+
622
+ .previews-grid a:active {
623
+ transform: translateY(0);
624
+ }
625
+
626
+ /* Preview Card Component Styles */
627
+ .preview-card-thumbnail {
628
+ height: 140px;
629
+ overflow: hidden;
630
+ position: relative;
631
+ background-color: var(--fd-muted);
632
+ pointer-events: none;
633
+ }
634
+
635
+ @media (min-width: 480px) {
636
+ .preview-card-thumbnail {
637
+ height: 160px;
638
+ }
639
+ }
640
+
641
+ @media (min-width: 768px) {
642
+ .preview-card-thumbnail {
643
+ height: 180px;
644
+ }
645
+ }
646
+
647
+ .preview-card-loading {
648
+ position: absolute;
649
+ inset: 0;
650
+ display: flex;
651
+ align-items: center;
652
+ justify-content: center;
653
+ background-color: var(--fd-muted);
654
+ z-index: 1;
655
+ }
656
+
657
+ .preview-card-spinner {
658
+ width: 24px;
659
+ height: 24px;
660
+ border: 2px solid var(--fd-border);
661
+ border-top-color: var(--fd-primary);
662
+ border-radius: 50%;
663
+ animation: spin 1s linear infinite;
664
+ }
665
+
666
+ .preview-card-placeholder {
667
+ position: absolute;
668
+ inset: 0;
669
+ display: flex;
670
+ flex-direction: column;
671
+ align-items: center;
672
+ justify-content: center;
673
+ gap: 8px;
674
+ background-color: var(--fd-muted);
675
+ color: var(--fd-muted-foreground);
676
+ z-index: 1;
677
+ }
678
+
679
+ .preview-card-placeholder span {
680
+ font-size: 12px;
681
+ font-weight: 500;
682
+ }
683
+
684
+ .preview-card-iframe {
685
+ border: none;
686
+ transform: scale(0.5);
687
+ transform-origin: top left;
688
+ width: 200%;
689
+ height: 200%;
690
+ transition: opacity 0.3s ease;
691
+ }
692
+
693
+ .preview-card-footer {
694
+ padding: 12px 14px;
695
+ border-top: 1px solid var(--fd-border);
696
+ background-color: var(--fd-card);
697
+ }
698
+
699
+ .preview-card-title {
700
+ font-size: 14px;
701
+ font-weight: 600;
702
+ margin: 0 0 2px 0;
703
+ color: var(--fd-foreground);
704
+ }
705
+
706
+ .preview-card-path {
707
+ font-size: 11px;
708
+ color: var(--fd-muted-foreground);
709
+ font-family: var(--fd-font-mono);
710
+ }
711
+
712
+ /* ===========================================
713
+ Dead Link Warning (Dev Mode Only)
714
+ =========================================== */
715
+
716
+ .dead-link {
717
+ color: #dc2626;
718
+ text-decoration: line-through;
719
+ text-decoration-color: #dc2626;
720
+ cursor: not-allowed;
721
+ position: relative;
722
+ }
723
+
724
+ .dead-link-icon {
725
+ font-size: 0.75em;
726
+ margin-left: 0.25em;
727
+ vertical-align: super;
728
+ }
729
+
730
+ /* Tooltip on hover */
731
+ .dead-link::after {
732
+ content: attr(title);
733
+ position: absolute;
734
+ bottom: 100%;
735
+ left: 50%;
736
+ transform: translateX(-50%);
737
+ padding: 6px 10px;
738
+ background: #1f2937;
739
+ color: #fff;
740
+ font-size: 12px;
741
+ border-radius: 6px;
742
+ white-space: nowrap;
743
+ opacity: 0;
744
+ pointer-events: none;
745
+ transition: opacity 0.15s ease;
746
+ z-index: 100;
747
+ }
748
+
749
+ .dead-link:hover::after {
750
+ opacity: 1;
751
+ }