feat(webapp): frontmatter moves to a collapsible side panel (BEA-154) (#187)

A doc's YAML frontmatter rendered as a table pinned to the top of the
reading column, so on anything with more than two or three keys the
document itself started below the fold. It is a panel beside the prose
now — a sticky rail on a wide window, a closed disclosure above the body
on anything narrower — and the reading column starts with the document.

The table was built on the server and handed to the client inside one HTML
string, so this is not a CSS change: markdown.go splits the parse
(frontmatterPairs) from the markup, /api/render gains an ordered
`frontmatter` field, and the viewer switches to RenderMarkdownPairs.

RenderMarkdown keeps its exact output — it is the public share page, and
every /s/ link ever minted serves it. shares_test now pins that, because
nothing else would have failed if a later cleanup moved shares.go onto the
pairs path.

Values cross the wire as literal text plus a `code` flag rather than
pre-escaped HTML, so the panel is ordinary React text nodes and never
touches dangerouslySetInnerHTML: "a value containing markup renders as
text" holds by construction.

The rail's breakpoint is 1400px, not the 1180px the plan named — 768 of
prose + 28 + 240 of rail needs 1036px of column, and at 1280 the reading
measure lost 110px, which is the squeeze the panel exists to avoid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-19 12:55:29 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent b5bd8dddff
commit 4f04b78c71
16 changed files with 498 additions and 62 deletions
+27 -4
View File
@@ -28,10 +28,11 @@ func dirServer(t *testing.T, files map[string]string) http.Handler {
func TestDirSourceServesFolder(t *testing.T) {
h := dirServer(t, map[string]string{
"README.md": "# Local",
"notes/plan.md": "content",
".bdrive": `{"volume":"x"}`, // settings file must be hidden
".git/config": "noise", // .git must be skipped
"README.md": "# Local",
"notes/plan.md": "content",
"notes/props.md": "---\ntitle: Plan\ntags: [a, b]\n---\n\n# Heading\n",
".bdrive": `{"volume":"x"}`, // settings file must be hidden
".git/config": "noise", // .git must be skipped
})
var root Node
@@ -65,6 +66,28 @@ func TestDirSourceServesFolder(t *testing.T) {
t.Errorf("plain-folder render carries identity: %s", rec.Body)
}
// Frontmatter travels as ordered data, not as a table inside html —
// the viewer puts it in a side panel, so the body starts with the body.
rec = get(t, h, "/api/render?path=notes/props.md")
var doc struct {
HTML string `json:"html"`
Frontmatter []FrontmatterPair `json:"frontmatter"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if len(doc.Frontmatter) != 2 || doc.Frontmatter[0].Key != "title" ||
doc.Frontmatter[1].Value != "a, b" {
t.Errorf("frontmatter = %+v", doc.Frontmatter)
}
if strings.Contains(doc.HTML, `class="frontmatter"`) {
t.Errorf("render still bakes the table into html: %s", doc.HTML)
}
// A doc without any: the field is absent, so the viewer shows no panel.
if rec := get(t, h, "/api/render?path=README.md"); strings.Contains(rec.Body.String(), `"frontmatter"`) {
t.Errorf("render carries an empty frontmatter field: %s", rec.Body)
}
if rec := get(t, h, "/api/file?path=.git/config"); rec.Code != 404 {
t.Fatalf(".git content must be hidden, got %d", rec.Code)
}
@@ -1203,3 +1203,95 @@ test("read counts disclose that your own views count", async ({ page }) => {
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.",
);
});
/* BEA-154: a doc's YAML frontmatter used to be a table pinned to the top of
the reading column, pushing the document below the fold. It is a panel
beside the prose now — a rail on a wide window, a closed disclosure on a
phone — and the reading column starts with the document. */
test("frontmatter is a side panel, not a slab on top of the document", async ({ page }) => {
// Wide enough for the rail: the breakpoint is arithmetic (style.css), and
// the default 1280 viewport is deliberately below it.
await page.setViewportSize({ width: 1440, height: 900 });
await login(page);
const pid = await wikiId(page);
// Seeded at runtime so no other spec's file counts move.
const put = async (path: string, body: string) => {
const r = await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent(path)}`,
{ data: body },
);
expect(r.ok(), `seeding ${path}: ${r.status()}`).toBeTruthy();
};
await put(
"meta/props.md",
"---\ntitle: Q3 findings\nstatus: draft\ntags: [churn, revenue]\nmeta:\n reviewed: true\n---\n\n# Q3 findings\n\nBody text.\n",
);
await page.goto(`/${pid}/meta/props.md`);
const panel = page.locator("#content .fmpanel");
await expect(panel).toBeVisible();
// The document leads: the h1 is the first thing in the prose column, and
// no frontmatter table survives inside the rendered markdown.
await expect(page.locator("#content h1")).toHaveText("Q3 findings");
await expect(page.locator("#content table.frontmatter")).toHaveCount(0);
expect(
await page.evaluate(() => {
const h1 = document.querySelector("#content h1") as HTMLElement;
return h1.getBoundingClientRect().top;
}),
).toBeLessThan(
await panel.evaluate((el) => el.getBoundingClientRect().bottom),
);
// Same keys, author order, nested value still compact YAML in <code>.
await expect(panel.locator("dt")).toHaveText(["title", "status", "tags", "meta"]);
await expect(panel.locator("dd").nth(2)).toHaveText("churn, revenue");
await expect(panel.locator("dd code")).toHaveText("reviewed: true");
// A rail, not a squeezed column: the prose keeps its 768px measure and the
// panel sits to its right.
const geom = await page.evaluate(() => {
const prose = document.querySelector("#content .markdown > div:not(.fmpanel)") as HTMLElement;
const p = document.querySelector(".fmpanel") as HTMLElement;
return { prose: prose.getBoundingClientRect(), panel: p.getBoundingClientRect() };
});
expect(Math.round(geom.prose.width), "prose measure unchanged").toBe(768);
expect(geom.panel.left, "panel is to the right of the prose").toBeGreaterThanOrEqual(
geom.prose.right,
);
// Collapsing is remembered — across a different file, and across a reload.
await panel.locator("summary").click();
await expect(panel).not.toHaveAttribute("open", /.*/);
await page.goto(`/${pid}/index.md`);
await expect(page.locator("#content .fmpanel")).toHaveCount(0); // no frontmatter, no panel
await page.goto(`/${pid}/meta/props.md`);
await expect(page.locator("#content .fmpanel")).not.toHaveAttribute("open", /.*/);
await page.reload();
await expect(page.locator("#content .fmpanel")).not.toHaveAttribute("open", /.*/);
});
test("frontmatter panel on a phone: closed disclosure above the body", async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await ctx.newPage();
await login(page);
const pid = await wikiId(page);
await page.request.put(`/api/p/${pid}/upload/content?path=meta/phone.md`, {
data: "---\ntitle: Q3\nowner: snow\n---\n\n# Q3\n\nBody.\n",
});
await page.goto(`/${pid}/meta/phone.md`);
const panel = page.locator("#content .fmpanel");
await expect(panel).toBeVisible();
// No stored choice yet: a phone has no room for a rail, so it opens closed
// and sits above the body rather than beside it.
await expect(panel).not.toHaveAttribute("open", /.*/);
const m = await page.evaluate(() => {
const p = document.querySelector(".fmpanel") as HTMLElement;
const h1 = document.querySelector("#content h1") as HTMLElement;
return {
above: p.getBoundingClientRect().bottom <= h1.getBoundingClientRect().top,
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
});
expect(m.above, "390px: panel sits above the body").toBe(true);
expect(m.overflow, "390px: horizontal page scroll").toBe(false);
await ctx.close();
});
+12
View File
@@ -146,10 +146,22 @@ export interface Node {
children?: Node[];
}
// One key/value row of a document's YAML frontmatter, in author order.
// `value` is plain text, never markup — the panel renders it as a text node,
// so escaping is React's job and not a rule anyone has to remember. `code`
// marks the nested values that read as compact YAML.
export interface FrontmatterPair {
key: string;
value: string;
code?: boolean;
}
// GET .../render (handleRender, server.go)
export interface RenderDoc {
path: string;
html: string;
// Absent when the document has none: no field, no panel.
frontmatter?: FrontmatterPair[];
size: number;
time?: string;
user?: string;
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import type { FrontmatterPair, HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
import { useTextAt } from "../hooks/useBlob";
@@ -12,8 +12,10 @@ import {
MD_EXT,
PDF_EXT,
TEXT_EXT,
fmPanelOpen,
humanSize,
joinPath,
rememberFmPanel,
resolveWiki,
whoChanged,
} from "../util";
@@ -234,11 +236,15 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
if (error) return <LoadError version={version} err={error as Error} />;
if (!doc) return null;
// The panel is a SIBLING of the prose, not a wrapper around it: that is
// what lets plain CSS hang it in the right margin (style.css, .page.read)
// without a third column in AppShell or new props through Browser.
// Server-rendered, server-sanitized markdown — same trust model as the
// classic app assigning innerHTML.
return (
<>
<SecretBadge findings={doc.findings} />
{doc.frontmatter?.length ? <FrontmatterPanel pairs={doc.frontmatter} /> : null}
<div
dangerouslySetInnerHTML={{ __html: diagrams ?? html }}
onClick={(e) => handleLinkClick(e, path, onOpenFile)}
@@ -247,6 +253,41 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
);
}
/* A document's YAML frontmatter, beside the prose instead of on top of it.
Native <details>, so the disclosure, its keyboard handling and its a11y
semantics come from the element rather than from us and values are
ordinary React text children, which is what makes "a value containing
markup renders as text" true by construction rather than by a rule. */
function FrontmatterPanel({ pairs }: { pairs: FrontmatterPair[] }) {
const [open, setOpen] = useState(fmPanelOpen);
return (
<details className="fmpanel" open={open}>
{/* The click, not the element's own `toggle` event: `toggle` is
dispatched asynchronously, so collapsing the panel and immediately
opening another file lost the preference the navigation started
before the handler ran. A summary click is also what Enter and
Space produce, so the keyboard path is the same one. */}
<summary
onClick={(e) => {
e.preventDefault();
setOpen(!open);
rememberFmPanel(!open); // every file and every reload, until changed
}}
>
Properties
</summary>
<dl>
{pairs.map((p) => (
<div key={p.key}>
<dt>{p.key}</dt>
<dd>{p.code ? <code>{p.value}</code> : p.value}</dd>
</div>
))}
</dl>
</details>
);
}
/* The share gate could already name the rule and the line well enough to
refuse to publish this file, while the file view rendered the same key as
ordinary prose (BEA-147). Advisory only, in VersionBanner's shape: a strip
+31 -6
View File
@@ -1145,12 +1145,37 @@ a.ai-main:hover { color: var(--accent); }
the caret marker only lines up in a monospace, non-wrapping box. The height
cap keeps a pathological message from pushing the document off the page. */
.markdown .mermaid-err-detail { margin: 0 0 1.3em; font: 11.5px/1.5 var(--mono); color: var(--text-faint); white-space: pre; overflow: auto; max-height: 12em; }
/* Frontmatter key/value table (server-rendered from a doc's YAML header). */
.markdown table.frontmatter { margin: 0 0 1.8em; font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; border-collapse: separate; border-spacing: 0; }
.markdown table.frontmatter th { text-transform: none; letter-spacing: 0; font-size: 11.5px; color: var(--text-faint); font-weight: 600; text-align: left; white-space: nowrap; vertical-align: top; padding: 6px 14px 6px 12px; border-bottom: 1px solid var(--border); }
.markdown table.frontmatter td { color: var(--text-dim); padding: 6px 12px 6px 0; border-bottom: 1px solid var(--border); }
.markdown table.frontmatter tr:last-child th, .markdown table.frontmatter tr:last-child td { border-bottom: none; }
.markdown table.frontmatter code { white-space: pre-wrap; font-size: 11px; }
/* Frontmatter panel the doc's YAML header beside the prose instead of on
top of it. The viewer's /api/render hands the pairs over as data; the
share page keeps the old table and inlines its own CSS (shares.go), which
is why no `table.frontmatter` rule lives here any more. */
.fmpanel { margin: 0 0 1.8em; font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.fmpanel > summary { list-style: none; cursor: pointer; padding: 7px 12px; color: var(--text-faint); font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; user-select: none; display: flex; align-items: center; gap: 6px; }
.fmpanel > summary::-webkit-details-marker { display: none; }
/* The caret is the only open/closed signal, so it is drawn rather than
inherited: the default marker is hidden above to keep the row on one line. */
.fmpanel > summary::before { content: ""; width: 0; height: 0; border: 4px solid transparent; border-left-color: currentColor; transition: transform .12s ease; }
.fmpanel[open] > summary::before { transform: rotate(90deg) translateX(-1px); }
.fmpanel > summary:hover { color: var(--text-dim); }
.fmpanel > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
.fmpanel dl { margin: 0; padding: 0 12px 4px; border-top: 1px solid var(--border); }
.fmpanel dl > div { display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.fmpanel dl > div:last-child { border-bottom: none; }
.fmpanel dt { flex: 0 0 auto; max-width: 40%; color: var(--text-faint); font-size: 11.5px; font-weight: 600; overflow-wrap: anywhere; }
.fmpanel dd { margin: 0; min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
.fmpanel code { white-space: pre-wrap; font: 11px/1.5 var(--mono); color: #e4d9c4; }
/* Wide enough for a rail, and the number is arithmetic rather than taste:
768 (prose) + 28 (gap) + 240 (rail) = 1036 of column, plus 265 of sidebar,
80 of #content padding and ~10 of scrollbar gutter = 1391. A 1280 laptop
is NOT wide enough asking for the rail there costs the prose 110px of
measure, which is the squeeze this panel exists to avoid. Below the
breakpoint none of this applies and the <details> renders where it sits in
the DOM: above the body, closed. Keep FM_RAIL in util.ts in step. */
@media (min-width: 1400px) {
.page.read:has(.fmpanel) { max-width: calc(var(--page-read) + 268px); display: grid; grid-template-columns: minmax(0, var(--page-read)) 240px; column-gap: 28px; align-items: start; }
.page.read:has(.fmpanel) > * { grid-column: 1; min-width: 0; }
.page.read:has(.fmpanel) > .fmpanel { grid-column: 2; grid-row: 1; position: sticky; top: 0; margin: 0; }
}
.markdown .admin input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.admin input[aria-invalid="true"]:focus-visible { outline-color: var(--del); }
[role="dialog"] input[aria-invalid="true"] { border-color: var(--del); }
+26
View File
@@ -83,6 +83,32 @@ export function rememberProject(id: string) {
}
}
/* Whether the frontmatter panel is expanded, remembered for this browser
the same way and with the same caveats as lastProject above. Unset (the
first visit) is not "closed": the rail only exists on a wide window, so
the default follows the width expanded on desktop, a closed disclosure
on a phone. */
const FM_PANEL = "bdrive.fmPanel";
export const FM_RAIL = "(min-width: 1400px)"; // must match style.css
export function fmPanelOpen(): boolean {
try {
const v = localStorage.getItem(FM_PANEL);
if (v !== null) return v === "1";
} catch {
/* fall through to the width default */
}
return window.matchMedia(FM_RAIL).matches;
}
export function rememberFmPanel(open: boolean) {
try {
localStorage.setItem(FM_PANEL, open ? "1" : "0");
} catch {
/* preference only */
}
}
/* Who made a change, as history renders it everywhere: the account, with
the display name in front when the server knows one, falling back to the
git/OS identity of an offline device. */
+13 -5
View File
@@ -193,8 +193,8 @@ func TestHistoryAPI(t *testing.T) {
func TestRenderVersion(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "# Guide\n\nFirst version.\n")
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "# Guide\n\nSecond version, longer.\n")
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "---\nstatus: draft\n---\n\n# Guide\n\nFirst version.\n")
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "---\nstatus: final\n---\n\n# Guide\n\nSecond version, longer.\n")
var err error
if srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0); err != nil {
t.Fatal(err)
@@ -216,12 +216,20 @@ func TestRenderVersion(t *testing.T) {
t.Fatalf("render version: %d %s", rec.Code, rec.Body)
}
var doc struct {
Path string `json:"path"`
HTML string `json:"html"`
Path string `json:"path"`
HTML string `json:"html"`
Frontmatter []FrontmatterPair `json:"frontmatter"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
// That version's frontmatter, not today's — the panel follows the bytes.
if len(doc.Frontmatter) != 1 || doc.Frontmatter[0] != (FrontmatterPair{Key: "status", Value: "draft"}) {
t.Fatalf("version frontmatter = %+v", doc.Frontmatter)
}
if strings.Contains(doc.HTML, `class="frontmatter"`) {
t.Fatalf("version render bakes the table into html: %q", doc.HTML)
}
if !strings.Contains(doc.HTML, "First version") || strings.Contains(doc.HTML, "Second version") {
t.Fatalf("rendered the wrong version: %q", doc.HTML)
}
@@ -233,7 +241,7 @@ func TestRenderVersion(t *testing.T) {
}
// current content still renders from the snapshot
rec = do(t, h, "GET", base+"render?path=guide.md", nil)
if !strings.Contains(rec.Body.String(), "Second version") {
if !strings.Contains(rec.Body.String(), "Second version") || !strings.Contains(rec.Body.String(), `"final"`) {
t.Fatalf("current render = %s", rec.Body)
}
+76 -21
View File
@@ -39,60 +39,115 @@ func expandWikilinks(src []byte) []byte {
// the source is escaped by goldmark's safe default. A leading YAML
// frontmatter block renders as a small key/value table instead of the
// broken thematic-break soup goldmark would make of it.
//
// This is the PUBLIC SHARE PAGE's renderer (shares.go) and its output is
// what every link ever minted serves — the table stays. The viewer uses
// RenderMarkdownPairs instead, which hands the frontmatter to the client as
// data so it can live in a side panel.
func RenderMarkdown(src []byte) (string, error) {
table, body := frontmatterTable(src)
out, err := renderBody(body)
if err != nil {
return "", err
}
return table + out, nil
}
// RenderMarkdownPairs is RenderMarkdown for the viewer: the frontmatter
// comes back as ordered key/value data and the HTML is body-only. Values
// are raw text (never markup) — the client escapes them by rendering them
// as text nodes.
func RenderMarkdownPairs(src []byte) ([]FrontmatterPair, string, error) {
pairs, body := frontmatterPairs(src)
out, err := renderBody(body)
if err != nil {
return nil, "", err
}
return pairs, out, nil
}
func renderBody(body []byte) (string, error) {
var buf bytes.Buffer
if err := md.Convert(expandWikilinks(body), &buf); err != nil {
return "", err
}
return table + buf.String(), nil
return buf.String(), nil
}
// fmCloseRe matches a frontmatter closing fence on its own line.
var fmCloseRe = regexp.MustCompile(`(?m)^(---|\.\.\.)\s*$`)
// frontmatterTable splits a leading YAML frontmatter block off src and
// renders it as an HTML table (keys in author order, values escaped).
// Anything that isn't a well-formed YAML mapping falls through untouched —
// a stray --- line must keep rendering exactly as it always did.
func frontmatterTable(src []byte) (string, []byte) {
// FrontmatterPair is one key/value row of a document's YAML frontmatter,
// in author order. Value is plain text, never markup; Code marks the values
// the table renders inside <code> (anything nested).
type FrontmatterPair struct {
Key string `json:"key"`
Value string `json:"value"`
Code bool `json:"code,omitempty"`
}
// frontmatterPairs splits a leading YAML frontmatter block off src and
// returns its keys and values in author order. Anything that isn't a
// well-formed YAML mapping falls through untouched — a stray --- line must
// keep rendering exactly as it always did — and empty frontmatter is
// dropped from the body with nothing to show for it.
func frontmatterPairs(src []byte) ([]FrontmatterPair, []byte) {
rest, ok := bytes.CutPrefix(src, []byte("---\n"))
if !ok {
if rest, ok = bytes.CutPrefix(src, []byte("---\r\n")); !ok {
return "", src
return nil, src
}
}
loc := fmCloseRe.FindIndex(rest)
if loc == nil {
return "", src
return nil, src
}
fm, body := rest[:loc[0]], rest[loc[1]:]
var doc yaml.Node
if yaml.Unmarshal(fm, &doc) != nil || len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode {
return "", src
return nil, src
}
m := doc.Content[0]
if len(m.Content) == 0 {
return "", body // empty frontmatter: hide it, nothing to tabulate
return nil, body // empty frontmatter: hide it, nothing to tabulate
}
pairs := make([]FrontmatterPair, 0, len(m.Content)/2)
for i := 0; i+1 < len(m.Content); i += 2 {
value, code := yamlValue(m.Content[i+1])
pairs = append(pairs, FrontmatterPair{Key: m.Content[i].Value, Value: value, Code: code})
}
return pairs, body
}
// frontmatterTable is frontmatterPairs rendered as the HTML table the share
// page has always served (keys in author order, everything escaped).
func frontmatterTable(src []byte) (string, []byte) {
pairs, body := frontmatterPairs(src)
if len(pairs) == 0 {
return "", body
}
var b strings.Builder
b.WriteString(`<table class="frontmatter"><tbody>`)
for i := 0; i+1 < len(m.Content); i += 2 {
key, val := m.Content[i], m.Content[i+1]
for _, p := range pairs {
val := html.EscapeString(p.Value)
if p.Code {
val = "<code>" + val + "</code>"
}
fmt.Fprintf(&b, `<tr><th scope="row">%s</th><td>%s</td></tr>`,
html.EscapeString(key.Value), yamlValueHTML(val))
html.EscapeString(p.Key), val)
}
b.WriteString(`</tbody></table>`)
return b.String(), body
}
// yamlValueHTML renders one frontmatter value: scalars as text, flat lists
// comma-joined, anything nested as compact YAML in a <code> block. Always
// escaped — frontmatter is user input, never markup.
func yamlValueHTML(n *yaml.Node) string {
// yamlValue renders one frontmatter value: scalars as text, flat lists
// comma-joined, anything nested as compact YAML — with code true for that
// last case, where the table reaches for <code>. Always the raw string:
// escaping belongs to whoever emits it.
func yamlValue(n *yaml.Node) (string, bool) {
switch n.Kind {
case yaml.ScalarNode:
return html.EscapeString(n.Value)
return n.Value, false
case yaml.SequenceNode:
flat := true
parts := make([]string, 0, len(n.Content))
@@ -104,12 +159,12 @@ func yamlValueHTML(n *yaml.Node) string {
parts = append(parts, c.Value)
}
if flat {
return html.EscapeString(strings.Join(parts, ", "))
return strings.Join(parts, ", "), false
}
}
raw, err := yaml.Marshal(n)
if err != nil {
return ""
return "", false
}
return "<code>" + html.EscapeString(strings.TrimSpace(string(raw))) + "</code>"
return strings.TrimSpace(string(raw)), true
}
+73 -5
View File
@@ -65,11 +65,11 @@ func TestRenderMarkdownFrontmatterFallthrough(t *testing.T) {
wantTable bool
want string
}{
"no frontmatter": {"# Hi\n\ntext", false, "<h1"},
"mid-doc fences": {"para\n\n---\n\nmore", false, "<hr"},
"unclosed fence": {"---\ntitle: x\n\nbody", false, ""},
"non-mapping yaml": {"---\n- just\n- a list\n---\nbody", false, ""},
"invalid yaml": {"---\n: : :\n---\nbody", false, ""},
"no frontmatter": {"# Hi\n\ntext", false, "<h1"},
"mid-doc fences": {"para\n\n---\n\nmore", false, "<hr"},
"unclosed fence": {"---\ntitle: x\n\nbody", false, ""},
"non-mapping yaml": {"---\n- just\n- a list\n---\nbody", false, ""},
"invalid yaml": {"---\n: : :\n---\nbody", false, ""},
"empty frontmatter hidden": {"---\n---\nbody", false, "<p>body</p>"},
}
for name, c := range cases {
@@ -85,3 +85,71 @@ func TestRenderMarkdownFrontmatterFallthrough(t *testing.T) {
}
}
}
// The viewer's split: frontmatter comes back as ordered data and the HTML
// is body-only, with the same value rules the table has always applied.
func TestFrontmatterPairs(t *testing.T) {
src := `---
title: Q3 findings
tags: [churn, revenue]
owner: snow@runbear.io
meta:
reviewed: true
---
# Body
Hello.`
pairs, out, err := RenderMarkdownPairs([]byte(src))
if err != nil {
t.Fatal(err)
}
want := []FrontmatterPair{
{Key: "title", Value: "Q3 findings"},
{Key: "tags", Value: "churn, revenue"}, // flat lists comma-join
{Key: "owner", Value: "snow@runbear.io"},
{Key: "meta", Value: "reviewed: true", Code: true}, // nested: compact YAML
}
if len(pairs) != len(want) {
t.Fatalf("pairs = %+v, want %d", pairs, len(want))
}
for i, w := range want {
if pairs[i] != w { // index-wise: author order is the contract
t.Errorf("pair %d = %+v, want %+v", i, pairs[i], w)
}
}
if strings.Contains(out, `class="frontmatter"`) {
t.Errorf("table leaked into the viewer's html:\n%s", out)
}
if !strings.HasPrefix(strings.TrimSpace(out), `<h1 id="body">Body</h1>`) {
t.Errorf("body does not start with its heading:\n%s", out)
}
}
// Same tri-state as the table: anything that isn't a clean YAML mapping
// falls through with the source untouched, and empty frontmatter is hidden.
func TestFrontmatterPairsFallthrough(t *testing.T) {
cases := map[string]struct {
src string
want string
}{
"no frontmatter": {"# Hi\n\ntext", "<h1"},
"mid-doc fences": {"para\n\n---\n\nmore", "<hr"},
"unclosed fence": {"---\ntitle: x\n\nbody", ""},
"non-mapping yaml": {"---\n- just\n- a list\n---\nbody", ""},
"invalid yaml": {"---\n: : :\n---\nbody", ""},
"empty frontmatter hidden": {"---\n---\nbody", "<p>body</p>"},
}
for name, c := range cases {
pairs, out, err := RenderMarkdownPairs([]byte(c.src))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if len(pairs) != 0 {
t.Errorf("%s: pairs = %+v, want none", name, pairs)
}
if c.want != "" && !strings.Contains(out, c.want) {
t.Errorf("%s: missing %q in:\n%s", name, c.want, out)
}
}
}
+37
View File
@@ -1048,6 +1048,43 @@ func TestSec_Render_MarkdownCannotShipActiveContent(t *testing.T) {
t.Errorf("RenderMarkdown emitted active content (%s) for input %q:\n%s",
why, p.src, out)
}
// The viewer's entry point is a second door onto the same room:
// it must not be a way around the escaping above. The frontmatter
// half is the deliberate difference — those values leave as
// LITERAL text (asserted below) and the client renders them as
// text nodes — so only the HTML is judged here.
_, out, err = RenderMarkdownPairs([]byte(p.src))
if err != nil {
return
}
for _, why := range secdefActiveContent(out) {
t.Errorf("RenderMarkdownPairs emitted active content (%s) for input %q:\n%s",
why, p.src, out)
}
})
}
// The new field carries the raw scalar: escaping moved to the client,
// which puts it in a React text node. A value that arrived pre-escaped
// here would show up as &lt;img …&gt; on screen instead.
for _, c := range []struct{ name, src, key, want string }{
{"value", "---\ntitle: <img src=x onerror=alert(1)>\n---\nbody\n",
"title", "<img src=x onerror=alert(1)>"},
{"key", "---\n\"<img src=x onerror=alert(1)>\": v\n---\nbody\n",
"<img src=x onerror=alert(1)>", "v"},
{"nested", "---\na:\n b: \"</code><script>alert(1)</script>\"\n---\nbody\n",
"a", "b: \"</code><script>alert(1)</script>\""},
} {
t.Run("pairs "+c.name, func(t *testing.T) {
pairs, _, err := RenderMarkdownPairs([]byte(c.src))
if err != nil || len(pairs) != 1 {
t.Fatalf("pairs = %+v, err = %v", pairs, err)
}
if pairs[0].Key != c.key || pairs[0].Value != c.want {
t.Errorf("pair = %+v, want key %q value %q — the frontmatter field is "+
"contracted to be literal text; pre-escaping it here would double-escape "+
"on the client", pairs[0], c.key, c.want)
}
})
}
}
+11 -2
View File
@@ -1413,7 +1413,7 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
html, err := RenderMarkdown(src)
pairs, html, err := RenderMarkdownPairs(src)
if err != nil {
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
return
@@ -1422,6 +1422,12 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
"path": p, "html": html,
"size": fi.Size, "time": fi.Time, "author": fi.Author, "device": fi.Device,
}
// Frontmatter travels as data, not as a table baked into html — the
// viewer lays it out in a side panel. Omitted when the document has
// none, so the client shows no panel rather than an empty one.
if len(pairs) > 0 {
doc["frontmatter"] = pairs
}
// Omitted rather than sent empty, so a journal from before accounts
// existed still renders its Author instead of a blank attribution.
if fi.User != "" {
@@ -1482,7 +1488,7 @@ func (s *Server) renderVersion(v *volume, w http.ResponseWriter, r *http.Request
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
html, err := RenderMarkdown(src)
pairs, html, err := RenderMarkdownPairs(src)
if err != nil {
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
return
@@ -1490,6 +1496,9 @@ func (s *Server) renderVersion(v *volume, w http.ResponseWriter, r *http.Request
doc := map[string]any{
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
}
if len(pairs) > 0 {
doc["frontmatter"] = pairs
}
// The history view goes through this same endpoint, so scanning here too
// is what stops the badge vanishing the moment you click into history on
// the very file it was warning about.
+25
View File
@@ -439,6 +439,31 @@ func TestShareLastUpdatedStamp(t *testing.T) {
}
}
// The share page keeps the frontmatter TABLE at the top of the document.
// The viewer moved its frontmatter into a side panel (BEA-154) by calling
// RenderMarkdownPairs; shares.go stayed on RenderMarkdown deliberately, and
// nothing else would fail if someone later "tidied" it onto the pairs path —
// which would silently change every public link ever minted.
func TestShareKeepsFrontmatterTable(t *testing.T) {
srv, p, _, f, h := shareHub(t)
f.put("dev1", "wiki/props.md", "---\ntitle: Q3\nowner: snow\n---\n\n# Q3\n")
token, _ := authedShare(t, srv, h, p.ID, "wiki/props.md")
body := do(t, h, "GET", "/s/"+token, nil).Body.String()
for _, want := range []string{
`<table class="frontmatter"><tbody>`,
`<th scope="row">title</th><td>Q3</td>`,
`<th scope="row">owner</th><td>snow</td>`,
} {
if !strings.Contains(body, want) {
t.Errorf("share page lost %q:\n%s", want, body)
}
}
// ...and above the document, where it has always been.
if strings.Index(body, `class="frontmatter"`) > strings.Index(body, "<h1") {
t.Error("frontmatter table no longer precedes the body")
}
}
// TestShareDarkThemeIsLast: the share page is the surface strangers see first,
// so in dark mode it must not show white slabs. Every dark rule sits at the
// same specificity as the light one it overrides, which makes SOURCE ORDER the
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-Gbsbki_z.js"></script>
<script type="module" crossorigin src="/assets/index-C9I9NHlF.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-DISTZ6FW.css">
<link rel="stylesheet" crossorigin href="/assets/index-L-I4D1mx.css">
</head>
<body>
<div id="root"></div>