diff --git a/lib/cache/src/cache-backend.test.ts b/lib/cache/src/cache-backend.test.ts new file mode 100644 index 0000000000..a2501c2608 --- /dev/null +++ b/lib/cache/src/cache-backend.test.ts @@ -0,0 +1,45 @@ +import { assert, describe, it } from "@effect/vitest" +import { Array as Arr, Effect } from "effect" +import { makeMemoryBackend } from "./cache-backend" + +const HOUR_MS = 3_600_000 + +describe("makeMemoryBackend", () => { + it.effect("drops expired entries whose keys are never read again", () => + Effect.gen(function* () { + const backend = makeMemoryBackend() + yield* backend.put("pinned", "live", "kept", 100_000 * 3600, 0) + // One key per hour with a one-hour TTL: each is expired before the next is written. + yield* Effect.forEach(Arr.range(1, 5000), (hour) => + backend.put("baseline", `org_1:${hour}`, hour, 3600, hour * HOUR_MS * 2), + ) + + assert.isBelow(backend.size(), 1000) + assert.strictEqual(yield* backend.get("pinned", "live", 5000 * HOUR_MS * 2), "kept") + }), + ) + + it.effect("keeps every entry that has not expired", () => + Effect.gen(function* () { + const backend = makeMemoryBackend() + const keys = Arr.range(1, 2000) + yield* Effect.forEach(keys, (n) => backend.put("bucket", `k${n}`, n, 3600, n)) + const values = yield* Effect.forEach(keys, (n) => backend.get("bucket", `k${n}`, 5000)) + + assert.deepStrictEqual(values, keys) + }), + ) + + it.effect("does not store an entry whose TTL is not a finite number", () => + Effect.gen(function* () { + const backend = makeMemoryBackend() + yield* backend.put("bucket", "key", "old", 60, 0) + yield* backend.put("bucket", "key", "nan", Number.NaN, 0) + yield* backend.put("bucket", "other", "forever", Number.POSITIVE_INFINITY, 0) + + assert.isUndefined(yield* backend.get("bucket", "key", 1)) + assert.isUndefined(yield* backend.get("bucket", "other", 1)) + assert.strictEqual(backend.size(), 0) + }), + ) +}) diff --git a/lib/cache/src/cache-backend.ts b/lib/cache/src/cache-backend.ts index 75eb8a99f6..d0e1e0c9e7 100644 --- a/lib/cache/src/cache-backend.ts +++ b/lib/cache/src/cache-backend.ts @@ -45,11 +45,6 @@ export interface EdgeCacheBackend { /** * Injected edge-cache storage backend (`caches.default` in prod, in-memory in * tests/dev). - * - * The tag string still names the old home. Tags are identity, not - * documentation — `EdgeCacheIOError` next door is a `Schema.TaggedError` - * whose tag is its serialized `_tag`, so renaming this family for tidiness - * would be a wire-contract change for no behavioural gain. */ export class CacheBackend extends Context.Service()( "@maple/cache/CacheBackend", @@ -60,13 +55,32 @@ interface MemoryEntry { readonly expiresAt: number } -/** A pure in-process `EdgeCacheBackend` — used for tests, dev, and non-Workers hosts. */ -export const makeMemoryBackend = (): EdgeCacheBackend => { +/** The in-memory backend, plus how many entries it currently holds. */ +export interface MemoryCacheBackend extends EdgeCacheBackend { + readonly size: () => number +} + +const MEMORY_SWEEP_MIN_ENTRIES = 256 + +/** A pure in-process `EdgeCacheBackend`, used for tests, dev, and non-Workers hosts. */ +export const makeMemoryBackend = (): MemoryCacheBackend => { const store = new Map() const composite = (bucket: string, hash: string) => `${bucket}:${hash}` + let sweepAt = MEMORY_SWEEP_MIN_ENTRIES + + // Keys with a rotating part (`orgId:hour`) are never read again, so reads alone + // would never evict them. Sweeping only once the map has doubled keeps `put` amortized O(1). + const sweepExpired = (nowMs: number) => { + if (store.size < sweepAt) return + store.forEach((entry, key) => { + if (entry.expiresAt <= nowMs) store.delete(key) + }) + sweepAt = Math.max(MEMORY_SWEEP_MIN_ENTRIES, store.size * 2) + } return { name: "memory", + size: () => store.size, get: (bucket, hash, nowMs) => Effect.sync(() => { const entry = store.get(composite(bucket, hash)) @@ -79,10 +93,14 @@ export const makeMemoryBackend = (): EdgeCacheBackend => { }), put: (bucket, hash, value, ttlSeconds, nowMs) => Effect.sync(() => { - store.set(composite(bucket, hash), { - value, - expiresAt: nowMs + ttlSeconds * 1000, - }) + const expiresAt = nowMs + ttlSeconds * 1000 + // A non-finite expiry would never compare as expired, so it is not stored. + if (!Number.isFinite(expiresAt)) { + store.delete(composite(bucket, hash)) + return + } + store.set(composite(bucket, hash), { value, expiresAt }) + sweepExpired(nowMs) }), delete: (bucket, hash) => Effect.sync(() => { diff --git a/lib/cache/src/edge-cache.ts b/lib/cache/src/edge-cache.ts index 4187034d67..8e9f0572aa 100644 --- a/lib/cache/src/edge-cache.ts +++ b/lib/cache/src/edge-cache.ts @@ -561,7 +561,7 @@ export class EdgeCacheService extends Context.Service { let callee = node.callee if (callee.type === "CallExpression") callee = callee.callee - if (callee.type === "Identifier") return TEST_CALLEES.has(callee.name) - if (callee.type !== "MemberExpression" || callee.computed) return false - if (callee.object.type !== "Identifier" || !TEST_CALLEES.has(callee.object.name)) return false - return TEST_MODIFIERS.has(callee.property.name) || callee.property.name === "each" + // Modifiers chain (`it.effect.skip`), so walk every link down to the `it` or `test` root. + while (callee.type === "MemberExpression") { + if (callee.computed) return false + if (!TEST_MODIFIERS.has(callee.property.name) && callee.property.name !== "each") return false + callee = callee.object + } + return callee.type === "Identifier" && TEST_CALLEES.has(callee.name) } const testCallback = (node) => { diff --git a/lib/oxlint-config/scripts/smoke.mjs b/lib/oxlint-config/scripts/smoke.mjs index 2e78f20f08..fa6a5d0aca 100644 --- a/lib/oxlint-config/scripts/smoke.mjs +++ b/lib/oxlint-config/scripts/smoke.mjs @@ -73,6 +73,12 @@ export const View = () => { useEffect(() => {}, []); return null } "src/math.test.ts": `import { expect, it } from "vitest" it("adds", () => { expect(1).toBeDefined() }) it("nothing", () => { const x = 1 + 1; void x }) +`, + "src/chained-skip.test.ts": `import { expect, it } from "vitest" +it.concurrent.skip("exists", () => { expect(1).toBeDefined() }) +`, + "src/chained-each.test.ts": `import { expect, it } from "vitest" +it.skip.each([1, 2])("exists %i", (n) => { expect(n).toBeDefined() }) `, } @@ -94,6 +100,9 @@ const expected = { "src/boundary-file.ts": [], "src/view.tsx": ["effect-strict(no-react-use-effect)"], "src/math.test.ts": ["test-slop(no-weak-only-assertions)", "vitest(expect-expect)"], + // Chained modifiers are still tests: `it.concurrent.skip(...)`, `it.skip.each(rows)(...)`. + "src/chained-skip.test.ts": ["test-slop(no-weak-only-assertions)"], + "src/chained-each.test.ts": ["test-slop(no-weak-only-assertions)"], "src/clean.ts": [], } diff --git a/lib/prometheus-otlp/src/otlp.ts b/lib/prometheus-otlp/src/otlp.ts index d768764c16..bb240df178 100644 --- a/lib/prometheus-otlp/src/otlp.ts +++ b/lib/prometheus-otlp/src/otlp.ts @@ -8,7 +8,7 @@ * - histogram `count`/`bucketCounts` are JSON numbers, not the spec's strings * - attribute values as `{ "stringValue": "…" }` * - * `__fixtures__/otlp-export.json` pins that shape; consumers assert against it. + * `apps/scraper/src/__fixtures__/otlp-export.json` pins that shape; consumers assert against it. */ import type { PromMetricFamily, PromSample } from "./parser" diff --git a/lib/safe-fetch/src/index.test.ts b/lib/safe-fetch/src/index.test.ts index dc74443fa9..1dc6e8bd26 100644 --- a/lib/safe-fetch/src/index.test.ts +++ b/lib/safe-fetch/src/index.test.ts @@ -540,6 +540,83 @@ describe("guard with strictRedirects", () => { ) }) +/** What the hop after a same-origin redirect looked like on the wire. */ +const hopAfterRedirect = ( + status: number, + send: ( + client: HttpClient.HttpClient.With, + url: string, + options: { readonly headers: Record; readonly body: HttpBody.HttpBody }, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const seen: Array> = [] + const fakeFetch: typeof fetch = async (_input, init) => { + const headers = new Headers(init?.headers) + seen.push([ + init?.method, + bodyOf(init?.body), + headers.get("content-type"), + headers.get("content-encoding"), + headers.get("content-language"), + headers.get("x-api-key"), + ]) + return seen.length === 1 + ? new Response(null, { status, headers: { location: "/next" } }) + : new Response("ok", { status: 200 }) + } + yield* run(fakeFetch, (client) => + send(client, "https://api.example.com/x", { + headers: { "Content-Encoding": "identity", "Content-Language": "en", "X-Api-Key": "k" }, + body: HttpBody.text("payload", "application/json"), + }), + ) + return seen[1] + }) + +describe("guard redirects follow the fetch standard", () => { + it.effect("a POST continues as a body-less GET after a 301, 302 or 303", () => + Effect.gen(function* () { + const hops = yield* Effect.forEach([301, 302, 303], (status) => + hopAfterRedirect(status, (client, url, options) => client.post(url, options)), + ) + const bodylessGet = ["GET", "", null, null, null, "k"] + assert.deepStrictEqual(hops, [bodylessGet, bodylessGet, bodylessGet]) + }), + ) + + it.effect("a PUT keeps its body on a 302 and becomes a GET only on a 303", () => + Effect.gen(function* () { + const hops = yield* Effect.forEach([302, 303], (status) => + hopAfterRedirect(status, (client, url, options) => client.put(url, options)), + ) + assert.deepStrictEqual(hops, [ + ["PUT", "payload", "application/json", "identity", "en", "k"], + ["GET", "", null, null, null, "k"], + ]) + }), + ) + + it.effect("a HEAD stays a HEAD after a 303", () => + Effect.gen(function* () { + const hop = yield* hopAfterRedirect(303, (client, url, options) => + client.head(url, { headers: options.headers }), + ) + assert.strictEqual(hop?.[0], "HEAD") + }), + ) + + it.effect("a POST is replayed with its body after a 307 or 308", () => + Effect.gen(function* () { + const hops = yield* Effect.forEach([307, 308], (status) => + hopAfterRedirect(status, (client, url, options) => client.post(url, options)), + ) + const replayed = ["POST", "payload", "application/json", "identity", "en", "k"] + assert.deepStrictEqual(hops, [replayed, replayed]) + }), + ) +}) + describe("describeHttpClientError", () => { it.effect("redacts the request URL from a transport failure's message", () => Effect.gen(function* () { diff --git a/lib/safe-fetch/src/index.ts b/lib/safe-fetch/src/index.ts index 4c30fe88fa..2366fd5220 100644 --- a/lib/safe-fetch/src/index.ts +++ b/lib/safe-fetch/src/index.ts @@ -238,7 +238,8 @@ export interface GuardOptions { /** * For requests whose headers all come from user config: a cross-origin hop drops every * header, 301/302/303 continue as a body-less GET, and 307/308 keep method and body only - * within one origin. Without it, only credential headers are dropped cross-origin. + * within one origin. Without it, only credential headers are dropped cross-origin and + * method and body follow the fetch standard (see `fetchRedirectRequest`). */ readonly strictRedirects?: boolean /** With `strictRedirects`: headers the caller sets itself (not user config), kept across origins. */ @@ -247,6 +248,14 @@ export interface GuardOptions { const SEE_OTHER_STATUSES: ReadonlySet = new Set([301, 302, 303]) +/** Headers describing a body. `setBody` already clears `content-type` and `content-length`. */ +const BODY_HEADERS = ["content-encoding", "content-language", "content-location"] as const + +const withoutBody = (request: HttpClientRequest.HttpClientRequest) => + Arr.reduce(BODY_HEADERS, HttpClientRequest.setBody(request, HttpBody.empty), (next, name) => + HttpClientRequest.removeHeader(next, name), + ) + /** The request a strict redirect sends: HEAD stays HEAD, anything else replays as a plain GET. */ const strictRedirectRequest = ( request: HttpClientRequest.HttpClientRequest, @@ -254,10 +263,22 @@ const strictRedirectRequest = ( crossOrigin: boolean, ) => { if (!SEE_OTHER_STATUSES.has(status) && !crossOrigin) return request - const bodyless = HttpClientRequest.setBody(request, HttpBody.empty) + const bodyless = withoutBody(request) return request.method === "HEAD" ? bodyless : HttpClientRequest.setMethod(bodyless, "GET") } +/** + * The fetch standard's rewrite: a 303 turns anything but GET or HEAD into a GET, a 301 or 302 + * turns a POST into a GET, both without the body. 307 and 308 replay the request as it was. + */ +const fetchRedirectRequest = (request: HttpClientRequest.HttpClientRequest, status: number) => { + const becomesGet = + status === 303 + ? request.method !== "GET" && request.method !== "HEAD" + : (status === 301 || status === 302) && request.method === "POST" + return becomesGet ? HttpClientRequest.setMethod(withoutBody(request), "GET") : request +} + /** The full URL a request targets, query params and hash included. */ const requestUrl = (request: HttpClientRequest.HttpClientRequest) => Effect.fromResult(Url.make(request.url, request.urlParams, Option.getOrUndefined(request.hash))).pipe( @@ -388,7 +409,7 @@ const guardWith = ( const next = HttpClientRequest.setUrl( strict ? strictRedirectRequest(outgoing, response.status, target.origin !== validated.origin) - : outgoing, + : fetchRedirectRequest(outgoing, response.status), target, ) return yield* hop(next, redirects + 1, validated.origin) diff --git a/lib/strings/src/index.test.ts b/lib/strings/src/index.test.ts index 360e2806f1..797f99203f 100644 --- a/lib/strings/src/index.test.ts +++ b/lib/strings/src/index.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest" import { pipe, Array as Arr, String as Str } from "effect" -import { codePointAt, codeUnitAt, indexOfFrom, matchAllIndexed, replaceWith } from "./index" +import { + codePointAt, + codeUnitAt, + escapeHtml, + indexOfFrom, + matchAllIndexed, + replaceWith, + truncate, +} from "./index" describe("replaceWith", () => { it("passes capture groups and replaces every match of a global regex", () => { @@ -59,3 +67,21 @@ describe("position reads", () => { ]) }) }) + +describe("escapeHtml", () => { + it("escapes markup and both quote styles, ampersands first", () => { + expect(escapeHtml(`&`)).toBe( + "<a href="x" title='y'>&amp;</a>", + ) + }) +}) + +describe("truncate", () => { + it("returns text that already fits untouched", () => { + expect(truncate("hello", 5)).toBe("hello") + }) + + it("cuts longer text to exactly max characters, ending in an ellipsis", () => { + expect(truncate("hello world", 5)).toBe("hell…") + }) +}) diff --git a/lib/strings/src/index.ts b/lib/strings/src/index.ts index 82df8e1530..3318e135dd 100644 --- a/lib/strings/src/index.ts +++ b/lib/strings/src/index.ts @@ -1,6 +1,6 @@ -// String operations effect/String lacks: function replacers and position-aware reads. -// Each wraps the one native call it needs, so callers stay on pipeable, typed helpers. -import { Array as Arr } from "effect" +// String operations effect/String lacks: function replacers, position-aware reads, HTML escaping +// and truncation. The wrappers each own the one native call they need. +import { Array as Arr, String as Str } from "effect" import { dual } from "effect/Function" /** Receives the full match and each capture group; an unmatched optional group is `undefined`. */ @@ -65,3 +65,19 @@ export const matchAllIndexed: { // @effect-diagnostics-next-line preferEffectString:off -- the native call this helper wraps Arr.fromIterable(self.matchAll(pattern)), ) + +/** Escapes text for HTML element content and quoted attributes alike. */ +export const escapeHtml = (value: string): string => + Str.replaceAll( + "'", + "'", + )( + Str.replaceAll( + '"', + """, + )(Str.replaceAll(">", ">")(Str.replaceAll("<", "<")(Str.replaceAll("&", "&")(value)))), + ) + +/** `text` cut to at most `max` characters, the last one an ellipsis when anything was cut. */ +export const truncate = (text: string, max: number): string => + text.length <= max ? text : `${Str.slice(0, max - 1)(text)}…` diff --git a/packages/agent-sessions/src/session-summary.ts b/packages/agent-sessions/src/session-summary.ts index c7c84dd36f..ce7c36bd94 100644 --- a/packages/agent-sessions/src/session-summary.ts +++ b/packages/agent-sessions/src/session-summary.ts @@ -369,7 +369,7 @@ export function findIdleGaps(spans: readonly AiSessionSpan[]): readonly IdleGap[ * carries the time, and borrows a TTFT from a nested span when it reported * none. */ -export function computeAgentTime(spans: readonly AiSessionSpan[]): SessionAgentTime { +function computeAgentTime(spans: readonly AiSessionSpan[]): SessionAgentTime { const totals = new Map() const add = (kind: AgentTimeKind, ms: number) => { if (ms > 0) totals.set(kind, (totals.get(kind) ?? 0) + ms) @@ -1138,7 +1138,7 @@ const AGENT_LIMIT_PATTERN = /\b(?:exceeded|reached) its ((?:[\w$.]+ ){0,6}?(?:duration (?:limit|budget)|limit|budget|allowance|bound|cap|duration))\b/i /** `duration limit`, `consecutive tool call failure limit`: the budget without its figure. */ -export function agentLimitName(text: string): string | undefined { +function agentLimitName(text: string): string | undefined { const match = AGENT_LIMIT_PATTERN.exec(text)?.[1] if (match === undefined) return undefined const words = Arr.filter( @@ -1217,7 +1217,7 @@ function countFailures(events: readonly SessionFailureEvent[]): SessionFailureCo } /** Events sharing a label, counted, busiest first. */ -export function groupFailures(events: readonly SessionFailureEvent[]): readonly SessionFailureGroup[] { +function groupFailures(events: readonly SessionFailureEvent[]): readonly SessionFailureGroup[] { const groups = new Map() for (const event of events) { const existing = groups.get(event.label) diff --git a/packages/alerting-core/src/escalation.test.ts b/packages/alerting-core/src/escalation.test.ts index d517c438aa..8395ba7228 100644 --- a/packages/alerting-core/src/escalation.test.ts +++ b/packages/alerting-core/src/escalation.test.ts @@ -5,7 +5,6 @@ import { ESCALATION_SILENCE_RECHECK_MS, type EscalationIncidentState, type EscalationRunPosition, - escalationStepCount, planEscalationAdvance, } from "./escalation" @@ -175,6 +174,7 @@ describe("planEscalationAdvance", () => { sent + 1, ) } - expect(walk(run({ repeatCount: 2 }), T0, 0)).toBe(escalationStepCount(steps, 2)) + // Two repeats: the chain runs three times in all. + expect(walk(run({ repeatCount: 2 }), T0, 0)).toBe(steps.length * 3) }) }) diff --git a/packages/alerting-core/src/escalation.ts b/packages/alerting-core/src/escalation.ts index 0cb70aa653..37005f874f 100644 --- a/packages/alerting-core/src/escalation.ts +++ b/packages/alerting-core/src/escalation.ts @@ -90,7 +90,3 @@ export const planEscalationAdvance = ( next: nextPosition(run, step, nowMs), } } - -/** Total steps a chain sends when nobody acknowledges. */ -export const escalationStepCount = (steps: EscalationSteps, repeatCount: number): number => - steps.length * (repeatCount + 1) diff --git a/packages/chat-platform/src/render/approval-outcome.ts b/packages/chat-platform/src/render/approval-outcome.ts index 99b5262679..341b64f674 100644 --- a/packages/chat-platform/src/render/approval-outcome.ts +++ b/packages/chat-platform/src/render/approval-outcome.ts @@ -6,6 +6,7 @@ * verbatim put a report's headings and a dashboard's validation table into a thread. The record * stays as it is — the model reads it on its next turn — and a channel gets one sentence and a link. */ +import { truncate } from "@maple/strings" import { Option, Schema, String as Str } from "effect" import { MAX_APPROVAL_OUTCOME_CHARS, type ChatApprovalOutcome, type ChatRenderContext } from "./blocks" @@ -202,6 +203,3 @@ const describe = (output: MutationPayload, app: string): Described => { } } } - -const truncate = (text: string, max: number): string => - text.length <= max ? text : `${Str.slice(0, max - 1)(text)}…` diff --git a/packages/chat-platform/src/render/message.ts b/packages/chat-platform/src/render/message.ts index ee2ead27ff..646435b068 100644 --- a/packages/chat-platform/src/render/message.ts +++ b/packages/chat-platform/src/render/message.ts @@ -24,6 +24,7 @@ import { } from "@maple/domain/chat-chart-spec" import { delegatedAgentOf, type ChatMessage, type ChatToolCall } from "@maple/domain/chat-session" import { formatDuration, formatNumber } from "@maple/domain/format" +import { truncate } from "@maple/strings" import { encodeChatActionToken } from "../action-token" import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" import { approvalOutcome } from "./approval-outcome" @@ -319,6 +320,3 @@ export const summarizeToolInput = (input: unknown): string => { } const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? "" : "s"}` - -const truncate = (text: string, max: number): string => - text.length <= max ? text : `${Str.slice(0, max - 1)(text)}…` diff --git a/packages/email/src/alert-notification.ts b/packages/email/src/alert-notification.ts index d471a84d82..38389258d7 100644 --- a/packages/email/src/alert-notification.ts +++ b/packages/email/src/alert-notification.ts @@ -6,7 +6,8 @@ * This module only splices those strings together. */ import { FRAGMENTS, PAGE } from "./generated/alert-notification" -import { escapeHtml, fill, preheaderPadding, truncate } from "./template" +import { escapeHtml } from "@maple/strings" +import { fill, preheaderPadding, truncate } from "./template" import { Array as Arr } from "effect" /** diff --git a/packages/email/src/template.ts b/packages/email/src/template.ts index 6175df79a0..4b35eae56a 100644 --- a/packages/email/src/template.ts +++ b/packages/email/src/template.ts @@ -1,5 +1,5 @@ import { String as Str } from "effect" -import { replaceWith } from "@maple/strings" +import { escapeHtml, replaceWith } from "@maple/strings" /** * The whole email runtime: HTML escaping plus a token/slot splicer for the * templates compiled into `src/generated/`. @@ -9,19 +9,6 @@ import { replaceWith } from "@maple/strings" * was the problem this package exists to solve. */ -/** Escapes into a form that is safe in both text nodes and quoted attributes. */ -export function escapeHtml(value: string): string { - return Str.replace( - /'/g, - "'", - )( - Str.replace( - /"/g, - """, - )(Str.replace(/>/g, ">")(Str.replace(/ = new Map( export const ORDINARY_BLOCK_REFS: ReadonlyArray = Arr.map(ordinaryBlocks(), (block) => block.ref) -export const blockRefs = (): ReadonlyArray => [...ALL_BLOCKS.keys()] - export const findBlock = (ref: string): SkillBlock | undefined => ALL_BLOCKS.get(ref) /** The sections every prompt using a companion must carry alongside its chosen classes. */ diff --git a/packages/status-page/src/document.ts b/packages/status-page/src/document.ts index 249dbbab42..56c04f19bc 100644 --- a/packages/status-page/src/document.ts +++ b/packages/status-page/src/document.ts @@ -1,5 +1,6 @@ import type { StatusPageSnapshot } from "@maple/domain/status-page" -import { escapeHtml, safeUrl } from "./escape" +import { escapeHtml } from "@maple/strings" +import { safeUrl } from "./escape" import { baseStyles, presetStyles } from "./styles" import { resolveSafeTheme, safeTheme } from "./theme" import { Option, Array as Arr, String as Str } from "effect" diff --git a/packages/status-page/src/escape.ts b/packages/status-page/src/escape.ts index 2549414701..fd564397b5 100644 --- a/packages/status-page/src/escape.ts +++ b/packages/status-page/src/escape.ts @@ -1,15 +1,5 @@ +import { escapeHtml } from "@maple/strings" import { String as Str } from "effect" -/** Escapes text for HTML element content and quoted attributes alike. */ -export const escapeHtml = (value: string): string => - Str.replaceAll( - "'", - "'", - )( - Str.replaceAll( - '"', - """, - )(Str.replaceAll(">", ">")(Str.replaceAll("<", "<")(Str.replaceAll("&", "&")(value)))), - ) /** XML 1.0 forbids most control characters even when escaped, so they are dropped. */ export const escapeXml = (value: string): string => diff --git a/packages/status-page/src/index.ts b/packages/status-page/src/index.ts index 51cd265768..c68ba3f206 100644 --- a/packages/status-page/src/index.ts +++ b/packages/status-page/src/index.ts @@ -1,6 +1,6 @@ export { renderBadgeSvg } from "./badge" export type { RenderOptions } from "./document" -export { escapeHtml, escapeXml } from "./escape" +export { escapeXml } from "./escape" export { renderAtomFeed } from "./feed" export { renderStatusJson, STATUS_INDICATOR } from "./json" export { MANAGE_FIELDS, type ManageMessage, NEW_INCIDENT, NO_CHANGE, renderManagePage } from "./manage" diff --git a/packages/status-page/src/manage.ts b/packages/status-page/src/manage.ts index ad0095c446..5daf96a7c1 100644 --- a/packages/status-page/src/manage.ts +++ b/packages/status-page/src/manage.ts @@ -5,7 +5,7 @@ import { } from "@maple/domain/primitives" import { type StatusPageSnapshot, statusFitsKind } from "@maple/domain/status-page" import { pageHref, type RenderOptions, renderDocument, renderHeader } from "./document" -import { escapeHtml } from "./escape" +import { escapeHtml } from "@maple/strings" import { IMPACT_LABEL, INCIDENT_STATUS_LABEL } from "./format" import { activeIncidents, upcomingMaintenance } from "./select" import { manageStyles } from "./styles" diff --git a/packages/status-page/src/page.ts b/packages/status-page/src/page.ts index db7a119d28..b4556148b0 100644 --- a/packages/status-page/src/page.ts +++ b/packages/status-page/src/page.ts @@ -14,7 +14,8 @@ import { utcDay, } from "@maple/domain/status-page" import { pageHref, previewIdAttr, type RenderOptions, renderDocument, renderHeader } from "./document" -import { escapeHtml, safeUrl } from "./escape" +import { escapeHtml } from "@maple/strings" +import { safeUrl } from "./escape" import { COMPONENT_STATUS_LABEL, dayTitle,