fix: replace stale docs() paths with redirect destinations (DOCS-253) (#25740)

## Summary

Replaces 9 stale docs paths and 2 stale doc anchors in `site/src/`, and
adds a TS/TSX audit script (`site/scripts/audit-docs-paths.mjs`) plus
unit tests that scan the codebase for paths that resolve via
`coder.com/redirects.json`.

### Redirect-target updates (9)

Each of these paths' `/docs/...` source matches a Next.js redirect rule,
so requests today produce a 302 on `coder.com`. The audit script
identifies them by cross-referencing against `redirects.json`.

- Five update product-code references to `/ai-coder/ai-bridge` (renamed
to `/ai-coder/ai-gateway` in v2.33).
- One updates a commented-out reference to
`/templates#template-filtering` in `TemplatesFilter.tsx`.
- Three update notification-template mock data in
`testHelpers/entities.ts` that pointed at the renamed
`/docs/templates/schedule`.

### Anchor-only updates (2)

These paths are still live on `coder.com` (no redirect), but their
`#fragment` no longer matches a heading on the destination page.
Fragments are evaluated client-side and never sent to the server, so the
audit script does not catch them. Found and verified manually against
the current docs.

- `AuditFilter.tsx`: `/admin/security/audit-logs#filtering-logs` →
`/admin/security/audit-logs#how-to-filter-audit-logs`. The current
heading is `## How to Filter Audit Logs` in
[`docs/admin/security/audit-logs.md`](https://github.com/coder/coder/blob/main/docs/admin/security/audit-logs.md).
- `UserAuthSettingsPageView.tsx`: drops the stale `#openid-connect`
anchor; the path itself (`/admin/users/oidc-auth`) is unchanged. The
page H1 is now `# OpenID Connect`, so the bare path lands at the same
place the anchor used to.

None are user-visible label changes; only the doc target URLs change.

## Audit script

`site/scripts/audit-docs-paths.mjs` cross-references TS/TSX docs-URL
references against `coder.com/redirects.json` and reports anything that
resolves via a redirect (which means stale source). It catches four
forms:

- `docs("/...")` / `docs('/...')` / `` docs(`/...`) ``
- `` docs(`/.../${expr}/...`) `` (literal prefix, flagged as dynamic)
- `"https://coder.com/docs/..."` and other quoted forms
- `](https://coder.com/docs/...)` and `](/docs/...)` markdown-link forms

The full audit (26 findings: 9 in `coder/coder/site/`, 17 in
`coder/coder.com/src/`) lives in [DOCS-253 on
Linear](https://linear.app/codercom/issue/DOCS-253) rather than being
committed to the repo. Re-run locally with:

```bash
node site/scripts/audit-docs-paths.mjs \
  --redirects=/path/to/coder.com/redirects.json \
  --roots=/path/to/coder/site/src,/path/to/coder.com/src
```

Default output goes to `docs/.audit/redirects-audit-YYYY-MM-DD.md`,
which is gitignored.

## Tests

`site/scripts/audit-docs-paths.test.mjs` has 59 cases covering the four
regexes, `matchRedirect` (exact, `:path*`, `:slug(.*)`, miss),
`findMatchingRedirect`, `stripQueryAndFragment`, `literalPrefix`,
`extractReferences` end-to-end with line numbers and multi-line `docs()`
calls, `buildReport` (empty input, repo grouping and sort order,
fragment annotation, unclassified section), `walk` against a real temp
filesystem (recursion, extension filter, `SKIP_DIRS` pruning,
missing/file inputs, seeded results), and `runCli` (missing-root
warning, real-but-empty root). Run with `pnpm exec vitest run
scripts/audit-docs-paths.test.mjs --project=unit` from `site/`.

## Notes

- User-visible "AI Bridge" label text is not changed here. Renaming the
product surface from "AI Bridge" to "AI Gateway" is tracked separately
by the AI Governance team in AIGOV-233.
- A vitest assertion that no literal path in `site/src/` resolves via a
redirect will land in a follow-up PR (DOCS-257), so future drift fails
fast in CI.
- A generated `DocsPath` type for the `docs()` helper is planned in
DOCS-254.
- The `/docs/templates/schedule` drift in `entities.ts` also appears in
`coderd/notifications/testdata/*.golden` test fixtures and in historical
SQL migrations under `coderd/database/migrations/`. Those are tracked
under DOCS-256 (A2: non-TS audit) and not in scope here.

## Related work

- Linear: DOCS-253 (this PR), parent DOCS-209.
- Companion redirect rule on the coder.com side: coder/coder.com#826.
- Companion fixes for the 17 coder.com findings: coder/coder.com#876
(supersedes the closed coder/coder.com#827, which was made redundant by
coder/coder.com#832).

<details>
<summary>Implementation plan (Linear DOCS-209)</summary>

| Phase | Scope | Linear | Status |
|---|---|---|---|
| D | Versioned redirect for `/docs/@v2.33.x/ai-coder/ai-bridge` in
`coder.com/redirects.json` | DOCS-255 | coder/coder.com#826 open |
| A1 | TS/TSX audit + autofix in `coder/coder/site/` | DOCS-253 | This
PR |
| A1 follow-up | Same autofix in `coder/coder.com/src/` (3 remaining
findings after coder/coder.com#832) | DOCS-281 | coder/coder.com#876
open |
| A2 | Non-TS audit in `coder/coder` (Go, comments, markdown) | DOCS-256
| Backlog |
| A3 | code-server audit | DOCS-252 | Backlog |
| B | vitest assertion against `redirects.json` | DOCS-257 | Blocked by
A1 |
| C | Generated `DocsPath` type | DOCS-254 | Blocked by B |

</details>

---

Generated by Coder Agent on behalf of @nickvigilante.

---------

Co-authored-by: Coder <coder@users.noreply.github.com>
This commit is contained in:
Nick Vigilante
2026-06-17 12:19:08 -04:00
committed by GitHub
co-authored by Coder
parent 286bd836f4
commit 6c95a618b4
11 changed files with 1184 additions and 11 deletions
+5
View File
@@ -115,4 +115,9 @@ license.txt
# Agent planning documents (local working files).
docs/plans/
# Local audit reports (e.g. site/scripts/audit-docs-paths.mjs output).
# The file is for local inspection only.
docs/.audit/
/release-action
+453
View File
@@ -0,0 +1,453 @@
// Audit script for docs-URL drift.
//
// Cross-references docs(...) and string-literal docs-URL references in TS/TSX
// files against the source side of every /docs/* rule in
// coder.com/redirects.json. Anything that matches a redirect source is stale
// and needs to be updated to the redirect's destination.
//
// Usage:
// node site/scripts/audit-docs-paths.mjs \
// --redirects=/path/to/coder.com/redirects.json \
// --roots=/path/to/coder/site,/path/to/coder.com/src \
// --out=docs/.audit/redirects-audit-YYYY-MM-DD.md
//
// All flags are optional. Defaults assume a standard Coder dev layout under
// /home/coder/. The script never modifies source files; it only emits the
// report. The output file defaults to today's date so each run produces a
// dated snapshot.
//
// docs/.audit/ is gitignored; the report file is a local working artifact.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
// ---------------------------------------------------------------------------
// Redirect indexing.
export function loadRedirects(p) {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
// Filter all entries to the /docs/* subset. Order matters because Next.js
// picks the first matching rule.
export function docsRedirects(all) {
if (!Array.isArray(all)) {
throw new TypeError(
`docsRedirects: expected an array of redirect rules, got ${typeof all}`,
);
}
return all.filter(
(r) => typeof r.source === "string" && r.source.startsWith("/docs/"),
);
}
// Match a path against a single redirect source. Returns the redirect's
// destination with path params substituted, or null if no match.
export function matchRedirect(refPath, redirect) {
const src = redirect.source;
const dst = redirect.destination;
if (src === refPath) return dst;
// Trailing /:path* wildcard (Next.js's "match anything below this prefix").
if (src.endsWith("/:path*")) {
const prefix = src.slice(0, -"/:path*".length);
if (refPath === prefix) {
return dst.endsWith("/:path*") ? dst.slice(0, -"/:path*".length) : dst;
}
if (refPath.startsWith(prefix + "/")) {
const tail = refPath.slice(prefix.length); // includes leading slash
if (dst.endsWith("/:path*")) {
return dst.slice(0, -"/:path*".length) + tail;
}
return dst;
}
}
// :slug(.*) at the end: same idea but params named "slug".
if (src.endsWith(":slug(.*)")) {
const prefix = src.slice(0, -":slug(.*)".length);
if (refPath.startsWith(prefix)) {
const tail = refPath.slice(prefix.length);
if (dst.endsWith(":slug")) {
return dst.slice(0, -":slug".length) + tail;
}
return dst;
}
}
// :version capture groups appear in the new versioned redirects and are
// rare elsewhere. The audit only cares whether a literal source path is
// stale, so paths containing @version segments would never appear as
// literals in TS/TSX. Skip.
return null;
}
export function findMatchingRedirect(refPath, redirects) {
for (const r of redirects) {
const dst = matchRedirect(refPath, r);
if (dst !== null) return { redirect: r, suggestedDestination: dst };
}
return null;
}
// ---------------------------------------------------------------------------
// Reference extraction.
// docs("/path") | docs('/path') | docs(`/path`) with NO ${expr}.
export const DOCS_LITERAL_RE = /\bdocs\(\s*(['"`])([^'"`)$]+)\1/g;
// docs(`/path/${expr}/more`). Captures the whole literal segment between the
// backticks so we can flag the literal prefix.
export const DOCS_TEMPLATE_RE = /\bdocs\(\s*`([^`]*\$\{[^`]*\}[^`]*)`/g;
// "https://coder.com/docs/..." wrapped in any string-literal delimiter.
// The backreference (\1) requires the closing delimiter to match the opening
// one, mirroring DOCS_LITERAL_RE.
export const HARDCODED_URL_RE =
/(['"`])https?:\/\/(?:[a-z0-9-]+\.)?coder\.com(\/docs\/[^'"`)\s]+)\1/g;
// Markdown-link form: [text](https://coder.com/docs/...) or [text](/docs/...).
// Used inside notification bodies, doc strings, and other prose. The URL is
// bounded by ( and ), not by string delimiters.
export const MARKDOWN_LINK_RE =
/\]\(\s*(?:https?:\/\/(?:[a-z0-9-]+\.)?coder\.com)?(\/docs\/[^\s)]+)\)/g;
export function stripQueryAndFragment(p) {
// Remove hash fragment and query string before redirect matching.
let out = p;
const hash = out.indexOf("#");
if (hash !== -1) out = out.slice(0, hash);
const query = out.indexOf("?");
if (query !== -1) out = out.slice(0, query);
return out;
}
export function literalPrefix(tmpl) {
// Return the literal portion before the first ${...} so we can do a
// partial redirect match on the static prefix.
const idx = tmpl.indexOf("${");
return idx === -1 ? tmpl : tmpl.slice(0, idx);
}
// Map a regex match's 0-based string index to a 1-based line number using
// a precomputed array of line-start offsets.
function buildLineIndex(content) {
const lineStarts = [0];
for (let i = 0; i < content.length; i++) {
if (content[i] === "\n") lineStarts.push(i + 1);
}
return lineStarts;
}
function indexToLine(idx, lineStarts) {
// Binary search for the largest lineStart <= idx.
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (lineStarts[mid] <= idx) lo = mid;
else hi = mid - 1;
}
return lo + 1; // 1-based
}
export function extractReferences(filePath, content) {
const refs = [];
const lineStarts = buildLineIndex(content);
const push = (m, kind, rawArg, docsPath, dynamic) => {
refs.push({
file: filePath,
lineNo: indexToLine(m.index, lineStarts),
kind,
rawArg,
docsPath,
dynamic,
});
};
for (const m of content.matchAll(DOCS_LITERAL_RE)) {
push(m, "docs-literal", m[2], "/docs" + stripQueryAndFragment(m[2]), false);
}
for (const m of content.matchAll(DOCS_TEMPLATE_RE)) {
push(
m,
"docs-template",
m[1],
"/docs" + stripQueryAndFragment(literalPrefix(m[1])),
true,
);
}
for (const m of content.matchAll(HARDCODED_URL_RE)) {
push(m, "hardcoded-url", m[2], stripQueryAndFragment(m[2]), false);
}
for (const m of content.matchAll(MARKDOWN_LINK_RE)) {
push(m, "markdown-link", m[1], stripQueryAndFragment(m[1]), false);
}
return refs;
}
// ---------------------------------------------------------------------------
// File discovery.
const SKIP_DIRS = new Set([
"node_modules",
"dist",
"build",
".next",
".cache",
"out",
".audit",
".style",
"storybook-static",
"__generated__",
]);
export function walk(dir, exts, results = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
if (e.code === "ENOENT" || e.code === "ENOTDIR") return results;
throw e;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
walk(full, exts, results);
} else if (e.isFile() && exts.some((ext) => e.name.endsWith(ext))) {
results.push(full);
}
}
return results;
}
// ---------------------------------------------------------------------------
// Report rendering.
export function repoForFile(file) {
if (file.includes("/coder/site/")) return "coder/coder/site";
if (file.includes("/coder.com/src/")) return "coder/coder.com/src";
return "unknown";
}
export function relForFile(file) {
// Strip absolute prefix so the report is portable.
return file
.replace(/^.*\/coder\/site\//, "site/")
.replace(/^.*\/coder\.com\/src\//, "src/");
}
export function escapeMd(s) {
return s.replace(/\|/g, "\\|");
}
export function suggestedFixForKind(kind, suggestedDestination) {
// Reverse the /docs prefix transformation for docs() callsites so the
// suggested fix is the literal string the developer should paste in.
if (kind === "hardcoded-url") {
return "https://coder.com" + suggestedDestination;
}
if (kind === "markdown-link") {
return suggestedDestination;
}
// docs-literal / docs-template: helper prepends /docs, so strip it.
return suggestedDestination.startsWith("/docs")
? suggestedDestination.slice("/docs".length)
: suggestedDestination;
}
export function buildReport({ findings, redirectsPath, roots, startedAt }) {
const byRepo = Map.groupBy(findings, (f) => repoForFile(f.file));
// Stable sort: dynamic last; otherwise by file then line.
for (const list of byRepo.values()) {
list.sort((a, b) => {
if (a.dynamic !== b.dynamic) return a.dynamic ? 1 : -1;
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
return a.lineNo - b.lineNo;
});
}
const sectionRows = (list) =>
list
.map((f) => {
const rel = relForFile(f.file);
const fix = suggestedFixForKind(f.kind, f.match.suggestedDestination);
const fragmentNote = f.rawArg.includes("#")
? " (preserve `#anchor` from current value)"
: "";
return `| \`${rel}:${f.lineNo}\` | \`${escapeMd(f.rawArg)}\` | \`${escapeMd(f.match.redirect.source)}\` -> \`${escapeMd(f.match.redirect.destination)}\` | \`${escapeMd(fix)}\`${fragmentNote} | ${f.dynamic ? "Yes" : "No"} |`;
})
.join("\n");
const totalDynamic = findings.filter((f) => f.dynamic).length;
const totalStatic = findings.length - totalDynamic;
const out = [
"# Redirects audit: TS/TSX docs-URL references",
"",
`Generated: ${startedAt.toISOString()}`,
"",
"## Method",
"",
'This audit cross-references every static `docs("...")` call, every `docs(`...`)` template literal with a literal prefix, every hardcoded `coder.com/docs/...` URL, and every `](/docs/...)`-style Markdown link in TS/TSX files against the source side of every `/docs/*` rule in `coder/coder.com/redirects.json`. Anything that matches a redirect source is stale and needs to be updated to the destination.',
"",
`Source of truth for the redirect set: \`${redirectsPath}\` at audit time.`,
"",
"Scanned roots:",
"",
...roots.map((r) => `* \`${r}\``),
"",
"Pattern matchers:",
"",
'* `docs("/...")` and `docs(\'/...\')` and `docs(`/...`)` (no `${}`).',
"* `docs(`/.../${expr}/...`)` (literal prefix only; flagged as dynamic for manual review).",
"* Any string literal containing `https://coder.com/docs/...` or `https://*.coder.com/docs/...`.",
"* Markdown-link form `](https://coder.com/docs/...)` or `](/docs/...)` inside prose, notifications, and mock data.",
"",
"Hash fragments (`#anchor`) and query strings (`?foo`) are stripped before redirect matching.",
"",
"## Summary",
"",
"| Total findings | Auto-fixable (literal) | Manual review (dynamic) |",
"|---|---|---|",
`| ${findings.length} | ${totalStatic} | ${totalDynamic} |`,
"",
];
const repoOrder = ["coder/coder/site", "coder/coder.com/src"];
for (const repo of repoOrder) {
const list = byRepo.get(repo) ?? [];
out.push(`## ${repo}`);
out.push("");
if (list.length === 0) {
out.push("No findings.");
out.push("");
continue;
}
out.push(`${list.length} ${list.length === 1 ? "finding" : "findings"}.`);
out.push("");
out.push(
"| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |",
);
out.push("|---|---|---|---|---|");
out.push(sectionRows(list));
out.push("");
}
const unknown = byRepo.get("unknown") ?? [];
if (unknown.length > 0) {
out.push("## Unclassified findings");
out.push("");
out.push(
"These came from a path that did not match the known repo prefixes. Investigate.",
);
out.push("");
out.push(
"| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |",
);
out.push("|---|---|---|---|---|");
out.push(sectionRows(unknown));
out.push("");
}
out.push("## Notes");
out.push("");
out.push(
"* Dynamic findings have a `${...}` expression somewhere in the path. The suggested fix shows what the literal prefix should become; the developer must keep the dynamic suffix intact.",
);
out.push(
"* Findings under `docs/.audit/` are excluded by file discovery (`.audit` is in SKIP_DIRS) to avoid feedback loops on the audit itself.",
);
out.push(
"* Re-run with `node site/scripts/audit-docs-paths.mjs` from the repo root.",
);
out.push("");
return out.join("\n");
}
// ---------------------------------------------------------------------------
// CLI entry point.
function defaultOutForToday() {
const today = new Date().toISOString().slice(0, 10);
return `/home/coder/coder/docs/.audit/redirects-audit-${today}.md`;
}
export function runCli(argv) {
const { values: args } = parseArgs({
args: argv,
options: {
redirects: { type: "string" },
roots: { type: "string" },
out: { type: "string" },
},
});
const redirectsPath = args.redirects ?? "/home/coder/coder.com/redirects.json";
const roots = (
args.roots ?? "/home/coder/coder/site/src,/home/coder/coder.com/src"
)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const outPath = args.out ?? defaultOutForToday();
const startedAt = new Date();
console.error(`Loading redirects from ${redirectsPath}`);
const redirects = docsRedirects(loadRedirects(redirectsPath));
console.error(` ${redirects.length} /docs/* redirect rules indexed`);
const exts = [".ts", ".tsx"];
const allFiles = [];
for (const root of roots) {
if (!fs.existsSync(root)) {
console.error(` WARNING: ${root} does not exist; skipping`);
continue;
}
console.error(`Scanning ${root}`);
const found = walk(root, exts);
console.error(` ${found.length} files`);
allFiles.push(...found);
}
const findings = [];
for (const file of allFiles) {
const content = fs.readFileSync(file, "utf-8");
const refs = extractReferences(file, content);
for (const ref of refs) {
const match = findMatchingRedirect(ref.docsPath, redirects);
if (match) findings.push({ ...ref, match });
}
}
console.error(`Total findings: ${findings.length}`);
const report = buildReport({
findings,
redirectsPath,
roots,
startedAt,
});
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, report);
const totalDynamic = findings.filter((f) => f.dynamic).length;
const totalStatic = findings.length - totalDynamic;
console.error(`Wrote ${outPath}`);
console.error(` Static: ${totalStatic}`);
console.error(` Dynamic: ${totalDynamic}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
runCli(process.argv.slice(2));
}
+715
View File
@@ -0,0 +1,715 @@
import { describe, expect, it, vi } from "vitest";
import {
DOCS_LITERAL_RE,
DOCS_TEMPLATE_RE,
HARDCODED_URL_RE,
MARKDOWN_LINK_RE,
buildReport,
docsRedirects,
escapeMd,
extractReferences,
findMatchingRedirect,
literalPrefix,
matchRedirect,
relForFile,
repoForFile,
runCli,
stripQueryAndFragment,
suggestedFixForKind,
walk,
} from "./audit-docs-paths.mjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const rule = (source, destination) => ({ source, destination, permanent: true });
describe("docsRedirects", () => {
it("keeps only /docs/* sources", () => {
const all = [
rule("/docs/a", "/docs/b"),
rule("/api/old", "/api/new"),
rule("/docs/c/:path*", "/docs/d/:path*"),
{ source: 42, destination: "/" }, // malformed; must be skipped
];
expect(docsRedirects(all)).toEqual([
rule("/docs/a", "/docs/b"),
rule("/docs/c/:path*", "/docs/d/:path*"),
]);
});
it("throws a TypeError when given a non-array (e.g. object)", () => {
expect(() => docsRedirects({})).toThrowError(TypeError);
expect(() => docsRedirects({})).toThrowError(/got object/);
});
it("throws a TypeError when given a non-array (e.g. string)", () => {
expect(() => docsRedirects("hello")).toThrowError(TypeError);
expect(() => docsRedirects("hello")).toThrowError(/got string/);
});
it("throws a TypeError when given null", () => {
expect(() => docsRedirects(null)).toThrowError(TypeError);
});
});
describe("matchRedirect", () => {
it("returns destination on an exact match", () => {
expect(
matchRedirect("/docs/admin/rbac", rule("/docs/admin/rbac", "/docs/x")),
).toBe("/docs/x");
});
it("returns null when nothing matches", () => {
expect(matchRedirect("/docs/foo", rule("/docs/bar", "/docs/baz"))).toBe(null);
});
it("matches /:path* with a real subpath and substitutes the tail", () => {
expect(
matchRedirect(
"/docs/old/sub/page",
rule("/docs/old/:path*", "/docs/new/:path*"),
),
).toBe("/docs/new/sub/page");
});
it("matches /:path* with an empty path (bare prefix)", () => {
expect(
matchRedirect("/docs/old", rule("/docs/old/:path*", "/docs/new/:path*")),
).toBe("/docs/new");
});
it("matches /:path* when the destination drops the wildcard", () => {
expect(
matchRedirect("/docs/old/x/y", rule("/docs/old/:path*", "/docs/new")),
).toBe("/docs/new");
});
it("matches :slug(.*) and substitutes the tail", () => {
expect(
matchRedirect(
"/docs/platforms/kubernetes",
rule("/docs/platforms/:slug(.*)", "/docs/install/:slug"),
),
).toBe("/docs/install/kubernetes");
});
it("matches :slug(.*) when destination drops the slug", () => {
expect(
matchRedirect(
"/docs/platforms/aws",
rule("/docs/platforms/:slug(.*)", "/docs/install/cloud"),
),
).toBe("/docs/install/cloud");
});
it("does not partial-match a path that overshoots the prefix", () => {
expect(
matchRedirect(
"/docs/oldsibling",
rule("/docs/old/:path*", "/docs/new/:path*"),
),
).toBe(null);
});
});
describe("findMatchingRedirect", () => {
const redirects = [
rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"),
rule("/docs/old/:path*", "/docs/new/:path*"),
];
it("returns the first matching rule and its destination", () => {
const got = findMatchingRedirect("/docs/admin/rbac", redirects);
expect(got).not.toBeNull();
expect(got.redirect).toBe(redirects[0]);
expect(got.suggestedDestination).toBe(
"/docs/admin/templates/template-permissions",
);
});
it("returns null when no rule matches", () => {
expect(findMatchingRedirect("/docs/never", redirects)).toBeNull();
});
});
describe("stripQueryAndFragment", () => {
it("removes a hash fragment", () => {
expect(stripQueryAndFragment("/docs/a#section")).toBe("/docs/a");
});
it("removes a query string", () => {
expect(stripQueryAndFragment("/docs/a?x=1")).toBe("/docs/a");
});
it("removes both when query precedes hash", () => {
expect(stripQueryAndFragment("/docs/a?x=1#section")).toBe("/docs/a");
});
it("removes both when hash precedes query", () => {
// Slices at the first hash; query suffix after the hash is dropped too.
expect(stripQueryAndFragment("/docs/a#section?x=1")).toBe("/docs/a");
});
it("is a no-op when neither is present", () => {
expect(stripQueryAndFragment("/docs/a/b")).toBe("/docs/a/b");
});
});
describe("literalPrefix", () => {
it("returns the whole string when there is no interpolation", () => {
expect(literalPrefix("/docs/a/b")).toBe("/docs/a/b");
});
it("returns the prefix up to the first ${...}", () => {
expect(literalPrefix("/docs/a/${slug}/b")).toBe("/docs/a/");
});
it("handles interpolation at position zero", () => {
expect(literalPrefix("${root}/docs/a")).toBe("");
});
});
describe("regex patterns", () => {
const exec = (re, content) => [...content.matchAll(re)].map((m) => [...m]);
it("DOCS_LITERAL_RE matches docs(\"...\"), docs('...'), docs(`...`)", () => {
const src = `docs("/a/b") docs('/c/d') docs(\`/e/f\`)`;
const m = exec(DOCS_LITERAL_RE, src);
expect(m.map((row) => row[2])).toEqual(["/a/b", "/c/d", "/e/f"]);
});
it("DOCS_LITERAL_RE skips template literals with interpolation", () => {
const src = "docs(`/a/${x}/b`)";
expect(exec(DOCS_LITERAL_RE, src)).toHaveLength(0);
});
it("DOCS_TEMPLATE_RE matches docs(`/.../${expr}/...`)", () => {
const src = "docs(`/a/${x}/b`)";
const m = exec(DOCS_TEMPLATE_RE, src);
expect(m).toHaveLength(1);
expect(m[0][1]).toBe("/a/${x}/b");
});
it("HARDCODED_URL_RE matches https://coder.com URLs in any quote", () => {
const src = `a = "https://coder.com/docs/foo"; b = 'https://coder.com/docs/bar';`;
const m = exec(HARDCODED_URL_RE, src);
expect(m.map((row) => row[2])).toEqual(["/docs/foo", "/docs/bar"]);
});
it("HARDCODED_URL_RE matches *.coder.com subdomains", () => {
const src = `"https://dev.coder.com/docs/foo"`;
const m = exec(HARDCODED_URL_RE, src);
expect(m).toHaveLength(1);
expect(m[0][2]).toBe("/docs/foo");
});
it("HARDCODED_URL_RE does NOT match markdown-link form", () => {
// The URL is bounded by ( and ), not by quotes. This pattern misses
// the URL; MARKDOWN_LINK_RE catches it.
const src = `[label](https://coder.com/docs/foo)`;
expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0);
});
it("HARDCODED_URL_RE rejects mismatched opening and closing delimiters", () => {
// Backreference ensures "...' or '...\" cannot match.
const src = `a = "https://coder.com/docs/foo'; b = 'https://coder.com/docs/bar";`;
expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0);
});
it("MARKDOWN_LINK_RE matches full URLs in markdown links", () => {
const src = `[label](https://coder.com/docs/foo) and [x](https://dev.coder.com/docs/bar)`;
const m = exec(MARKDOWN_LINK_RE, src);
expect(m.map((row) => row[1])).toEqual(["/docs/foo", "/docs/bar"]);
});
it("MARKDOWN_LINK_RE matches relative /docs/... links too", () => {
const src = `See [the docs](/docs/admin/rbac) for details.`;
const m = exec(MARKDOWN_LINK_RE, src);
expect(m.map((row) => row[1])).toEqual(["/docs/admin/rbac"]);
});
it("MARKDOWN_LINK_RE ignores trailing punctuation outside the parentheses", () => {
const src = `See [the docs](/docs/x).`;
const m = exec(MARKDOWN_LINK_RE, src);
expect(m[0][1]).toBe("/docs/x");
});
});
describe("extractReferences", () => {
it("captures all four kinds and sets line numbers (1-based)", () => {
const content = [
'docs("/admin/rbac");', // line 1
"const x = `${base}/docs/admin/groups`;", // line 2: no match
'const y = "https://coder.com/docs/admin/quotas";', // line 3
"// see [the docs](/docs/admin/audit-logs)", // line 4
"docs(`/templates/${slug}/edit`);", // line 5
].join("\n");
const refs = extractReferences("/tmp/foo.ts", content);
const byKind = (k) => refs.filter((r) => r.kind === k);
expect(byKind("docs-literal")).toEqual([
expect.objectContaining({
lineNo: 1,
rawArg: "/admin/rbac",
docsPath: "/docs/admin/rbac",
dynamic: false,
}),
]);
expect(byKind("hardcoded-url")).toEqual([
expect.objectContaining({
lineNo: 3,
rawArg: "/docs/admin/quotas",
docsPath: "/docs/admin/quotas",
dynamic: false,
}),
]);
expect(byKind("markdown-link")).toEqual([
expect.objectContaining({
lineNo: 4,
rawArg: "/docs/admin/audit-logs",
docsPath: "/docs/admin/audit-logs",
dynamic: false,
}),
]);
expect(byKind("docs-template")).toEqual([
expect.objectContaining({
lineNo: 5,
docsPath: "/docs/templates/",
dynamic: true,
}),
]);
});
it("strips fragments from the redirect-matching docsPath but preserves rawArg", () => {
const content = `docs("/admin/rbac#perms");`;
const refs = extractReferences("/tmp/foo.ts", content);
expect(refs[0].rawArg).toBe("/admin/rbac#perms");
expect(refs[0].docsPath).toBe("/docs/admin/rbac");
});
it("returns an empty array when content has no docs refs", () => {
expect(extractReferences("/tmp/foo.ts", "const x = 1;")).toEqual([]);
});
it("handles a multi-line docs() invocation", () => {
const content = [
"link={docs(",
' "/admin/rbac",',
")}",
].join("\n");
const refs = extractReferences("/tmp/foo.tsx", content);
expect(refs).toHaveLength(1);
expect(refs[0].kind).toBe("docs-literal");
expect(refs[0].docsPath).toBe("/docs/admin/rbac");
// The match starts on the line with "docs(", which is line 1.
expect(refs[0].lineNo).toBe(1);
});
});
describe("repoForFile / relForFile", () => {
it("classifies coder/coder/site/ files", () => {
expect(repoForFile("/home/coder/coder/site/src/app.tsx")).toBe(
"coder/coder/site",
);
});
it("classifies coder/coder.com/src/ files", () => {
expect(repoForFile("/home/coder/coder.com/src/foo.ts")).toBe(
"coder/coder.com/src",
);
});
it("returns 'unknown' for paths that do not match a known repo", () => {
expect(repoForFile("/var/tmp/foo.ts")).toBe("unknown");
});
it("rewrites coder/coder/site paths to a site/ relative form", () => {
expect(relForFile("/home/coder/coder/site/src/app.tsx")).toBe(
"site/src/app.tsx",
);
});
it("rewrites coder.com paths to an src/ relative form", () => {
expect(relForFile("/home/coder/coder.com/src/data/x.ts")).toBe(
"src/data/x.ts",
);
});
});
describe("escapeMd", () => {
it("escapes pipes so table rows do not break", () => {
expect(escapeMd("a|b|c")).toBe("a\\|b\\|c");
});
it("leaves other characters alone", () => {
expect(escapeMd("/docs/a-b.c")).toBe("/docs/a-b.c");
});
});
describe("suggestedFixForKind", () => {
it("strips /docs from docs-literal suggestions", () => {
expect(suggestedFixForKind("docs-literal", "/docs/admin/x")).toBe(
"/admin/x",
);
});
it("strips /docs from docs-template suggestions", () => {
expect(suggestedFixForKind("docs-template", "/docs/admin/x")).toBe(
"/admin/x",
);
});
it("prefixes hardcoded-url suggestions with https://coder.com", () => {
expect(suggestedFixForKind("hardcoded-url", "/docs/admin/x")).toBe(
"https://coder.com/docs/admin/x",
);
});
it("returns markdown-link suggestions verbatim", () => {
expect(suggestedFixForKind("markdown-link", "/docs/admin/x")).toBe(
"/docs/admin/x",
);
});
});
describe("buildReport", () => {
const finding = (file, lineNo, overrides = {}) => ({
file,
lineNo,
kind: "docs-literal",
rawArg: "/admin/rbac",
docsPath: "/docs/admin/rbac",
dynamic: false,
match: {
redirect: rule(
"/docs/admin/rbac",
"/docs/admin/templates/template-permissions",
),
suggestedDestination: "/docs/admin/templates/template-permissions",
},
...overrides,
});
const startedAt = new Date("2026-05-27T12:00:00.000Z");
it("renders an empty report with zero findings", () => {
const report = buildReport({
findings: [],
redirectsPath: "/redirects.json",
roots: ["/home/coder/coder/site/src"],
startedAt,
});
expect(report).toContain("# Redirects audit");
expect(report).toContain("| 0 | 0 | 0 |");
expect(report).toContain("## coder/coder/site");
expect(report).toContain("No findings.");
expect(report).toContain("/redirects.json");
expect(report).toContain("`/home/coder/coder/site/src`");
});
it("groups findings by repo, counts dynamic/static, and sorts within sections", () => {
const findings = [
// Dynamic finding under site (sorted last in section).
finding("/home/coder/coder/site/src/b.tsx", 5, {
kind: "docs-template",
rawArg: "/templates/${slug}/edit",
docsPath: "/docs/templates/",
dynamic: true,
}),
// Static finding under site, file b, later line.
finding("/home/coder/coder/site/src/b.tsx", 20),
// Static finding under site, file a (sorts before file b).
finding("/home/coder/coder/site/src/a.tsx", 10),
// Static finding under coder.com/src.
finding("/home/coder/coder.com/src/page.ts", 3),
];
const report = buildReport({
findings,
redirectsPath: "/redirects.json",
roots: ["/home/coder/coder/site/src", "/home/coder/coder.com/src"],
startedAt,
});
// Summary: 4 total, 3 static, 1 dynamic.
expect(report).toContain("| 4 | 3 | 1 |");
// Both repo sections present, each with finding counts.
expect(report).toMatch(/## coder\/coder\/site\n\n3 findings\./);
expect(report).toMatch(/## coder\/coder\.com\/src\n\n1 finding\./);
// Within the site section, file a should appear before file b, and the
// dynamic finding should appear after the static ones from the same file.
const siteIdx = report.indexOf("## coder/coder/site");
const comIdx = report.indexOf("## coder/coder.com/src");
const siteSection = report.slice(siteIdx, comIdx);
const aIdx = siteSection.indexOf("site/src/a.tsx:10");
const bStaticIdx = siteSection.indexOf("site/src/b.tsx:20");
const bDynamicIdx = siteSection.indexOf("site/src/b.tsx:5");
expect(aIdx).toBeGreaterThan(-1);
expect(bStaticIdx).toBeGreaterThan(-1);
expect(bDynamicIdx).toBeGreaterThan(-1);
expect(aIdx).toBeLessThan(bStaticIdx);
expect(bStaticIdx).toBeLessThan(bDynamicIdx);
// Dynamic? column reflects the dynamic flag.
expect(siteSection).toMatch(/site\/src\/a\.tsx:10\b[^\n]*\| No \|/);
expect(siteSection).toMatch(/site\/src\/b\.tsx:5\b[^\n]*\| Yes \|/);
});
it("annotates findings whose rawArg contains a # fragment", () => {
const report = buildReport({
findings: [
finding("/home/coder/coder/site/src/a.tsx", 1, {
rawArg: "/admin/rbac#perms",
}),
],
redirectsPath: "/redirects.json",
roots: ["/home/coder/coder/site/src"],
startedAt,
});
expect(report).toContain("(preserve `#anchor` from current value)");
});
it("emits an Unclassified findings section for paths outside known repos", () => {
const report = buildReport({
findings: [finding("/var/tmp/foo.ts", 1)],
redirectsPath: "/redirects.json",
roots: ["/var/tmp"],
startedAt,
});
expect(report).toContain("## Unclassified findings");
expect(report).toContain("/var/tmp/foo.ts:1");
});
});
describe("walk", () => {
// Builds a temp tree of the shape:
// <tmpDir>/a.ts
// <tmpDir>/b.tsx
// <tmpDir>/c.js
// <tmpDir>/nested/d.ts
// <tmpDir>/nested/e.md
// <tmpDir>/node_modules/skipme.ts
// <tmpDir>/dist/also-skipped.ts
// <tmpDir>/.audit/audit-report.ts
const buildTree = (root) => {
fs.writeFileSync(path.join(root, "a.ts"), "export const a = 1;");
fs.writeFileSync(path.join(root, "b.tsx"), "export const B = () => null;");
fs.writeFileSync(path.join(root, "c.js"), "module.exports = {};");
fs.mkdirSync(path.join(root, "nested"));
fs.writeFileSync(path.join(root, "nested", "d.ts"), "export const d = 1;");
fs.writeFileSync(path.join(root, "nested", "e.md"), "# notes");
fs.mkdirSync(path.join(root, "node_modules"));
fs.writeFileSync(
path.join(root, "node_modules", "skipme.ts"),
"export const x = 1;",
);
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(
path.join(root, "dist", "also-skipped.ts"),
"export const x = 1;",
);
fs.mkdirSync(path.join(root, ".audit"));
fs.writeFileSync(
path.join(root, ".audit", "audit-report.ts"),
"export const x = 1;",
);
};
it("returns matching files, recurses, and filters by extension", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
try {
buildTree(tmpDir);
const found = walk(tmpDir, [".ts", ".tsx"]).sort();
const rel = found.map((f) => path.relative(tmpDir, f)).sort();
expect(rel).toEqual([
"a.ts",
"b.tsx",
path.join("nested", "d.ts"),
]);
// c.js excluded by extension filter; e.md excluded by extension filter;
// node_modules/dist/.audit excluded by SKIP_DIRS.
expect(rel).not.toContain("c.js");
expect(rel).not.toContain(path.join("nested", "e.md"));
expect(rel).not.toContain(path.join("node_modules", "skipme.ts"));
expect(rel).not.toContain(path.join("dist", "also-skipped.ts"));
expect(rel).not.toContain(path.join(".audit", "audit-report.ts"));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("honors the extension filter (.tsx only)", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
try {
buildTree(tmpDir);
const found = walk(tmpDir, [".tsx"]);
const rel = found.map((f) => path.relative(tmpDir, f));
expect(rel).toEqual(["b.tsx"]);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns an empty array when the directory does not exist", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
try {
const missing = path.join(tmpDir, "does-not-exist");
expect(walk(missing, [".ts"])).toEqual([]);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns an empty array when handed a file instead of a directory", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
try {
const filePath = path.join(tmpDir, "a.ts");
fs.writeFileSync(filePath, "export const a = 1;");
expect(walk(filePath, [".ts"])).toEqual([]);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("appends to an existing results array instead of replacing it", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-"));
try {
fs.writeFileSync(path.join(tmpDir, "a.ts"), "export const a = 1;");
const seed = ["/seed/value.ts"];
const out = walk(tmpDir, [".ts"], seed);
expect(out).toBe(seed);
expect(out).toContain("/seed/value.ts");
expect(out.some((f) => f.endsWith("a.ts"))).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe("runCli", () => {
// Minimal redirects file written to a tmp dir so the CLI can load it.
const writeTmpRedirects = (tmpDir) => {
const redirectsPath = path.join(tmpDir, "redirects.json");
fs.writeFileSync(
redirectsPath,
JSON.stringify([
rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"),
]),
);
return redirectsPath;
};
it("warns and continues when a --roots directory does not exist", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
try {
const redirectsPath = writeTmpRedirects(tmpDir);
const missingRoot = path.join(tmpDir, "does-not-exist");
const outPath = path.join(tmpDir, "out.md");
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let stderr;
try {
runCli([
`--redirects=${redirectsPath}`,
`--roots=${missingRoot}`,
`--out=${outPath}`,
]);
stderr = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
} finally {
errSpy.mockRestore();
}
expect(stderr).toContain(missingRoot);
expect(stderr).toContain("does not exist");
// Report was still written with zero findings.
expect(fs.existsSync(outPath)).toBe(true);
const report = fs.readFileSync(outPath, "utf-8");
expect(report).toContain("| 0 | 0 | 0 |");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("writes a report when --roots contains a real directory with no matches", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
try {
const redirectsPath = writeTmpRedirects(tmpDir);
const emptyRoot = path.join(tmpDir, "empty");
fs.mkdirSync(emptyRoot);
const outPath = path.join(tmpDir, "out.md");
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
runCli([
`--redirects=${redirectsPath}`,
`--roots=${emptyRoot}`,
`--out=${outPath}`,
]);
} finally {
errSpy.mockRestore();
}
expect(fs.existsSync(outPath)).toBe(true);
const report = fs.readFileSync(outPath, "utf-8");
expect(report).toContain("| 0 | 0 | 0 |");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("wires the full pipeline end-to-end and reports a real finding", () => {
// Exercises walk -> readFile -> extractReferences ->
// findMatchingRedirect -> buildReport -> writeFile against a seeded
// .ts file. The path layout mimics coder/coder/site so repoForFile
// classifies the finding into the expected report section.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-"));
try {
const redirectsPath = writeTmpRedirects(tmpDir);
const siteRoot = path.join(tmpDir, "coder", "site", "src");
const nested = path.join(siteRoot, "pages");
fs.mkdirSync(nested, { recursive: true });
const sourceFile = path.join(nested, "Example.tsx");
fs.writeFileSync(
sourceFile,
'import { docs } from "./docs";\nconst href = docs("/admin/rbac");\n',
);
const outPath = path.join(tmpDir, "out.md");
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
runCli([
`--redirects=${redirectsPath}`,
`--roots=${siteRoot}`,
`--out=${outPath}`,
]);
} finally {
errSpy.mockRestore();
}
expect(fs.existsSync(outPath)).toBe(true);
const report = fs.readFileSync(outPath, "utf-8");
// Summary row reflects exactly one auto-fixable finding.
expect(report).toContain("| 1 | 1 | 0 |");
// Repo section header for coder/coder/site.
expect(report).toContain("## coder/coder/site");
expect(report).toContain("1 finding.");
// Table row includes the relative file path, the raw arg, the
// matched redirect, and the suggested fix.
expect(report).toContain("site/src/pages/Example.tsx:2");
expect(report).toContain("`/admin/rbac`");
expect(report).toContain(
"`/docs/admin/rbac` -> `/docs/admin/templates/template-permissions`",
);
expect(report).toContain("`/admin/templates/template-permissions`");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -44,7 +44,7 @@ const PaywallAIGovernance = () => {
<span>
Visit{" "}
<a
href={docs("/ai-coder/ai-bridge")}
href={docs("/ai-coder/ai-gateway")}
target="_blank"
rel="noreferrer"
className="text-content-link"
@@ -22,7 +22,7 @@ const AIBridgeSessionsLayout: FC<PropsWithChildren> = () => {
Review and audit AI activity, token usage, and prompt history across
sessions.{" "}
<Link
href={docs("/ai-coder/ai-bridge/audit")}
href={docs("/ai-coder/ai-gateway/audit")}
className="ml-auto"
target="_blank"
>
@@ -10,7 +10,7 @@ export const AIBridgeSetupAlert: FC = () => {
AI Gateway is included in your license, but not set up yet.
</AlertTitle>
<AlertDescription>
You have access to AI Governance, but it still needs to be setup. Check
You have access to AI Governance, but it still needs to be set up. Check
out the{" "}
<Link href={docs("/ai-coder/ai-gateway")} target="_blank">
AI Gateway
@@ -319,7 +319,7 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
</p>
<Link
href={docs(
"/ai-coder/ai-bridge/audit#human-vs-agent-attribution",
"/ai-coder/ai-gateway/audit#human-vs-agent-attribution",
)}
target="_blank"
className="text-sm"
+3 -1
View File
@@ -58,7 +58,9 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined;
return (
<Filter
learnMoreLink={docs("/admin/security/audit-logs#filtering-logs")}
learnMoreLink={docs(
"/admin/security/audit-logs#how-to-filter-audit-logs",
)}
presets={PRESET_FILTERS}
isLoading={menus.user.isInitializing}
filter={filter}
@@ -41,9 +41,7 @@ export const UserAuthSettingsPageView = ({
<SettingsHeader
actions={
<SettingsHeaderDocsLink
href={docs("/admin/users/oidc-auth#openid-connect")}
/>
<SettingsHeaderDocsLink href={docs("/admin/users/oidc-auth")} />
}
>
<SettingsHeaderTitle level="h2" hierarchy="secondary">
@@ -60,7 +60,7 @@ export const TemplatesFilter: FC<TemplatesFilterProps> = ({
{ query: "deprecated:true", name: "Deprecated templates" },
]}
// TODO: Add docs for this
// learnMoreLink={docs("/templates#template-filtering")}
// learnMoreLink={docs("/admin/templates#template-filtering")}
isLoading={false}
filter={filter}
error={error}
+2 -2
View File
@@ -4955,7 +4955,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
name: "Workspace Marked as Dormant",
title_template: 'Workspace "{{.Labels.name}}" marked as dormant',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
@@ -4981,7 +4981,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
name: "Workspace Marked for Deletion",
title_template: 'Workspace "{{.Labels.name}}" marked for deletion',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",