@gmickel/gno 1.30.6 → 1.31.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.
Files changed (32) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +2 -0
  3. package/assets/skill/mcp-reference.md +6 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.30.6.zip → gno-browser-clipper-v1.31.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +1 -1
  8. package/spec/mcp.md +62 -0
  9. package/spec/output-schemas/section-target-create-result.schema.json +20 -0
  10. package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
  11. package/spec/output-schemas/section-target.schema.json +118 -0
  12. package/spec/output-schemas/section.schema.json +113 -0
  13. package/src/core/section-parse.ts +187 -0
  14. package/src/core/section-target-link.ts +154 -0
  15. package/src/core/section-target-resolve.ts +351 -0
  16. package/src/core/section-target-transport.ts +519 -0
  17. package/src/core/section-target.ts +263 -0
  18. package/src/core/sections.ts +60 -115
  19. package/src/mcp/AGENTS.md +1 -0
  20. package/src/mcp/CLAUDE.md +1 -0
  21. package/src/mcp/http-egress.ts +1 -0
  22. package/src/mcp/tools/index.ts +19 -0
  23. package/src/mcp/tools/sections.ts +512 -0
  24. package/src/sdk/client.ts +71 -1
  25. package/src/sdk/index.ts +5 -0
  26. package/src/sdk/types.ts +29 -1
  27. package/src/serve/public/globals.built.css +1 -1
  28. package/src/serve/public/lib/section-links.ts +189 -0
  29. package/src/serve/public/pages/DocView.tsx +219 -36
  30. package/src/serve/routes/section-targets.ts +221 -0
  31. package/src/serve/server.ts +34 -0
  32. package/browser-extension/artifacts/gno-browser-clipper-v1.30.6.zip.sha256 +0 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Readable section deep links and optional citation-safe selectors.
