diff --git a/__tests__/markdown-anchors.test.ts b/__tests__/markdown-anchors.test.ts
new file mode 100644
index 0000000..df3411f
--- /dev/null
+++ b/__tests__/markdown-anchors.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest'
+import {
+ headingText,
+ slugifyHeading,
+ makeHeadingSlugger,
+ isInDocumentHref,
+} from '@/lib/markdown-anchors'
+
+describe('headingText', () => {
+ it('reads strings and numbers', () => {
+ expect(headingText('Hello')).toBe('Hello')
+ expect(headingText(42)).toBe('42')
+ })
+ it('flattens arrays and element children', () => {
+ expect(headingText(['Get ', 'started'])).toBe('Get started')
+ expect(headingText({ props: { children: ['Bold ', { props: { children: 'bit' } }] } })).toBe('Bold bit')
+ })
+ it('ignores null/boolean', () => {
+ expect(headingText(null)).toBe('')
+ expect(headingText(true)).toBe('')
+ })
+})
+
+describe('slugifyHeading', () => {
+ it('lowercases, strips punctuation, hyphenates', () => {
+ expect(slugifyHeading('Overview')).toBe('overview')
+ expect(slugifyHeading(' Getting Started! ')).toBe('getting-started')
+ expect(slugifyHeading('A & B / C')).toBe('a-b-c')
+ })
+ it('collapses repeats and trims hyphens', () => {
+ expect(slugifyHeading('-- weird spacing --')).toBe('weird-spacing')
+ })
+})
+
+describe('makeHeadingSlugger', () => {
+ it('dedupes repeated headings within a document', () => {
+ const slug = makeHeadingSlugger()
+ expect(slug('Setup')).toBe('setup')
+ expect(slug('Setup')).toBe('setup-1')
+ expect(slug('Setup')).toBe('setup-2')
+ expect(slug('Other')).toBe('other')
+ })
+ it('falls back to "section" for empty headings', () => {
+ const slug = makeHeadingSlugger()
+ expect(slug('')).toBe('section')
+ expect(slug(' ')).toBe('section-1')
+ })
+})
+
+describe('isInDocumentHref', () => {
+ it('is true only for hash links', () => {
+ expect(isInDocumentHref('#overview')).toBe(true)
+ expect(isInDocumentHref('https://example.com')).toBe(false)
+ expect(isInDocumentHref('/ops')).toBe(false)
+ expect(isInDocumentHref(undefined)).toBe(false)
+ })
+})
diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx
index 9161a76..c7fc9b4 100644
--- a/app/(app)/ops/ops-client.tsx
+++ b/app/(app)/ops/ops-client.tsx
@@ -3,8 +3,7 @@
import { useState, useMemo, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, ChevronDown, Users2, FileText, Copy, Check } from 'lucide-react'
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
+import { SafeMarkdown } from '@/components/safe-markdown'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
@@ -572,9 +571,7 @@ function KbEntryRow({
-
- {entry.content ?? ''}
-
+
{entry.content ?? ''}
{canManageAcl && (
{children}
,
- h2: ({ children }) => {children}
,
- h3: ({ children }) => {children}
,
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ h3: ({ children }) => {children}
,
+ h4: ({ children }) => {children}
,
+ h5: ({ children }) => {children}
,
+ h6: ({ children }) => {children}
,
p: ({ children }) => {children}
,
ul: ({ children }) => ,
ol: ({ children }) => {children}
,
li: ({ children }) => {children},
- a: ({ href, children }) => (
-
- {children}
-
- ),
+ a: ({ href, children }) =>
+ isInDocumentHref(href) ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ ),
code: ({ children }) => (
{children}
),
diff --git a/components/safe-markdown.tsx b/components/safe-markdown.tsx
index 70da3b6..e79db1e 100644
--- a/components/safe-markdown.tsx
+++ b/components/safe-markdown.tsx
@@ -1,9 +1,11 @@
'use client'
+import type { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
+import { makeHeadingSlugger, isInDocumentHref } from '@/lib/markdown-anchors'
// Renders admin-authored markdown that MAY contain a constrained subset of raw
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything
@@ -27,11 +29,35 @@ const schema = {
}
export function SafeMarkdown({ children, className }: { children: string; className?: string }) {
+ // Per-render so heading ids are stable and de-duplicated within this doc.
+ const slug = makeHeadingSlugger()
+ const heading = (Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6') =>
+ function H({ children: c }: { children?: ReactNode }) {
+ return {c}
+ }
return (
+ isInDocumentHref(href) ? (
+ {c}
+ ) : (
+
+ {c}
+
+ ),
+ }}
>
{children}
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 67a9b66..5d99715 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -25,7 +25,7 @@ Built phase-by-phase, each gated (vitest + `pnpm build` read in-run) + its own c
- Certifications is LOCAL (6 commits ahead). Awaiting deploy approval. After push: confirm `030` applied, board has both perms, exercise create-type -> grant -> revoke -> renew -> `/me` -> non-admin-instructor path; nothing is browser-verified (no live browser this session).
- Pure logic is unit-tested; action *orchestration* (RLS/visibility/grant uniqueness) is NOT (no Supabase mock harness — same systemic gap as forms).
- Secrets-permission audit (user-requested) FOUND A REAL BUG and FIXED IT (user-approved, LOCAL): `revealSecret` hard-gated on built-in role `['admin','board']` and `secrets_select` never consulted `ops.secrets.read`, so the permission was inert and even an ACL-granted member was pre-blocked by the action; the Ops page also short-circuited the secrets fetch by built-in role. Fix (additive, guarded-surface, approved): migration `031` adds `OR user_has_permission(...,'ops.secrets.read')` to `secrets_select` (admin/board + per-secret `ops_acl` branches unchanged, access-neutral); `revealSecret` now `requireMember` + RLS-bounded read (RLS is the boundary); Ops page always queries secrets (RLS filters) and shows the section for admin/board OR the perm OR any ACL-returned row. Writes unchanged. Mirrored in schema.sql + migration docs. NOT browser-verified.
-- Queued by user, NOT yet started: KB + processes should render markdown with working in-document anchor links.
+- KB/process markdown + anchors (user-requested) DONE, LOCAL: KB/process detail (the ops-client view dialog, the only render path; `/ops/[id]` is the editor) rendered via a bare unsanitized `ReactMarkdown`; now uses the shared `SafeMarkdown`. New dependency-free `lib/markdown-anchors.ts` (heading text flatten + GitHub-ish slug + per-render dedupe + in-doc href test; 8 unit tests, suite 364 -> 372) wired into BOTH `SafeMarkdown` and `MarkdownBody` so headings get ids and `[x](#heading)` resolves; in-document links no longer force a new tab (was breaking the anchor jump in governance docs too). No new npm dep (rehype-slug unavailable offline). NOT browser-verified.
- STILL OPEN: production incident-INSERT RLS bug — raised to user this pass; awaiting a prod retry to diagnose (#1 SSR auth.uid() vs #3 data drift). Do not touch the permissions/RLS surface without that diagnosis + approval.
---
diff --git a/lib/markdown-anchors.ts b/lib/markdown-anchors.ts
new file mode 100644
index 0000000..b17faa8
--- /dev/null
+++ b/lib/markdown-anchors.ts
@@ -0,0 +1,47 @@
+// Dependency-free heading anchors for the markdown renderers, so an
+// in-document link like [Back to top](#overview) resolves to the matching
+// heading. (We can't add rehype-slug as a dependency here.) Pure and
+// unit-tested; no React/Next imports beyond the ReactNode type.
+
+import type { ReactNode } from 'react'
+
+// Flatten a React markdown heading's children to its plain text.
+export function headingText(node: ReactNode): string {
+ if (node == null || typeof node === 'boolean') return ''
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
+ if (Array.isArray(node)) return node.map(headingText).join('')
+ if (typeof node === 'object' && 'props' in (node as { props?: { children?: ReactNode } })) {
+ return headingText((node as { props?: { children?: ReactNode } }).props?.children)
+ }
+ return ''
+}
+
+// GitHub-ish slug: lowercase, drop punctuation, spaces to hyphens. Matches
+// what an author would naturally type as the link target.
+export function slugifyHeading(text: string): string {
+ return text
+ .trim()
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-+|-+$/g, '')
+}
+
+// Per-render slugger that de-duplicates repeated headings (foo, foo-1, ...)
+// the same way GitHub does, so links stay unambiguous within one document.
+export function makeHeadingSlugger(): (node: ReactNode) => string {
+ const seen = new Map
()
+ return (node: ReactNode) => {
+ const base = slugifyHeading(headingText(node)) || 'section'
+ const count = seen.get(base) ?? 0
+ seen.set(base, count + 1)
+ return count === 0 ? base : `${base}-${count}`
+ }
+}
+
+// True for an in-document target ("#section"): such a link must navigate in
+// place, NOT open a new tab, or the anchor jump is lost.
+export function isInDocumentHref(href: string | undefined): boolean {
+ return typeof href === 'string' && href.startsWith('#')
+}