fix relative markdown refs, so images and and links work correctly
Msrc/routes/patches.tsx
| @@ -99,6 +99,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 99 | 99 | "patches.created_at", | |
| 100 | 100 | "patches.updated_at", | |
| 101 | 101 | "patches.edited_at", | |
| 102 | + | "patches.version", | |
| 102 | 103 | "users.username as author_username", | |
| 103 | 104 | "users.avatar_version as author_avatar_version", | |
| 104 | 105 | ]) | |
Msrc/routes/repos.tsx
| @@ -2,14 +2,15 @@ import { readFileSync, rmSync } from "node:fs"; | |||
|---|---|---|---|
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | 4 | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | + | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 5 | 6 | import { | |
| 6 | 7 | ALLOWED_SIGNERS_PATH, | |
| 7 | 8 | COMMITS_PER_PAGE, | |
| 8 | 9 | REPOS_PER_PAGE, | |
| 9 | 10 | VALID_REPO_NAME_RE, | |
| 10 | 11 | } from "../constants.ts"; | |
| 11 | - | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 12 | 12 | import { db } from "../db/index.ts"; | |
| 13 | + | import { redirect } from "../lib/redirect.ts"; | |
| 13 | 14 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 14 | 15 | import { git, repoPath } from "../services/git.ts"; | |
| 15 | 16 | import { | |
| @@ -19,7 +20,6 @@ import { | |||
|---|---|---|---|
| 19 | 20 | } from "../services/highlightWorker.ts"; | |
| 20 | 21 | import { renderMarkdown } from "../services/markdown.ts"; | |
| 21 | 22 | import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts"; | |
| 22 | - | import { redirect } from "../lib/redirect.ts"; | |
| 23 | 23 | import { html } from "../views/render.tsx"; | |
| 24 | 24 | import { CommitDetail } from "../views/repos/CommitDetail.tsx"; | |
| 25 | 25 | import { CommitLog } from "../views/repos/CommitLog.tsx"; | |
| @@ -38,9 +38,18 @@ async function getRepo(name: string, isAdmin: boolean) { | |||
|---|---|---|---|
| 38 | 38 | return repo; | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | async function mimeForContent(content: Buffer): Promise<string> { | |
| 41 | + | async function mimeForContent( | |
| 42 | + | filename: string, | |
| 43 | + | content: Buffer, | |
| 44 | + | ): Promise<string> { | |
| 42 | 45 | const result = await fileTypeFromBuffer(content); | |
| 43 | 46 | if (result) return result.mime; | |
| 47 | + | ||
| 48 | + | const typeFromName = Bun.file(filename).type; | |
| 49 | + | if (typeFromName !== "application/octet-stream") { | |
| 50 | + | return typeFromName; | |
| 51 | + | } | |
| 52 | + | ||
| 44 | 53 | return hasBinaryContent(content.subarray(0, 8000)) | |
| 45 | 54 | ? "application/octet-stream" | |
| 46 | 55 | : "text/plain; charset=utf-8"; | |
| @@ -90,8 +99,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 90 | 99 | const user = await resolveSession(cookie.session.value); | |
| 91 | 100 | const search = query.q?.trim() || undefined; | |
| 92 | 101 | const page = Math.max(1, query.page ?? 1); | |
| 93 | - | const sort = | |
| 94 | - | cookie.repo_sort.value === "name" ? "name" : "created"; | |
| 102 | + | const sort = cookie.repo_sort.value === "name" ? "name" : "created"; | |
| 95 | 103 | ||
| 96 | 104 | const isAdmin = user?.isAdmin ?? false; | |
| 97 | 105 | ||
| @@ -280,7 +288,11 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 280 | 288 | const key = resolved | |
| 281 | 289 | ? `readme:${repo.name}:${resolved}:` | |
| 282 | 290 | : undefined; | |
| 283 | - | readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key); | |
| 291 | + | readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key, { | |
| 292 | + | repo: repo.name, | |
| 293 | + | ref: repo.default_branch, | |
| 294 | + | dir: "", | |
| 295 | + | }); | |
| 284 | 296 | } | |
| 285 | 297 | } | |
| 286 | 298 | ||
| @@ -361,6 +373,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 361 | 373 | ? renderMarkdown( | |
| 362 | 374 | readmeBuf.toString("utf-8"), | |
| 363 | 375 | `readme:${repo.name}:${resolved}:`, | |
| 376 | + | { repo: repo.name, ref: params.ref, dir: "" }, | |
| 364 | 377 | ) | |
| 365 | 378 | : null; | |
| 366 | 379 | return html( | |
| @@ -403,6 +416,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 403 | 416 | ? renderMarkdown( | |
| 404 | 417 | readmeBuf.toString("utf-8"), | |
| 405 | 418 | `readme:${repo.name}:${resolved}:${subpath}`, | |
| 419 | + | { repo: repo.name, ref: params.ref, dir: subpath }, | |
| 406 | 420 | ) | |
| 407 | 421 | : null; | |
| 408 | 422 | return html( | |
| @@ -460,7 +474,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 460 | 474 | if (!content) return new Response("Not found", { status: 404 }); | |
| 461 | 475 | ||
| 462 | 476 | const filename = path.basename(filePath); | |
| 463 | - | const contentType = await mimeForContent(content); | |
| 477 | + | const contentType = await mimeForContent(filename, content); | |
| 464 | 478 | const total = content.length; | |
| 465 | 479 | ||
| 466 | 480 | const rangeHeader = request.headers.get("Range"); | |
| @@ -535,7 +549,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 535 | 549 | if (!branches.includes(params.ref)) | |
| 536 | 550 | return new Response("Not found", { status: 404 }); | |
| 537 | 551 | ||
| 538 | - | const message = body.message?.trim() || `Edited ${path.basename(filePath)}`; | |
| 552 | + | const message = | |
| 553 | + | body.message?.trim() || `Edited ${path.basename(filePath)}`; | |
| 539 | 554 | const content = (body.content ?? "").replaceAll("\r\n", "\n"); | |
| 540 | 555 | ||
| 541 | 556 | const commit = await git.editFile( | |
Msrc/services/diffHighlight.ts
| @@ -177,7 +177,7 @@ export async function parseDiff( | |||
|---|---|---|---|
| 177 | 177 | return files; | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | function escapeHtml(s: string): string { | |
| 180 | + | function _escapeHtml(s: string): string { | |
| 181 | 181 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 182 | 182 | } | |
| 183 | 183 | ||
| @@ -206,7 +206,9 @@ async function highlightHunk( | |||
|---|---|---|---|
| 206 | 206 | // single newline and silently drops the \r from every line except the last. | |
| 207 | 207 | const contents = hunk.lines.map((l) => l.content); | |
| 208 | 208 | const trailingCR = contents.map((c) => c.endsWith("\r")); | |
| 209 | - | const stripped = contents.map((c, i) => (trailingCR[i] ? c.slice(0, -1) : c)); | |
| 209 | + | const stripped = contents.map((c, i) => | |
| 210 | + | trailingCR[i] ? c.slice(0, -1) : c, | |
| 211 | + | ); | |
| 210 | 212 | const code = stripped.join("\n"); | |
| 211 | 213 | try { | |
| 212 | 214 | const h = await getHighlighter(); | |
| @@ -227,7 +229,7 @@ async function highlightHunk( | |||
|---|---|---|---|
| 227 | 229 | }) | |
| 228 | 230 | .join(""); | |
| 229 | 231 | return trailingCR[i] | |
| 230 | - | ? html + '<span class="diff-ctrl">^M</span>' | |
| 232 | + | ? `${html}<span class="diff-ctrl">^M</span>` | |
| 231 | 233 | : html; | |
| 232 | 234 | }); | |
| 233 | 235 | while (lines.length < hunk.lines.length) lines.push(""); | |
Msrc/services/git.ts
| @@ -507,9 +507,7 @@ export const git = { | |||
|---|---|---|---|
| 507 | 507 | await $`git -C ${p} hash-object -w ${tmpFile}`.text() | |
| 508 | 508 | ).trim(); | |
| 509 | 509 | await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`; | |
| 510 | - | const tree = ( | |
| 511 | - | await $`git -C ${p} write-tree`.text() | |
| 512 | - | ).trim(); | |
| 510 | + | const tree = (await $`git -C ${p} write-tree`.text()).trim(); | |
| 513 | 511 | const parent = ( | |
| 514 | 512 | await $`git -C ${p} rev-parse refs/heads/${branch}`.text() | |
| 515 | 513 | ).trim(); | |
Msrc/services/markdown.ts
| @@ -6,12 +6,67 @@ marked.setOptions({ gfm: true }); | |||
|---|---|---|---|
| 6 | 6 | ||
| 7 | 7 | const mdCache = new Map<string, string>(); | |
| 8 | 8 | ||
| 9 | - | export function renderMarkdown(md: string, cacheKey?: string): string { | |
| 9 | + | export interface MarkdownContext { | |
| 10 | + | repo: string; | |
| 11 | + | ref: string; | |
| 12 | + | /** Directory of the markdown file relative to repo root, e.g. "" or "docs/subdir" */ | |
| 13 | + | dir: string; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * Resolves a markdown href to a repo-root-relative path for rewriting. | |
| 18 | + | * Returns null if the href should not be rewritten (protocol-absolute or anchor). | |
| 19 | + | * | |
| 20 | + | * - Protocol-absolute (http://, mailto:, data:, …): returns null | |
| 21 | + | * - Anchor (#section): returns null | |
| 22 | + | * - Root-relative (/subdir/img.png): strips leading slash → "subdir/img.png" | |
| 23 | + | * - Path-relative (./img.png, ../img.png, subdir/img.png): resolved against dir | |
| 24 | + | */ | |
| 25 | + | export function resolveMarkdownHref(dir: string, href: string): string | null { | |
| 26 | + | if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) return null; // protocol-absolute | |
| 27 | + | if (href.startsWith("#")) return null; // anchor | |
| 28 | + | if (href.startsWith("/")) return href.slice(1); // root-relative | |
| 29 | + | // Path-relative: resolve against current directory using URL API | |
| 30 | + | const base = new URL(`http://x/${dir ? `${dir}/` : ""}`); | |
| 31 | + | return new URL(href, base).pathname.slice(1); // strip leading / | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function makeContextualMarked(ctx: MarkdownContext): Marked { | |
| 35 | + | const m = new Marked({ gfm: true }); | |
| 36 | + | m.use({ | |
| 37 | + | renderer: { | |
| 38 | + | image({ href, title, text }) { | |
| 39 | + | const resolved = resolveMarkdownHref(ctx.dir, href); | |
| 40 | + | if (resolved !== null) { | |
| 41 | + | href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`; | |
| 42 | + | } | |
| 43 | + | return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ""}>`; | |
| 44 | + | }, | |
| 45 | + | link({ href, title, tokens }) { | |
| 46 | + | const resolved = resolveMarkdownHref(ctx.dir, href); | |
| 47 | + | if (resolved !== null) { | |
| 48 | + | href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`; | |
| 49 | + | } | |
| 50 | + | const text = String(this.parser.parseInline(tokens)); | |
| 51 | + | return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`; | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | }); | |
| 55 | + | return m; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | export function renderMarkdown( | |
| 59 | + | md: string, | |
| 60 | + | cacheKey?: string, | |
| 61 | + | ctx?: MarkdownContext, | |
| 62 | + | ): string { | |
| 10 | 63 | if (cacheKey) { | |
| 11 | 64 | const cached = mdCache.get(cacheKey); | |
| 12 | 65 | if (cached) return cached; | |
| 13 | 66 | } | |
| 14 | - | const raw = marked(md) as string; | |
| 67 | + | const raw = ctx | |
| 68 | + | ? (makeContextualMarked(ctx).parse(md) as string) | |
| 69 | + | : (marked(md) as string); | |
| 15 | 70 | const result = DOMPurify.sanitize(raw, { | |
| 16 | 71 | ADD_TAGS: ["details", "summary"], | |
| 17 | 72 | ADD_ATTR: ["class"], | |
Msrc/views/patches/PatchDetail.tsx
| @@ -451,8 +451,12 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 451 | 451 | ) : ( | |
| 452 | 452 | <div> | |
| 453 | 453 | <div class="commit-card"> | |
| 454 | - | <h2 class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}> | |
| 455 | - | {patchMeta.subject ? escapeHtml(patchMeta.subject) : "No commit message"} | |
| 454 | + | <h2 | |
| 455 | + | class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`} | |
| 456 | + | > | |
| 457 | + | {patchMeta.subject | |
| 458 | + | ? escapeHtml(patchMeta.subject) | |
| 459 | + | : "No commit message"} | |
| 456 | 460 | </h2> | |
| 457 | 461 | {patchMeta.body && ( | |
| 458 | 462 | <pre class="commit-card-body"> | |
Msrc/views/repos/CommitDetail.tsx
| @@ -49,7 +49,9 @@ export function CommitDetail({ | |||
|---|---|---|---|
| 49 | 49 | ||
| 50 | 50 | {/* Commit metadata card */} | |
| 51 | 51 | <div class="commit-card"> | |
| 52 | - | <h2 class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}> | |
| 52 | + | <h2 | |
| 53 | + | class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`} | |
| 54 | + | > | |
| 53 | 55 | {meta.subject || "No commit message"} | |
| 54 | 56 | </h2> | |
| 55 | 57 | {meta.body && ( | |
Msrc/views/repos/FileEdit.tsx
| @@ -51,7 +51,9 @@ export function FileEdit({ | |||
|---|---|---|---|
| 51 | 51 | ); | |
| 52 | 52 | })} | |
| 53 | 53 | </div> | |
| 54 | - | <p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p> | |
| 54 | + | <p class="form-hint"> | |
| 55 | + | WARNING: Line endings are normalized to LF (\n) on save. | |
| 56 | + | </p> | |
| 55 | 57 | {error && <p class="form-error">{error}</p>} | |
| 56 | 58 | <form | |
| 57 | 59 | method="POST" | |
| @@ -74,17 +76,19 @@ export function FileEdit({ | |||
|---|---|---|---|
| 74 | 76 | class="file-edit-textarea" | |
| 75 | 77 | rows="30" | |
| 76 | 78 | spellcheck="false" | |
| 77 | - | autocomplete="off" | |
| 78 | 79 | autocorrect="off" | |
| 79 | 80 | autocapitalize="off" | |
| 81 | + | {...{ autocomplete: "off" }} | |
| 80 | 82 | > | |
| 81 | 83 | {content} | |
| 82 | 84 | </textarea> | |
| 83 | 85 | </div> | |
| 84 | 86 | <div class="form-card"> | |
| 85 | - | <p class="form-hint" style="margin-bottom: var(--space-4);"> | |
| 86 | - | Committing directly to{" "} | |
| 87 | - | <strong>{editRef}</strong> | |
| 87 | + | <p | |
| 88 | + | class="form-hint" | |
| 89 | + | style="margin-bottom: var(--space-4);" | |
| 90 | + | > | |
| 91 | + | Committing directly to <strong>{editRef}</strong> | |
| 88 | 92 | </p> | |
| 89 | 93 | <div class="form-group"> | |
| 90 | 94 | <label for="message">Commit message</label> | |
Atests/markdown.test.ts
| @@ -0,0 +1,114 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, expect, test } from "bun:test"; | |
| 2 | + | import { resolveMarkdownHref } from "../src/services/markdown.ts"; | |
| 3 | + | ||
| 4 | + | describe("resolveMarkdownHref", () => { | |
| 5 | + | describe("protocol-absolute URLs (returns null)", () => { | |
| 6 | + | test("http", () => { | |
| 7 | + | expect(resolveMarkdownHref("", "http://example.com/img.png")).toBeNull(); | |
| 8 | + | expect(resolveMarkdownHref("docs", "http://example.com/img.png")).toBeNull(); | |
| 9 | + | }); | |
| 10 | + | test("https", () => { | |
| 11 | + | expect(resolveMarkdownHref("", "https://example.com/img.png")).toBeNull(); | |
| 12 | + | }); | |
| 13 | + | test("ftp", () => { | |
| 14 | + | expect(resolveMarkdownHref("", "ftp://files.example.com/")).toBeNull(); | |
| 15 | + | }); | |
| 16 | + | test("mailto", () => { | |
| 17 | + | expect(resolveMarkdownHref("", "mailto:foo@bar.com")).toBeNull(); | |
| 18 | + | }); | |
| 19 | + | test("data", () => { | |
| 20 | + | expect(resolveMarkdownHref("", "data:image/png;base64,abc123")).toBeNull(); | |
| 21 | + | }); | |
| 22 | + | test("ssh", () => { | |
| 23 | + | expect(resolveMarkdownHref("", "ssh://git@example.com/repo.git")).toBeNull(); | |
| 24 | + | }); | |
| 25 | + | test("custom protocol", () => { | |
| 26 | + | expect(resolveMarkdownHref("", "myapp://open/something")).toBeNull(); | |
| 27 | + | }); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | describe("anchor links (returns null)", () => { | |
| 31 | + | test("simple anchor", () => { | |
| 32 | + | expect(resolveMarkdownHref("", "#section-heading")).toBeNull(); | |
| 33 | + | }); | |
| 34 | + | test("anchor with any dir", () => { | |
| 35 | + | expect(resolveMarkdownHref("docs/guide", "#toc")).toBeNull(); | |
| 36 | + | }); | |
| 37 | + | test("bare hash", () => { | |
| 38 | + | expect(resolveMarkdownHref("", "#")).toBeNull(); | |
| 39 | + | }); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | describe("root-relative URLs (strips leading /)", () => { | |
| 43 | + | test("single file at root", () => { | |
| 44 | + | expect(resolveMarkdownHref("", "/img.png")).toBe("img.png"); | |
| 45 | + | }); | |
| 46 | + | test("subdir file — dir is ignored", () => { | |
| 47 | + | expect(resolveMarkdownHref("docs", "/subdir/img.png")).toBe("subdir/img.png"); | |
| 48 | + | }); | |
| 49 | + | test("deep path — any dir is ignored", () => { | |
| 50 | + | expect(resolveMarkdownHref("x/y/z", "/a/b/c.png")).toBe("a/b/c.png"); | |
| 51 | + | }); | |
| 52 | + | test("root-relative with no filename (trailing slash)", () => { | |
| 53 | + | expect(resolveMarkdownHref("docs", "/assets/")).toBe("assets/"); | |
| 54 | + | }); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | describe("path-relative from root dir (dir = '')", () => { | |
| 58 | + | test("bare filename", () => { | |
| 59 | + | expect(resolveMarkdownHref("", "img.png")).toBe("img.png"); | |
| 60 | + | }); | |
| 61 | + | test("./filename", () => { | |
| 62 | + | expect(resolveMarkdownHref("", "./img.png")).toBe("img.png"); | |
| 63 | + | }); | |
| 64 | + | test("subdir/file", () => { | |
| 65 | + | expect(resolveMarkdownHref("", "subdir/img.png")).toBe("subdir/img.png"); | |
| 66 | + | }); | |
| 67 | + | test("./subdir/file", () => { | |
| 68 | + | expect(resolveMarkdownHref("", "./subdir/img.png")).toBe("subdir/img.png"); | |
| 69 | + | }); | |
| 70 | + | test("../ from root clamps to root", () => { | |
| 71 | + | // URL API resolves http://x/../img.png → http://x/img.png | |
| 72 | + | expect(resolveMarkdownHref("", "../img.png")).toBe("img.png"); | |
| 73 | + | }); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | describe("path-relative from one-level dir (dir = 'docs')", () => { | |
| 77 | + | test("bare filename", () => { | |
| 78 | + | expect(resolveMarkdownHref("docs", "img.png")).toBe("docs/img.png"); | |
| 79 | + | }); | |
| 80 | + | test("./filename", () => { | |
| 81 | + | expect(resolveMarkdownHref("docs", "./img.png")).toBe("docs/img.png"); | |
| 82 | + | }); | |
| 83 | + | test("../ traversal to root", () => { | |
| 84 | + | expect(resolveMarkdownHref("docs", "../img.png")).toBe("img.png"); | |
| 85 | + | }); | |
| 86 | + | test("subdir/file", () => { | |
| 87 | + | expect(resolveMarkdownHref("docs", "subdir/img.png")).toBe("docs/subdir/img.png"); | |
| 88 | + | }); | |
| 89 | + | test("./subdir/file", () => { | |
| 90 | + | expect(resolveMarkdownHref("docs", "./subdir/img.png")).toBe("docs/subdir/img.png"); | |
| 91 | + | }); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | describe("path-relative from nested dir (dir = 'docs/guide')", () => { | |
| 95 | + | test("bare filename", () => { | |
| 96 | + | expect(resolveMarkdownHref("docs/guide", "img.png")).toBe("docs/guide/img.png"); | |
| 97 | + | }); | |
| 98 | + | test("one level up", () => { | |
| 99 | + | expect(resolveMarkdownHref("docs/guide", "../img.png")).toBe("docs/img.png"); | |
| 100 | + | }); | |
| 101 | + | test("two levels up", () => { | |
| 102 | + | expect(resolveMarkdownHref("docs/guide", "../../img.png")).toBe("img.png"); | |
| 103 | + | }); | |
| 104 | + | test("sibling directory", () => { | |
| 105 | + | expect(resolveMarkdownHref("docs/guide", "../assets/img.png")).toBe("docs/assets/img.png"); | |
| 106 | + | }); | |
| 107 | + | test("./filename", () => { | |
| 108 | + | expect(resolveMarkdownHref("docs/guide", "./img.png")).toBe("docs/guide/img.png"); | |
| 109 | + | }); | |
| 110 | + | test("deeper subdir", () => { | |
| 111 | + | expect(resolveMarkdownHref("docs/guide", "sub/img.png")).toBe("docs/guide/sub/img.png"); | |
| 112 | + | }); | |
| 113 | + | }); | |
| 114 | + | }); | |