@zeldrisho/pi-web-fetch 0.9.1 → 0.9.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.2] - 2026-09-23
11
+
12
+ ### Fixed
13
+
14
+ - Resolve GitHub Markdown heading fragments and source line, line-range, and column anchors in raw files
15
+
10
16
  ## [0.9.1] - 2026-09-22
11
17
 
12
18
  ### Changed
@@ -158,7 +164,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
158
164
 
159
165
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
160
166
 
161
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.1...HEAD
167
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.2...HEAD
168
+ [0.9.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.1...pi-web-fetch-v0.9.2
162
169
  [0.9.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.0...pi-web-fetch-v0.9.1
163
170
  [0.9.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.2...pi-web-fetch-v0.9.0
164
171
  [0.8.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...pi-web-fetch-v0.8.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
package/src/fetch.ts CHANGED
@@ -78,6 +78,87 @@ export function detectAppShell(raw: string, markdown: string): boolean {
78
78
 
79
79
  const REQUEST_TIMEOUT_MS = 20_000;
80
80
 
81
+ /** Indexes GitHub-style Markdown heading slugs without treating fenced examples as headings. */
82
+ function markdownFragmentOffsets(markdown: string) {
83
+ const offsets: Record<string, number> = {};
84
+ const duplicates = new Map<string, number>();
85
+ const lines = markdown.split("\n");
86
+ let offset = 0;
87
+ let fence: { marker: string; length: number } | undefined;
88
+
89
+ const addHeading = (text: string, headingOffset: number) => {
90
+ let withoutHtml = "";
91
+
92
+ for (let index = 0; index < text.length; index += 1) {
93
+ if (text[index] !== "<") {
94
+ withoutHtml += text[index];
95
+ continue;
96
+ }
97
+
98
+ const tagEnd = text.indexOf(">", index + 1);
99
+
100
+ if (tagEnd < 0) {
101
+ withoutHtml += text.slice(index + 1);
102
+ break;
103
+ }
104
+
105
+ index = tagEnd;
106
+ }
107
+
108
+ const base = withoutHtml
109
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
110
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
111
+ .replace(/[`*_~]/g, "")
112
+ .normalize("NFKD")
113
+ .replace(/[\u0300-\u036f]/g, "")
114
+ .toLowerCase()
115
+ .replace(/[^\p{Letter}\p{Number} _-]/gu, "")
116
+ .trim()
117
+ .replace(/[ _]+/g, "-");
118
+
119
+ if (!base) return;
120
+
121
+ const duplicate = duplicates.get(base) ?? 0;
122
+ duplicates.set(base, duplicate + 1);
123
+ offsets[duplicate ? `${base}-${duplicate}` : base] = headingOffset;
124
+ };
125
+
126
+ for (let index = 0; index < lines.length; index += 1) {
127
+ const line = lines[index];
128
+ const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
129
+
130
+ if (fenceMatch) {
131
+ const marker = fenceMatch[1][0];
132
+
133
+ if (!fence) fence = { marker, length: fenceMatch[1].length };
134
+ else if (fence.marker === marker && fenceMatch[1].length >= fence.length) fence = undefined;
135
+ } else if (!fence) {
136
+ const atxHeading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
137
+ const setextHeading = /^\s{0,3}(=+|-+)\s*$/.test(lines[index + 1] ?? "");
138
+
139
+ if (atxHeading) addHeading(atxHeading[1], offset);
140
+ else if (setextHeading) addHeading(line.trim(), offset);
141
+ }
142
+
143
+ offset += line.length + 1;
144
+ }
145
+
146
+ return offsets;
147
+ }
148
+
149
+ /** Indexes GitHub line anchors such as `#L78` in plain-text source files. */
150
+ function lineFragmentOffsets(text: string) {
151
+ const offsets: Record<string, number> = {};
152
+ let offset = 0;
153
+
154
+ for (const [index, line] of text.split("\n").entries()) {
155
+ offsets[`L${index + 1}`] = offset;
156
+ offset += line.length + 1;
157
+ }
158
+
159
+ return offsets;
160
+ }
161
+
81
162
  /** Agent-discovery hints advertised in an HTTP `Link:` header. */
82
163
  export interface LinkHeaderAgentHints {
83
164
  describedBy?: string;
@@ -292,7 +373,19 @@ async function documentFromResponse(
292
373
  } catch {
293
374
  markdown = raw;
294
375
  }
295
- } else markdown = raw.trim();
376
+ } else {
377
+ markdown = raw;
378
+
379
+ const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
380
+ contentTypeHeader + target.url.pathname,
381
+ );
382
+
383
+ if (contentType.startsWith("text/") || markdownContent) {
384
+ fragmentOffsets = lineFragmentOffsets(markdown);
385
+
386
+ if (markdownContent) Object.assign(fragmentOffsets, markdownFragmentOffsets(markdown));
387
+ }
388
+ }
296
389
 
297
390
  const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
298
391
  const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
package/src/service.ts CHANGED
@@ -568,8 +568,25 @@ function urlWithoutFragment(rawUrl: string): string {
568
568
  interface ResolvedFragment {
569
569
  fragment?: string;
570
570
  offset?: number;
571
+ endOffset?: number;
571
572
  }
572
573
 
574
+ /** Converts a one-based source line and column into a Markdown character offset. */
575
+ function sourceOffset(markdown: string, lineNumber: number, columnNumber = 1): number | undefined {
576
+ if (lineNumber < 1 || columnNumber < 1) return undefined;
577
+
578
+ const lines = markdown.split("\n");
579
+
580
+ if (lineNumber > lines.length) return undefined;
581
+
582
+ let offset = 0;
583
+
584
+ for (let index = 0; index < lineNumber - 1; index += 1) offset += lines[index].length + 1;
585
+
586
+ return offset + Math.min(columnNumber - 1, lines[lineNumber - 1].length);
587
+ }
588
+
589
+ /** Resolves heading and source-location URL fragments against a fetched document. */
573
590
  function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): ResolvedFragment {
574
591
  let fragment: string | undefined;
575
592
 
@@ -582,6 +599,36 @@ function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): Reso
582
599
  }
583
600
 
584
601
  if (!fragment) return {};
602
+
603
+ const lineAnchor = /^L(\d+)(?:C(\d+))?(?:-L?(\d+)(?:C(\d+))?)?$/i.exec(fragment);
604
+
605
+ if (lineAnchor) {
606
+ const [, firstLine, firstColumn, lastLine, lastColumn] = lineAnchor;
607
+ const offset = sourceOffset(document.markdown, Number(firstLine), Number(firstColumn ?? 1));
608
+
609
+ if (offset === undefined) return { fragment };
610
+
611
+ if (lastLine) {
612
+ const endLine = Number(lastLine);
613
+ const endColumn = lastColumn === undefined ? undefined : Number(lastColumn);
614
+ const lines = document.markdown.split("\n");
615
+ const line = lines[endLine - 1];
616
+
617
+ if (line === undefined) return { fragment };
618
+
619
+ const endOffset =
620
+ endColumn === undefined
621
+ ? sourceOffset(document.markdown, endLine, line.length + 1)
622
+ : sourceOffset(document.markdown, endLine, endColumn + 1);
623
+
624
+ return endOffset !== undefined && endOffset >= offset
625
+ ? { fragment, offset, endOffset }
626
+ : { fragment };
627
+ }
628
+
629
+ return { fragment, offset };
630
+ }
631
+
585
632
  const offsets = document.fragmentOffsets ?? {};
586
633
 
587
634
  return { fragment, offset: offsets[fragment] ?? offsets[fragment.toLowerCase()] };
@@ -688,19 +735,31 @@ export async function executeWebFetch(
688
735
  const fragmentMatched = resolvedFragment.fragment !== undefined && fragmentOffset !== undefined;
689
736
  const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
690
737
 
738
+ const hasFragmentRange =
739
+ explicitOffset === undefined &&
740
+ fragmentOffset !== undefined &&
741
+ resolvedFragment.endOffset !== undefined;
742
+
691
743
  const fragmentDocument =
692
744
  explicitOffset === undefined && fragmentOffset !== undefined
693
- ? { ...document, markdown: document.markdown.slice(fragmentOffset) }
745
+ ? {
746
+ ...document,
747
+ markdown: hasFragmentRange
748
+ ? document.markdown.slice(fragmentOffset, resolvedFragment.endOffset)
749
+ : document.markdown.slice(fragmentOffset),
750
+ }
694
751
  : document;
695
752
 
696
753
  const focused =
697
754
  params.query === undefined ? undefined : focusMarkdown(fragmentDocument.markdown, params.query);
698
755
 
699
- const outputDocument = focused ? { ...fragmentDocument, markdown: focused.markdown } : document;
756
+ const outputDocument = focused
757
+ ? { ...fragmentDocument, markdown: focused.markdown }
758
+ : fragmentDocument;
700
759
 
701
760
  const resultOffset = focused
702
761
  ? (explicitOffset ?? 0)
703
- : (explicitOffset ?? fragmentOffset ?? startingOffset);
762
+ : (explicitOffset ?? (fragmentMatched ? 0 : startingOffset));
704
763
 
705
764
  const result = sliceCompleteDocument(outputDocument, resultOffset, maxCharacters);
706
765