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
@@ -316,6 +316,18 @@ classDiagram
|
||||
+Token +Project +Path +Creator +Expires
|
||||
}
|
||||
|
||||
class markdownRender {
|
||||
<<markdown.go>>
|
||||
frontmatterPairs(src) pairs + body
|
||||
RenderMarkdown → table + body HTML
|
||||
RenderMarkdownPairs → pairs + body HTML
|
||||
yamlValue(node) text + code flag
|
||||
}
|
||||
class FrontmatterPair {
|
||||
+Key +Value +Code
|
||||
}
|
||||
note for markdownRender "One parse, two surfaces. The share page is the reason the split exists: RenderMarkdown still bakes the key/value TABLE into its HTML, because every /s/ link ever minted serves that output, while the viewer takes RenderMarkdownPairs and gets the frontmatter as DATA so it can hang it beside the prose instead of on top of it (BEA-154). Values cross the wire as literal text plus a code flag, never pre-escaped markup — the panel is a React text node, so escaping is the client's by construction; the table escapes on its way out. shares_test pins the table, because nothing else would fail if someone later tidied shares.go onto the pairs path"
|
||||
|
||||
class mermaidTag {
|
||||
<<shares.go, the .md branch>>
|
||||
body contains language-mermaid?
|
||||
@@ -489,6 +501,9 @@ classDiagram
|
||||
projectPerm ..> Directory : org role
|
||||
ShareDB ..> Share
|
||||
ShareDB ..> mermaidTag : markdown shares only
|
||||
ShareDB ..> markdownRender : RenderMarkdown (table stays)
|
||||
Server ..> markdownRender : RenderMarkdownPairs (viewer)
|
||||
markdownRender ..> FrontmatterPair
|
||||
DeviceRegistry ..> DeviceInfo
|
||||
DeviceRegistry *-- devKey : (account, id)
|
||||
RemoteSource ..> sourcedOp : attribution comes from the journal key
|
||||
|
||||
@@ -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