3
+ *
4
+ * Copy-link stays human-readable (`#anchor` only). Citation links add a
5
+ * bounded, versioned `st` query param for conservative recovery.
6
+ *
7
+ * @module src/serve/public/lib/section-links
8
+ */
9
+
10
+ import {
11
+ createSectionTarget,
12
+ decodeSectionTargetLinkParam,
13
+ encodeSectionTargetLinkParam,
14
+ isNavigableSectionResolution,
15
+ resolveSectionTarget,
16
+ SECTION_TARGET_LINK_PARAM,
17
+ type SectionResolutionStatus,
18
+ type SectionTargetV1,
19
+ } from "../../../core/sections";
20
+ import { buildDocDeepLink, type DocumentDeepLinkTarget } from "./deep-links";
21
+
22
+ export interface SectionLinkTarget extends DocumentDeepLinkTarget {
23
+ /** Readable heading anchor (HTML id). */
24
+ anchor: string;
25
+ }
26
+
27
+ export type SectionLinkNoticeKind =
28
+ | "copied_link"
29
+ | "copied_citation"
30
+ | "citation_unavailable"
31
+ | "clipboard_unavailable"
32
+ | SectionResolutionStatus
33
+ | "invalid_citation";
34
+
35
+ /** Ephemeral, content-free status copy for outline/rail feedback. */
36
+ export const SECTION_LINK_NOTICE_COPY: Record<SectionLinkNoticeKind, string> = {
37
+ copied_link: "Copied section link",
38
+ copied_citation: "Copied citation link",
39
+ citation_unavailable: "Citation unavailable for this section",
40
+ clipboard_unavailable: "Could not copy link",
41
+ exact: "Exact section match",
42
+ recovered: "Section recovered",
43
+ ambiguous: "Ambiguous section link — not navigating",
44
+ stale: "Stale section link — not navigating",
45
+ missing: "Section link not found — not navigating",
46
+ invalid_citation: "Invalid section citation — not navigating",
47
+ };
48
+
49
+ /** Absolute readable section URL for human sharing (no durable selector). */
50
+ export function buildReadableSectionUrl(
51
+ origin: string,
52
+ target: SectionLinkTarget
53
+ ): string {
54
+ const path = `${buildDocDeepLink({
55
+ uri: target.uri,
56
+ view: target.view ?? "rendered",
57
+ })}#${target.anchor}`;
58
+ return `${origin}${path}`;
59
+ }
60
+
61
+ /**
62
+ * Absolute citation URL: readable `#anchor` plus bounded `st` selector.
63
+ * Returns null when a faithful bounded encoding cannot be produced.
64
+ */
65
+ export function buildCitationSectionUrl(
66
+ origin: string,
67
+ target: SectionLinkTarget,
68
+ sectionTarget: SectionTargetV1
69
+ ): string | null {
70
+ const encoded = encodeSectionTargetLinkParam(sectionTarget);
71
+ if (!encoded) {
72
+ return null;
73
+ }
74
+ const params = new URLSearchParams({ uri: target.uri });
75
+ params.set("view", target.view ?? "rendered");
76
+ params.set(SECTION_TARGET_LINK_PARAM, encoded);
77
+ return `${origin}/doc?${params.toString()}#${target.anchor}`;
78
+ }
79
+
80
+ /** Read the raw `st` query value from a location search string. */
81
+ export function readSectionTargetLinkParam(search: string): string | null {
82
+ const value = new URLSearchParams(search).get(SECTION_TARGET_LINK_PARAM);
83
+ return value && value.length > 0 ? value : null;
84
+ }
85
+
86
+ /** Strip the additive `st` param while preserving other query fields. */
87
+ export function stripSectionTargetLinkParam(search: string): string {
88
+ const params = new URLSearchParams(
89
+ search.startsWith("?") ? search.slice(1) : search
90
+ );
91
+ params.delete(SECTION_TARGET_LINK_PARAM);
92
+ const serialized = params.toString();
93
+ return serialized.length > 0 ? `?${serialized}` : "";
94
+ }
95
+
96
+ export interface ResolveSectionLinkInput {
97
+ content: string;
98
+ uri: string;
99
+ /** Raw `st` query value. */
100
+ encodedTarget: string | null;
101
+ /** Readable hash without leading `#`. */
102
+ hashAnchor: string;
103
+ }
104
+
105
+ export interface ResolveSectionLinkResult {
106
+ /** When true, hash-only scroll must not run. */
107
+ blockHashNavigation: boolean;
108
+ /** Anchor to scroll to when navigable. */
109
+ navigateAnchor: string | null;
110
+ notice: SectionLinkNoticeKind | null;
111
+ /** Drop `st` from the address bar after a successful recovery. */
112
+ cleanCitationParam: boolean;
113
+ }
114
+
115
+ /**
116
+ * Resolve an optional durable selector against current document content.
117
+ * Readable `#anchor`-only links keep legacy behavior (no block).
118
+ */
119
+ export async function resolveSectionLinkNavigation(
120
+ input: ResolveSectionLinkInput
121
+ ): Promise<ResolveSectionLinkResult> {
122
+ if (!input.encodedTarget) {
123
+ return {
124
+ blockHashNavigation: false,
125
+ navigateAnchor: input.hashAnchor || null,
126
+ notice: null,
127
+ cleanCitationParam: false,
128
+ };
129
+ }
130
+
131
+ const target = decodeSectionTargetLinkParam(input.encodedTarget);
132
+ if (!target) {
133
+ return {
134
+ blockHashNavigation: true,
135
+ navigateAnchor: null,
136
+ notice: "invalid_citation",
137
+ cleanCitationParam: false,
138
+ };
139
+ }
140
+
141
+ const resolution = await resolveSectionTarget({
142
+ content: input.content,
143
+ target,
144
+ uri: input.uri,
145
+ });
146
+
147
+ if (isNavigableSectionResolution(resolution)) {
148
+ return {
149
+ blockHashNavigation: false,
150
+ navigateAnchor: resolution.section.anchor,
151
+ notice: resolution.status,
152
+ cleanCitationParam: true,
153
+ };
154
+ }
155
+
156
+ return {
157
+ blockHashNavigation: true,
158
+ navigateAnchor: null,
159
+ notice: resolution.status,
160
+ cleanCitationParam: false,
161
+ };
162
+ }
163
+
164
+ /** Create a citation URL for an outline section using shared core create. */
165
+ export async function createCitationSectionUrl(input: {
166
+ origin: string;
167
+ uri: string;
168
+ content: string;
169
+ anchor: string;
170
+ view?: "rendered" | "source";
171
+ }): Promise<string | null> {
172
+ const target = await createSectionTarget({
173
+ content: input.content,
174
+ uri: input.uri,
175
+ anchor: input.anchor,
176
+ });
177
+ if (!target) {
178
+ return null;
179
+ }
180
+ return buildCitationSectionUrl(
181
+ input.origin,
182
+ {
183
+ uri: input.uri,
184
+ view: input.view ?? "rendered",
185
+ anchor: input.anchor,
186
+ },
187
+ target
188
+ );
189
+ }
@@ -13,6 +13,7 @@ import {
13
13
  LinkIcon,
14
14
  Loader2Icon,
15
15
  PencilIcon,
16
+ QuoteIcon,
16
17
  Share2Icon,
17
18
  SquareArrowOutUpRightIcon,
18
19
  TextIcon,
@@ -83,6 +84,15 @@ import {
83
84
  downloadPublishArtifactFile,
84
85
  type PublishExportResponse,
85
86
  } from "../lib/publish-export";
87
+ import {
88
+ buildReadableSectionUrl,
89
+ createCitationSectionUrl,
90
+ readSectionTargetLinkParam,
91
+ resolveSectionLinkNavigation,
92
+ SECTION_LINK_NOTICE_COPY,
93
+ stripSectionTargetLinkParam,
94
+ type SectionLinkNoticeKind,
95
+ } from "../lib/section-links";
86
96
  import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
87
97
 
88
98
  /** Lazy so pdfjs is never pulled for non-PDF documents. */
@@ -389,9 +399,13 @@ export default function DocView({ navigate }: PageProps) {
389
399
  const [activeSectionAnchor, setActiveSectionAnchor] = useState<string | null>(
390
400
  null
391
401
  );
402
+ const [sectionLinkNotice, setSectionLinkNotice] =
403
+ useState<SectionLinkNoticeKind | null>(null);
404
+ const [blockHashNavigation, setBlockHashNavigation] = useState(false);
392
405
 
393
406
  // Request sequencing - ignore stale responses on rapid navigation
394
407
  const requestIdRef = useRef(0);
408
+ const sectionResolveRequestRef = useRef(0);
395
409
  const latestDocEvent = useDocEvents();
396
410
 
397
411
  // App remounts page on route/query changes, so URI is stable per render.
@@ -404,6 +418,10 @@ export default function DocView({ navigate }: PageProps) {
404
418
  () => window.location.hash.replace(/^#/u, ""),
405
419
  []
406
420
  );
421
+ const encodedSectionTarget = useMemo(
422
+ () => readSectionTargetLinkParam(window.location.search),
423
+ []
424
+ );
407
425
  const highlightedLines = useMemo(() => {
408
426
  if (!currentTarget.lineStart) return [];
409
427
  const end = currentTarget.lineEnd ?? currentTarget.lineStart;
@@ -564,7 +582,78 @@ export default function DocView({ navigate }: PageProps) {
564
582
  }, []);
565
583
 
566
584
  useEffect(() => {
567
- if (!currentHash || showRawView || loading) {
585
+ if (!sectionLinkNotice) {
586
+ return;
587
+ }
588
+ const timer = window.setTimeout(() => setSectionLinkNotice(null), 3200);
589
+ return () => {
590
+ window.clearTimeout(timer);
591
+ };
592
+ }, [sectionLinkNotice]);
593
+
594
+ useEffect(() => {
595
+ if (!doc?.content || loading || !encodedSectionTarget) {
596
+ return;
597
+ }
598
+
599
+ const requestId = ++sectionResolveRequestRef.current;
600
+ const content = doc.content;
601
+ void resolveSectionLinkNavigation({
602
+ content,
603
+ uri: doc.uri,
604
+ encodedTarget: encodedSectionTarget,
605
+ hashAnchor: currentHash,
606
+ }).then((result) => {
607
+ if (requestId !== sectionResolveRequestRef.current) {
608
+ return;
609
+ }
610
+ setBlockHashNavigation(result.blockHashNavigation);
611
+ if (result.notice) {
612
+ setSectionLinkNotice(result.notice);
613
+ }
614
+ if (result.cleanCitationParam) {
615
+ const cleanedSearch = stripSectionTargetLinkParam(
616
+ window.location.search
617
+ );
618
+ const nextHash = result.navigateAnchor
619
+ ? `#${result.navigateAnchor}`
620
+ : window.location.hash;
621
+ window.history.replaceState(
622
+ {},
623
+ "",
624
+ `${window.location.pathname}${cleanedSearch}${nextHash}`
625
+ );
626
+ }
627
+ if (result.blockHashNavigation || !result.navigateAnchor) {
628
+ return;
629
+ }
630
+ if (showRawView) {
631
+ return;
632
+ }
633
+ requestAnimationFrame(() => {
634
+ document
635
+ .getElementById(result.navigateAnchor ?? "")
636
+ ?.scrollIntoView({ behavior: "smooth", block: "start" });
637
+ setActiveSectionAnchor(result.navigateAnchor);
638
+ });
639
+ });
640
+ }, [
641
+ currentHash,
642
+ doc?.content,
643
+ doc?.uri,
644
+ encodedSectionTarget,
645
+ loading,
646
+ showRawView,
647
+ ]);
648
+
649
+ useEffect(() => {
650
+ if (
651
+ blockHashNavigation ||
652
+ encodedSectionTarget ||
653
+ !currentHash ||
654
+ showRawView ||
655
+ loading
656
+ ) {
568
657
  return;
569
658
  }
570
659
 
@@ -574,7 +663,13 @@ export default function DocView({ navigate }: PageProps) {
574
663
  ?.scrollIntoView({ behavior: "smooth", block: "start" });
575
664
  setActiveSectionAnchor(currentHash);
576
665
  });
577
- }, [currentHash, loading, showRawView]);
666
+ }, [
667
+ blockHashNavigation,
668
+ currentHash,
669
+ encodedSectionTarget,
670
+ loading,
671
+ showRawView,
672
+ ]);
578
673
 
579
674
  const breadcrumbs = doc ? parseBreadcrumbs(doc.collection, doc.relPath) : [];
580
675
  const sections = useMemo(
@@ -582,6 +677,79 @@ export default function DocView({ navigate }: PageProps) {
582
677
  [parsedContent.body]
583
678
  );
584
679
 
680
+ const copyReadableSectionLink = useCallback(
681
+ (anchor: string) => {
682
+ if (!doc) {
683
+ return;
684
+ }
685
+ void navigator.clipboard
686
+ .writeText(
687
+ buildReadableSectionUrl(window.location.origin, {
688
+ uri: doc.uri,
689
+ view: "rendered",
690
+ anchor,
691
+ })
692
+ )
693
+ .then(() => {
694
+ setSectionLinkNotice("copied_link");
695
+ })
696
+ .catch(() => {
697
+ setSectionLinkNotice("clipboard_unavailable");
698
+ });
699
+ },
700
+ [doc]
701
+ );
702
+
703
+ const copyCitationSectionLink = useCallback(
704
+ async (anchor: string) => {
705
+ const content = doc?.content;
706
+ if (!doc || !content) {
707
+ return;
708
+ }
709
+ const citationUrl = await createCitationSectionUrl({
710
+ origin: window.location.origin,
711
+ uri: doc.uri,
712
+ content,
713
+ anchor,
714
+ view: "rendered",
715
+ });
716
+ if (!citationUrl) {
717
+ setSectionLinkNotice("citation_unavailable");
718
+ return;
719
+ }
720
+ try {
721
+ await navigator.clipboard.writeText(citationUrl);
722
+ setSectionLinkNotice("copied_citation");
723
+ } catch {
724
+ setSectionLinkNotice("clipboard_unavailable");
725
+ }
726
+ },
727
+ [doc]
728
+ );
729
+
730
+ const jumpToSection = useCallback(
731
+ (anchor: string) => {
732
+ setShowRawView(false);
733
+ setBlockHashNavigation(false);
734
+ requestAnimationFrame(() => {
735
+ document.getElementById(anchor)?.scrollIntoView({
736
+ behavior: "smooth",
737
+ block: "start",
738
+ });
739
+ window.history.replaceState(
740
+ {},
741
+ "",
742
+ `${buildDocDeepLink({
743
+ uri: doc?.uri ?? "",
744
+ view: "rendered",
745
+ })}#${anchor}`
746
+ );
747
+ setActiveSectionAnchor(anchor);
748
+ });
749
+ },
750
+ [doc?.uri]
751
+ );
752
+
585
753
  useEffect(() => {
586
754
  if (showRawView || sections.length === 0) {
587
755
  setActiveSectionAnchor(sections[0]?.anchor ?? null);
@@ -1170,8 +1338,19 @@ export default function DocView({ navigate }: PageProps) {
1170
1338
  <>
1171
1339
  <div className="mx-3 border-border/20 border-t" />
1172
1340
  <div className="px-3 py-3">
1173
- <div className="mb-2 font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
1174
- Outline
1341
+ <div className="mb-2 flex items-center justify-between gap-2">
1342
+ <div className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
1343
+ Outline
1344
+ </div>
1345
+ {sectionLinkNotice && (
1346
+ <div
1347
+ aria-live="polite"
1348
+ className="min-w-0 truncate font-mono text-[10px] text-muted-foreground/70"
1349
+ role="status"
1350
+ >
1351
+ {SECTION_LINK_NOTICE_COPY[sectionLinkNotice]}
1352
+ </div>
1353
+ )}
1175
1354
  </div>
1176
1355
  <div className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden">
1177
1356
  {sections.map((section) => (
@@ -1185,25 +1364,9 @@ export default function DocView({ navigate }: PageProps) {
1185
1364
  style={{ paddingLeft: `${section.level * 7}px` }}
1186
1365
  >
1187
1366
  <button
1188
- 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"
1367
+ 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-12 text-left text-xs transition-colors hover:bg-muted/20 hover:text-foreground"
1189
1368
  onClick={() => {
1190
- setShowRawView(false);
1191
- requestAnimationFrame(() => {
1192
- document
1193
- .getElementById(section.anchor)
1194
- ?.scrollIntoView({
1195
- behavior: "smooth",
1196
- block: "start",
1197
- });
1198
- window.history.replaceState(
1199
- {},
1200
- "",
1201
- `${buildDocDeepLink({
1202
- uri: doc?.uri ?? "",
1203
- view: "rendered",
1204
- })}#${section.anchor}`
1205
- );
1206
- });
1369
+ jumpToSection(section.anchor);
1207
1370
  }}
1208
1371
  type="button"
1209
1372
  >
@@ -1221,20 +1384,40 @@ export default function DocView({ navigate }: PageProps) {
1221
1384
  </TooltipContent>
1222
1385
  </Tooltip>
1223
1386
  </button>
1224
- <button
1225
- 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"
1226
- onClick={() => {
1227
- void navigator.clipboard.writeText(
1228
- `${window.location.origin}${buildDocDeepLink({
1229
- uri: doc?.uri ?? "",
1230
- view: "rendered",
1231
- })}#${section.anchor}`
1232
- );
1233
- }}
1234
- type="button"
1235
- >
1236
- <CopyIcon className="size-3" />
1237
- </button>
1387
+ <div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 transition-all focus-within:opacity-100 group-hover:opacity-100">
1388
+ <Tooltip>
1389
+ <TooltipTrigger asChild>
1390
+ <button
1391
+ aria-label={`Copy link to ${section.title}`}
1392
+ className="cursor-pointer rounded p-1 hover:bg-muted/20 hover:text-foreground"
1393
+ onClick={() => {
1394
+ copyReadableSectionLink(section.anchor);
1395
+ }}
1396
+ type="button"
1397
+ >
1398
+ <CopyIcon className="size-3" />
1399
+ </button>
1400
+ </TooltipTrigger>
1401
+ <TooltipContent side="left">Copy link</TooltipContent>
1402
+ </Tooltip>
1403
+ <Tooltip>
1404
+ <TooltipTrigger asChild>
1405
+ <button
1406
+ aria-label={`Copy local citation link to ${section.title}`}
1407
+ className="cursor-pointer rounded p-1 hover:bg-muted/20 hover:text-foreground"
1408
+ onClick={() => {
1409
+ void copyCitationSectionLink(section.anchor);
1410
+ }}
1411
+ type="button"
1412
+ >
1413
+ <QuoteIcon className="size-3" />
1414
+ </button>
1415
+ </TooltipTrigger>
1416
+ <TooltipContent side="left">
1417
+ Copy local citation link
1418
+ </TooltipContent>
1419
+ </Tooltip>
1420
+ </div>
1238
1421
  </div>
1239
1422
  ))}
1240
1423
  </div>