@weatherboard/annotation 0.4.0 → 0.7.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 (75) hide show
  1. package/ADOPTING.md +147 -10
  2. package/COMPATIBILITY.md +29 -0
  3. package/README.md +84 -10
  4. package/dist/anchor.cjs +1 -1
  5. package/dist/anchor.cjs.map +2 -2
  6. package/dist/anchor.mjs.map +2 -2
  7. package/dist/arming.d.cts +18 -21
  8. package/dist/arming.d.mts +18 -21
  9. package/dist/arming.d.ts +18 -21
  10. package/dist/bootstrap-runtime.d.cts +30 -0
  11. package/dist/bootstrap-runtime.d.mts +30 -0
  12. package/dist/bootstrap-runtime.d.ts +30 -0
  13. package/dist/bootstrap.cjs +2 -0
  14. package/dist/bootstrap.cjs.map +7 -0
  15. package/dist/bootstrap.d.cts +8 -0
  16. package/dist/bootstrap.d.mts +8 -0
  17. package/dist/bootstrap.d.ts +8 -0
  18. package/dist/bootstrap.mjs +2 -0
  19. package/dist/bootstrap.mjs.map +7 -0
  20. package/dist/host.cjs +1 -1
  21. package/dist/host.cjs.map +3 -3
  22. package/dist/host.mjs +1 -1
  23. package/dist/host.mjs.map +3 -3
  24. package/dist/index.cjs +4 -4
  25. package/dist/index.cjs.map +4 -4
  26. package/dist/index.d.cts +10 -4
  27. package/dist/index.d.mts +10 -4
  28. package/dist/index.d.ts +10 -4
  29. package/dist/index.mjs +4 -4
  30. package/dist/index.mjs.map +4 -4
  31. package/dist/markers.cjs +2 -2
  32. package/dist/markers.cjs.map +2 -2
  33. package/dist/markers.mjs +2 -2
  34. package/dist/markers.mjs.map +2 -2
  35. package/dist/page-capture.cjs +2 -0
  36. package/dist/page-capture.cjs.map +7 -0
  37. package/dist/page-capture.d.cts +115 -0
  38. package/dist/page-capture.d.mts +115 -0
  39. package/dist/page-capture.d.ts +115 -0
  40. package/dist/page-capture.mjs +2 -0
  41. package/dist/page-capture.mjs.map +7 -0
  42. package/dist/protocol.cjs +1 -1
  43. package/dist/protocol.cjs.map +3 -3
  44. package/dist/protocol.d.cts +19 -0
  45. package/dist/protocol.d.mts +19 -0
  46. package/dist/protocol.d.ts +19 -0
  47. package/dist/protocol.mjs +1 -1
  48. package/dist/protocol.mjs.map +3 -3
  49. package/dist/read.cjs +1 -1
  50. package/dist/read.cjs.map +2 -2
  51. package/dist/read.mjs +1 -1
  52. package/dist/read.mjs.map +2 -2
  53. package/dist/screenshot.d.cts +56 -7
  54. package/dist/screenshot.d.mts +56 -7
  55. package/dist/screenshot.d.ts +56 -7
  56. package/dist/selection.cjs +5 -0
  57. package/dist/selection.cjs.map +7 -0
  58. package/dist/selection.d.cts +27 -0
  59. package/dist/selection.d.mts +27 -0
  60. package/dist/selection.d.ts +27 -0
  61. package/dist/selection.mjs +5 -0
  62. package/dist/selection.mjs.map +7 -0
  63. package/dist/session.d.cts +17 -0
  64. package/dist/session.d.mts +17 -0
  65. package/dist/session.d.ts +17 -0
  66. package/dist/shortcut.d.cts +52 -0
  67. package/dist/shortcut.d.mts +52 -0
  68. package/dist/shortcut.d.ts +52 -0
  69. package/dist/toolbar.d.cts +13 -0
  70. package/dist/toolbar.d.mts +13 -0
  71. package/dist/toolbar.d.ts +13 -0
  72. package/dist/transport.d.cts +10 -0
  73. package/dist/transport.d.mts +10 -0
  74. package/dist/transport.d.ts +10 -0
  75. package/package.json +45 -10
