@tikoci/rosetta 0.10.0 → 0.11.0-alpha.87

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/src/restraml.ts CHANGED
@@ -2,14 +2,16 @@
2
2
  * restraml.ts — Shared helpers for fetching data from tikoci/restraml.
3
3
  *
4
4
  * restraml publishes inspect.json files to GitHub Pages. Version discovery
5
- * uses the GitHub API (1 call), but all inspect.json fetches go through
6
- * GitHub Pages (no rate limit).
5
+ * uses the authenticated GitHub API when a token is available (1 call), but
6
+ * all inspect.json fetches go through GitHub Pages (no rate limit).
7
7
  */
8
8
 
9
+ import { fetchGitHub, githubApiHeaders } from "./github.ts";
10
+
9
11
  /** GitHub Pages base URL — inspect.json files served here (no rate limit) */
10
12
  export const RESTRAML_PAGES_URL = "https://tikoci.github.io/restraml";
11
13
 
12
- /** GitHub API endpoint for version directory listing (60 req/hr unauthenticated) */
14
+ /** GitHub API endpoint for version directory listing. */
13
15
  const RESTRAML_API_CONTENTS_URL = "https://api.github.com/repos/tikoci/restraml/contents/docs";
14
16
 
15
17
  export function isHttpUrl(value: string): boolean {
@@ -30,8 +32,8 @@ interface GitHubContentEntry {
30
32
  * Uses 1 GitHub API call to list the docs/ directory, returns version strings.
31
33
  */
32
34
  export async function discoverRemoteVersions(): Promise<string[]> {
33
- const response = await fetch(RESTRAML_API_CONTENTS_URL, {
34
- headers: { Accept: "application/vnd.github.v3+json" },
35
+ const response = await fetchGitHub(RESTRAML_API_CONTENTS_URL, {
36
+ headers: githubApiHeaders(),
35
37
  });
36
38
 
37
39
  if (!response.ok) {
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { checkCollisions, deriveRosettaId, parseSitemapLocs, rosettaIdToUrl } from "./rosetta-id.ts";
3
+
4
+ describe("deriveRosettaId", () => {
5
+ test("strips scheme, host, and leading/trailing slashes", () => {
6
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/ip/address/")).toBe("docs/ip/address");
7
+ });
8
+
9
+ test("lowercases mixed-case path segments", () => {
10
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/CLI-Reference/Routing/BGP")).toBe(
11
+ "docs/cli-reference/routing/bgp",
12
+ );
13
+ });
14
+
15
+ test("passes through a bare path (no scheme/host)", () => {
16
+ expect(deriveRosettaId("/docs/ip/dhcp-server")).toBe("docs/ip/dhcp-server");
17
+ });
18
+
19
+ test("strips an optional /docs/next/ version prefix", () => {
20
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/next/ip/address")).toBe("docs/ip/address");
21
+ });
22
+
23
+ test("strips an optional /docs/<semver>/ version prefix", () => {
24
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/7.22/ip/address")).toBe("docs/ip/address");
25
+ });
26
+
27
+ test("does not strip a real path segment that merely looks numeric-ish but isn't a version", () => {
28
+ // "next" and semver-like segments only get stripped directly after /docs/ — elsewhere they're just path.
29
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/ip/next")).toBe("docs/ip/next");
30
+ });
31
+
32
+ test("strips a .md/.mdx extension so a page and a link resolving to it agree", () => {
33
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/network-management/dhcp")).toBe(
34
+ deriveRosettaId("https://manual.mikrotik.com/docs/network-management/dhcp.md"),
35
+ );
36
+ expect(deriveRosettaId("https://manual.mikrotik.com/docs/network-management/dhcp.md#dhcp-server")).toBe(
37
+ "docs/network-management/dhcp",
38
+ );
39
+ });
40
+
41
+ test("handles non-/docs sections unchanged", () => {
42
+ expect(deriveRosettaId("https://manual.mikrotik.com/hardware/rb5009")).toBe("hardware/rb5009");
43
+ expect(deriveRosettaId("https://manual.mikrotik.com/changelog/changelog-2026-05-25")).toBe(
44
+ "changelog/changelog-2026-05-25",
45
+ );
46
+ });
47
+ });
48
+
49
+ describe("rosettaIdToUrl", () => {
50
+ test("round-trips with deriveRosettaId for a canonical URL", () => {
51
+ const url = "https://manual.mikrotik.com/docs/network-management/dhcp";
52
+ expect(rosettaIdToUrl(deriveRosettaId(url))).toBe(url);
53
+ });
54
+ });
55
+
56
+ describe("checkCollisions", () => {
57
+ test("reports zero collisions for distinct paths", () => {
58
+ const report = checkCollisions([
59
+ "https://manual.mikrotik.com/docs/ip/address",
60
+ "https://manual.mikrotik.com/docs/ip/route",
61
+ ]);
62
+ expect(report.collisions.size).toBe(0);
63
+ expect(report.uniqueIds).toBe(2);
64
+ });
65
+
66
+ test("detects a real collision (case-only difference)", () => {
67
+ const report = checkCollisions([
68
+ "https://manual.mikrotik.com/docs/IP/Address",
69
+ "https://manual.mikrotik.com/docs/ip/address",
70
+ ]);
71
+ expect(report.collisions.size).toBe(1);
72
+ expect(report.collisions.get("docs/ip/address")?.length).toBe(2);
73
+ });
74
+
75
+ test("detects a collision introduced by version-prefix stripping", () => {
76
+ const report = checkCollisions([
77
+ "https://manual.mikrotik.com/docs/ip/address",
78
+ "https://manual.mikrotik.com/docs/next/ip/address",
79
+ ]);
80
+ expect(report.collisions.size).toBe(1);
81
+ });
82
+ });
83
+
84
+ describe("parseSitemapLocs", () => {
85
+ test("extracts <loc> entries from sitemap XML", () => {
86
+ const xml = `<?xml version="1.0"?><urlset>
87
+ <url><loc>https://manual.mikrotik.com/docs/ip/address</loc></url>
88
+ <url><loc>https://manual.mikrotik.com/docs/ip/route</loc></url>
89
+ </urlset>`;
90
+ expect(parseSitemapLocs(xml)).toEqual([
91
+ "https://manual.mikrotik.com/docs/ip/address",
92
+ "https://manual.mikrotik.com/docs/ip/route",
93
+ ]);
94
+ });
95
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * rosetta-id.ts — URL-path-derived stable identifiers for Docusaurus-sourced content.
3
+ *
4
+ * Promoted from the T-0034 spike (src/spike-docusaurus-rosetta-id.ts, now removed) after
5
+ * its H7 Option-2 shape (separate `rosetta_id TEXT UNIQUE` column, existing INTEGER PKs
6
+ * untouched) was validated against a live 20-page prototype and confirmed in
7
+ * briefings/B-0012-docusaurus-manual-migration.md, "H7 — Identity / rosetta-id design".
8
+ *
9
+ * Used by extract-docusaurus.ts to mint `pages.rosetta_id` and to resolve relative
10
+ * Markdown links inside property descriptions to a canonical id/URL.
11
+ */
12
+
13
+ const SITEMAP_URL = "https://manual.mikrotik.com/sitemap.xml";
14
+
15
+ /**
16
+ * URL path -> rosetta-id. Pure function so it's independently testable.
17
+ *
18
+ * Strips scheme+host, leading/trailing slashes, and lowercases. Also strips an
19
+ * optional Docusaurus version-prefix segment (/docs/next/... or /docs/<semver>/...)
20
+ * if present, even though manual.mikrotik.com does not version today — B-0012 H7
21
+ * flags this as cheap to build in now, expensive to retrofit later.
22
+ */
23
+ export function deriveRosettaId(urlOrPath: string): string {
24
+ let path: string;
25
+ try {
26
+ path = new URL(urlOrPath).pathname;
27
+ } catch {
28
+ path = urlOrPath;
29
+ }
30
+
31
+ path = path.replace(/^\/+|\/+$/g, "").toLowerCase();
32
+ // Markdown-source links (and the docusaurus-plugin-llms .md endpoint) point at the
33
+ // same page's .md/.mdx sibling URL — strip it so a page's own canonical id and an
34
+ // internal link resolving to it collapse to the same rosetta-id (found empirically
35
+ // during T-0034: without this, ./dhcp.md#anchor resolved to
36
+ // "docs/network-management/dhcp.md" while the page itself was "docs/.../dhcp").
37
+ path = path.replace(/\.mdx?$/, "");
38
+
39
+ const segments = path.split("/");
40
+ if (segments[0] === "docs" && segments.length > 1) {
41
+ const second = segments[1];
42
+ const isVersionPrefix = second === "next" || /^v?\d+(\.\d+)*(-[a-z0-9.]+)?$/.test(second);
43
+ if (isVersionPrefix) {
44
+ segments.splice(1, 1);
45
+ }
46
+ }
47
+
48
+ return segments.join("/");
49
+ }
50
+
51
+ /** Build a live manual.mikrotik.com URL from a rosetta-id (inverse of deriveRosettaId, minus version-prefix). */
52
+ export function rosettaIdToUrl(rosettaId: string): string {
53
+ return `https://manual.mikrotik.com/${rosettaId}`;
54
+ }
55
+
56
+ export interface CollisionReport {
57
+ total: number;
58
+ uniqueIds: number;
59
+ collisions: Map<string, string[]>;
60
+ maxLength: number;
61
+ unexpectedChars: string[];
62
+ }
63
+
64
+ /** Derive rosetta-ids for a list of URLs and report any collisions. */
65
+ export function checkCollisions(urls: string[]): CollisionReport {
66
+ const byId = new Map<string, string[]>();
67
+ let maxLength = 0;
68
+ const unexpectedChars = new Set<string>();
69
+
70
+ for (const url of urls) {
71
+ const id = deriveRosettaId(url);
72
+ maxLength = Math.max(maxLength, id.length);
73
+ for (const ch of id) {
74
+ if (!/[a-z0-9/_-]/.test(ch)) unexpectedChars.add(ch);
75
+ }
76
+ const existing = byId.get(id);
77
+ if (existing) existing.push(url);
78
+ else byId.set(id, [url]);
79
+ }
80
+
81
+ const collisions = new Map<string, string[]>();
82
+ for (const [id, sourceUrls] of byId) {
83
+ if (sourceUrls.length > 1) collisions.set(id, sourceUrls);
84
+ }
85
+
86
+ return {
87
+ total: urls.length,
88
+ uniqueIds: byId.size,
89
+ collisions,
90
+ maxLength,
91
+ unexpectedChars: [...unexpectedChars],
92
+ };
93
+ }
94
+
95
+ /** Decode the five predefined XML entities. `&amp;` is decoded last so already-decoded `&` isn't re-processed. */
96
+ function decodeXmlEntities(s: string): string {
97
+ return s
98
+ .replace(/&lt;/g, "<")
99
+ .replace(/&gt;/g, ">")
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&apos;/g, "'")
102
+ .replace(/&amp;/g, "&");
103
+ }
104
+
105
+ /** Extract <loc> entries from a sitemap.xml document, decoding XML entities in each URL. */
106
+ export function parseSitemapLocs(xml: string): string[] {
107
+ return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => decodeXmlEntities(m[1]));
108
+ }
109
+
110
+ /** Fetch and parse the live manual.mikrotik.com sitemap, or read a local file if `source` is given. */
111
+ export async function loadSitemapUrls(source?: string): Promise<string[]> {
112
+ let xml: string;
113
+ if (source) {
114
+ xml = await Bun.file(source).text();
115
+ } else {
116
+ // 10s timeout matches the per-page/llms.txt fetches in extract-docusaurus.ts so a
117
+ // hung connection can't stall the whole extractor indefinitely.
118
+ const res = await fetch(SITEMAP_URL, { signal: AbortSignal.timeout(10_000) });
119
+ // Fail loud on a non-2xx: an HTML error page has no <loc> matches, which would
120
+ // otherwise masquerade as "0 pages in scope" and be diagnosed as a filter bug.
121
+ if (!res.ok) throw new Error(`Failed to fetch sitemap ${SITEMAP_URL}: HTTP ${res.status} ${res.statusText}`);
122
+ xml = await res.text();
123
+ }
124
+ return parseSitemapLocs(xml);
125
+ }
126
+
127
+ if (import.meta.main) {
128
+ (async () => {
129
+ const source = process.argv[2];
130
+ const urls = await loadSitemapUrls(source);
131
+ const report = checkCollisions(urls);
132
+
133
+ console.log(`Source: ${source ?? SITEMAP_URL}`);
134
+ console.log(`Total URLs: ${report.total}`);
135
+ console.log(`Unique rosetta-ids: ${report.uniqueIds}`);
136
+ console.log(`Max id length: ${report.maxLength}`);
137
+ console.log(`Unexpected chars: ${report.unexpectedChars.length === 0 ? "none" : report.unexpectedChars.join(" ")}`);
138
+ console.log(`Collisions: ${report.collisions.size}`);
139
+
140
+ if (report.collisions.size > 0) {
141
+ for (const [id, sourceUrls] of report.collisions) {
142
+ console.log(` ${id}:`);
143
+ for (const u of sourceUrls) console.log(` ${u}`);
144
+ }
145
+ process.exitCode = 1;
146
+ }
147
+ })().catch((err) => {
148
+ console.error(err);
149
+ process.exitCode = 1;
150
+ });
151
+ }