@nshipster/sosumi 1.0.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/LICENSE.md +19 -0
- package/README.md +304 -0
- package/bin/sosumi.mjs +76 -0
- package/package.json +53 -0
- package/public/_headers +2 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +7 -0
- package/public/icons/square.and.pencil.svg +15 -0
- package/public/index.html +898 -0
- package/public/llms.txt +184 -0
- package/public/sosumi.m4a +0 -0
- package/src/cli.ts +214 -0
- package/src/index.ts +507 -0
- package/src/lib/cli-endpoints.ts +106 -0
- package/src/lib/external/fetch.ts +133 -0
- package/src/lib/external/index.ts +8 -0
- package/src/lib/external/policy.ts +308 -0
- package/src/lib/external/types.ts +10 -0
- package/src/lib/fetch.ts +43 -0
- package/src/lib/hig/fetch.ts +186 -0
- package/src/lib/hig/index.ts +9 -0
- package/src/lib/hig/render.ts +514 -0
- package/src/lib/hig/types.ts +206 -0
- package/src/lib/hig/util.ts +30 -0
- package/src/lib/mcp.ts +315 -0
- package/src/lib/reference/fetch.ts +53 -0
- package/src/lib/reference/index.ts +8 -0
- package/src/lib/reference/render.ts +739 -0
- package/src/lib/reference/types.ts +31 -0
- package/src/lib/search.ts +221 -0
- package/src/lib/types.ts +334 -0
- package/src/lib/url.ts +55 -0
- package/src/lib/video/index.ts +179 -0
- package/wrangler.jsonc +27 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { StreamableHTTPTransport } from "@hono/mcp"
|
|
2
|
+
import { Hono } from "hono"
|
|
3
|
+
import { cache } from "hono/cache"
|
|
4
|
+
import { cors } from "hono/cors"
|
|
5
|
+
import { HTTPException } from "hono/http-exception"
|
|
6
|
+
import { trimTrailingSlash } from "hono/trailing-slash"
|
|
7
|
+
import {
|
|
8
|
+
decodeExternalTargetPath,
|
|
9
|
+
ExternalAccessError,
|
|
10
|
+
extractExternalDocumentationBasePath,
|
|
11
|
+
fetchExternalDocCJSON,
|
|
12
|
+
validateExternalDocumentationUrl,
|
|
13
|
+
} from "./lib/external"
|
|
14
|
+
import { NotFoundError } from "./lib/fetch"
|
|
15
|
+
import {
|
|
16
|
+
fetchHIGPageData,
|
|
17
|
+
fetchHIGTableOfContents,
|
|
18
|
+
renderHIGFromJSON,
|
|
19
|
+
renderHIGTableOfContents,
|
|
20
|
+
} from "./lib/hig"
|
|
21
|
+
import { createMcpServer } from "./lib/mcp"
|
|
22
|
+
import { fetchJSONData, renderFromJSON } from "./lib/reference"
|
|
23
|
+
import { searchAppleDeveloperDocs } from "./lib/search"
|
|
24
|
+
import { generateAppleDocUrl, isValidAppleDocUrl, normalizeDocumentationPath } from "./lib/url"
|
|
25
|
+
import { fetchVideoTranscriptMarkdown, TranscriptNotFoundError } from "./lib/video"
|
|
26
|
+
|
|
27
|
+
interface Env {
|
|
28
|
+
ASSETS: Fetcher
|
|
29
|
+
NODE_ENV: string
|
|
30
|
+
EXTERNAL_DOC_HOST_ALLOWLIST?: string
|
|
31
|
+
EXTERNAL_DOC_HOST_BLOCKLIST?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const app = new Hono<{ Bindings: Env }>()
|
|
35
|
+
const mcpServerCache = new Map<string, ReturnType<typeof createMcpServer>>()
|
|
36
|
+
|
|
37
|
+
function getMcpServer(env: Env) {
|
|
38
|
+
const allowlist = env.EXTERNAL_DOC_HOST_ALLOWLIST ?? ""
|
|
39
|
+
const blocklist = env.EXTERNAL_DOC_HOST_BLOCKLIST ?? ""
|
|
40
|
+
const cacheKey = `${allowlist}\n---\n${blocklist}`
|
|
41
|
+
const cached = mcpServerCache.get(cacheKey)
|
|
42
|
+
if (cached) {
|
|
43
|
+
return cached
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const server = createMcpServer({
|
|
47
|
+
EXTERNAL_DOC_HOST_ALLOWLIST: env.EXTERNAL_DOC_HOST_ALLOWLIST,
|
|
48
|
+
EXTERNAL_DOC_HOST_BLOCKLIST: env.EXTERNAL_DOC_HOST_BLOCKLIST,
|
|
49
|
+
})
|
|
50
|
+
mcpServerCache.set(cacheKey, server)
|
|
51
|
+
return server
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
app.use("*", async (c, next) => {
|
|
55
|
+
await next()
|
|
56
|
+
|
|
57
|
+
// Security headers
|
|
58
|
+
c.header("X-Content-Type-Options", "nosniff")
|
|
59
|
+
c.header("X-Frame-Options", "DENY")
|
|
60
|
+
c.header("X-XSS-Protection", "1; mode=block")
|
|
61
|
+
c.header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
62
|
+
c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
63
|
+
|
|
64
|
+
// Performance headers
|
|
65
|
+
c.header("Vary", "Accept")
|
|
66
|
+
|
|
67
|
+
// Development-specific headers
|
|
68
|
+
if (c.env.NODE_ENV === "development") {
|
|
69
|
+
c.header("Cache-Control", "no-store")
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
app.use("*", cors())
|
|
74
|
+
|
|
75
|
+
app.use(trimTrailingSlash())
|
|
76
|
+
|
|
77
|
+
app.use("*", async (c, next) => {
|
|
78
|
+
if (c.env.NODE_ENV !== "development") {
|
|
79
|
+
cache({
|
|
80
|
+
cacheName: "sosumi-cache",
|
|
81
|
+
cacheControl: "max-age=86400", // 24 hours
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
await next()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
app.all("/mcp", async (c) => {
|
|
88
|
+
const mcpServer = getMcpServer(c.env)
|
|
89
|
+
const transport = new StreamableHTTPTransport()
|
|
90
|
+
await mcpServer.connect(transport)
|
|
91
|
+
return transport.handleRequest(c)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
app.get("/bot", (c) => c.redirect("/#bot", 302))
|
|
95
|
+
|
|
96
|
+
app.get("/search", async (c) => {
|
|
97
|
+
const query = c.req.query("q")?.trim() ?? ""
|
|
98
|
+
if (!query) {
|
|
99
|
+
throw new HTTPException(400, {
|
|
100
|
+
message: "Missing search query. Provide ?q=...",
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const searchResponse = await searchAppleDeveloperDocs(query)
|
|
105
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
106
|
+
return c.json(searchResponse, 200, {
|
|
107
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
108
|
+
"Cache-Control": "public, max-age=300, s-maxage=600",
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (searchResponse.results.length === 0) {
|
|
113
|
+
return c.text(`No results found for "${query}"`, 200, {
|
|
114
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
115
|
+
"Cache-Control": "public, max-age=300, s-maxage=600",
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const summary =
|
|
120
|
+
`Found ${searchResponse.results.length} result(s) for "${query}":\n\n` +
|
|
121
|
+
searchResponse.results
|
|
122
|
+
.map(
|
|
123
|
+
(result, index) =>
|
|
124
|
+
`${index + 1}. ${result.title}\n ${result.url}\n ${result.description || "No description"}`,
|
|
125
|
+
)
|
|
126
|
+
.join("\n\n")
|
|
127
|
+
|
|
128
|
+
return c.text(summary, 200, {
|
|
129
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
130
|
+
"Cache-Control": "public, max-age=300, s-maxage=600",
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
app.get("/documentation/*", async (c) => {
|
|
135
|
+
const path = c.req.path
|
|
136
|
+
|
|
137
|
+
// Normalize path and generate Apple Developer URL
|
|
138
|
+
const normalizedPath = normalizeDocumentationPath(path.replace("/documentation/", ""))
|
|
139
|
+
const appleUrl = generateAppleDocUrl(normalizedPath)
|
|
140
|
+
|
|
141
|
+
// Validate the URL is a proper Apple documentation URL
|
|
142
|
+
if (!isValidAppleDocUrl(appleUrl)) {
|
|
143
|
+
const errorResponse = new Response(
|
|
144
|
+
`# Invalid Apple Documentation URL
|
|
145
|
+
|
|
146
|
+
The URL \`${appleUrl}\` is not a valid Apple Developer documentation page.
|
|
147
|
+
|
|
148
|
+
## Supported URL Patterns
|
|
149
|
+
|
|
150
|
+
This service only works with Apple Developer documentation URLs:
|
|
151
|
+
|
|
152
|
+
- \`https://developer.apple.com/documentation/*\`
|
|
153
|
+
|
|
154
|
+
## Examples
|
|
155
|
+
|
|
156
|
+
- [Swift Documentation](https://sosumi.ai/documentation/swift)
|
|
157
|
+
- [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
|
|
158
|
+
- [UIKit Documentation](https://sosumi.ai/documentation/uikit)
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
*[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
|
|
162
|
+
{
|
|
163
|
+
status: 400,
|
|
164
|
+
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
throw new HTTPException(400, { res: errorResponse })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const jsonData = await fetchJSONData(path)
|
|
171
|
+
const markdown = await renderFromJSON(jsonData, appleUrl)
|
|
172
|
+
|
|
173
|
+
// Validate that we got meaningful content
|
|
174
|
+
if (!markdown || markdown.trim().length < 100) {
|
|
175
|
+
throw new HTTPException(502, {
|
|
176
|
+
message:
|
|
177
|
+
"The Apple documentation page loaded but contained insufficient content. This may be a temporary issue with the page.",
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const headers = {
|
|
182
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
183
|
+
"Content-Location": appleUrl,
|
|
184
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
|
185
|
+
ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
189
|
+
return c.json(
|
|
190
|
+
{
|
|
191
|
+
url: appleUrl,
|
|
192
|
+
content: markdown,
|
|
193
|
+
},
|
|
194
|
+
200,
|
|
195
|
+
{ ...headers, "Content-Type": "application/json; charset=utf-8" },
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return c.text(markdown, 200, {
|
|
200
|
+
...headers,
|
|
201
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
app.get("/external/*", async (c) => {
|
|
206
|
+
const path = c.req.path
|
|
207
|
+
const rawTarget = decodeExternalTargetPath(path)
|
|
208
|
+
const targetUrl = validateExternalDocumentationUrl(rawTarget)
|
|
209
|
+
|
|
210
|
+
const jsonData = await fetchExternalDocCJSON(targetUrl, c.env)
|
|
211
|
+
const externalBasePath = extractExternalDocumentationBasePath(targetUrl)
|
|
212
|
+
const markdown = await renderFromJSON(jsonData, targetUrl.toString(), {
|
|
213
|
+
externalOrigin: `${targetUrl.origin}${externalBasePath}`,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
if (!markdown || markdown.trim().length < 100) {
|
|
217
|
+
throw new HTTPException(502, {
|
|
218
|
+
message:
|
|
219
|
+
"The external documentation page loaded but contained insufficient content. This may be a temporary issue with the page.",
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const headers = {
|
|
224
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
225
|
+
"Content-Location": targetUrl.toString(),
|
|
226
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
|
227
|
+
ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
231
|
+
return c.json(
|
|
232
|
+
{
|
|
233
|
+
url: targetUrl.toString(),
|
|
234
|
+
content: markdown,
|
|
235
|
+
},
|
|
236
|
+
200,
|
|
237
|
+
{ ...headers, "Content-Type": "application/json; charset=utf-8" },
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return c.text(markdown, 200, {
|
|
242
|
+
...headers,
|
|
243
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
app.get("/design/human-interface-guidelines", async (c) => {
|
|
248
|
+
// Handle the table of contents for HIG
|
|
249
|
+
const tocData = await fetchHIGTableOfContents()
|
|
250
|
+
const markdown = await renderHIGTableOfContents(tocData)
|
|
251
|
+
|
|
252
|
+
// Validate that we got meaningful content
|
|
253
|
+
if (!markdown || markdown.trim().length < 100) {
|
|
254
|
+
throw new HTTPException(502, {
|
|
255
|
+
message:
|
|
256
|
+
"The HIG table of contents loaded but contained insufficient content. This may be a temporary issue.",
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const sourceUrl = "https://developer.apple.com/design/human-interface-guidelines/"
|
|
261
|
+
const headers = {
|
|
262
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
263
|
+
"Content-Location": sourceUrl,
|
|
264
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
|
265
|
+
ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
269
|
+
return c.json(
|
|
270
|
+
{
|
|
271
|
+
url: sourceUrl,
|
|
272
|
+
content: markdown,
|
|
273
|
+
},
|
|
274
|
+
200,
|
|
275
|
+
{ ...headers, "Content-Type": "application/json; charset=utf-8" },
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return c.text(markdown, 200, headers)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
app.get("/design/human-interface-guidelines/:path{.+}", async (c) => {
|
|
283
|
+
const higPath = c.req.param("path")
|
|
284
|
+
if (!higPath) {
|
|
285
|
+
// This should be caught by the route above, but just in case
|
|
286
|
+
throw new HTTPException(400, {
|
|
287
|
+
message: "Invalid HIG path",
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const jsonData = await fetchHIGPageData(higPath)
|
|
292
|
+
const sourceUrl = `https://developer.apple.com/design/human-interface-guidelines/${higPath}`
|
|
293
|
+
const markdown = await renderHIGFromJSON(jsonData, sourceUrl)
|
|
294
|
+
|
|
295
|
+
// Validate that we got meaningful content
|
|
296
|
+
if (!markdown || markdown.trim().length < 100) {
|
|
297
|
+
throw new HTTPException(502, {
|
|
298
|
+
message:
|
|
299
|
+
"The HIG page loaded but contained insufficient content. This may be a temporary issue with the page.",
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const headers = {
|
|
304
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
305
|
+
"Content-Location": sourceUrl,
|
|
306
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
|
307
|
+
ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
311
|
+
return c.json(
|
|
312
|
+
{
|
|
313
|
+
url: sourceUrl,
|
|
314
|
+
content: markdown,
|
|
315
|
+
},
|
|
316
|
+
200,
|
|
317
|
+
{ ...headers, "Content-Type": "application/json; charset=utf-8" },
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return c.text(markdown, 200, headers)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
app.get("/videos/play/:collection/:id", async (c) => {
|
|
325
|
+
const collection = c.req.param("collection")
|
|
326
|
+
const id = c.req.param("id")
|
|
327
|
+
|
|
328
|
+
if (!/^[a-z0-9-]+$/i.test(collection) || !/^\d+$/.test(id)) {
|
|
329
|
+
throw new HTTPException(400, {
|
|
330
|
+
message:
|
|
331
|
+
"Invalid video path. Supported format: /videos/play/COLLECTION/VIDEO_ID (for example, /videos/play/wwdc2021/10133).",
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const sourceUrl = `https://developer.apple.com/videos/play/${collection}/${id}/`
|
|
336
|
+
|
|
337
|
+
let markdown: string
|
|
338
|
+
try {
|
|
339
|
+
markdown = await fetchVideoTranscriptMarkdown(sourceUrl, collection, id)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error instanceof TranscriptNotFoundError) {
|
|
342
|
+
throw new HTTPException(404, {
|
|
343
|
+
message: "Transcript not found. Some Apple Developer videos may not include a transcript.",
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!markdown || markdown.trim().length < 100) {
|
|
350
|
+
throw new HTTPException(502, {
|
|
351
|
+
message:
|
|
352
|
+
"The video transcript loaded but contained insufficient content. This may be a temporary issue with the page.",
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const headers = {
|
|
357
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
358
|
+
"Content-Location": sourceUrl,
|
|
359
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
|
360
|
+
ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (c.req.header("Accept")?.includes("application/json")) {
|
|
364
|
+
return c.json(
|
|
365
|
+
{
|
|
366
|
+
url: sourceUrl,
|
|
367
|
+
content: markdown,
|
|
368
|
+
},
|
|
369
|
+
200,
|
|
370
|
+
{ ...headers, "Content-Type": "application/json; charset=utf-8" },
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return c.text(markdown, 200, headers)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// Catch-all route for any other requests - returns 404
|
|
378
|
+
app.all("*", (c) => {
|
|
379
|
+
return c.text(
|
|
380
|
+
`# Not Found
|
|
381
|
+
|
|
382
|
+
The requested resource was not found on this server.
|
|
383
|
+
|
|
384
|
+
This service only works with Apple Developer documentation URLs:
|
|
385
|
+
- \`https://sosumi.ai/documentation/*\`
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
*[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
|
|
389
|
+
404,
|
|
390
|
+
{ "Content-Type": "text/markdown; charset=utf-8" },
|
|
391
|
+
)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
app.onError((err, c) => {
|
|
395
|
+
console.error("Error occurred:", err)
|
|
396
|
+
|
|
397
|
+
if (err instanceof HTTPException) {
|
|
398
|
+
// Get the custom response
|
|
399
|
+
return err.getResponse()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (err instanceof NotFoundError) {
|
|
403
|
+
const accept = c.req.header("Accept")
|
|
404
|
+
if (accept?.includes("application/json")) {
|
|
405
|
+
return c.json(
|
|
406
|
+
{
|
|
407
|
+
error: "Documentation not found",
|
|
408
|
+
message: "The requested Apple Developer documentation page does not exist.",
|
|
409
|
+
},
|
|
410
|
+
404,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return c.text(
|
|
415
|
+
`# Not Found
|
|
416
|
+
|
|
417
|
+
The requested Apple Developer documentation page does not exist.
|
|
418
|
+
|
|
419
|
+
## What you can try:
|
|
420
|
+
|
|
421
|
+
1. **Check the URL** - Make sure the path is correct
|
|
422
|
+
2. **Browse from a parent page** - Try starting from a higher-level documentation page
|
|
423
|
+
3. **Search Apple Developer Documentation** - Use Apple's official search
|
|
424
|
+
|
|
425
|
+
## Examples of valid URLs:
|
|
426
|
+
|
|
427
|
+
- [Swift Documentation](https://sosumi.ai/documentation/swift)
|
|
428
|
+
- [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
|
|
429
|
+
- [UIKit Documentation](https://sosumi.ai/documentation/uikit)
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
*[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
|
|
433
|
+
404,
|
|
434
|
+
{ "Content-Type": "text/markdown; charset=utf-8" },
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (err instanceof ExternalAccessError) {
|
|
439
|
+
const accept = c.req.header("Accept")
|
|
440
|
+
if (accept?.includes("application/json")) {
|
|
441
|
+
return c.json(
|
|
442
|
+
{
|
|
443
|
+
error: "External documentation access denied",
|
|
444
|
+
message: err.message,
|
|
445
|
+
},
|
|
446
|
+
{ status: err.status as 400 | 403 | 404 },
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return c.text(
|
|
451
|
+
`# External Documentation Access Denied
|
|
452
|
+
|
|
453
|
+
${err.message}
|
|
454
|
+
|
|
455
|
+
## Opt-out controls supported
|
|
456
|
+
|
|
457
|
+
- \`robots.txt\` disallow for \`sosumi-ai\` (or \`*\`)
|
|
458
|
+
- \`X-Robots-Tag\` response directives such as \`noai\`, \`noimageai\`, \`noindex\`
|
|
459
|
+
- Local operator host controls: \`EXTERNAL_DOC_HOST_ALLOWLIST\`, \`EXTERNAL_DOC_HOST_BLOCKLIST\`
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
*[sosumi.ai](https://sosumi.ai) - Making docs AI-readable*`,
|
|
463
|
+
err.status as 400 | 403 | 404,
|
|
464
|
+
{ "Content-Type": "text/markdown; charset=utf-8" },
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Handle unexpected errors
|
|
469
|
+
const accept = c.req.header("Accept")
|
|
470
|
+
if (accept?.includes("application/json")) {
|
|
471
|
+
return c.json(
|
|
472
|
+
{
|
|
473
|
+
error: "Service temporarily unavailable",
|
|
474
|
+
message:
|
|
475
|
+
"We encountered an unexpected issue while processing your request. Please try again in a few moments.",
|
|
476
|
+
},
|
|
477
|
+
500,
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return c.text(
|
|
482
|
+
`# Service Temporarily Unavailable
|
|
483
|
+
|
|
484
|
+
We encountered an unexpected issue while processing your request.
|
|
485
|
+
|
|
486
|
+
## What you can try:
|
|
487
|
+
|
|
488
|
+
1. **Wait a moment and try again** - This is often a temporary issue
|
|
489
|
+
2. **Check the URL** - Make sure you're using a valid Apple Developer documentation URL
|
|
490
|
+
3. **Try a different page** - Some pages may have temporary issues
|
|
491
|
+
|
|
492
|
+
## Examples of valid URLs:
|
|
493
|
+
|
|
494
|
+
- [Swift Documentation](https://sosumi.ai/documentation/swift)
|
|
495
|
+
- [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
|
|
496
|
+
- [UIKit Documentation](https://sosumi.ai/documentation/uikit)
|
|
497
|
+
|
|
498
|
+
If this issue persists, please report it to <info@sosumi.ai>.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
*[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
|
|
502
|
+
500,
|
|
503
|
+
{ "Content-Type": "text/markdown; charset=utf-8" },
|
|
504
|
+
)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
export default app
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { normalizeDocumentationPath } from "./url"
|
|
2
|
+
|
|
3
|
+
const VIDEO_PATH_RE = /^\/videos\/play\/([a-z0-9-]+)\/(\d+)\/?$/i
|
|
4
|
+
|
|
5
|
+
export interface CliParseResult {
|
|
6
|
+
help: boolean
|
|
7
|
+
flags: {
|
|
8
|
+
json: boolean
|
|
9
|
+
}
|
|
10
|
+
positionals: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseCliArgs(argv: string[]): CliParseResult {
|
|
14
|
+
const flags = { json: false }
|
|
15
|
+
const positionals: string[] = []
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const arg = argv[i]
|
|
19
|
+
if (arg === "--json") {
|
|
20
|
+
flags.json = true
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
if (arg === "--base-url") {
|
|
24
|
+
throw new Error("--base-url is no longer supported. CLI runs using local src logic.")
|
|
25
|
+
}
|
|
26
|
+
if (arg === "-h" || arg === "--help") {
|
|
27
|
+
return { help: true, flags, positionals: [] }
|
|
28
|
+
}
|
|
29
|
+
if (arg.startsWith("--")) {
|
|
30
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
31
|
+
}
|
|
32
|
+
positionals.push(arg)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { help: false, flags, positionals }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeHigPath(pathname: string): string {
|
|
39
|
+
if (pathname === "/design/human-interface-guidelines/") {
|
|
40
|
+
return "/design/human-interface-guidelines"
|
|
41
|
+
}
|
|
42
|
+
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveFetchEndpoint(input: string): string {
|
|
46
|
+
const trimmed = input.trim()
|
|
47
|
+
if (!trimmed) {
|
|
48
|
+
throw new Error("Fetch input cannot be empty")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
52
|
+
const target = new URL(trimmed)
|
|
53
|
+
if (target.protocol !== "https:") {
|
|
54
|
+
throw new Error("Only https URLs are supported")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (target.hostname === "developer.apple.com") {
|
|
58
|
+
if (target.pathname.startsWith("/documentation/")) {
|
|
59
|
+
return target.pathname
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (target.pathname.startsWith("/design/human-interface-guidelines")) {
|
|
63
|
+
return normalizeHigPath(target.pathname)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const videoMatch = target.pathname.match(VIDEO_PATH_RE)
|
|
67
|
+
if (videoMatch) {
|
|
68
|
+
return `/videos/play/${videoMatch[1]}/${videoMatch[2]}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`Unsupported developer.apple.com URL path: ${target.pathname}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return `/external/${trimmed}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (trimmed.startsWith("/documentation/")) {
|
|
78
|
+
return trimmed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (trimmed.startsWith("/design/human-interface-guidelines")) {
|
|
82
|
+
return normalizeHigPath(trimmed)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (trimmed.startsWith("/videos/play/")) {
|
|
86
|
+
const videoMatch = trimmed.match(VIDEO_PATH_RE)
|
|
87
|
+
if (!videoMatch) {
|
|
88
|
+
throw new Error("Invalid video path. Expected /videos/play/COLLECTION/VIDEO_ID")
|
|
89
|
+
}
|
|
90
|
+
return `/videos/play/${videoMatch[1]}/${videoMatch[2]}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (trimmed.startsWith("/external/")) {
|
|
94
|
+
return trimmed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return `/documentation/${normalizeDocumentationPath(trimmed)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveSearchEndpoint(query: string): string {
|
|
101
|
+
const trimmed = query.trim()
|
|
102
|
+
if (!trimmed) {
|
|
103
|
+
throw new Error("Search query cannot be empty")
|
|
104
|
+
}
|
|
105
|
+
return `/search?q=${encodeURIComponent(trimmed)}`
|
|
106
|
+
}
|