feat(markdown): KB/process render + working in-document anchor links

KB/process content was shown via a bare unsanitized ReactMarkdown in the
ops view dialog (the /ops/[id] route is the editor, not a view). Route
it through the shared SafeMarkdown instead. New dependency-free
lib/markdown-anchors.ts (heading-text flatten, GitHub-ish slug,
per-render dedupe, in-doc href test; unit-tested) is wired into both
SafeMarkdown and MarkdownBody so headings get stable ids and a
[link](#heading) resolves. In-document links no longer force a new tab,
which previously broke the anchor jump for policies/proposals too.
rehype-slug was not added (no offline install); the helper is equivalent
and tested.
This commit is contained in:
Moheeb Zara
2026-05-16 19:11:49 -07:00
parent 2687c1578d
commit 70da0c3752
6 changed files with 157 additions and 19 deletions
+57
View File
@@ -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)
})
})
+2 -5
View File
@@ -3,8 +3,7 @@
import { useState, useMemo, useEffect, useRef } from 'react' import { useState, useMemo, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, ChevronDown, Users2, FileText, Copy, Check } from 'lucide-react' import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, ChevronDown, Users2, FileText, Copy, Check } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import { SafeMarkdown } from '@/components/safe-markdown'
import remarkGfm from 'remark-gfm'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions' import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets' import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
import { OpsAclEditor } from '@/components/ops/ops-acl-editor' import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
@@ -572,9 +571,7 @@ function KbEntryRow({
</div> </div>
</DialogHeader> </DialogHeader>
<div> <div>
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words"> <SafeMarkdown>{entry.content ?? ''}</SafeMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.content ?? ''}</ReactMarkdown>
</div>
{canManageAcl && ( {canManageAcl && (
<OpsAclEditor <OpsAclEditor
entityType={aclEntityType} entityType={aclEntityType}
+24 -13
View File
@@ -1,5 +1,6 @@
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { makeHeadingSlugger, isInDocumentHref } from '@/lib/markdown-anchors'
/** /**
* Server-renderable markdown component for governance text (proposals, * Server-renderable markdown component for governance text (proposals,
@@ -15,28 +16,38 @@ export function MarkdownBody({ content, className = '' }: { content: string | nu
return null return null
} }
const slug = makeHeadingSlugger()
return ( return (
<div className={`markdown-body font-sans text-sm text-foreground/90 ${className}`}> <div className={`markdown-body font-sans text-sm text-foreground/90 ${className}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => <h1 className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h1>, h1: ({ children }) => <h1 id={slug(children)} className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-lg font-semibold text-foreground mt-4 mb-2">{children}</h2>, h2: ({ children }) => <h2 id={slug(children)} className="text-lg font-semibold text-foreground mt-4 mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-semibold text-foreground mt-3 mb-1">{children}</h3>, h3: ({ children }) => <h3 id={slug(children)} className="text-base font-semibold text-foreground mt-3 mb-1">{children}</h3>,
h4: ({ children }) => <h4 id={slug(children)} className="text-sm font-semibold text-foreground mt-3 mb-1">{children}</h4>,
h5: ({ children }) => <h5 id={slug(children)} className="text-sm font-semibold text-foreground/90 mt-2 mb-1">{children}</h5>,
h6: ({ children }) => <h6 id={slug(children)} className="text-xs font-semibold text-muted-foreground mt-2 mb-1">{children}</h6>,
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>, p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
ul: ({ children }) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>, ul: ({ children }) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>, ol: ({ children }) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>, li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => ( a: ({ href, children }) =>
<a isInDocumentHref(href) ? (
href={href} <a href={href} className="text-primary underline hover:no-underline">
target="_blank" {children}
rel="noopener noreferrer" </a>
className="text-primary underline hover:no-underline" ) : (
> <a
{children} href={href}
</a> target="_blank"
), rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
>
{children}
</a>
),
code: ({ children }) => ( code: ({ children }) => (
<code className="bg-muted px-1 py-0.5 rounded font-mono text-xs">{children}</code> <code className="bg-muted px-1 py-0.5 rounded font-mono text-xs">{children}</code>
), ),
+26
View File
@@ -1,9 +1,11 @@
'use client' 'use client'
import type { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' 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 // Renders admin-authored markdown that MAY contain a constrained subset of raw
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything // 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 }) { 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 <Tag id={slug(c)}>{c}</Tag>
}
return ( return (
<div className={`prose prose-sm prose-neutral dark:prose-invert max-w-none break-words ${className ?? ''}`}> <div className={`prose prose-sm prose-neutral dark:prose-invert max-w-none break-words ${className ?? ''}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
components={{
h1: heading('h1'),
h2: heading('h2'),
h3: heading('h3'),
h4: heading('h4'),
h5: heading('h5'),
h6: heading('h6'),
// In-document links (#section) must stay in the page so the anchor
// jump works; everything else opens safely in a new tab.
a: ({ href, children: c }) =>
isInDocumentHref(href) ? (
<a href={href}>{c}</a>
) : (
<a href={href} target="_blank" rel="noopener noreferrer">
{c}
</a>
),
}}
> >
{children} {children}
</ReactMarkdown> </ReactMarkdown>
+1 -1
View File
@@ -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). - 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). - 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. - 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. - 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.
--- ---
+47
View File
@@ -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<string, number>()
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('#')
}