package/dist/read.mjs.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/transport.ts", "../src/read.ts"],
4
- "sourcesContent": ["import { assertConcreteOrigin, type FrameToParent } from \"./protocol\";\n\n/**\n * Egress (A-239).\n *\n * ===========================================================================\n * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`\n * ===========================================================================\n *\n * `sdk.ts` had exactly one way out \u2014 `win.parent.postMessage(...)` \u2014 and\n * first-party there is no parent: `win.parent === win`. Every capture then\n * posts a message to the page that sent it and nothing receives it. **That\n * fails silently**, which is the shape to be careful of here: the overlay\n * behaves correctly, the reviewer clicks, and no issue is ever filed.\n *\n * See `docs/plans/first-party-annotation.md`. The console stops framing the\n * product and links out to it instead, so the capture has to leave the page\n * over HTTP. The frame is NOT being deleted \u2014 `postMessageTransport` is\n * today's behaviour, extracted unchanged, and it is still the default.\n *\n * ===========================================================================\n * TWO PROPERTIES THAT HAD TO SURVIVE\n * ===========================================================================\n *\n * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still\n * guards the message path, and `assertConcreteEndpoint` is its equivalent\n * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,\n * because a misconfiguration that only surfaces when a reviewer finally\n * clicks something is a misconfiguration nobody finds in review.\n *\n * 2. **The screenshot never costs the capture**, and neither may the network.\n * `attachScreenshot` resolves within its own timeout and never rejects, so\n * the payload goes out either way. An HTTP transport would reintroduce\n * exactly the failure mode that was designed out \u2014 a slow network losing a\n * capture \u2014 unless it carries its own timeout and its own `catch`, and\n * unless a failed send is REPORTABLE rather than swallowed. A dropped\n * capture that looks like a successful one is the worst outcome available\n * here, so `send` rejects loudly and `createEgress` routes that to the\n * caller.\n */\n\n/** Every message the page half can emit. */\nexport type FrameMessageKind = FrameToParent[\"type\"];\n\n/**\n * What the far end said about a capture it accepted (A-240).\n *\n * Everything on it is optional and always will be. `postMessageTransport`\n * cannot answer at all \u2014 `postMessage` has no reply \u2014 and the ingest may\n * answer with nothing. A reviewer who is told *sent* and given no link is in a\n * strictly better position than one told nothing, so an absent `issueUrl` is a\n * successful send and never a failed one.\n */\nexport interface SendReceipt {\n /**\n * The issue this capture became, when the far end names one.\n *\n * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type\n * alone is not the contract \u2014 see `safeIssueUrl`, which is what every value\n * crossing the network boundary is put through before it gets to be one.\n */\n issueUrl?: string;\n}\n\n/** Longer than any issue URL and short enough not to be a payload. */\nconst MAX_ISSUE_URL = 2048;\n\n/**\n * A URL from the network that is safe to put in somebody else's page (A-240).\n *\n * ===========================================================================\n * WHY THIS IS NOT PARANOIA\n * ===========================================================================\n *\n * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets\n * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there\n * executes in the product's origin the moment the reviewer clicks *View\n * issue* \u2014 a script injection into a third party's site, shipped by us, on\n * every page of every managed product.\n *\n * `assertConcreteEndpoint` does not cover this and was never going to:\n * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is\n * misconfigured, compromised, or simply behind a proxy that rewrites bodies is\n * exactly the case a transport has to survive, and \"our own console would\n * never\" is not a security boundary.\n *\n * It lives HERE and not in the toolbar so the guarantee travels with the\n * receipt rather than with one renderer of it. The toolbar calls it too \u2014 see\n * `showResult` \u2014 because it renders receipts from ANY transport, including one\n * a consumer wrote, which never passes through this function.\n *\n * Anything not `http(s)` is dropped rather than repaired. A dropped link is a\n * toast that says the capture was sent and offers no link, which is the\n * outcome the receipt is already designed to survive.\n */\nexport function safeIssueUrl(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0 || value.length > MAX_ISSUE_URL) {\n return undefined;\n }\n let url: URL;\n try {\n // Absolute only: `new URL` throws on a relative one, and a relative href in\n // somebody else's page points somewhere in THEIR app.\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return undefined;\n return url.href;\n}\n\n/** Long enough for any refusal the ingest writes, short enough not to be a body. */\nconst MAX_REFUSAL_REASON = 300;\n\n/**\n * A refusal sentence from the network that is safe to put in front of a\n * reviewer (A-285).\n *\n * `safeIssueUrl`'s argument, applied to prose. The ingest composes refusals a\n * human can act on \u2014 a link issued for a different deployment, an empty\n * comment \u2014 and until this slice the transport threw `HTTP 403` and discarded\n * every one of them, so **not one of those sentences had ever reached a\n * human.**\n *\n * The value is still untrusted, and it ends up as `textContent` in the\n * CUSTOMER'S OWN PAGE. `textContent` is not an injection seam the way `href`\n * is, so the risk here is not script execution \u2014 it is a hostile or broken\n * ingest writing a screenful of control characters, ANSI escapes or a whole\n * JSON document into a toast the reviewer cannot dismiss. So: strings only,\n * control characters dropped, whitespace collapsed, and a hard ceiling.\n *\n * Anything that fails is dropped rather than repaired, and the caller falls\n * back to the HTTP status \u2014 which is what every refusal rendered as before\n * this existed, so the floor cannot get worse.\n */\nexport function safeRefusalReason(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > MAX_REFUSAL_REASON\n ? `${cleaned.slice(0, MAX_REFUSAL_REASON - 1).trimEnd()}\u2026`\n : cleaned;\n}\n\n/**\n * The error a refused capture throws, carrying the reason as a FIELD.\n *\n * The reason is in the `message` too, prefixed for a log. A renderer that had\n * to recover it by string-slicing that message would be parsing our own prose,\n * which breaks the first time somebody edits the prefix \u2014 so the sentence a\n * human should see is a property, and `refusalReason` is the one way to read\n * it.\n */\nexport interface IngestRefusal extends Error {\n /** The ingest's own words, already through `safeRefusalReason`. */\n reason?: string;\n /** The status, always \u2014 it is the fallback and it is never absent. */\n status?: number;\n}\n\n/**\n * The reviewer-facing sentence for a failed capture, or `undefined`.\n *\n * SANITISED AGAIN, deliberately, exactly as `safeIssueUrl` is called at both\n * ends: this reads an `EgressOutcome.error` from WHATEVER transport produced\n * it, including one a consumer wrote, which never passed through\n * `httpTransport`. One function, so the two ends cannot disagree.\n */\nexport function refusalReason(error: unknown): string | undefined {\n return safeRefusalReason((error as IngestRefusal | null | undefined)?.reason);\n}\n\nexport interface AnnotationTransport {\n /** For diagnostics and for tests; never sent anywhere. */\n readonly name: string;\n /**\n * May this transport carry this kind of message?\n *\n * The interface needs this because `annotate:hover` fires once per animation\n * frame while the reviewer moves the mouse. Over `postMessage` that is a\n * structured clone to a frame that is already listening; over HTTP it is one\n * request per frame, which is a denial of service aimed at our own console.\n *\n * A transport that DROPPED such a message silently would be the same bug\n * this issue exists to remove, one layer down. So declining is an explicit\n * answer the SDK asks for and respects, and `send` is never called for a\n * kind that was declined.\n */\n carries(kind: FrameMessageKind): boolean;\n /**\n * Deliver the message. May be synchronous.\n *\n * A returned promise MUST reject on failure. Resolving on a failed send is\n * the one thing a transport may not do.\n */\n send(message: FrameToParent): void | Promise<void | SendReceipt>;\n}\n\n/** What went wrong, handed back to whoever constructed the SDK. */\nexport interface EgressFailure {\n message: FrameToParent;\n transport: string;\n error: unknown;\n}\n\nexport type EgressErrorHandler = (failure: EgressFailure) => void;\n\n/**\n * The parent-window transport \u2014 today's behaviour, extracted unchanged.\n *\n * It carries everything: the frame is a live listener on the other end of a\n * cheap channel, and the handshake (`annotate:ready`) and the hover highlight\n * both belong to it.\n */\nexport function postMessageTransport(\n parentOrigin: string,\n win: Pick<Window, \"parent\"> = window,\n): AnnotationTransport {\n const origin = assertConcreteOrigin(parentOrigin);\n return {\n name: \"postMessage\",\n carries: () => true,\n send(message) {\n // Never a wildcard, in either direction.\n win.parent.postMessage(message, origin);\n },\n };\n}\n\n/**\n * Reject anything that is not one concrete `http(s)` URL.\n *\n * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL\n * resolves against whatever page the SDK happens to be on \u2014 so a capture\n * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`\n * or `data:` endpoint is a script-injection seam handed to whoever controls\n * the deep link. Neither is a thing to discover at first capture.\n */\nexport function assertConcreteEndpoint(endpoint: string): string {\n if (!endpoint || endpoint === \"*\") {\n throw new Error(\n \"[annotation] a concrete ingest endpoint is required; \" +\n \"'*' is not an address and an empty one posts nowhere.\",\n );\n }\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw new Error(\n `[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}; ` +\n \"a relative one resolves against the product's own origin and the capture is lost there.\",\n );\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\n `[annotation] the ingest endpoint must be http(s), got ${url.protocol}`,\n );\n }\n return url.toString();\n}\n\n/** Node's `fetch` and the browser's, narrowed to what we use. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n /**\n * Optional since A-276: the read client issues a `GET`, which has no body,\n * and a required field would make it pass `undefined` explicitly \u2014 a value\n * a stub asserting on `init` then has to know to ignore. `httpTransport`\n * still always sends one.\n */\n body?: string;\n credentials?: \"omit\";\n signal?: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n /**\n * Optional because `serve()`'s loopback responses are read through the\n * platform `fetch`, which has it, while a stub written to assert the `init`\n * need not. A transport that made the body mandatory would be asserting\n * something about the ingest that the ingest does not promise.\n */\n json?: () => Promise<unknown>;\n}>;\n\nexport interface HttpTransportOptions {\n /** The console's ingest URL. Absolute, `http(s)`, never `*`. */\n endpoint: string;\n /**\n * The capture token from the deep link \u2014 signed by the console, carried by\n * the product, never validated by it. See the plan's *Authentication*\n * section for why this is a second credential and not the arming one.\n */\n captureToken: string;\n /**\n * Own timeout, own catch. The default is deliberately short: a capture the\n * reviewer is told failed can be retried, and a request that hangs is\n * indistinguishable from one that worked.\n */\n timeoutMs?: number;\n /** Injectable only so the tests can drive a transport with no `fetch` at all. */\n fetch?: FetchLike;\n}\n\nexport const HTTP_TRANSPORT_TIMEOUT_MS = 8_000;\n\n/**\n * The first-party transport: POST the capture to the console's ingest.\n *\n * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one\n * request per animation frame, and `annotate:ready` / `annotate:cancel` are\n * handshake with a parent window that does not exist first-party \u2014 sending\n * them would be filing HTTP requests nobody asked for.\n */\nexport function httpTransport(options: HttpTransportOptions): AnnotationTransport {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.captureToken) {\n throw new Error(\n \"[annotation] a capture token is required; the ingest rejects an unsigned capture \" +\n \"and the reviewer would only find out after writing one.\",\n );\n }\n const token = options.captureToken;\n const timeoutMs = options.timeoutMs ?? HTTP_TRANSPORT_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n name: \"http\",\n carries: (kind) => kind === \"annotate:capture\",\n async send(message) {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the ingest\");\n }\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${token}`,\n },\n // Never the reviewer's cookies. The token is the whole credential,\n // and an ambient one would make this endpoint CSRF-shaped.\n credentials: \"omit\",\n body: JSON.stringify(message),\n signal: controller.signal,\n });\n if (!response.ok) {\n /**\n * THE REFUSAL IS READ, AND THE STATUS IS THE FALLBACK (A-285).\n *\n * The ingest composes sentences a reviewer can act on \u2014 \"this link\n * was issued for a different deployment\", \"an empty comment files an\n * issue nobody can action\" \u2014 and this line used to throw `HTTP 403`\n * and drop every one of them on the floor. A status code is not a\n * refusal a human can do anything with; it is what we say when the\n * far end gave us nothing better.\n *\n * The body read is inside its own `catch` for the same reason the\n * success path's is: a refusal we could not parse must still be a\n * refusal, never a `TypeError` from `json()` masquerading as one.\n * Unlike the success path, this branch was ALREADY a failure, so a\n * parse problem loses information rather than a capture.\n */\n let reason: string | undefined;\n try {\n const refusal = (await response.json?.()) as { error?: unknown } | undefined;\n reason = safeRefusalReason(refusal?.error);\n } catch {\n reason = undefined;\n }\n const failure: IngestRefusal = new Error(\n `[annotation] the ingest rejected the capture: ${reason ?? `HTTP ${response.status}`}`,\n );\n failure.status = response.status;\n if (reason !== undefined) failure.reason = reason;\n throw failure;\n }\n /**\n * The capture is delivered at this point and nothing below may undo\n * that. A body that is empty, not JSON, or JSON of some other shape is\n * an ingest that chose not to name an issue \u2014 telling the reviewer\n * their capture failed because we could not parse a courtesy would be\n * the same lie as a silent drop, pointing the other way.\n *\n * A-285 read a body on the REFUSAL path above and this property is\n * untouched by that: the two branches are opposites. Up there the\n * capture was refused and an unparseable body costs only the wording;\n * down here the capture is DELIVERED, and no failure to read a\n * courtesy may ever turn it into a failed one.\n */\n try {\n const body = (await response.json?.()) as { issueUrl?: unknown } | undefined;\n // Untrusted, all of it. `safeIssueUrl` returns undefined for a\n // non-string, a relative URL, an over-long one and any scheme that\n // is not http(s) \u2014 so a hostile body degrades to a capture that was\n // sent and named no issue.\n const issueUrl = safeIssueUrl(body?.issueUrl);\n if (issueUrl !== undefined) return { issueUrl };\n } catch {}\n return {};\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the capture timed out after ${timeoutMs}ms`, {\n cause: error,\n });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n\n/**\n * What became of one message (A-240).\n *\n * `createEgress` used to return `void`, which was enough while the only reader\n * of a failure was `console.error`. The composer has to TELL THE REVIEWER \u2014 the\n * issue link on success, a retry on failure \u2014 and it cannot do that from a\n * function that answers nothing. So the outcome is returned as a value, and the\n * promise NEVER REJECTS: a rejection here would be a second way for a lost\n * capture to disappear, one `await`-less caller away from silence.\n *\n * `declined` is its own case rather than a failure, because it is not one:\n * `annotate:hover` over HTTP is a kind the transport correctly refuses, and\n * calling that a failed capture would put a red toast in front of a reviewer\n * who did nothing wrong. It is only a problem if `annotate:capture` is what was\n * declined, and that is the caller's judgement rather than this function's.\n */\nexport type EgressOutcome =\n | { status: \"sent\"; receipt: SendReceipt }\n | { status: \"declined\" }\n | { status: \"failed\"; error: unknown };\n\n/**\n * The SDK's one way out, given a transport.\n *\n * Pure and DOM-free on purpose, so the two things worth arguing about \u2014 that a\n * declined kind is never sent, and that a failed send reaches the caller \u2014 are\n * testable without a browser. `sdk.ts` holds no other egress path.\n */\nexport function createEgress(\n transport: AnnotationTransport,\n onError?: EgressErrorHandler,\n): (message: FrameToParent) => Promise<EgressOutcome> {\n const report = (message: FrameToParent, error: unknown) => {\n const failure: EgressFailure = { message, transport: transport.name, error };\n if (onError) {\n onError(failure);\n return;\n }\n /**\n * No handler is not a licence to swallow it. Until A-240 puts a toast in\n * front of the reviewer, the console is the only surface there is, and a\n * lost capture must leave a trace somewhere.\n */\n console.error(\"[annotation] a capture could not be delivered\", failure);\n };\n\n /**\n * `async`, and the synchronous half still runs synchronously \u2014 everything up\n * to the first `await` does, which is `transport.send` itself. So\n * `postMessageTransport` still posts before this returns, and a transport\n * that throws is still reported before this returns. That is not an\n * incidental detail: `sdk.test.ts` asserts the frame path's handshake has\n * been posted the instant `createAnnotationSdk` returns, and it must stay\n * true, because the frame is not being deleted.\n */\n return async (message: FrameToParent): Promise<EgressOutcome> => {\n if (!transport.carries(message.type)) return { status: \"declined\" };\n try {\n const receipt = await transport.send(message);\n return { status: \"sent\", receipt: receipt ?? {} };\n } catch (error) {\n report(message, error);\n return { status: \"failed\", error };\n }\n };\n}\n", "import type { Anchor } from \"./anchor\";\nimport { assertConcreteEndpoint, type FetchLike } from \"./transport\";\n\n/**\n * Reading the captures already made on this page (A-276).\n *\n * ===========================================================================\n * WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`\n * ===========================================================================\n *\n * `./anchor`'s note applies unchanged: this runs on the product page and only\n * for a product that RENDERS markers, while the SDK that only captures does\n * not need a byte of it. Folding it into `index` would tax every page of every\n * managed product with code most of them never call.\n *\n * ===========================================================================\n * WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER\n * ===========================================================================\n *\n * A-268 is the reason, and it is worth restating because it cost a night. The\n * SDK posted `{type, payload}` with the comment nested inside `payload`; the\n * ingest read `comment` as a sibling of it. Both halves were correct. Both were\n * tested. The two suites had different ideas of the bytes, and nothing anywhere\n * held the two ends of the same string \u2014 a human clicking Send on staging is\n * what found it.\n *\n * So the request this file builds and the response it parses are the ones the\n * console's `read-wire.test.ts` runs against the REAL handler: SDK client \u2192\n * real URL and headers \u2192 real `GET` export \u2192 real response \u2192 this parser. If\n * the two ends disagree about a field name, that test is red on the commit that\n * did it rather than on a staging page a week later.\n *\n * ===========================================================================\n * EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE\n * ===========================================================================\n *\n * `transport.ts`'s `safeIssueUrl` header has the full argument; the same one\n * applies here with more surface. A marker's `comment` and `reviewer` are\n * rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken\n * console must not be able to put a screenful of control characters into a\n * panel nobody can dismiss. Strings only, bounded, control characters dropped;\n * anything that fails is dropped rather than repaired, and a marker that cannot\n * be read is SKIPPED rather than rendered half-built.\n *\n * ===========================================================================\n * THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED\n * ===========================================================================\n *\n * The console and the product deploy on different cadences from different\n * repositories, so an SDK meeting a newer console is the normal state. A marker\n * whose `anchor.kind` this version does not recognise is kept in the counts and\n * placed by nothing: `resolveAnchor` already answers `\"none\"` for an unknown\n * kind. It must never be guessed at \u2014 a marker on the wrong element does not\n * degrade this feature, it inverts it.\n */\n\n/** The wire version this client was written against. */\nexport const ANNOTATION_READ_WIRE_VERSION = 1;\n\nexport type AnnotationStatus = \"open\" | \"in-progress\" | \"done\" | \"cancelled\" | \"unknown\";\n\n/**\n * The statuses this version knows.\n *\n * A status the console adds later parses to `\"unknown\"` and keeps its display\n * string, which is the additive-only rule applied to an enum: the SDK renders\n * the console's words and switches on a value it understands, rather than\n * dropping a marker because a vocabulary grew.\n */\nconst KNOWN_STATUSES: readonly string[] = [\"open\", \"in-progress\", \"done\", \"cancelled\", \"unknown\"];\n\nexport interface AnnotationStatusView {\n state: AnnotationStatus;\n display: string;\n}\n\nexport interface AnnotationMarker {\n id: string;\n anchor: Anchor;\n comment: string;\n /** A display name. The console never sends an email; this never expects one. */\n reviewer: string;\n createdAt: string;\n status: AnnotationStatusView;\n}\n\nexport type AnnotationsReason =\n | \"no-path\"\n | \"scan-truncated\"\n | \"capped\"\n | \"rate-limited\"\n | \"not-authorised\"\n /** The token is a format the console has retired (A-277). Press the button again. */\n | \"retired-link\";\n\n/**\n * The counted envelope, never a bare array.\n *\n * On a read path zero markers is exactly what \"working, nothing here\" looks\n * like, and at least nine causes produce zero. A caller renders these counts \u2014\n * *\"3 annotations here, 1 shown\"* \u2014 so that a failure disagrees with itself\n * instead of being absent. The numerator is the PAGE's tally and the denominator\n * is `total`; `anchored` is a diagnostic and not the page's answer. See\n * `./markers`.\n */\nexport interface AnnotationsResponse {\n ok: boolean;\n wireVersion: number;\n asOf: string;\n total: number;\n /**\n * The CONSOLE's count of servable anchors, not a count of markers on screen.\n *\n * Named `anchored` and not `placed` (A-277): the server has no DOM, so what it\n * counts is whether an anchor COULD resolve to something \u2014 a chain, a\n * dom-path, or a route path \u2014 while whether one DID is `tallyAnchors` in\n * `./anchor`, measured against this document. The two disagree routinely; a\n * listing page yields about 25% unique resolution. The word `placed` belongs\n * to the page, because that is what a human means by it.\n */\n anchored: number;\n anchorless: number;\n /**\n * Captures the console held back because they are `done` or `cancelled`\n * (A-288), and the reason this field is not optional.\n *\n * The default read SUPPRESSES a resolved capture, so without this number a\n * marker that was made, answered and fixed is indistinguishable on the page\n * from a marker that was never made \u2014 which is the exact failure the counted\n * envelope exists to prevent, reintroduced by a display default.\n *\n * It is not a count of anything on screen and it is never added to `total`:\n * `total` counts what the response is ABOUT, so a suppressed capture is\n * outside it. This says only \"there is history here\", which is what makes the\n * toggle a control somebody knows to press rather than one nobody finds.\n *\n * A console older than A-288 does not send it. `parseAnnotationsResponse`\n * answers `0` then \u2014 \"nothing is being hidden from you\" \u2014 which is the honest\n * reading of an older console that hides nothing.\n */\n resolvedHidden: number;\n reason?: AnnotationsReason;\n annotations: AnnotationMarker[];\n}\n\n/** Long enough for any comment a reviewer writes, short enough not to be a payload. */\nconst MAX_COMMENT = 4000;\nconst MAX_SHORT = 200;\n\nfunction cleanText(value: unknown, max: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}\u2026` : cleaned;\n}\n\nfunction cleanCount(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n ? Math.floor(value)\n : 0;\n}\n\n/**\n * An anchor from the network, or `undefined`.\n *\n * Shape-checked rather than cast. `resolveAnchor` reads `chain` as an array and\n * `path` as a string, and a marker whose anchor is `null` would take the\n * placement call down for every other marker on the page.\n */\nfunction parseAnchor(value: unknown): Anchor | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n const kind = raw[\"kind\"];\n\n if (kind === \"route\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? { kind: \"route\" } : { kind: \"route\", path };\n }\n if (kind === \"dom-path\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? undefined : { kind: \"dom-path\", path };\n }\n if (kind === \"source-ref\") {\n const file = raw[\"file\"];\n const line = raw[\"line\"];\n const chain = raw[\"chain\"];\n if (typeof file !== \"string\" || typeof line !== \"number\" || !Array.isArray(chain)) {\n return undefined;\n }\n const frames = chain.flatMap((frame) => {\n if (typeof frame !== \"object\" || frame === null) return [];\n const f = frame as Record<string, unknown>;\n if (typeof f[\"file\"] !== \"string\" || typeof f[\"line\"] !== \"number\") return [];\n const out: { file: string; line: number; column?: number } = {\n file: f[\"file\"],\n line: f[\"line\"],\n };\n if (typeof f[\"column\"] === \"number\") out.column = f[\"column\"];\n return [out];\n });\n const anchor: Anchor = { kind: \"source-ref\", file, line, chain: frames };\n if (typeof raw[\"column\"] === \"number\") anchor.column = raw[\"column\"];\n if (typeof raw[\"ordinal\"] === \"number\") anchor.ordinal = raw[\"ordinal\"];\n return anchor;\n }\n\n /**\n * A kind this version does not know. Skipped \u2014 never placed on a guess. The\n * console's own `wireVersion` is what tells a caller this happened, and\n * `resolveAnchor` would answer `\"none\"` even if one got through.\n */\n return undefined;\n}\n\nfunction parseMarker(value: unknown): AnnotationMarker | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n\n const id = cleanText(raw[\"id\"], MAX_SHORT);\n const comment = cleanText(raw[\"comment\"], MAX_COMMENT);\n const anchor = parseAnchor(raw[\"anchor\"]);\n if (id === undefined || comment === undefined || anchor === undefined) return undefined;\n\n const statusRaw = (\n typeof raw[\"status\"] === \"object\" && raw[\"status\"] !== null ? raw[\"status\"] : {}\n ) as Record<string, unknown>;\n const state = statusRaw[\"state\"];\n\n return {\n id,\n anchor,\n comment,\n reviewer: cleanText(raw[\"reviewer\"], MAX_SHORT) ?? \"A reviewer\",\n createdAt: cleanText(raw[\"createdAt\"], MAX_SHORT) ?? \"\",\n status: {\n state: (typeof state === \"string\" && KNOWN_STATUSES.includes(state)\n ? state\n : \"unknown\") as AnnotationStatus,\n display: cleanText(statusRaw[\"display\"], MAX_SHORT) ?? \"Unknown\",\n },\n };\n}\n\n/**\n * Parse a response body into an envelope, whatever it turns out to be.\n *\n * NEVER THROWS, and never returns `undefined`. A caller on a product page has to\n * render something, and \"the console said something I could not read\" is a state\n * with an answer \u2014 an envelope whose counts are zero and whose `ok` is false \u2014\n * rather than an exception that takes the marker layer down.\n */\nexport function parseAnnotationsResponse(value: unknown): AnnotationsResponse {\n const raw = (typeof value === \"object\" && value !== null ? value : {}) as Record<string, unknown>;\n const list = Array.isArray(raw[\"annotations\"]) ? raw[\"annotations\"] : [];\n const annotations = list.flatMap((entry) => {\n const marker = parseMarker(entry);\n return marker === undefined ? [] : [marker];\n });\n const reason = raw[\"reason\"];\n\n return {\n ok: raw[\"ok\"] === true,\n wireVersion: cleanCount(raw[\"wireVersion\"]),\n asOf: cleanText(raw[\"asOf\"], MAX_SHORT) ?? \"\",\n total: cleanCount(raw[\"total\"]),\n anchored: cleanCount(raw[\"anchored\"]),\n anchorless: cleanCount(raw[\"anchorless\"]),\n resolvedHidden: cleanCount(raw[\"resolvedHidden\"]),\n ...(typeof reason === \"string\" ? { reason: reason as AnnotationsReason } : {}),\n annotations,\n };\n}\n\nexport interface AnnotationReaderOptions {\n /** The console's ingest URL \u2014 the same one `httpTransport` posts to. */\n endpoint: string;\n /**\n * A READ-scoped claim, and never the capture token.\n *\n * The console refuses a write claim here and refuses this one at the ingest;\n * the two are structurally different credentials with different lifetimes (one\n * hour against four). Handing the capture token to this function does not\n * degrade to a read \u2014 it fails, which is the intended outcome.\n */\n readToken: string;\n /** Own timeout, own catch. A hung read must not hang the page. */\n timeoutMs?: number;\n fetch?: FetchLike;\n}\n\nexport const READ_TIMEOUT_MS = 8_000;\n\n/**\n * The literal the console compares against, and the only one (A-288).\n *\n * The handler is `params.get(\"resolved\") === \"include\"` \u2014 an equality against\n * this exact string, deliberately, with tests in `route.test.ts` pinning that\n * `true`, `1`, `yes`, `Include`, `INCLUDE` and a trailing space all get the\n * DEFAULT rather than the wider response. So this is a constant rather than a\n * literal typed at the call site: a client that sent `true` would widen nothing\n * and report nothing wrong, and the page would say \"2 resolved\" beside a button\n * that does not work.\n */\nexport const RESOLVED_INCLUDE = \"include\";\n\nexport interface AnnotationReadOptions {\n /**\n * Ask for the `done` and `cancelled` captures as well.\n *\n * Default `false`, matching the console's default rather than overriding it:\n * a month of daily use leaves every reviewed component under a stack of\n * markers for things fixed weeks ago, and the layer becomes noise while every\n * individual marker remains a true statement.\n *\n * Suppression is not removal, and the number held back comes back as\n * `resolvedHidden` whether or not this is set.\n */\n includeResolved?: boolean;\n}\n\nexport interface AnnotationReader {\n /**\n * The markers for one resolved path.\n *\n * `path` is `location.pathname` and NOT the route pattern. The pattern is one\n * value for every page of a dynamic route, so asking by pattern returns\n * another page's captures \u2014 measured, and the reason this parameter exists at\n * all rather than being derived from the payload.\n *\n * Rejects only on a transport failure. Every answer the console gives,\n * including a refusal and a 429, comes back as an envelope with a `reason`,\n * because a caller cannot tell \"your budget is spent\" from \"nothing here\"\n * out of a status code.\n */\n read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse>;\n}\n\n/**\n * The read client. `httpTransport`'s sibling, pointed the other way.\n */\nexport function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.readToken) {\n throw new Error(\n \"[annotation] a read token is required; the console refuses an unsigned read \" +\n \"and the page would render an empty marker layer with no way to say why.\",\n );\n }\n const token = options.readToken;\n const timeoutMs = options.timeoutMs ?? READ_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n async read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse> {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the read\");\n }\n const url = new URL(endpoint);\n /**\n * `searchParams.set` and not string concatenation, so a path containing a\n * `&`, a `#` or a space is encoded once and correctly. A hand-built query\n * string is how one page's markers end up requested for another.\n */\n url.searchParams.set(\"path\", path);\n /**\n * ONLY when asked, and only ever this exact string.\n *\n * The parameter is omitted rather than sent as `exclude` on the default\n * path: the console's default IS suppression, and inventing a second\n * literal for it would be a value no handler compares against \u2014 which\n * reads, on the wire and in a log, exactly like a request that asked for\n * something.\n */\n if (options?.includeResolved === true) {\n url.searchParams.set(\"resolved\", RESOLVED_INCLUDE);\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(url.toString(), {\n method: \"GET\",\n headers: { authorization: `Bearer ${token}` },\n // Never the reviewer's cookies; the token is the whole credential.\n credentials: \"omit\",\n signal: controller.signal,\n });\n let body: unknown;\n try {\n body = await response.json?.();\n } catch {\n body = undefined;\n }\n return parseAnnotationsResponse(body);\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the read timed out after ${timeoutMs}ms`, { cause: error });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { assertConcreteOrigin, type FrameToParent } from \"./protocol\";\n\n/**\n * Egress (A-239).\n *\n * ===========================================================================\n * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`\n * ===========================================================================\n *\n * `sdk.ts` had exactly one way out \u2014 `win.parent.postMessage(...)` \u2014 and\n * first-party there is no parent: `win.parent === win`. Every capture then\n * posts a message to the page that sent it and nothing receives it. **That\n * fails silently**, which is the shape to be careful of here: the overlay\n * behaves correctly, the reviewer clicks, and no issue is ever filed.\n *\n * See `docs/plans/first-party-annotation.md`. The console stops framing the\n * product and links out to it instead, so the capture has to leave the page\n * over HTTP. The frame is NOT being deleted \u2014 `postMessageTransport` is\n * today's behaviour, extracted unchanged, and it is still the default.\n *\n * ===========================================================================\n * TWO PROPERTIES THAT HAD TO SURVIVE\n * ===========================================================================\n *\n * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still\n * guards the message path, and `assertConcreteEndpoint` is its equivalent\n * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,\n * because a misconfiguration that only surfaces when a reviewer finally\n * clicks something is a misconfiguration nobody finds in review.\n *\n * 2. **The screenshot never costs the capture**, and neither may the network.\n * `attachScreenshot` resolves within its own timeout and never rejects, so\n * the payload goes out either way. An HTTP transport would reintroduce\n * exactly the failure mode that was designed out \u2014 a slow network losing a\n * capture \u2014 unless it carries its own timeout and its own `catch`, and\n * unless a failed send is REPORTABLE rather than swallowed. A dropped\n * capture that looks like a successful one is the worst outcome available\n * here, so `send` rejects loudly and `createEgress` routes that to the\n * caller.\n */\n\n/** Every message the page half can emit. */\nexport type FrameMessageKind = FrameToParent[\"type\"];\n\n/**\n * What the far end said about a capture it accepted (A-240).\n *\n * Everything on it is optional and always will be. `postMessageTransport`\n * cannot answer at all \u2014 `postMessage` has no reply \u2014 and the ingest may\n * answer with nothing. A reviewer who is told *sent* and given no link is in a\n * strictly better position than one told nothing, so an absent `issueUrl` is a\n * successful send and never a failed one.\n */\nexport interface SendReceipt {\n /**\n * Durable console status for this capture's workflow. This is an opaque URL\n * supplied by the console, not a client-derived run identifier.\n */\n progressUrl?: string;\n /**\n * The issue this capture became, when the far end names one.\n *\n * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type\n * alone is not the contract \u2014 see `safeIssueUrl`, which is what every value\n * crossing the network boundary is put through before it gets to be one.\n */\n issueUrl?: string;\n saved?: { feedbackId: string; recoveryUrl?: string; message: string };\n}\n\n/** Longer than any issue URL and short enough not to be a payload. */\nconst MAX_ISSUE_URL = 2048;\n\n/**\n * A URL from the network that is safe to put in somebody else's page (A-240).\n *\n * ===========================================================================\n * WHY THIS IS NOT PARANOIA\n * ===========================================================================\n *\n * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets\n * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there\n * executes in the product's origin the moment the reviewer clicks *View\n * issue* \u2014 a script injection into a third party's site, shipped by us, on\n * every page of every managed product.\n *\n * `assertConcreteEndpoint` does not cover this and was never going to:\n * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is\n * misconfigured, compromised, or simply behind a proxy that rewrites bodies is\n * exactly the case a transport has to survive, and \"our own console would\n * never\" is not a security boundary.\n *\n * It lives HERE and not in the toolbar so the guarantee travels with the\n * receipt rather than with one renderer of it. The toolbar calls it too \u2014 see\n * `showResult` \u2014 because it renders receipts from ANY transport, including one\n * a consumer wrote, which never passes through this function.\n *\n * Anything not `http(s)` is dropped rather than repaired. A dropped link is a\n * toast that says the capture was sent and offers no link, which is the\n * outcome the receipt is already designed to survive.\n */\nexport function safeIssueUrl(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0 || value.length > MAX_ISSUE_URL) {\n return undefined;\n }\n let url: URL;\n try {\n // Absolute only: `new URL` throws on a relative one, and a relative href in\n // somebody else's page points somewhere in THEIR app.\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return undefined;\n return url.href;\n}\n\n/** Long enough for any refusal the ingest writes, short enough not to be a body. */\nconst MAX_REFUSAL_REASON = 300;\n\n/**\n * A refusal sentence from the network that is safe to put in front of a\n * reviewer (A-285).\n *\n * `safeIssueUrl`'s argument, applied to prose. The ingest composes refusals a\n * human can act on \u2014 a link issued for a different deployment, an empty\n * comment \u2014 and until this slice the transport threw `HTTP 403` and discarded\n * every one of them, so **not one of those sentences had ever reached a\n * human.**\n *\n * The value is still untrusted, and it ends up as `textContent` in the\n * CUSTOMER'S OWN PAGE. `textContent` is not an injection seam the way `href`\n * is, so the risk here is not script execution \u2014 it is a hostile or broken\n * ingest writing a screenful of control characters, ANSI escapes or a whole\n * JSON document into a toast the reviewer cannot dismiss. So: strings only,\n * control characters dropped, whitespace collapsed, and a hard ceiling.\n *\n * Anything that fails is dropped rather than repaired, and the caller falls\n * back to the HTTP status \u2014 which is what every refusal rendered as before\n * this existed, so the floor cannot get worse.\n */\nexport function safeRefusalReason(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > MAX_REFUSAL_REASON\n ? `${cleaned.slice(0, MAX_REFUSAL_REASON - 1).trimEnd()}\u2026`\n : cleaned;\n}\n\n/**\n * The error a refused capture throws, carrying the reason as a FIELD.\n *\n * The reason is in the `message` too, prefixed for a log. A renderer that had\n * to recover it by string-slicing that message would be parsing our own prose,\n * which breaks the first time somebody edits the prefix \u2014 so the sentence a\n * human should see is a property, and `refusalReason` is the one way to read\n * it.\n */\nexport interface IngestRefusal extends Error {\n /** The ingest's own words, already through `safeRefusalReason`. */\n reason?: string;\n /** The status, always \u2014 it is the fallback and it is never absent. */\n status?: number;\n}\n\n/**\n * The reviewer-facing sentence for a failed capture, or `undefined`.\n *\n * SANITISED AGAIN, deliberately, exactly as `safeIssueUrl` is called at both\n * ends: this reads an `EgressOutcome.error` from WHATEVER transport produced\n * it, including one a consumer wrote, which never passed through\n * `httpTransport`. One function, so the two ends cannot disagree.\n */\nexport function refusalReason(error: unknown): string | undefined {\n return safeRefusalReason((error as IngestRefusal | null | undefined)?.reason);\n}\n\nexport interface AnnotationTransport {\n /** For diagnostics and for tests; never sent anywhere. */\n readonly name: string;\n /**\n * May this transport carry this kind of message?\n *\n * The interface needs this because `annotate:hover` fires once per animation\n * frame while the reviewer moves the mouse. Over `postMessage` that is a\n * structured clone to a frame that is already listening; over HTTP it is one\n * request per frame, which is a denial of service aimed at our own console.\n *\n * A transport that DROPPED such a message silently would be the same bug\n * this issue exists to remove, one layer down. So declining is an explicit\n * answer the SDK asks for and respects, and `send` is never called for a\n * kind that was declined.\n */\n carries(kind: FrameMessageKind): boolean;\n /**\n * Deliver the message. May be synchronous.\n *\n * A returned promise MUST reject on failure. Resolving on a failed send is\n * the one thing a transport may not do.\n */\n send(message: FrameToParent): void | Promise<void | SendReceipt>;\n}\n\n/** What went wrong, handed back to whoever constructed the SDK. */\nexport interface EgressFailure {\n message: FrameToParent;\n transport: string;\n error: unknown;\n}\n\nexport type EgressErrorHandler = (failure: EgressFailure) => void;\n\n/**\n * The parent-window transport \u2014 today's behaviour, extracted unchanged.\n *\n * It carries everything: the frame is a live listener on the other end of a\n * cheap channel, and the handshake (`annotate:ready`) and the hover highlight\n * both belong to it.\n */\nexport function postMessageTransport(\n parentOrigin: string,\n win: Pick<Window, \"parent\"> = window,\n): AnnotationTransport {\n const origin = assertConcreteOrigin(parentOrigin);\n return {\n name: \"postMessage\",\n carries: () => true,\n send(message) {\n // Never a wildcard, in either direction.\n win.parent.postMessage(message, origin);\n },\n };\n}\n\n/**\n * Reject anything that is not one concrete `http(s)` URL.\n *\n * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL\n * resolves against whatever page the SDK happens to be on \u2014 so a capture\n * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`\n * or `data:` endpoint is a script-injection seam handed to whoever controls\n * the deep link. Neither is a thing to discover at first capture.\n */\nexport function assertConcreteEndpoint(endpoint: string): string {\n if (!endpoint || endpoint === \"*\") {\n throw new Error(\n \"[annotation] a concrete ingest endpoint is required; \" +\n \"'*' is not an address and an empty one posts nowhere.\",\n );\n }\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw new Error(\n `[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}; ` +\n \"a relative one resolves against the product's own origin and the capture is lost there.\",\n );\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\n `[annotation] the ingest endpoint must be http(s), got ${url.protocol}`,\n );\n }\n return url.toString();\n}\n\n/** Node's `fetch` and the browser's, narrowed to what we use. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n /**\n * Optional since A-276: the read client issues a `GET`, which has no body,\n * and a required field would make it pass `undefined` explicitly \u2014 a value\n * a stub asserting on `init` then has to know to ignore. `httpTransport`\n * still always sends one.\n */\n body?: string;\n credentials?: \"omit\";\n signal?: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n /**\n * Optional because `serve()`'s loopback responses are read through the\n * platform `fetch`, which has it, while a stub written to assert the `init`\n * need not. A transport that made the body mandatory would be asserting\n * something about the ingest that the ingest does not promise.\n */\n json?: () => Promise<unknown>;\n}>;\n\nexport interface HttpTransportOptions {\n /** The console's ingest URL. Absolute, `http(s)`, never `*`. */\n endpoint: string;\n /**\n * The capture token from the deep link \u2014 signed by the console, carried by\n * the product, never validated by it. See the plan's *Authentication*\n * section for why this is a second credential and not the arming one.\n */\n captureToken: string;\n /**\n * Own timeout, own catch. The default is deliberately short: a capture the\n * reviewer is told failed can be retried, and a request that hangs is\n * indistinguishable from one that worked.\n */\n timeoutMs?: number;\n /** Injectable only so the tests can drive a transport with no `fetch` at all. */\n fetch?: FetchLike;\n}\n\nexport const HTTP_TRANSPORT_TIMEOUT_MS = 8_000;\n\n/**\n * The first-party transport: POST the capture to the console's ingest.\n *\n * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one\n * request per animation frame, and `annotate:ready` / `annotate:cancel` are\n * handshake with a parent window that does not exist first-party \u2014 sending\n * them would be filing HTTP requests nobody asked for.\n */\nexport function httpTransport(options: HttpTransportOptions): AnnotationTransport {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.captureToken) {\n throw new Error(\n \"[annotation] a capture token is required; the ingest rejects an unsigned capture \" +\n \"and the reviewer would only find out after writing one.\",\n );\n }\n const token = options.captureToken;\n const timeoutMs = options.timeoutMs ?? HTTP_TRANSPORT_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n name: \"http\",\n carries: (kind) => kind === \"annotate:capture\",\n async send(message) {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the ingest\");\n }\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${token}`,\n },\n // Never the reviewer's cookies. The token is the whole credential,\n // and an ambient one would make this endpoint CSRF-shaped.\n credentials: \"omit\",\n body: JSON.stringify(message),\n signal: controller.signal,\n });\n if (!response.ok) {\n /**\n * THE REFUSAL IS READ, AND THE STATUS IS THE FALLBACK (A-285).\n *\n * The ingest composes sentences a reviewer can act on \u2014 \"this link\n * was issued for a different deployment\", \"an empty comment files an\n * issue nobody can action\" \u2014 and this line used to throw `HTTP 403`\n * and drop every one of them on the floor. A status code is not a\n * refusal a human can do anything with; it is what we say when the\n * far end gave us nothing better.\n *\n * The body read is inside its own `catch` for the same reason the\n * success path's is: a refusal we could not parse must still be a\n * refusal, never a `TypeError` from `json()` masquerading as one.\n * Unlike the success path, this branch was ALREADY a failure, so a\n * parse problem loses information rather than a capture.\n */\n let reason: string | undefined;\n try {\n const refusal = (await response.json?.()) as { error?: unknown } | undefined;\n reason = safeRefusalReason(refusal?.error);\n } catch {\n reason = undefined;\n }\n const failure: IngestRefusal = new Error(\n `[annotation] the ingest rejected the capture: ${reason ?? `HTTP ${response.status}`}`,\n );\n failure.status = response.status;\n if (reason !== undefined) failure.reason = reason;\n throw failure;\n }\n /**\n * The capture is delivered at this point and nothing below may undo\n * that. A body that is empty, not JSON, or JSON of some other shape is\n * an ingest that chose not to name an issue \u2014 telling the reviewer\n * their capture failed because we could not parse a courtesy would be\n * the same lie as a silent drop, pointing the other way.\n *\n * A-285 read a body on the REFUSAL path above and this property is\n * untouched by that: the two branches are opposites. Up there the\n * capture was refused and an unparseable body costs only the wording;\n * down here the capture is DELIVERED, and no failure to read a\n * courtesy may ever turn it into a failed one.\n */\n try {\n const body = (await response.json?.()) as { issueUrl?: unknown; url?: unknown; progressUrl?: unknown; kind?: unknown; feedbackId?: unknown; recoveryUrl?: unknown; error?: unknown } | undefined;\n // Untrusted, all of it. `safeIssueUrl` returns undefined for a\n // non-string, a relative URL, an over-long one and any scheme that\n // is not http(s) \u2014 so a hostile body degrades to a capture that was\n // sent and named no issue.\n const progressUrl = safeIssueUrl(body?.progressUrl);\n if (body?.kind === \"saved\" && typeof body.feedbackId === \"string\" && /^[a-zA-Z0-9-]{1,80}$/.test(body.feedbackId)) {\n const recoveryUrl = safeIssueUrl(body.recoveryUrl);\n return {\n ...(progressUrl ? { progressUrl } : {}),\n saved: { feedbackId: body.feedbackId,\n ...(recoveryUrl ? { recoveryUrl } : {}),\n message: safeRefusalReason(body.error) ?? \"Capture saved. Finish filing in the console.\" },\n };\n }\n const issueUrl = safeIssueUrl(body?.issueUrl ?? body?.url);\n if (issueUrl !== undefined || progressUrl !== undefined) {\n return { ...(issueUrl ? { issueUrl } : {}), ...(progressUrl ? { progressUrl } : {}) };\n }\n } catch {}\n return {};\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the capture timed out after ${timeoutMs}ms`, {\n cause: error,\n });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n\n/**\n * What became of one message (A-240).\n *\n * `createEgress` used to return `void`, which was enough while the only reader\n * of a failure was `console.error`. The composer has to TELL THE REVIEWER \u2014 the\n * issue link on success, a retry on failure \u2014 and it cannot do that from a\n * function that answers nothing. So the outcome is returned as a value, and the\n * promise NEVER REJECTS: a rejection here would be a second way for a lost\n * capture to disappear, one `await`-less caller away from silence.\n *\n * `declined` is its own case rather than a failure, because it is not one:\n * `annotate:hover` over HTTP is a kind the transport correctly refuses, and\n * calling that a failed capture would put a red toast in front of a reviewer\n * who did nothing wrong. It is only a problem if `annotate:capture` is what was\n * declined, and that is the caller's judgement rather than this function's.\n */\nexport type EgressOutcome =\n | { status: \"sent\"; receipt: SendReceipt }\n | { status: \"declined\" }\n | { status: \"failed\"; error: unknown };\n\n/**\n * The SDK's one way out, given a transport.\n *\n * Pure and DOM-free on purpose, so the two things worth arguing about \u2014 that a\n * declined kind is never sent, and that a failed send reaches the caller \u2014 are\n * testable without a browser. `sdk.ts` holds no other egress path.\n */\nexport function createEgress(\n transport: AnnotationTransport,\n onError?: EgressErrorHandler,\n): (message: FrameToParent) => Promise<EgressOutcome> {\n const report = (message: FrameToParent, error: unknown) => {\n const failure: EgressFailure = { message, transport: transport.name, error };\n if (onError) {\n onError(failure);\n return;\n }\n /**\n * No handler is not a licence to swallow it. Until A-240 puts a toast in\n * front of the reviewer, the console is the only surface there is, and a\n * lost capture must leave a trace somewhere.\n */\n console.error(\"[annotation] a capture could not be delivered\", failure);\n };\n\n /**\n * `async`, and the synchronous half still runs synchronously \u2014 everything up\n * to the first `await` does, which is `transport.send` itself. So\n * `postMessageTransport` still posts before this returns, and a transport\n * that throws is still reported before this returns. That is not an\n * incidental detail: `sdk.test.ts` asserts the frame path's handshake has\n * been posted the instant `createAnnotationSdk` returns, and it must stay\n * true, because the frame is not being deleted.\n */\n return async (message: FrameToParent): Promise<EgressOutcome> => {\n if (!transport.carries(message.type)) return { status: \"declined\" };\n try {\n const receipt = await transport.send(message);\n return { status: \"sent\", receipt: receipt ?? {} };\n } catch (error) {\n report(message, error);\n return { status: \"failed\", error };\n }\n };\n}\n", "import type { Anchor } from \"./anchor\";\nimport { assertConcreteEndpoint, type FetchLike } from \"./transport\";\n\n/**\n * Reading the captures already made on this page (A-276).\n *\n * ===========================================================================\n * WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`\n * ===========================================================================\n *\n * `./anchor`'s note applies unchanged: this runs on the product page and only\n * for a product that RENDERS markers, while the SDK that only captures does\n * not need a byte of it. Folding it into `index` would tax every page of every\n * managed product with code most of them never call.\n *\n * ===========================================================================\n * WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER\n * ===========================================================================\n *\n * A-268 is the reason, and it is worth restating because it cost a night. The\n * SDK posted `{type, payload}` with the comment nested inside `payload`; the\n * ingest read `comment` as a sibling of it. Both halves were correct. Both were\n * tested. The two suites had different ideas of the bytes, and nothing anywhere\n * held the two ends of the same string \u2014 a human clicking Send on staging is\n * what found it.\n *\n * So the request this file builds and the response it parses are the ones the\n * console's `read-wire.test.ts` runs against the REAL handler: SDK client \u2192\n * real URL and headers \u2192 real `GET` export \u2192 real response \u2192 this parser. If\n * the two ends disagree about a field name, that test is red on the commit that\n * did it rather than on a staging page a week later.\n *\n * ===========================================================================\n * EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE\n * ===========================================================================\n *\n * `transport.ts`'s `safeIssueUrl` header has the full argument; the same one\n * applies here with more surface. A marker's `comment` and `reviewer` are\n * rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken\n * console must not be able to put a screenful of control characters into a\n * panel nobody can dismiss. Strings only, bounded, control characters dropped;\n * anything that fails is dropped rather than repaired, and a marker that cannot\n * be read is SKIPPED rather than rendered half-built.\n *\n * ===========================================================================\n * THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED\n * ===========================================================================\n *\n * The console and the product deploy on different cadences from different\n * repositories, so an SDK meeting a newer console is the normal state. A marker\n * whose `anchor.kind` this version does not recognise is kept in the counts and\n * placed by nothing: `resolveAnchor` already answers `\"none\"` for an unknown\n * kind. It must never be guessed at \u2014 a marker on the wrong element does not\n * degrade this feature, it inverts it.\n */\n\n/** The wire version this client was written against. */\nexport const ANNOTATION_READ_WIRE_VERSION = 1;\n\nexport type AnnotationStatus = \"open\" | \"in-progress\" | \"done\" | \"cancelled\" | \"unknown\";\n\n/**\n * The statuses this version knows.\n *\n * A status the console adds later parses to `\"unknown\"` and keeps its display\n * string, which is the additive-only rule applied to an enum: the SDK renders\n * the console's words and switches on a value it understands, rather than\n * dropping a marker because a vocabulary grew.\n */\nconst KNOWN_STATUSES: readonly string[] = [\"open\", \"in-progress\", \"done\", \"cancelled\", \"unknown\"];\n\nexport interface AnnotationStatusView {\n state: AnnotationStatus;\n display: string;\n}\n\nexport interface AnnotationMarker {\n id: string;\n anchor: Anchor;\n comment: string;\n /** A display name. The console never sends an email; this never expects one. */\n reviewer: string;\n createdAt: string;\n status: AnnotationStatusView;\n}\n\nexport type AnnotationsReason =\n | \"no-path\"\n | \"scan-truncated\"\n | \"capped\"\n | \"rate-limited\"\n | \"not-authorised\"\n /** The token is a format the console has retired (A-277). Press the button again. */\n | \"retired-link\";\n\n/**\n * The counted envelope, never a bare array.\n *\n * On a read path zero markers is exactly what \"working, nothing here\" looks\n * like, and at least nine causes produce zero. A caller renders these counts \u2014\n * *\"3 annotations here, 1 shown\"* \u2014 so that a failure disagrees with itself\n * instead of being absent. The numerator is the PAGE's tally and the denominator\n * is `total`; `anchored` is a diagnostic and not the page's answer. See\n * `./markers`.\n */\nexport interface AnnotationsResponse {\n ok: boolean;\n wireVersion: number;\n asOf: string;\n total: number;\n /**\n * The CONSOLE's count of servable anchors, not a count of markers on screen.\n *\n * Named `anchored` and not `placed` (A-277): the server has no DOM, so what it\n * counts is whether an anchor COULD resolve to something \u2014 a chain, a\n * dom-path, or a route path \u2014 while whether one DID is `tallyAnchors` in\n * `./anchor`, measured against this document. The two disagree routinely; a\n * listing page yields about 25% unique resolution. The word `placed` belongs\n * to the page, because that is what a human means by it.\n */\n anchored: number;\n anchorless: number;\n /**\n * Captures the console held back because they are `done` or `cancelled`\n * (A-288), and the reason this field is not optional.\n *\n * The default read SUPPRESSES a resolved capture, so without this number a\n * marker that was made, answered and fixed is indistinguishable on the page\n * from a marker that was never made \u2014 which is the exact failure the counted\n * envelope exists to prevent, reintroduced by a display default.\n *\n * It is not a count of anything on screen and it is never added to `total`:\n * `total` counts what the response is ABOUT, so a suppressed capture is\n * outside it. This says only \"there is history here\", which is what makes the\n * toggle a control somebody knows to press rather than one nobody finds.\n *\n * A console older than A-288 does not send it. `parseAnnotationsResponse`\n * answers `0` then \u2014 \"nothing is being hidden from you\" \u2014 which is the honest\n * reading of an older console that hides nothing.\n */\n resolvedHidden: number;\n reason?: AnnotationsReason;\n annotations: AnnotationMarker[];\n}\n\n/** Long enough for any comment a reviewer writes, short enough not to be a payload. */\nconst MAX_COMMENT = 4000;\nconst MAX_SHORT = 200;\n\nfunction cleanText(value: unknown, max: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}\u2026` : cleaned;\n}\n\nfunction cleanCount(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n ? Math.floor(value)\n : 0;\n}\n\n/**\n * An anchor from the network, or `undefined`.\n *\n * Shape-checked rather than cast. `resolveAnchor` reads `chain` as an array and\n * `path` as a string, and a marker whose anchor is `null` would take the\n * placement call down for every other marker on the page.\n */\nfunction parseAnchor(value: unknown): Anchor | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n const kind = raw[\"kind\"];\n\n if (kind === \"route\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? { kind: \"route\" } : { kind: \"route\", path };\n }\n if (kind === \"dom-path\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? undefined : { kind: \"dom-path\", path };\n }\n if (kind === \"source-ref\") {\n const file = raw[\"file\"];\n const line = raw[\"line\"];\n const chain = raw[\"chain\"];\n if (typeof file !== \"string\" || typeof line !== \"number\" || !Array.isArray(chain)) {\n return undefined;\n }\n const frames = chain.flatMap((frame) => {\n if (typeof frame !== \"object\" || frame === null) return [];\n const f = frame as Record<string, unknown>;\n if (typeof f[\"file\"] !== \"string\" || typeof f[\"line\"] !== \"number\") return [];\n const out: { file: string; line: number; column?: number } = {\n file: f[\"file\"],\n line: f[\"line\"],\n };\n if (typeof f[\"column\"] === \"number\") out.column = f[\"column\"];\n return [out];\n });\n const anchor: Anchor = { kind: \"source-ref\", file, line, chain: frames };\n if (typeof raw[\"column\"] === \"number\") anchor.column = raw[\"column\"];\n if (typeof raw[\"ordinal\"] === \"number\") anchor.ordinal = raw[\"ordinal\"];\n return anchor;\n }\n\n /**\n * A kind this version does not know. Skipped \u2014 never placed on a guess. The\n * console's own `wireVersion` is what tells a caller this happened, and\n * `resolveAnchor` would answer `\"none\"` even if one got through.\n */\n return undefined;\n}\n\nfunction parseMarker(value: unknown): AnnotationMarker | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n\n const id = cleanText(raw[\"id\"], MAX_SHORT);\n const comment = cleanText(raw[\"comment\"], MAX_COMMENT);\n const anchor = parseAnchor(raw[\"anchor\"]);\n if (id === undefined || comment === undefined || anchor === undefined) return undefined;\n\n const statusRaw = (\n typeof raw[\"status\"] === \"object\" && raw[\"status\"] !== null ? raw[\"status\"] : {}\n ) as Record<string, unknown>;\n const state = statusRaw[\"state\"];\n\n return {\n id,\n anchor,\n comment,\n reviewer: cleanText(raw[\"reviewer\"], MAX_SHORT) ?? \"A reviewer\",\n createdAt: cleanText(raw[\"createdAt\"], MAX_SHORT) ?? \"\",\n status: {\n state: (typeof state === \"string\" && KNOWN_STATUSES.includes(state)\n ? state\n : \"unknown\") as AnnotationStatus,\n display: cleanText(statusRaw[\"display\"], MAX_SHORT) ?? \"Unknown\",\n },\n };\n}\n\n/**\n * Parse a response body into an envelope, whatever it turns out to be.\n *\n * NEVER THROWS, and never returns `undefined`. A caller on a product page has to\n * render something, and \"the console said something I could not read\" is a state\n * with an answer \u2014 an envelope whose counts are zero and whose `ok` is false \u2014\n * rather than an exception that takes the marker layer down.\n */\nexport function parseAnnotationsResponse(value: unknown): AnnotationsResponse {\n const raw = (typeof value === \"object\" && value !== null ? value : {}) as Record<string, unknown>;\n const list = Array.isArray(raw[\"annotations\"]) ? raw[\"annotations\"] : [];\n const annotations = list.flatMap((entry) => {\n const marker = parseMarker(entry);\n return marker === undefined ? [] : [marker];\n });\n const reason = raw[\"reason\"];\n\n return {\n ok: raw[\"ok\"] === true,\n wireVersion: cleanCount(raw[\"wireVersion\"]),\n asOf: cleanText(raw[\"asOf\"], MAX_SHORT) ?? \"\",\n total: cleanCount(raw[\"total\"]),\n anchored: cleanCount(raw[\"anchored\"]),\n anchorless: cleanCount(raw[\"anchorless\"]),\n resolvedHidden: cleanCount(raw[\"resolvedHidden\"]),\n ...(typeof reason === \"string\" ? { reason: reason as AnnotationsReason } : {}),\n annotations,\n };\n}\n\nexport interface AnnotationReaderOptions {\n /** The console's ingest URL \u2014 the same one `httpTransport` posts to. */\n endpoint: string;\n /**\n * A READ-scoped claim, and never the capture token.\n *\n * The console refuses a write claim here and refuses this one at the ingest;\n * the two are structurally different credentials with different lifetimes (one\n * hour against four). Handing the capture token to this function does not\n * degrade to a read \u2014 it fails, which is the intended outcome.\n */\n readToken: string;\n /** Own timeout, own catch. A hung read must not hang the page. */\n timeoutMs?: number;\n fetch?: FetchLike;\n}\n\nexport const READ_TIMEOUT_MS = 8_000;\n\n/**\n * The literal the console compares against, and the only one (A-288).\n *\n * The handler is `params.get(\"resolved\") === \"include\"` \u2014 an equality against\n * this exact string, deliberately, with tests in `route.test.ts` pinning that\n * `true`, `1`, `yes`, `Include`, `INCLUDE` and a trailing space all get the\n * DEFAULT rather than the wider response. So this is a constant rather than a\n * literal typed at the call site: a client that sent `true` would widen nothing\n * and report nothing wrong, and the page would say \"2 resolved\" beside a button\n * that does not work.\n */\nexport const RESOLVED_INCLUDE = \"include\";\n\nexport interface AnnotationReadOptions {\n /**\n * Ask for the `done` and `cancelled` captures as well.\n *\n * Default `false`, matching the console's default rather than overriding it:\n * a month of daily use leaves every reviewed component under a stack of\n * markers for things fixed weeks ago, and the layer becomes noise while every\n * individual marker remains a true statement.\n *\n * Suppression is not removal, and the number held back comes back as\n * `resolvedHidden` whether or not this is set.\n */\n includeResolved?: boolean;\n}\n\nexport interface AnnotationReader {\n /**\n * The markers for one resolved path.\n *\n * `path` is `location.pathname` and NOT the route pattern. The pattern is one\n * value for every page of a dynamic route, so asking by pattern returns\n * another page's captures \u2014 measured, and the reason this parameter exists at\n * all rather than being derived from the payload.\n *\n * Rejects only on a transport failure. Every answer the console gives,\n * including a refusal and a 429, comes back as an envelope with a `reason`,\n * because a caller cannot tell \"your budget is spent\" from \"nothing here\"\n * out of a status code.\n */\n read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse>;\n}\n\n/**\n * The read client. `httpTransport`'s sibling, pointed the other way.\n */\nexport function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.readToken) {\n throw new Error(\n \"[annotation] a read token is required; the console refuses an unsigned read \" +\n \"and the page would render an empty marker layer with no way to say why.\",\n );\n }\n const token = options.readToken;\n const timeoutMs = options.timeoutMs ?? READ_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n async read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse> {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the read\");\n }\n const url = new URL(endpoint);\n /**\n * `searchParams.set` and not string concatenation, so a path containing a\n * `&`, a `#` or a space is encoded once and correctly. A hand-built query\n * string is how one page's markers end up requested for another.\n */\n url.searchParams.set(\"path\", path);\n /**\n * ONLY when asked, and only ever this exact string.\n *\n * The parameter is omitted rather than sent as `exclude` on the default\n * path: the console's default IS suppression, and inventing a second\n * literal for it would be a value no handler compares against \u2014 which\n * reads, on the wire and in a log, exactly like a request that asked for\n * something.\n */\n if (options?.includeResolved === true) {\n url.searchParams.set(\"resolved\", RESOLVED_INCLUDE);\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(url.toString(), {\n method: \"GET\",\n headers: { authorization: `Bearer ${token}` },\n // Never the reviewer's cookies; the token is the whole credential.\n credentials: \"omit\",\n signal: controller.signal,\n });\n let body: unknown;\n try {\n body = await response.json?.();\n } catch {\n body = undefined;\n }\n return parseAnnotationsResponse(body);\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the read timed out after ${timeoutMs}ms`, { cause: error });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["assertConcreteEndpoint", "endpoint", "url", "ANNOTATION_READ_WIRE_VERSION", "KNOWN_STATUSES", "MAX_COMMENT", "MAX_SHORT", "cleanText", "value", "max", "cleaned", "cleanCount", "parseAnchor", "raw", "kind", "path", "file", "line", "chain", "frames", "frame", "f", "out", "anchor", "parseMarker", "id", "comment", "statusRaw", "state", "parseAnnotationsResponse", "annotations", "entry", "marker", "reason", "READ_TIMEOUT_MS", "RESOLVED_INCLUDE", "createAnnotationReader", "options", "endpoint", "assertConcreteEndpoint", "token", "timeoutMs", "doFetch", "url", "controller", "timer", "response", "body", "error"]
7
7
  }
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Screenshot capture for the annotation payload (A-31).
3
3
  *
