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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.content ?? ''}</ReactMarkdown>
|
||||
</div>
|
||||
<SafeMarkdown>{entry.content ?? ''}</SafeMarkdown>
|
||||
{canManageAcl && (
|
||||
<OpsAclEditor
|
||||
entityType={aclEntityType}
|
||||
|
||||
+15
-4
@@ -1,5 +1,6 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { makeHeadingSlugger, isInDocumentHref } from '@/lib/markdown-anchors'
|
||||
|
||||
/**
|
||||
* Server-renderable markdown component for governance text (proposals,
|
||||
@@ -15,19 +16,29 @@ export function MarkdownBody({ content, className = '' }: { content: string | nu
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = makeHeadingSlugger()
|
||||
|
||||
return (
|
||||
<div className={`markdown-body font-sans text-sm text-foreground/90 ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 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>,
|
||||
h3: ({ children }) => <h3 className="text-base font-semibold text-foreground mt-3 mb-1">{children}</h3>,
|
||||
h1: ({ children }) => <h1 id={slug(children)} className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 id={slug(children)} className="text-lg font-semibold text-foreground mt-4 mb-2">{children}</h2>,
|
||||
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>,
|
||||
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>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
a: ({ href, children }) =>
|
||||
isInDocumentHref(href) ? (
|
||||
<a href={href} className="text-primary underline hover:no-underline">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
|
||||
@@ -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 <Tag id={slug(c)}>{c}</Tag>
|
||||
}
|
||||
return (
|
||||
<div className={`prose prose-sm prose-neutral dark:prose-invert max-w-none break-words ${className ?? ''}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
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}
|
||||
</ReactMarkdown>
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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('#')
|
||||
}
|
||||
Reference in New Issue
Block a user