@gmickel/gno 0.40.1 → 0.40.2
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/package.json +1 -1
- package/src/serve/public/components/FrontmatterDisplay.tsx +4 -2
- package/src/serve/public/components/RelatedNotesSidebar.tsx +2 -2
- package/src/serve/public/components/editor/MarkdownPreview.tsx +57 -2
- package/src/serve/public/pages/DocView.tsx +46 -12
- package/src/serve/public/pages/DocumentEditor.tsx +1 -1
- package/src/serve/routes/api.ts +114 -0
- package/src/serve/server.ts +10 -0
package/package.json
CHANGED
|
@@ -303,8 +303,9 @@ const ValueDisplay: FC<ValueDisplayProps> = ({ keyName, value }) => {
|
|
|
303
303
|
<div className="flex flex-wrap gap-1.5">
|
|
304
304
|
{normalizedValues.map((item, i) => (
|
|
305
305
|
<Badge
|
|
306
|
-
className="rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary"
|
|
306
|
+
className="max-w-full overflow-hidden rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary whitespace-nowrap text-ellipsis"
|
|
307
307
|
key={`${item}-${i}`}
|
|
308
|
+
title={String(item)}
|
|
308
309
|
variant="outline"
|
|
309
310
|
>
|
|
310
311
|
{String(item)}
|
|
@@ -339,8 +340,9 @@ const ValueDisplay: FC<ValueDisplayProps> = ({ keyName, value }) => {
|
|
|
339
340
|
<div className="flex flex-wrap gap-1.5">
|
|
340
341
|
{normalizedValues.map((item, i) => (
|
|
341
342
|
<Badge
|
|
342
|
-
className="font-mono text-xs"
|
|
343
|
+
className="max-w-full overflow-hidden font-mono text-xs whitespace-nowrap text-ellipsis"
|
|
343
344
|
key={`${item}-${i}`}
|
|
345
|
+
title={String(item)}
|
|
344
346
|
variant="secondary"
|
|
345
347
|
>
|
|
346
348
|
{String(item)}
|
|
@@ -250,14 +250,14 @@ function RelatedNoteItem({
|
|
|
250
250
|
<Tooltip>
|
|
251
251
|
<TooltipTrigger asChild>
|
|
252
252
|
<div className="min-w-0 flex-1">
|
|
253
|
-
<span className="block break-
|
|
253
|
+
<span className="line-clamp-2 block break-all font-medium leading-tight text-foreground/90 group-hover:text-foreground">
|
|
254
254
|
{doc.title || "Untitled"}
|
|
255
255
|
</span>
|
|
256
256
|
<SimilarityBar score={doc.score} />
|
|
257
257
|
</div>
|
|
258
258
|
</TooltipTrigger>
|
|
259
259
|
<TooltipContent side="left" className="max-w-[300px]">
|
|
260
|
-
<p className="break-
|
|
260
|
+
<p className="break-all">{doc.title || "Untitled"}</p>
|
|
261
261
|
</TooltipContent>
|
|
262
262
|
</Tooltip>
|
|
263
263
|
</button>
|
|
@@ -41,9 +41,46 @@ export interface MarkdownPreviewProps {
|
|
|
41
41
|
targetAnchor?: string;
|
|
42
42
|
resolvedUri?: string;
|
|
43
43
|
}>;
|
|
44
|
+
/** Current document URI for resolving note-relative assets */
|
|
45
|
+
docUri?: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
|
|
49
|
+
const EXTERNAL_OR_APP_SCHEME_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
50
|
+
const ABSOLUTE_FILESYSTEM_PATH_REGEX =
|
|
51
|
+
/^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/;
|
|
52
|
+
|
|
53
|
+
function resolveMarkdownAssetSrc(
|
|
54
|
+
src: string | undefined,
|
|
55
|
+
docUri?: string
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (!src) {
|
|
58
|
+
return src;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const trimmed = src.trim();
|
|
62
|
+
if (trimmed.length === 0) {
|
|
63
|
+
return trimmed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (EXTERNAL_OR_APP_SCHEME_REGEX.test(trimmed)) {
|
|
67
|
+
return trimmed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ABSOLUTE_FILESYSTEM_PATH_REGEX.test(trimmed)) {
|
|
71
|
+
return `/api/doc-asset?path=${encodeURIComponent(trimmed)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (trimmed.startsWith("/")) {
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!docUri) {
|
|
79
|
+
return trimmed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `/api/doc-asset?uri=${encodeURIComponent(docUri)}&path=${encodeURIComponent(trimmed)}`;
|
|
83
|
+
}
|
|
47
84
|
|
|
48
85
|
function renderMarkdownWithWikiLinks(
|
|
49
86
|
content: string,
|
|
@@ -364,6 +401,7 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
|
|
|
364
401
|
alt,
|
|
365
402
|
className,
|
|
366
403
|
node: _node,
|
|
404
|
+
src,
|
|
367
405
|
...props
|
|
368
406
|
}) => (
|
|
369
407
|
<img
|
|
@@ -372,6 +410,7 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
|
|
|
372
410
|
"my-4 max-w-full rounded-lg border border-border/40",
|
|
373
411
|
className
|
|
374
412
|
)}
|
|
413
|
+
src={src}
|
|
375
414
|
{...props}
|
|
376
415
|
/>
|
|
377
416
|
);
|
|
@@ -381,7 +420,13 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
|
|
|
381
420
|
* Sanitizes HTML to prevent XSS attacks.
|
|
382
421
|
*/
|
|
383
422
|
export const MarkdownPreview = memo(
|
|
384
|
-
({
|
|
423
|
+
({
|
|
424
|
+
content,
|
|
425
|
+
className,
|
|
426
|
+
collection,
|
|
427
|
+
wikiLinks,
|
|
428
|
+
docUri,
|
|
429
|
+
}: MarkdownPreviewProps) => {
|
|
385
430
|
if (!content) {
|
|
386
431
|
return (
|
|
387
432
|
<div className={cn("text-muted-foreground italic", className)}>
|
|
@@ -416,7 +461,17 @@ export const MarkdownPreview = memo(
|
|
|
416
461
|
td: TableCell,
|
|
417
462
|
th: TableHeaderCell,
|
|
418
463
|
hr: Hr,
|
|
419
|
-
img:
|
|
464
|
+
img: ({
|
|
465
|
+
node,
|
|
466
|
+
src,
|
|
467
|
+
...props
|
|
468
|
+
}: ComponentProps<"img"> & { node?: unknown }) => (
|
|
469
|
+
<Image
|
|
470
|
+
{...props}
|
|
471
|
+
node={node}
|
|
472
|
+
src={resolveMarkdownAssetSrc(src, docUri)}
|
|
473
|
+
/>
|
|
474
|
+
),
|
|
420
475
|
};
|
|
421
476
|
|
|
422
477
|
return (
|
|
@@ -50,6 +50,11 @@ import {
|
|
|
50
50
|
} from "../components/ui/dialog";
|
|
51
51
|
import { Input } from "../components/ui/input";
|
|
52
52
|
import { Separator } from "../components/ui/separator";
|
|
53
|
+
import {
|
|
54
|
+
Tooltip,
|
|
55
|
+
TooltipContent,
|
|
56
|
+
TooltipTrigger,
|
|
57
|
+
} from "../components/ui/tooltip";
|
|
53
58
|
import { apiFetch } from "../hooks/use-api";
|
|
54
59
|
import { useDocEvents } from "../hooks/use-doc-events";
|
|
55
60
|
import {
|
|
@@ -904,7 +909,10 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
904
909
|
|
|
905
910
|
/** Left rail — metadata + outline */
|
|
906
911
|
const renderDocumentFactsRail = () => (
|
|
907
|
-
<nav
|
|
912
|
+
<nav
|
|
913
|
+
aria-label="Document facts"
|
|
914
|
+
className="w-full min-w-0 max-w-full space-y-0 overflow-x-hidden"
|
|
915
|
+
>
|
|
908
916
|
{/* Frontmatter + tags */}
|
|
909
917
|
{(hasFrontmatter || showStandaloneTags) && (
|
|
910
918
|
<>
|
|
@@ -1005,19 +1013,19 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1005
1013
|
<div className="mb-2 font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
|
|
1006
1014
|
Outline
|
|
1007
1015
|
</div>
|
|
1008
|
-
<div className="space-y-0.5">
|
|
1016
|
+
<div className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden">
|
|
1009
1017
|
{sections.map((section) => (
|
|
1010
1018
|
<div
|
|
1011
|
-
className={`
|
|
1019
|
+
className={`group relative w-full min-w-0 max-w-full overflow-hidden rounded px-1 py-0.5 ${
|
|
1012
1020
|
activeSectionAnchor === section.anchor
|
|
1013
1021
|
? "bg-primary/10 text-primary"
|
|
1014
1022
|
: "text-muted-foreground"
|
|
1015
1023
|
}`}
|
|
1016
1024
|
key={section.anchor}
|
|
1017
|
-
style={{ paddingLeft: `${section.level *
|
|
1025
|
+
style={{ paddingLeft: `${section.level * 7}px` }}
|
|
1018
1026
|
>
|
|
1019
1027
|
<button
|
|
1020
|
-
className="flex min-w-0
|
|
1028
|
+
className="flex w-full min-w-0 max-w-full cursor-pointer items-start gap-2 overflow-hidden rounded px-1 py-0.5 pr-7 text-left text-xs transition-colors hover:bg-muted/20 hover:text-foreground"
|
|
1021
1029
|
onClick={() => {
|
|
1022
1030
|
setShowRawView(false);
|
|
1023
1031
|
requestAnimationFrame(() => {
|
|
@@ -1040,10 +1048,21 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1040
1048
|
type="button"
|
|
1041
1049
|
>
|
|
1042
1050
|
<ChevronRightIcon className="size-3 shrink-0" />
|
|
1043
|
-
<
|
|
1051
|
+
<Tooltip>
|
|
1052
|
+
<TooltipTrigger asChild>
|
|
1053
|
+
<div className="min-w-0 max-w-full flex-1 overflow-hidden">
|
|
1054
|
+
<span className="line-clamp-2 block break-words leading-snug">
|
|
1055
|
+
{section.title}
|
|
1056
|
+
</span>
|
|
1057
|
+
</div>
|
|
1058
|
+
</TooltipTrigger>
|
|
1059
|
+
<TooltipContent side="right" className="max-w-[320px]">
|
|
1060
|
+
<p className="break-words">{section.title}</p>
|
|
1061
|
+
</TooltipContent>
|
|
1062
|
+
</Tooltip>
|
|
1044
1063
|
</button>
|
|
1045
1064
|
<button
|
|
1046
|
-
className="cursor-pointer rounded p-1 transition-
|
|
1065
|
+
className="absolute top-1 right-1 cursor-pointer rounded p-1 opacity-0 transition-all hover:bg-muted/20 hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
|
1047
1066
|
onClick={() => {
|
|
1048
1067
|
void navigator.clipboard.writeText(
|
|
1049
1068
|
`${window.location.origin}${buildDocDeepLink({
|
|
@@ -1394,9 +1413,17 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1394
1413
|
<div className="mx-auto flex max-w-[1800px] gap-5 px-6 xl:px-8">
|
|
1395
1414
|
{/* Left rail — metadata + outline */}
|
|
1396
1415
|
{doc && (
|
|
1397
|
-
<aside
|
|
1398
|
-
|
|
1399
|
-
|
|
1416
|
+
<aside
|
|
1417
|
+
className="hidden min-w-0 flex-none border-border/15 border-r pr-2 pt-2 pb-6 lg:block"
|
|
1418
|
+
style={{ width: 252, minWidth: 252, maxWidth: 252, flexBasis: 252 }}
|
|
1419
|
+
>
|
|
1420
|
+
<div
|
|
1421
|
+
className="sticky min-w-0 max-w-full overflow-x-hidden overflow-y-auto pr-1"
|
|
1422
|
+
style={{ top: 72, maxHeight: "calc(100vh - 5.5rem)" }}
|
|
1423
|
+
>
|
|
1424
|
+
<div className="min-w-0 max-w-full overflow-hidden">
|
|
1425
|
+
{renderDocumentFactsRail()}
|
|
1426
|
+
</div>
|
|
1400
1427
|
</div>
|
|
1401
1428
|
</aside>
|
|
1402
1429
|
)}
|
|
@@ -1520,6 +1547,7 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1520
1547
|
<MarkdownPreview
|
|
1521
1548
|
collection={doc.collection}
|
|
1522
1549
|
content={parsedContent.body}
|
|
1550
|
+
docUri={doc.uri}
|
|
1523
1551
|
wikiLinks={resolvedWikiLinks}
|
|
1524
1552
|
/>
|
|
1525
1553
|
</div>
|
|
@@ -1562,8 +1590,14 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1562
1590
|
|
|
1563
1591
|
{/* Right rail — properties/path + relationships */}
|
|
1564
1592
|
{doc && (
|
|
1565
|
-
<aside
|
|
1566
|
-
|
|
1593
|
+
<aside
|
|
1594
|
+
className="hidden min-w-0 flex-none overflow-hidden border-border/15 border-l pl-2 pt-2 pb-6 lg:block"
|
|
1595
|
+
style={{ width: 250, minWidth: 250, maxWidth: 250, flexBasis: 250 }}
|
|
1596
|
+
>
|
|
1597
|
+
<div
|
|
1598
|
+
className="sticky min-w-0 space-y-1 overflow-y-auto overflow-x-hidden pr-1"
|
|
1599
|
+
style={{ top: 72, maxHeight: "calc(100vh - 5.5rem)" }}
|
|
1600
|
+
>
|
|
1567
1601
|
{renderPropertiesPathRail()}
|
|
1568
1602
|
<BacklinksPanel
|
|
1569
1603
|
docId={doc.docid}
|
|
@@ -1138,7 +1138,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1138
1138
|
ref={previewRef}
|
|
1139
1139
|
>
|
|
1140
1140
|
<div className="mx-auto max-w-3xl">
|
|
1141
|
-
<MarkdownPreview content={parsedContent.body} />
|
|
1141
|
+
<MarkdownPreview content={parsedContent.body} docUri={doc?.uri} />
|
|
1142
1142
|
</div>
|
|
1143
1143
|
</div>
|
|
1144
1144
|
)}
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -430,6 +430,24 @@ async function resolveAbsoluteDocPath(
|
|
|
430
430
|
};
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
function isAbsoluteFilesystemPath(pathValue: string): boolean {
|
|
434
|
+
return /^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/.test(
|
|
435
|
+
pathValue
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function isPathWithinRoot(
|
|
440
|
+
root: string,
|
|
441
|
+
candidate: string
|
|
442
|
+
): Promise<boolean> {
|
|
443
|
+
const nodePath = await import("node:path"); // no bun equivalent
|
|
444
|
+
const relative = nodePath.relative(root, candidate);
|
|
445
|
+
return (
|
|
446
|
+
relative === "" ||
|
|
447
|
+
(!relative.startsWith("..") && !nodePath.isAbsolute(relative))
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
433
451
|
async function listCollectionRelPaths(
|
|
434
452
|
store: Pick<SqliteAdapter, "listDocuments">,
|
|
435
453
|
collection: string
|
|
@@ -1445,6 +1463,98 @@ export async function handleDoc(
|
|
|
1445
1463
|
});
|
|
1446
1464
|
}
|
|
1447
1465
|
|
|
1466
|
+
/**
|
|
1467
|
+
* GET /api/doc-asset
|
|
1468
|
+
* Query params:
|
|
1469
|
+
* - path (required): relative to current doc, or absolute filesystem path
|
|
1470
|
+
* - uri (required for relative paths): current document uri
|
|
1471
|
+
*/
|
|
1472
|
+
export async function handleDocAsset(
|
|
1473
|
+
store: SqliteAdapter,
|
|
1474
|
+
config: Config,
|
|
1475
|
+
url: URL
|
|
1476
|
+
): Promise<Response> {
|
|
1477
|
+
const assetPath = url.searchParams.get("path")?.trim();
|
|
1478
|
+
if (!assetPath) {
|
|
1479
|
+
return errorResponse("VALIDATION", "Missing path parameter");
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
let resolvedPath: string | null = null;
|
|
1483
|
+
|
|
1484
|
+
if (isAbsoluteFilesystemPath(assetPath)) {
|
|
1485
|
+
for (const collection of config.collections) {
|
|
1486
|
+
if (await isPathWithinRoot(collection.path, assetPath)) {
|
|
1487
|
+
resolvedPath = assetPath;
|
|
1488
|
+
break;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (!resolvedPath) {
|
|
1493
|
+
return errorResponse(
|
|
1494
|
+
"FORBIDDEN",
|
|
1495
|
+
"Absolute asset path is outside configured collections",
|
|
1496
|
+
403
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
} else {
|
|
1500
|
+
const uri = url.searchParams.get("uri");
|
|
1501
|
+
if (!uri) {
|
|
1502
|
+
return errorResponse(
|
|
1503
|
+
"VALIDATION",
|
|
1504
|
+
"uri is required for relative asset paths"
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const docResult = await store.getDocumentByUri(uri);
|
|
1509
|
+
if (!docResult.ok) {
|
|
1510
|
+
return errorResponse("RUNTIME", docResult.error.message, 500);
|
|
1511
|
+
}
|
|
1512
|
+
if (!docResult.value) {
|
|
1513
|
+
return errorResponse("NOT_FOUND", "Document not found", 404);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const resolvedDoc = await resolveAbsoluteDocPath(
|
|
1517
|
+
config.collections,
|
|
1518
|
+
docResult.value
|
|
1519
|
+
);
|
|
1520
|
+
if (!resolvedDoc) {
|
|
1521
|
+
return errorResponse(
|
|
1522
|
+
"NOT_FOUND",
|
|
1523
|
+
"Document path could not be resolved",
|
|
1524
|
+
404
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const nodePath = await import("node:path"); // no bun equivalent
|
|
1529
|
+
const candidate = nodePath.resolve(
|
|
1530
|
+
nodePath.dirname(resolvedDoc.fullPath),
|
|
1531
|
+
assetPath
|
|
1532
|
+
);
|
|
1533
|
+
|
|
1534
|
+
if (!(await isPathWithinRoot(resolvedDoc.collection.path, candidate))) {
|
|
1535
|
+
return errorResponse(
|
|
1536
|
+
"FORBIDDEN",
|
|
1537
|
+
"Asset path escapes collection root",
|
|
1538
|
+
403
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
resolvedPath = candidate;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const file = Bun.file(resolvedPath);
|
|
1546
|
+
if (!(await file.exists())) {
|
|
1547
|
+
return errorResponse("NOT_FOUND", "Asset not found", 404);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
return new Response(file, {
|
|
1551
|
+
headers: {
|
|
1552
|
+
"Cache-Control": "no-store",
|
|
1553
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
1554
|
+
},
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1448
1558
|
/**
|
|
1449
1559
|
* GET /api/tags
|
|
1450
1560
|
* Query params: collection, prefix
|
|
@@ -3796,6 +3906,10 @@ export async function routeApi(
|
|
|
3796
3906
|
return handleDoc(store, config, url);
|
|
3797
3907
|
}
|
|
3798
3908
|
|
|
3909
|
+
if (path === "/api/doc-asset") {
|
|
3910
|
+
return handleDocAsset(store, config, url);
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3799
3913
|
if (path === "/api/search" && req.method === "POST") {
|
|
3800
3914
|
return handleSearch(store, req);
|
|
3801
3915
|
}
|
package/src/serve/server.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
handleDeactivateDoc,
|
|
28
28
|
handleDeleteCollection,
|
|
29
29
|
handleDoc,
|
|
30
|
+
handleDocAsset,
|
|
30
31
|
handleDocSections,
|
|
31
32
|
handleDocsAutocomplete,
|
|
32
33
|
handleDocs,
|
|
@@ -423,6 +424,15 @@ export async function startServer(
|
|
|
423
424
|
);
|
|
424
425
|
},
|
|
425
426
|
},
|
|
427
|
+
"/api/doc-asset": {
|
|
428
|
+
GET: async (req: Request) => {
|
|
429
|
+
const url = new URL(req.url);
|
|
430
|
+
return withSecurityHeaders(
|
|
431
|
+
await handleDocAsset(store, ctxHolder.config, url),
|
|
432
|
+
isDev
|
|
433
|
+
);
|
|
434
|
+
},
|
|
435
|
+
},
|
|
426
436
|
"/api/events": {
|
|
427
437
|
GET: () =>
|
|
428
438
|
withSecurityHeaders(
|