4
- * WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
5
- * This is not a page rasteriser. It captures the bitmap of a single
4
+ * A-2385 CHANGED WHAT THIS CAPTURES FIRST: the VIEWPORT, rendered in the page
5
+ * by `page-capture.ts` (with its one approved dependency, loaded on demand).
6
+ * Everything below this paragraph describes the FALLBACK, which runs when the
7
+ * page render is unavailable, fails or times out, and which was the whole of
8
+ * this module until then.
9
+ *
10
+ * WHAT THE FALLBACK CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
11
+ * It is not a page rasteriser. It captures the bitmap of a single
6
12
  * `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
7
- * nothing else. Click a heading and the payload has no screenshot.
13
+ * nothing else. Click a heading and the fallback has no screenshot.
8
14
  *
9
15
  * WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
10
16
  * "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
@@ -31,6 +37,23 @@
31
37
  * with `screenshot` absent from an otherwise complete payload, never with a
32
38
  * capture the reviewer loses.
33
39
  */
40
+ import type { CanvasRead, CanvasReadout, PageRenderer } from "./page-capture.cjs";
41
+ /** Widest viewport image we send, in pixels (A-2385). A 1440px laptop at 2x renders at 1600. */
42
+ export declare const MAX_VIEWPORT_WIDTH = 1600;
43
+ /** The decoded viewport image, not its data URL: what Linear stores (A-2385). */
44
+ export declare const MAX_VIEWPORT_IMAGE_BYTES: number;
45
+ /**
46
+ * The longest data URL a viewport screenshot can be. The manager's size limits
47
+ * are derived from this, so the client cap and the server cap are one number.
48
+ */
49
+ export declare const MAX_VIEWPORT_DATA_URL_LENGTH: number;
50
+ /**
51
+ * How long the page render may take before the element bitmap is used
52
+ * instead. Rendering clones the DOM and inlines its images and fonts, which on
53
+ * a heavy page is a second or two; the composer is open meanwhile, so the
54
+ * reviewer is not waiting on it.
55
+ */
56
+ export declare const VIEWPORT_TIMEOUT_MS = 4000;
34
57
  /** The canvas surface this module needs. Narrow so tests need no browser. */
35
58
  export interface CanvasLike {
36
59
  width: number;
@@ -67,6 +90,11 @@ export interface CaptureCandidate {
67
90
  export type Screenshot = {
68
91
  dataUrl: string;
69
92
  region: "element" | "viewport";
93
+ /** Pixel size of the encoded image (viewport captures, A-2385). */
94
+ w?: number;
95
+ h?: number;
96
+ /** What the canvases in the viewport read back as (viewport captures, A-2385). */
97
+ canvases?: CanvasReadout;
70
98
  };
71
99
  /**
72
100
  * The byte budget for the encoded data URL.
@@ -118,6 +146,18 @@ export interface ScreenshotOptions {
118
146
  * reachable from a fake DOM object alone.
119
147
  */
120
148
  createCanvas?: () => CanvasLike | null;
149
+ /**
150
+ * Render the viewport (A-2385). On by default; `false` keeps only the element
151
+ * bitmap below, which is what this module did before.
152
+ */
153
+ page?: boolean;
154
+ /** Viewport image caps: width in pixels and decoded bytes. */
155
+ maxViewportWidth?: number;
156
+ maxViewportBytes?: number;
157
+ viewportTimeoutMs?: number;
158
+ /** Injected so tests need neither a browser nor the library. */
159
+ loadRenderer?: () => Promise<PageRenderer>;
160
+ readCanvas?: (canvas: unknown) => CanvasRead;
121
161
  }
122
162
  interface ResolvedOptions {
123
163
  enabled: boolean;
@@ -125,6 +165,15 @@ interface ResolvedOptions {
125
165
  maxEdge: number;
126
166
  timeoutMs: number;
127
167
  createCanvas: () => CanvasLike | null;
168
+ /** The document to render, or null where there is none (a test, a server). */
169
+ doc: Document | null;
170
+ page: boolean;
171
+ maxViewportWidth: number;
172
+ maxViewportBytes: number;
173
+ viewportTimeoutMs: number;
174
+ /** Null for the real library / the real canvas read. */
175
+ loadRenderer: (() => Promise<PageRenderer>) | null;
176
+ readCanvas: ((canvas: unknown) => CanvasRead) | null;
128
177
  }
129
178
  export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
130
179
  export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
@@ -162,10 +211,10 @@ export declare function fitWithin(size: {
162
211
  */
163
212
  export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
164
213
  /**
165
- * The entry point the SDK calls. Resolves to a screenshot or to null, within
166
- * `timeoutMs`, and never rejects.
214
+ * The entry point the SDK calls. Resolves to the viewport, or failing that to
215
+ * the element bitmap within `timeoutMs`, or to null, and never rejects.
167
216
  */
168
- export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
217
+ export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<Screenshot | null>;
169
218
  /**
170
219
  * Merge a capture into a payload, or hand the payload back untouched.
171
220
  *
@@ -176,7 +225,7 @@ export declare function captureScreenshot(el: CaptureCandidate | null, options:
176
225
  * rather than set to `undefined` (which `JSON.stringify` drops anyway, but
177
226
  * which reads as "we tried and got nothing" to anyone inspecting the object).
178
227
  */
179
- export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
228
+ export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<T & {
180
229
  screenshot?: Screenshot;
181
230
  }>;
182
231
  export {};
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Screenshot capture for the annotation payload (A-31).
3
3
  *
4
- * WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
5
- * This is not a page rasteriser. It captures the bitmap of a single
4
+ * A-2385 CHANGED WHAT THIS CAPTURES FIRST: the VIEWPORT, rendered in the page
5
+ * by `page-capture.ts` (with its one approved dependency, loaded on demand).
6
+ * Everything below this paragraph describes the FALLBACK, which runs when the
7
+ * page render is unavailable, fails or times out, and which was the whole of
8
+ * this module until then.
9
+ *
10
+ * WHAT THE FALLBACK CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
11
+ * It is not a page rasteriser. It captures the bitmap of a single
6
12
  * `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
