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 +137 -81
- package/dist/vite/config.d.ts +1 -0
- package/dist/vite/start.d.ts +1 -0
- package/package.json +2 -1
- package/src/theme/Preview.tsx +4 -1
- package/src/theme/TOCPanel.css +34 -6
- package/src/theme/Toolbar.css +44 -0
- package/src/theme/Toolbar.tsx +13 -3
- package/src/theme/entry.tsx +62 -93
- package/src/theme/mdx-components.tsx +74 -0
- package/src/theme/styles.css +182 -0
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
338
|
+
let dir = path4.dirname(fileURLToPath(import.meta.url));
|
|
294
339
|
for (let i = 0;i < 10; i++) {
|
|
295
|
-
const pkgPath =
|
|
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 =
|
|
349
|
+
const parent = path4.dirname(dir);
|
|
305
350
|
if (parent === dir)
|
|
306
351
|
break;
|
|
307
352
|
dir = parent;
|
|
308
353
|
}
|
|
309
|
-
return
|
|
354
|
+
return path4.dirname(path4.dirname(fileURLToPath(import.meta.url)));
|
|
310
355
|
}
|
|
311
356
|
var cliRoot = findCliRoot();
|
|
312
|
-
var srcRoot =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 === "." ?
|
|
452
|
+
const name = dir === "." ? path5.basename(previewsDir) : dir;
|
|
408
453
|
return {
|
|
409
454
|
name,
|
|
410
455
|
route: `/_preview/${name}`,
|
|
411
|
-
htmlPath:
|
|
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(
|
|
422
|
-
const ext =
|
|
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
|
|
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 =
|
|
600
|
-
const targetDir =
|
|
601
|
-
const previewsDir =
|
|
602
|
-
const oldPreviewsDir =
|
|
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 =
|
|
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 =
|
|
668
|
+
const outputDir = path6.join(targetDir, preview.name);
|
|
624
669
|
mkdirSync(outputDir, { recursive: true });
|
|
625
|
-
writeFileSync2(
|
|
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
|
|
741
|
+
import path7 from "path";
|
|
697
742
|
import yaml from "js-yaml";
|
|
698
743
|
function findConfigFile(rootDir) {
|
|
699
|
-
const yamlPath =
|
|
700
|
-
const ymlPath =
|
|
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) ||
|
|
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 =
|
|
836
|
+
let dir = path8.dirname(fileURLToPath2(import.meta.url));
|
|
792
837
|
for (let i = 0;i < 10; i++) {
|
|
793
|
-
const pkgPath =
|
|
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 =
|
|
847
|
+
const parent = path8.dirname(dir);
|
|
803
848
|
if (parent === dir)
|
|
804
849
|
break;
|
|
805
850
|
dir = parent;
|
|
806
851
|
}
|
|
807
|
-
return
|
|
852
|
+
return path8.dirname(path8.dirname(fileURLToPath2(import.meta.url)));
|
|
808
853
|
}
|
|
809
854
|
function findNodeModules(cliRoot2) {
|
|
810
|
-
const localNodeModules =
|
|
811
|
-
if (existsSync5(
|
|
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 =
|
|
861
|
+
const parent = path8.dirname(dir);
|
|
817
862
|
if (parent === dir)
|
|
818
863
|
break;
|
|
819
|
-
if (
|
|
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 =
|
|
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 =
|
|
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 =
|
|
905
|
-
const resolved =
|
|
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 =
|
|
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 =
|
|
926
|
-
const previewDir =
|
|
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 = !
|
|
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 =
|
|
951
|
-
const htmlPath =
|
|
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":
|
|
979
|
-
"@prev/theme":
|
|
980
|
-
react:
|
|
981
|
-
"react-dom":
|
|
982
|
-
"@tanstack/react-router":
|
|
983
|
-
"@mdx-js/react":
|
|
984
|
-
mermaid:
|
|
985
|
-
dayjs:
|
|
986
|
-
"@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
|
-
|
|
1026
|
-
|
|
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:
|
|
1082
|
+
outDir: path8.join(rootDir, "dist"),
|
|
1036
1083
|
reportCompressedSize: false,
|
|
1037
1084
|
chunkSizeWarningLimit: 1e4,
|
|
1038
1085
|
rollupOptions: {
|
|
1039
1086
|
input: {
|
|
1040
|
-
main:
|
|
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
|
|
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 =
|
|
1115
|
-
const nodeModulesVite =
|
|
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 =
|
|
1269
|
+
let dir = path10.dirname(fileURLToPath3(import.meta.url));
|
|
1216
1270
|
for (let i = 0;i < 5; i++) {
|
|
1217
|
-
const pkgPath =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1349
|
-
const nodeModulesVite =
|
|
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 =
|
|
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 =
|
|
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(
|
|
1587
|
-
writeFileSync4(
|
|
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 });
|
package/dist/vite/config.d.ts
CHANGED
package/dist/vite/start.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/src/theme/Preview.tsx
CHANGED
|
@@ -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}` :
|
|
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
|
package/src/theme/TOCPanel.css
CHANGED
|
@@ -120,8 +120,20 @@
|
|
|
120
120
|
position: fixed;
|
|
121
121
|
inset: 0;
|
|
122
122
|
z-index: 9998;
|
|
123
|
-
background: rgba(0, 0, 0, 0.
|
|
124
|
-
backdrop-filter: blur(
|
|
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:
|
|
144
|
+
max-height: 70vh;
|
|
133
145
|
background: var(--fd-background);
|
|
134
|
-
border-radius:
|
|
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(
|
|
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) {
|
package/src/theme/Toolbar.css
CHANGED
|
@@ -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
|
}
|
package/src/theme/Toolbar.tsx
CHANGED
|
@@ -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
|
package/src/theme/entry.tsx
CHANGED
|
@@ -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
|
-
//
|
|
68
|
-
|
|
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}`]
|
|
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: '
|
|
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: '
|
|
108
|
+
backgroundColor: 'var(--fd-muted)',
|
|
109
109
|
borderRadius: '8px',
|
|
110
|
-
fontFamily: '
|
|
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
|
|
120
|
-
<div style={{ marginBottom: '
|
|
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: '
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{/*
|
|
244
|
-
{
|
|
245
|
-
<div
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
}
|
package/src/theme/styles.css
CHANGED
|
@@ -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
|
+
}
|