mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
b5bd8dddff
commit
4f04b78c71
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <img …> 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-15
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user