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 }) =>
    {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('#') +}