7
- * nothing else. Click a heading and the payload has no screenshot.
13
+ * nothing else. Click a heading and the fallback has no screenshot.
8
14
  *
9
15
  * WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
10
16
  * "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
@@ -31,6 +37,23 @@
31
37
  * with `screenshot` absent from an otherwise complete payload, never with a
32
38
  * capture the reviewer loses.
33
39
  */
40
+ import type { CanvasRead, CanvasReadout, PageRenderer } from "./page-capture.mjs";
41
+ /** Widest viewport image we send, in pixels (A-2385). A 1440px laptop at 2x renders at 1600. */
42
+ export declare const MAX_VIEWPORT_WIDTH = 1600;
43
+ /** The decoded viewport image, not its data URL: what Linear stores (A-2385). */
44
+ export declare const MAX_VIEWPORT_IMAGE_BYTES: number;
45
+ /**
46
+ * The longest data URL a viewport screenshot can be. The manager's size limits
47
+ * are derived from this, so the client cap and the server cap are one number.
48
+ */
49
+ export declare const MAX_VIEWPORT_DATA_URL_LENGTH: number;
50
+ /**
51
+ * How long the page render may take before the element bitmap is used
52
+ * instead. Rendering clones the DOM and inlines its images and fonts, which on
53
+ * a heavy page is a second or two; the composer is open meanwhile, so the
54
+ * reviewer is not waiting on it.
55
+ */
56
+ export declare const VIEWPORT_TIMEOUT_MS = 4000;
34
57
  /** The canvas surface this module needs. Narrow so tests need no browser. */
