mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 08:59:01 +02:00
Merge origin/main (v1.64.0.0) into garrytan/time-attack-fork-review
Both waves fixed several of the same bugs; resolutions keep whichever shape this branch's tests pin (#2018 jq bind, #1798 set-- pattern, stop-ack, lock errors, polyfill windowsHide) and take main's richer codex Step 2A (it absorbed the same mktemp fix). True unions: memory- ingest keeps main's capability-probed --include-gitignored inside our GIT_CEILING defense; setup wraps main's Playwright platform override in our stale-healing install lock; package.json takes main's diff@^9 and the combined test glob (design/test + ios-qa/daemon/test, 30s timeout). Generated SKILL.md files regenerated from resolved templates, never hand-picked. Ship goldens refreshed; parity/carve budgets re-measured for the summed preamble growth of both waves (itemized per entry).
This commit is contained in:
+14
-6
@@ -81,13 +81,15 @@ if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
_UPDATE_CHECK=$(~/.claude/skills/gstack/bin/gstack-config get update_check 2>/dev/null || echo "true")
|
||||
echo "UPDATE_CHECK: $_UPDATE_CHECK"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"make-pdf","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||
if [ -f "$_PF" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "$HOME/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$_PF" 2>/dev/null || true
|
||||
@@ -189,6 +191,8 @@ If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. I
|
||||
|
||||
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
|
||||
|
||||
If `UPDATE_CHECK` is `"false"`, skip the next two lines — the update-check binary emits nothing in that mode, so there is no `UPGRADE_AVAILABLE` / `JUST_UPGRADED` output to act on.
|
||||
|
||||
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined).
|
||||
|
||||
If output shows `JUST_UPGRADED <from> <to>`: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery.
|
||||
@@ -376,8 +380,8 @@ if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
|
||||
else
|
||||
_BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt"
|
||||
fi
|
||||
_BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync"
|
||||
_BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config"
|
||||
_BRAIN_SYNC_BIN="$HOME/.claude/skills/gstack/bin/gstack-brain-sync"
|
||||
_BRAIN_CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config"
|
||||
|
||||
# /sync-gbrain context-load: teach the agent to use gbrain when it's available.
|
||||
# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the
|
||||
@@ -486,8 +490,8 @@ If A/B and `~/.gstack/.git` is missing, ask whether to run `gstack-artifacts-ini
|
||||
At skill END before telemetry:
|
||||
|
||||
```bash
|
||||
"~/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true
|
||||
"~/.claude/skills/gstack/bin/gstack-brain-sync" --once 2>/dev/null || true
|
||||
"$HOME/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true
|
||||
"$HOME/.claude/skills/gstack/bin/gstack-brain-sync" --once 2>/dev/null || true
|
||||
```
|
||||
|
||||
|
||||
@@ -560,11 +564,15 @@ fi
|
||||
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log \
|
||||
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" \
|
||||
--error-message "ERROR_MESSAGE" --failed-step "FAILED_STEP" 2>/dev/null &
|
||||
fi
|
||||
```
|
||||
|
||||
Replace `SKILL_NAME`, `OUTCOME`, and `USED_BROWSE` before running.
|
||||
Replace `ERROR_MESSAGE` with a short description of the error (if outcome is error,
|
||||
otherwise use empty string ""), and `FAILED_STEP` with the step name or number where
|
||||
the failure occurred (if outcome is error, otherwise use empty string "").
|
||||
|
||||
## Plan Status Footer
|
||||
|
||||
|
||||
@@ -158,6 +158,11 @@ export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string {
|
||||
|
||||
function isExecutable(p: string): boolean {
|
||||
try {
|
||||
// Must be a regular FILE. access(X_OK) alone is true for directories — they carry the
|
||||
// execute/traverse bit on POSIX and pass the Windows check too — so discovery happily
|
||||
// "found" ~/.claude/skills/browse, which is the skill's docs folder containing nothing
|
||||
// but SKILL.md, and returned a directory as the browse binary.
|
||||
if (!fs.statSync(p).isFile()) return false;
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -190,16 +195,18 @@ function runBrowse(args: string[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a payload to a tmp file and return the path. Used for any payload
|
||||
* >4KB to avoid Windows argv limits (Codex round 2 #3).
|
||||
* Temp dir for any file handed to browse (payloads, rendered HTML, PDF output).
|
||||
*
|
||||
* Path must be under the browse safe-dirs allowlist (/tmp or cwd on
|
||||
* non-Windows; os.tmpdir on Windows). v1.6.0.0 tightened --from-file
|
||||
* validation to close a CLI/API parity gap (PR #1103), so os.tmpdir()
|
||||
* on macOS (/var/folders/...) now fails validateReadPath. Use the same
|
||||
* TEMP_DIR convention as browse/src/platform.ts.
|
||||
*
|
||||
* Exported because orchestrator.ts and setup.ts write files that browse must
|
||||
* read back; os.tmpdir() there trips the same validateReadPath rejection.
|
||||
*/
|
||||
const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
export const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
|
||||
function writePayloadFile(payload: Record<string, unknown>): string {
|
||||
const hash = crypto.createHash("sha256")
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
@@ -86,7 +85,7 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
|
||||
const to = opts.to ?? "pdf";
|
||||
const outputPath = path.resolve(
|
||||
opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.${to}`),
|
||||
opts.output ?? path.join(browseClient.PAYLOAD_TMP_DIR, `${deriveSlug(input)}.${to}`),
|
||||
);
|
||||
|
||||
// Stage 1: read markdown
|
||||
@@ -358,7 +357,7 @@ export async function preview(opts: PreviewOptions): Promise<string> {
|
||||
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
|
||||
|
||||
// Write to a stable path under /tmp so the user can reload in the same tab.
|
||||
const previewPath = path.join(os.tmpdir(), `make-pdf-preview-${deriveSlug(input)}.html`);
|
||||
const previewPath = path.join(browseClient.PAYLOAD_TMP_DIR, `make-pdf-preview-${deriveSlug(input)}.html`);
|
||||
fs.writeFileSync(previewPath, rendered.html, "utf8");
|
||||
|
||||
progress.begin("Opening preview");
|
||||
@@ -378,7 +377,7 @@ function deriveSlug(p: string): string {
|
||||
|
||||
function tmpFile(ext: string): string {
|
||||
const hash = crypto.randomBytes(6).toString("hex");
|
||||
return path.join(os.tmpdir(), `make-pdf-${process.pid}-${hash}.${ext}`);
|
||||
return path.join(browseClient.PAYLOAD_TMP_DIR, `make-pdf-${process.pid}-${hash}.${ext}`);
|
||||
}
|
||||
|
||||
function tryOpen(pathOrUrl: string): void {
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
|
||||
// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
|
||||
const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
|
||||
// CJK fallback families, appended to the body stack only.
|
||||
const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`;
|
||||
// CJK fallback families (Simplified-Chinese first), appended to the body stack only.
|
||||
const CJK_STACK = `"PingFang SC", "Heiti SC", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP"`;
|
||||
// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
|
||||
const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
|
||||
|
||||
|
||||
+40
-4
@@ -66,8 +66,10 @@ export interface RenderResult {
|
||||
* Pure renderer. No side effects.
|
||||
*/
|
||||
export function render(opts: RenderOptions): RenderResult {
|
||||
// 1. Markdown → HTML
|
||||
const rawHtml = marked.parse(opts.markdown, { async: false }) as string;
|
||||
// 1. Markdown → HTML (strip a leading YAML frontmatter block first; marked
|
||||
// has no frontmatter awareness and would otherwise render it as a literal
|
||||
// paragraph of body text on its own first page).
|
||||
const rawHtml = marked.parse(stripFrontmatter(opts.markdown), { async: false }) as string;
|
||||
|
||||
// 1.5. Image directive suffixes: `{width=50%}` → data-gstack-*
|
||||
// attributes. Before the sanitizer (which keeps data- attrs) so the brace
|
||||
@@ -494,17 +496,51 @@ function wrapChaptersByH1(html: string): string {
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
const preamble = html.slice(0, matches[0]);
|
||||
// A preamble that renders nothing visible (a leading <style> block, an HTML
|
||||
// comment) must NOT become its own .chapter. That section would take the
|
||||
// `.chapter:first-of-type { break-before: auto }` exception, so the first
|
||||
// *real* chapter inherits `break-before: page` and starts on page 2 — leaving
|
||||
// a blank page 1. Keep the non-rendering markup (so its styling still applies)
|
||||
// but fold it into the first real chapter instead of giving it a page break.
|
||||
let carriedPreamble = "";
|
||||
if (preamble.trim().length > 0) {
|
||||
chunks.push(`<section class="chapter">${preamble}</section>`);
|
||||
if (stripNonRendering(preamble).trim().length > 0) {
|
||||
chunks.push(`<section class="chapter">${preamble}</section>`);
|
||||
} else {
|
||||
carriedPreamble = preamble;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i];
|
||||
const end = i + 1 < matches.length ? matches[i + 1] : html.length;
|
||||
chunks.push(`<section class="chapter">${html.slice(start, end)}</section>`);
|
||||
const body = i === 0 ? carriedPreamble + html.slice(start, end) : html.slice(start, end);
|
||||
chunks.push(`<section class="chapter">${body}</section>`);
|
||||
}
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip leading YAML frontmatter (`---\n...\n---`). Only a block at the very
|
||||
* start of the document is removed, so a `---` thematic break elsewhere is
|
||||
* untouched.
|
||||
*/
|
||||
function stripFrontmatter(md: string): string {
|
||||
return md.replace(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove non-rendering markup (style/script blocks, HTML comments) so an
|
||||
* otherwise-empty preamble is recognized as visually empty. Used only to decide
|
||||
* whether a preamble deserves its own page — the original markup is preserved in
|
||||
* the output.
|
||||
*/
|
||||
function stripNonRendering(html: string): string {
|
||||
return html
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
function extractFirstHeading(html: string): string | null {
|
||||
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* 6. Print a 3-command cheatsheet
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
@@ -77,8 +76,8 @@ export async function runSetup(): Promise<void> {
|
||||
"The second paragraph contains curly quotes (\"hello\"), an em dash -- like this, and an ellipsis... all of which should render correctly.",
|
||||
"",
|
||||
].join("\n");
|
||||
const fixturePath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.md`);
|
||||
const outPath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.pdf`);
|
||||
const fixturePath = path.join(browseClient.PAYLOAD_TMP_DIR, `make-pdf-smoke-${process.pid}.md`);
|
||||
const outPath = path.join(browseClient.PAYLOAD_TMP_DIR, `make-pdf-smoke-${process.pid}.pdf`);
|
||||
fs.writeFileSync(fixturePath, fixture, "utf8");
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
|
||||
const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
||||
const TAG_RE = /<[^>]+>/g;
|
||||
const URL_RE = /\bhttps?:\/\/\S+/g;
|
||||
// \u0000 is the placeholder sentinel (see PLACEHOLDER below). It must be
|
||||
// excluded here: URLs are carved AFTER tags, so a URL sitting flush against
|
||||
// a tag (`<a href="...">https://ex.com</a>`) would otherwise let \S+ swallow
|
||||
// the placeholder standing in for `</a>` — that placeholder then never
|
||||
// restores (restore is a single pass) and the tag is lost.
|
||||
const URL_RE = /\bhttps?:\/\/[^\s\u0000]+/g;
|
||||
|
||||
/**
|
||||
* Apply smartypants to an HTML string. Zones that should not be touched:
|
||||
@@ -44,7 +49,7 @@ export function smartypants(html: string): string {
|
||||
});
|
||||
};
|
||||
|
||||
let s = html;
|
||||
let s = html.replace(/\u0000/g, ""); // drop stray input NUL (can't forge a placeholder)
|
||||
s = carve(s, CODE_ZONE_RE);
|
||||
s = carve(s, TAG_RE);
|
||||
s = carve(s, URL_RE);
|
||||
@@ -89,11 +94,11 @@ function transformText(text: string): string {
|
||||
|
||||
// Double quotes: open if preceded by whitespace/bol, close if preceded
|
||||
// by word char or punctuation.
|
||||
s = s.replace(/(^|[\s\(\[\{\-])"/g, "$1\u201c"); // opening "
|
||||
s = s.replace(/(^|[\s\(\[\{\-\uff1a\uff08\u3010\u300c\u300e\u3008\u300a])"/g, "$1\u201c"); // opening "
|
||||
s = s.replace(/"/g, "\u201d"); // remaining " are closing
|
||||
|
||||
// Single quotes (after apostrophe pass):
|
||||
s = s.replace(/(^|[\s\(\[\{\-])'/g, "$1\u2018"); // opening '
|
||||
s = s.replace(/(^|[\s\(\[\{\-\uff1a\uff08\u3010\u300c\u300e\u3008\u300a])'/g, "$1\u2018"); // opening '
|
||||
s = s.replace(/'/g, "\u2019"); // remaining ' are closing
|
||||
|
||||
return s;
|
||||
|
||||
@@ -59,6 +59,41 @@ describe("findExecutable", () => {
|
||||
const found = findExecutable("/nonexistent/path/to/nothing");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
// access(X_OK) is TRUE for directories — they carry the execute/traverse bit — so a
|
||||
// bare X_OK test returned ~/.claude/skills/browse, the skill's docs folder, as "the
|
||||
// browse binary". Every browse call then failed with an empty error, which surfaced
|
||||
// as make-pdf reporting "Chromium failed to launch".
|
||||
test("rejects a DIRECTORY even though it passes access(X_OK)", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mkpdf-dir-"));
|
||||
try {
|
||||
// Prove the precondition: the directory really does pass the old test.
|
||||
let passesXok = true;
|
||||
try {
|
||||
fs.accessSync(dir, fs.constants.X_OK);
|
||||
} catch {
|
||||
passesXok = false;
|
||||
}
|
||||
expect(passesXok).toBe(true);
|
||||
|
||||
expect(findExecutable(dir)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a directory that shadows a real binary name", () => {
|
||||
// The exact shape of the bug: a directory named like the thing being looked for.
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "mkpdf-shadow-"));
|
||||
const shadow = path.join(base, "browse");
|
||||
fs.mkdirSync(shadow);
|
||||
fs.writeFileSync(path.join(shadow, "SKILL.md"), "# not a binary\n");
|
||||
try {
|
||||
expect(findExecutable(shadow)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBrowseBin", () => {
|
||||
|
||||
@@ -56,6 +56,34 @@ describe("smartypants", () => {
|
||||
expect(out).toContain("\u201cdetails\u201d");
|
||||
});
|
||||
|
||||
// A URL flush against a following tag (no whitespace between) used to let
|
||||
// URL_RE's \S+ swallow the placeholder standing in for that tag. The
|
||||
// placeholder never restored, so the tag vanished and its styling bled
|
||||
// into the rest of the document. The test above misses this because its
|
||||
// URL is followed by a space.
|
||||
test("does not leak placeholders for a bare autolinked URL", () => {
|
||||
const input = `<p>see <a href="https://ex.com">https://ex.com</a> ok</p>`;
|
||||
const out = smartypants(input);
|
||||
expect(out).not.toContain("SMARTPANTS_PRESERVED");
|
||||
expect(out).toContain("</a>");
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
test("does not leak placeholders for a bold URL", () => {
|
||||
const input = `<p>see <strong><a href="https://ex.com">https://ex.com</a></strong> ok</p>`;
|
||||
const out = smartypants(input);
|
||||
expect(out).not.toContain("SMARTPANTS_PRESERVED");
|
||||
expect(out).toContain("</a></strong>");
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
test("does not leak placeholders when a URL is followed by punctuation", () => {
|
||||
const input = `<p>see <a href="https://ex.com">https://ex.com</a>, ok</p>`;
|
||||
const out = smartypants(input);
|
||||
expect(out).not.toContain("SMARTPANTS_PRESERVED");
|
||||
expect(out).toContain("</a>");
|
||||
});
|
||||
|
||||
test("does NOT touch HTML attribute values", () => {
|
||||
const out = smartypants(`<a href="it's-a-test.html">link</a>`);
|
||||
expect(out).toContain(`href="it's-a-test.html"`);
|
||||
@@ -249,6 +277,48 @@ describe("render (end-to-end)", () => {
|
||||
expect(result.printCss).toContain("Hiragino Kaku Gothic");
|
||||
expect(result.printCss).toContain("Noto Sans CJK");
|
||||
});
|
||||
|
||||
// ─── blank-first-page regression (#1904) ────────────────────────
|
||||
// Any content before the first <h1> used to become a standalone preamble
|
||||
// .chapter. That section takes the `:first-of-type { break-before: auto }`
|
||||
// exception, so the first real chapter inherits `break-before: page` and
|
||||
// starts on page 2 — leaving a blank/near-blank page 1.
|
||||
|
||||
test("a leading <style> preamble does not get its own chapter (no blank page 1)", () => {
|
||||
const result = render({ markdown: `<style>h1{color:#000}</style>\n\n# Hello\n\nBody.\n` });
|
||||
const chapters = result.html.match(/class="chapter"/g) ?? [];
|
||||
// One chapter, not two — the invisible <style> no longer forces a page break.
|
||||
expect(chapters.length).toBe(1);
|
||||
// ...and the style is preserved (folded into the first chapter, not dropped).
|
||||
expect(result.html).toContain("<style>h1{color:#000}</style>");
|
||||
expect(result.html).toMatch(/<section class="chapter"><style>[\s\S]*?<\/style>[\s\S]*?<h1/);
|
||||
});
|
||||
|
||||
test("leading YAML frontmatter is stripped, not rendered as body text", () => {
|
||||
const result = render({
|
||||
markdown: `---\ntitle: My Doc\ntype: memo\ntags: [a, b]\n---\n\n# Hello\n\nBody.\n`,
|
||||
});
|
||||
// Frontmatter keys must not leak into the rendered body.
|
||||
expect(result.html).not.toContain("type: memo");
|
||||
expect(result.html).not.toContain("tags: [a, b]");
|
||||
// No preamble chapter -> single chapter, first H1 on page 1.
|
||||
const chapters = result.html.match(/class="chapter"/g) ?? [];
|
||||
expect(chapters.length).toBe(1);
|
||||
expect(result.meta.title).toBe("Hello");
|
||||
});
|
||||
|
||||
test("a real text preamble still gets its own chapter (unchanged behavior)", () => {
|
||||
const result = render({ markdown: `Intro paragraph.\n\n# Hello\n\nBody.\n` });
|
||||
const chapters = result.html.match(/class="chapter"/g) ?? [];
|
||||
expect(chapters.length).toBe(2);
|
||||
});
|
||||
|
||||
test("a `---` thematic break that is not frontmatter is left intact", () => {
|
||||
// Opening with visible text means the later `---` is a real <hr>, not frontmatter.
|
||||
const result = render({ markdown: `# Hello\n\nBefore.\n\n---\n\nAfter.\n` });
|
||||
expect(result.html).toContain("<hr>");
|
||||
expect(result.html).toContain("After.");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── print-css ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user