35
58
  export interface CanvasLike {
36
59
  width: number;
@@ -67,6 +90,11 @@ export interface CaptureCandidate {
67
90
  export type Screenshot = {
68
91
  dataUrl: string;
69
92
  region: "element" | "viewport";
93
+ /** Pixel size of the encoded image (viewport captures, A-2385). */
94
+ w?: number;
95
+ h?: number;
96
+ /** What the canvases in the viewport read back as (viewport captures, A-2385). */
97
+ canvases?: CanvasReadout;
70
98
  };
71
99
  /**
72
100
  * The byte budget for the encoded data URL.
@@ -118,6 +146,18 @@ export interface ScreenshotOptions {
118
146
  * reachable from a fake DOM object alone.
119
147
  */
120
148
  createCanvas?: () => CanvasLike | null;
149
+ /**
150
+ * Render the viewport (A-2385). On by default; `false` keeps only the element
151
+ * bitmap below, which is what this module did before.
152
+ */
153
+ page?: boolean;
154
+ /** Viewport image caps: width in pixels and decoded bytes. */
155
+ maxViewportWidth?: number;
156
+ maxViewportBytes?: number;
157
+ viewportTimeoutMs?: number;
158
+ /** Injected so tests need neither a browser nor the library. */
159
+ loadRenderer?: () => Promise<PageRenderer>;
160
+ readCanvas?: (canvas: unknown) => CanvasRead;
121
161
  }
122
162
  interface ResolvedOptions {
123
163
  enabled: boolean;
@@ -125,6 +165,15 @@ interface ResolvedOptions {
125
165
  maxEdge: number;
126
166
  timeoutMs: number;
127
167
  createCanvas: () => CanvasLike | null;
168
+ /** The document to render, or null where there is none (a test, a server). */
169
+ doc: Document | null;
170
+ page: boolean;
171
+ maxViewportWidth: number;
172
+ maxViewportBytes: number;
173
+ viewportTimeoutMs: number;
174
+ /** Null for the real library / the real canvas read. */
175
+ loadRenderer: (() => Promise<PageRenderer>) | null;
176
+ readCanvas: ((canvas: unknown) => CanvasRead) | null;
128
177
  }
129
178
  export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
130
179
  export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
@@ -162,10 +211,10 @@ export declare function fitWithin(size: {
162
211
  */
163
212
  export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
164
213
  /**
165
- * The entry point the SDK calls. Resolves to a screenshot or to null, within
166
- * `timeoutMs`, and never rejects.
214
+ * The entry point the SDK calls. Resolves to the viewport, or failing that to
215
+ * the element bitmap within `timeoutMs`, or to null, and never rejects.
167
216
  */
168
- export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
217
+ export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<Screenshot | null>;
169
218
  /**
170
219
  * Merge a capture into a payload, or hand the payload back untouched.
171
220
  *
@@ -176,7 +225,7 @@ export declare function captureScreenshot(el: CaptureCandidate | null, options:
176
225
  * rather than set to `undefined` (which `JSON.stringify` drops anyway, but
177
226
  * which reads as "we tried and got nothing" to anyone inspecting the object).
178
227
  */
179
- export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
228
+ export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<T & {
180
229
  screenshot?: Screenshot;
181
230
  }>;
182
231
  export {};
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Screenshot capture for the annotation payload (A-31).
3
3
  *
4
- * WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
5
- * This is not a page rasteriser. It captures the bitmap of a single
4
+ * A-2385 CHANGED WHAT THIS CAPTURES FIRST: the VIEWPORT, rendered in the page
5
+ * by `page-capture.ts` (with its one approved dependency, loaded on demand).
6
+ * Everything below this paragraph describes the FALLBACK, which runs when the
7
+ * page render is unavailable, fails or times out, and which was the whole of
8
+ * this module until then.
9
+ *
10
+ * WHAT THE FALLBACK CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
11
+ * It is not a page rasteriser. It captures the bitmap of a single
6
12
  * `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
7
- * nothing else. Click a heading and the payload has no screenshot.
13
+ * nothing else. Click a heading and the fallback has no screenshot.
8
14
  *
9
15
  * WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
10
16
  * "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
@@ -31,6 +37,23 @@
31
37
  * with `screenshot` absent from an otherwise complete payload, never with a
32
38
  * capture the reviewer loses.
33
39
  */
40
+ import type { CanvasRead, CanvasReadout, PageRenderer } from "./page-capture.js";
41
+ /** Widest viewport image we send, in pixels (A-2385). A 1440px laptop at 2x renders at 1600. */
42
+ export declare const MAX_VIEWPORT_WIDTH = 1600;
43
+ /** The decoded viewport image, not its data URL: what Linear stores (A-2385). */
44
+ export declare const MAX_VIEWPORT_IMAGE_BYTES: number;
45
+ /**
46
+ * The longest data URL a viewport screenshot can be. The manager's size limits
47
+ * are derived from this, so the client cap and the server cap are one number.
48
+ */
49
+ export declare const MAX_VIEWPORT_DATA_URL_LENGTH: number;
50
+ /**
51
+ * How long the page render may take before the element bitmap is used
52
+ * instead. Rendering clones the DOM and inlines its images and fonts, which on
53
+ * a heavy page is a second or two; the composer is open meanwhile, so the
54
+ * reviewer is not waiting on it.
55
+ */
56
+ export declare const VIEWPORT_TIMEOUT_MS = 4000;
34
57
  /** The canvas surface this module needs. Narrow so tests need no browser. */
35
58
  export interface CanvasLike {
36
59
  width: number;
@@ -67,6 +90,11 @@ export interface CaptureCandidate {
67
90
  export type Screenshot = {
68
91
  dataUrl: string;
69
92
  region: "element" | "viewport";
93
+ /** Pixel size of the encoded image (viewport captures, A-2385). */
94
+ w?: number;
95
+ h?: number;
96
+ /** What the canvases in the viewport read back as (viewport captures, A-2385). */
97
+ canvases?: CanvasReadout;
70
98
  };
71
99
  /**
72
100
  * The byte budget for the encoded data URL.
@@ -118,6 +146,18 @@ export interface ScreenshotOptions {
118
146
  * reachable from a fake DOM object alone.
119
147
  */
120
148
  createCanvas?: () => CanvasLike | null;
149
+ /**
150
+ * Render the viewport (A-2385). On by default; `false` keeps only the element
151
+ * bitmap below, which is what this module did before.
152
+ */
153
+ page?: boolean;
154
+ /** Viewport image caps: width in pixels and decoded bytes. */
155
+ maxViewportWidth?: number;
156
+ maxViewportBytes?: number;
157
+ viewportTimeoutMs?: number;
158
+ /** Injected so tests need neither a browser nor the library. */
159
+ loadRenderer?: () => Promise<PageRenderer>;
160
+ readCanvas?: (canvas: unknown) => CanvasRead;
121
161
  }
122
162
  interface ResolvedOptions {
123
163
  enabled: boolean;
@@ -125,6 +165,15 @@ interface ResolvedOptions {
125
165
  maxEdge: number;
126
166
  timeoutMs: number;
127
167
  createCanvas: () => CanvasLike | null;
168
+ /** The document to render, or null where there is none (a test, a server). */
169
+ doc: Document | null;
170
+ page: boolean;
171
+ maxViewportWidth: number;
172
+ maxViewportBytes: number;
173
+ viewportTimeoutMs: number;
174
+ /** Null for the real library / the real canvas read. */
175
+ loadRenderer: (() => Promise<PageRenderer>) | null;
176
+ readCanvas: ((canvas: unknown) => CanvasRead) | null;
128
177
  }
129
178
  export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
130
179
  export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
@@ -162,10 +211,10 @@ export declare function fitWithin(size: {
162
211
  */
163
212
  export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
164
213
  /**
165
- * The entry point the SDK calls. Resolves to a screenshot or to null, within
166
- * `timeoutMs`, and never rejects.
214
+ * The entry point the SDK calls. Resolves to the viewport, or failing that to
215
+ * the element bitmap within `timeoutMs`, or to null, and never rejects.
167
216
  */
168
- export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
217
+ export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<Screenshot | null>;
169
218
  /**
170
219
  * Merge a capture into a payload, or hand the payload back untouched.
171
220
  *
@@ -176,7 +225,7 @@ export declare function captureScreenshot(el: CaptureCandidate | null, options:
176
225
  * rather than set to `undefined` (which `JSON.stringify` drops anyway, but
177
226
  * which reads as "we tried and got nothing" to anyone inspecting the object).
178
227
  */
179
- export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
228
+ export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions, ignore?: ((el: Element) => boolean) | null): Promise<T & {
180
229
  screenshot?: Screenshot;
181
230
  }>;
182
231
  export {};
@@ -0,0 +1,5 @@
1
+ "use strict";var m=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var P=Object.getOwnPropertyNames;var S=Object.prototype.hasOwnProperty;var q=(e,t)=>{for(var i in t)m(e,i,{get:t[i],enumerable:!0})},C=(e,t,i,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of P(t))!S.call(e,n)&&n!==i&&m(e,n,{get:()=>t[n],enumerable:!(r=A(t,n))||r.enumerable});return e};var N=e=>C(m({},"__esModule",{value:!0}),e);var v={};q(v,{captureTextSelection:()=>R,prepareTextSelections:()=>w,relativePoint:()=>y,resolveTextSelection:()=>b});module.exports=N(v);function y(e,t,i){return![e.left,e.top,e.width,e.height,t,i].every(Number.isFinite)||e.width<=0||e.height<=0||t<e.left||t>e.left+e.width||i<e.top||i>e.top+e.height?null:{x:(t-e.left)/e.width,y:(i-e.top)/e.height,width:e.width,height:e.height}}function E(e,t){let i=Array.from(e.querySelectorAll("[data-annotation-text]")).filter(u=>t==="slide"||u.dataset.annotationText===t);if(t!=="slide"&&i.length!==1)return null;let r=[],n="",a=null,f=0;for(let u of i){let d=e.ownerDocument.createTreeWalker(u,4),o=[],l=d.nextNode();for(;l;){if(++f>1e4)return null;l.parentElement?.closest("[data-annotation-text]")===u&&o.push(l),l=d.nextNode()}n.length&&o.length&&t==="slide"&&(n+=`
2
+ `,r.push({node:o[0],offset:0}));for(let s of o){for(let c=0;c<s.data.length;c++){let x=s.data[c];if(r.push({node:s,offset:c}),n+=x==="\r"?`
3
+ `:x,x==="\r"&&s.data[c+1]===`
4
+ `&&c++,n.length>25e4)return null}a={node:s,offset:s.data.length}}}return{text:n,positions:r,end:a}}function p(e,t,i){if(e.end?.node===t&&e.end.offset===i)return e.text.length;let r=e.positions.findIndex(n=>n.node===t&&n.offset===i);if(r>=0)return r;if(t.nodeType===3&&i===t.textContent?.length){for(let n=e.positions.length-1;n>=0;n--)if(e.positions[n]?.node===t)return n+1}return null}function R(e,t){if(!t||t.isCollapsed||t.rangeCount!==1)return null;let i=t.getRangeAt(0);if(!e.contains(i.startContainer)||!e.contains(i.endContainer))return null;let r=(c,x,g)=>{if(c.nodeType===3)return{node:c,offset:x};let T=c.childNodes[g?x-1:x];if(!T)return null;let h=T;for(;g?h.lastChild:h.firstChild;)h=g?h.lastChild:h.firstChild;return h.nodeType===3?{node:h,offset:g?h.textContent.length:0}:null},n=r(i.startContainer,i.startOffset,!1),a=r(i.endContainer,i.endOffset,!0);if(!n||!a)return null;let f=n.node.parentElement?.closest("[data-annotation-text]"),u=a.node.parentElement?.closest("[data-annotation-text]");if(!f||!u)return null;let d=f===u?f.dataset.annotationText:"slide",o=E(e,d);if(!o)return null;let l=p(o,n.node,n.offset),s=p(o,a.node,a.offset);return l===null||s===null||l>=s||s-l>1024?null:{path:d,start:l,end:s,quote:o.text.slice(l,s),prefix:o.text.slice(Math.max(0,l-128),l),suffix:o.text.slice(s,s+128)}}function M(e,t,i,r){let n=t.positions[i],a=r===t.text.length?t.end:t.positions[r];if(!n||!a)return null;let f=e.ownerDocument.createRange();return f.setStart(n.node,n.offset),f.setEnd(a.node,a.offset),f}function w(e){let t=new Map,i=n=>(t.has(n)||t.set(n,E(e,n)),t.get(n)??null),r=Array.from(new Set(Array.from(e.querySelectorAll("[data-annotation-text]"),n=>n.dataset.annotationText)));return(n,a)=>O(e,n,a,i,r)}function b(e,t,i){return w(e)(t,i)}function O(e,t,i,r,n){if(!t.quote.length)return{state:"missing",reason:"No selected text was recorded."};let a=r(t.path);if(i&&a?.text.slice(t.start,t.end)===t.quote){let d=M(e,a,t.start,t.end);if(d)return{state:"exact",range:d}}let f=t.path==="slide"?["slide"]:n,u=[];for(let d of f){let o=r(d);if(!o)continue;let l=o.text.indexOf(t.quote);for(;l>=0;){if(o.text.slice(Math.max(0,l-t.prefix.length),l)===t.prefix&&o.text.slice(l+t.quote.length,l+t.quote.length+t.suffix.length)===t.suffix){let s=M(e,o,l,l+t.quote.length);if(s&&u.push(s),u.length>1)return{state:"ambiguous",reason:"This text matches more than one location."}}l=o.text.indexOf(t.quote,l+1)}}return u[0]?{state:"exact",range:u[0]}:{state:"stale",reason:"The selected text has changed. The original quote is retained."}}
5
+ //# sourceMappingURL=selection.cjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/selection.ts"],
4
+ "sourcesContent": ["/** Host-neutral authored-content selection. No capture token, transport or production arming. */\nexport interface RelativePoint { x: number; y: number; width: number; height: number }\nexport interface TextSelectionAnchor {\n path: string; start: number; end: number; quote: string; prefix: string; suffix: string;\n}\nexport type TextPlacement =\n | { state: \"exact\"; range: Range }\n | { state: \"missing\" | \"ambiguous\" | \"stale\"; reason: string };\ninterface Position { node: Text; offset: number }\ninterface TextMap { text: string; positions: Position[]; end: Position | null }\nconst MAX_NODES = 10000;\nconst MAX_TEXT = 250000;\n\nexport function relativePoint(box: Pick<DOMRect, \"left\" | \"top\" | \"width\" | \"height\">, x: number, y: number): RelativePoint | null {\n if (![box.left, box.top, box.width, box.height, x, y].every(Number.isFinite) || box.width <= 0 || box.height <= 0 ||\n x < box.left || x > box.left + box.width || y < box.top || y > box.top + box.height) return null;\n return { x: (x - box.left) / box.width, y: (y - box.top) / box.height, width: box.width, height: box.height };\n}\n\n/** Maps original UTF-16 offsets, folding CRLF as HTML does. Newlines separate authored fields. */\nfunction textMap(scope: Element, path: string): TextMap | null {\n const fields = Array.from(scope.querySelectorAll<HTMLElement>(\"[data-annotation-text]\"))\n .filter((field) => path === \"slide\" || field.dataset.annotationText === path);\n if (path !== \"slide\" && fields.length !== 1) return null;\n const positions: Position[] = [];\n let text = \"\";\n let end: Position | null = null;\n let nodes = 0;\n for (const field of fields) {\n const walker = scope.ownerDocument.createTreeWalker(field, 4);\n const fieldNodes: Text[] = [];\n let next = walker.nextNode();\n while (next) {\n if (++nodes > MAX_NODES) return null;\n if (next.parentElement?.closest(\"[data-annotation-text]\") === field) fieldNodes.push(next as Text);\n next = walker.nextNode();\n }\n if (text.length && fieldNodes.length && path === \"slide\") {\n text += \"\\n\";\n positions.push({ node: fieldNodes[0]!, offset: 0 });\n }\n for (const node of fieldNodes) {\n for (let offset = 0; offset < node.data.length; offset++) {\n const character = node.data[offset]!;\n positions.push({ node, offset });\n text += character === \"\\r\" ? \"\\n\" : character;\n if (character === \"\\r\" && node.data[offset + 1] === \"\\n\") offset++;\n if (text.length > MAX_TEXT) return null;\n }\n end = { node, offset: node.data.length };\n }\n }\n return { text, positions, end };\n}\nfunction offsetAt(map: TextMap, node: Node, offset: number): number | null {\n if (map.end?.node === node && map.end.offset === offset) return map.text.length;\n const found = map.positions.findIndex((position) => position.node === node && position.offset === offset);\n if (found >= 0) return found;\n // Selection can end exactly after a text node before the next field.\n if (node.nodeType === 3 && offset === node.textContent?.length) {\n for (let i = map.positions.length - 1; i >= 0; i--) if (map.positions[i]?.node === node) return i + 1;\n }\n return null;\n}\n\nexport function captureTextSelection(scope: Element, selection: Selection | null): TextSelectionAnchor | null {\n if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return null;\n const range = selection.getRangeAt(0);\n if (!scope.contains(range.startContainer) || !scope.contains(range.endContainer)) return null;\n // Browser selection may use element boundaries (select-all, keyboard), not\n // just text nodes. Resolve those boundaries to the authored text they enclose.\n const boundary = (node: Node, offset: number, ending: boolean): Position | null => {\n if (node.nodeType === 3) return { node: node as Text, offset };\n const child = node.childNodes[ending ? offset - 1 : offset];\n if (!child) return null;\n let leaf: Node = child;\n while (ending ? leaf.lastChild : leaf.firstChild) leaf = (ending ? leaf.lastChild : leaf.firstChild)!;\n return leaf.nodeType === 3 ? { node: leaf as Text, offset: ending ? leaf.textContent!.length : 0 } : null;\n };\n const a = boundary(range.startContainer, range.startOffset, false);\n const b = boundary(range.endContainer, range.endOffset, true);\n if (!a || !b) return null;\n const first = a.node.parentElement?.closest<HTMLElement>(\"[data-annotation-text]\");\n const last = b.node.parentElement?.closest<HTMLElement>(\"[data-annotation-text]\");\n if (!first || !last) return null;\n const path = first === last ? first.dataset.annotationText! : \"slide\";\n const map = textMap(scope, path);\n if (!map) return null;\n const start = offsetAt(map, a.node, a.offset);\n const end = offsetAt(map, b.node, b.offset);\n if (start === null || end === null || start >= end || end - start > 1024) return null;\n return { path, start, end, quote: map.text.slice(start, end),\n prefix: map.text.slice(Math.max(0, start - 128), start), suffix: map.text.slice(end, end + 128) };\n}\n\nfunction rangeAt(scope: Element, map: TextMap, start: number, end: number): Range | null {\n const a = map.positions[start];\n const b = end === map.text.length ? map.end : map.positions[end];\n if (!a || !b) return null;\n const range = scope.ownerDocument.createRange();\n range.setStart(a.node, a.offset);\n range.setEnd(b.node, b.offset);\n return range;\n}\n/** A snapshot resolver batches DOM traversal for all annotations in one frame. */\nexport function prepareTextSelections(scope: Element): (anchor: TextSelectionAnchor, unchanged: boolean) => TextPlacement {\n const maps = new Map<string, TextMap | null>();\n const getMap = (path: string) => {\n if (!maps.has(path)) maps.set(path, textMap(scope, path));\n return maps.get(path) ?? null;\n };\n const authoredPaths = Array.from(new Set(Array.from(scope.querySelectorAll<HTMLElement>(\"[data-annotation-text]\"), field => field.dataset.annotationText!)));\n return (anchor, unchanged) => resolvePrepared(scope, anchor, unchanged, getMap, authoredPaths);\n}\nexport function resolveTextSelection(scope: Element, anchor: TextSelectionAnchor, unchanged: boolean): TextPlacement {\n return prepareTextSelections(scope)(anchor, unchanged);\n}\nfunction resolvePrepared(scope: Element, anchor: TextSelectionAnchor, unchanged: boolean, getMap: (path: string) => TextMap | null, authoredPaths: string[]): TextPlacement {\n if (!anchor.quote.length) return { state: \"missing\", reason: \"No selected text was recorded.\" };\n const original = getMap(anchor.path);\n if (unchanged && original?.text.slice(anchor.start, anchor.end) === anchor.quote) {\n const range = rangeAt(scope, original, anchor.start, anchor.end);\n if (range) return { state: \"exact\", range };\n }\n // Positional field paths are valid only at the captured revision. Search all\n // authored fields after edits; two identical matches must remain ambiguous.\n const paths = anchor.path === \"slide\" ? [\"slide\"] : authoredPaths;\n const matches: Range[] = [];\n for (const path of paths) {\n const map = getMap(path);\n if (!map) continue;\n let index = map.text.indexOf(anchor.quote);\n while (index >= 0) {\n if (map.text.slice(Math.max(0, index - anchor.prefix.length), index) === anchor.prefix &&\n map.text.slice(index + anchor.quote.length, index + anchor.quote.length + anchor.suffix.length) === anchor.suffix) {\n const range = rangeAt(scope, map, index, index + anchor.quote.length);\n if (range) matches.push(range);\n if (matches.length > 1) return { state: \"ambiguous\", reason: \"This text matches more than one location.\" };\n }\n index = map.text.indexOf(anchor.quote, index + 1);\n }\n }\n if (matches[0]) return { state: \"exact\", range: matches[0] };\n return { state: \"stale\", reason: \"The selected text has changed. The original quote is retained.\" };\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["selection_exports", "__export", "captureTextSelection", "prepareTextSelections", "relativePoint", "resolveTextSelection", "__toCommonJS", "relativePoint", "box", "x", "y", "textMap", "scope", "path", "fields", "field", "positions", "text", "end", "nodes", "walker", "fieldNodes", "next", "node", "offset", "character", "offsetAt", "map", "found", "position", "i", "captureTextSelection", "selection", "range", "boundary", "ending", "child", "leaf", "a", "b", "first", "last", "start", "rangeAt", "prepareTextSelections", "maps", "getMap", "authoredPaths", "anchor", "unchanged", "resolvePrepared", "resolveTextSelection", "original", "paths", "matches", "index"]
7
+ }