diff --git a/.changeset/heading-slug-ids.md b/.changeset/heading-slug-ids.md new file mode 100644 index 0000000000..13cb6c4e25 --- /dev/null +++ b/.changeset/heading-slug-ids.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds automatic slug `id` attributes on Portable Text headings from their text (e.g. "This is a new heading" → `this-is-a-new-heading`), keeping any existing id and the block key as additional fragment targets so anchors survive heading renames. diff --git a/packages/core/src/components/Block.astro b/packages/core/src/components/Block.astro index 560f40b090..7f2bd1091a 100644 --- a/packages/core/src/components/Block.astro +++ b/packages/core/src/components/Block.astro @@ -3,21 +3,33 @@ * EmDash custom block override for `astro-portabletext`. * * Renders the same HTML as the upstream Block component (h1..h6, blockquote, - * `

` for `normal`) and additionally surfaces `textAlign` from the rich-text - * editor as a WordPress-style `has-text-align-{value}` class on the rendered - * paragraph or heading. + * `

` for `normal`) and additionally: + * - surfaces `textAlign` as a WordPress-style `has-text-align-{value}` class + * - gives headings a slug `id` from their text (plus any pre-assigned extras) * * `left` is the editor default and intentionally does not produce a class — * paragraphs/headings without explicit alignment render exactly as they did - * before this fix, so existing content is byte-for-byte unchanged. + * before text-align support. * - * Related: https://github.com/emdash-cms/emdash/issues/1201 + * Heading ids are normally stamped by `PortableText.astro` via + * `assignHeadingIds` (document-wide uniqueness). When this component is used + * standalone, it still allocates a per-heading id from the block text. */ import type { BlockProps } from "astro-portabletext/types"; +import { + allocateHeadingId, + isHeadingStyle, + isSafeHtmlId, +} from "./portable-text-heading-id.js"; import { textAlignClassName } from "./portable-text-text-align.js"; -type TextAlignedNode = BlockProps["node"] & { textAlign?: string }; +type TextAlignedNode = BlockProps["node"] & { + textAlign?: string; + id?: string; + _key?: string; + _headingExtraIds?: string[]; +}; type Props = Omit & { node: TextAlignedNode }; const props = Astro.props as Props; @@ -28,43 +40,89 @@ const styleIs = (style: string) => style === node.style; // Allowlist-based; attacker-controlled PT data cannot inject arbitrary classes. // See `./portable-text-text-align.ts`. const alignClass = textAlignClassName(node.textAlign); + +let headingId: string | undefined; +let headingExtraIds: string[] = []; + +if (isHeadingStyle(node.style)) { + if (typeof node.id === "string" && isSafeHtmlId(node.id)) { + // Pre-assigned by assignHeadingIds (or an explicit block id). + headingId = node.id; + headingExtraIds = Array.isArray(node._headingExtraIds) + ? node._headingExtraIds.filter((id) => typeof id === "string" && isSafeHtmlId(id)) + : []; + } else { + const allocated = allocateHeadingId({ + style: node.style, + children: node.children, + blockKey: node._key, + existingId: undefined, + usedIds: new Set(), + }); + if (allocated) { + headingId = allocated.id; + headingExtraIds = allocated.extraIds; + } + } +} + +const { id: _ignoredId, ...headingAttrs } = attrs as Record & { id?: unknown }; +const restAttrs = isHeadingStyle(node.style) ? headingAttrs : attrs; --- { styleIs("h1") ? ( -

+

+ {headingExtraIds.map((extra) => ( + + ))}

) : styleIs("h2") ? ( -

+

+ {headingExtraIds.map((extra) => ( + + ))}

) : styleIs("h3") ? ( -

+

+ {headingExtraIds.map((extra) => ( + + ))}

) : styleIs("h4") ? ( -

+

+ {headingExtraIds.map((extra) => ( + + ))}

) : styleIs("h5") ? ( -
+
+ {headingExtraIds.map((extra) => ( + + ))}
) : styleIs("h6") ? ( -
+
+ {headingExtraIds.map((extra) => ( + + ))}
) : styleIs("blockquote") ? ( -
+
) : styleIs("normal") ? ( -

+

) : ( -

+

) diff --git a/packages/core/src/components/PortableText.astro b/packages/core/src/components/PortableText.astro index 59c1a0e369..ab86a53ebd 100644 --- a/packages/core/src/components/PortableText.astro +++ b/packages/core/src/components/PortableText.astro @@ -23,6 +23,7 @@ import { pluginBlockComponents } from "virtual:emdash/block-components"; import { emdashComponents } from "./index.js"; import InlineEditor from "./InlineEditor.astro"; import { groupBlockquoteRuns } from "./portable-text-blockquote-group.js"; +import { assignHeadingIds } from "./portable-text-heading-id.js"; export interface Props extends Omit { value: PortableTextProps["value"]; @@ -42,7 +43,9 @@ const mergedComponents = userComponents // A multi-paragraph quote is stored as consecutive blockquote-styled blocks // (Portable Text is flat); merge each run into one blockquoteGroup node so // it renders as a single
instead of several (#1884). -const renderValue = Array.isArray(value) ? groupBlockquoteRuns(value) : value; +// Then stamp heading blocks with slug ids (unique within this document). +const grouped = Array.isArray(value) ? groupBlockquoteRuns(value) : value; +const renderValue = Array.isArray(grouped) ? assignHeadingIds(grouped) : grouped; --- {editMeta ? ( diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index dd765470b8..671e8855d2 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -81,7 +81,8 @@ import TableComponent from "./Table.astro"; * * Includes renderers for: * - Block styles: paragraph, h1..h6, blockquote — with `textAlign` honoured - * as a WordPress-style `has-text-align-{value}` class (#1201) + * as a WordPress-style `has-text-align-{value}` class (#1201), and heading + * `id`s slugged from text (plus stable block-key / existing-id extras) * - Block types: image, code, embed, gallery, columns, break, htmlBlock, table, * button, buttons, cover, file, pullquote * - Marks: superscript, subscript, underline, strike-through, link diff --git a/packages/core/src/components/portable-text-heading-id.ts b/packages/core/src/components/portable-text-heading-id.ts new file mode 100644 index 0000000000..e2a09d5665 --- /dev/null +++ b/packages/core/src/components/portable-text-heading-id.ts @@ -0,0 +1,178 @@ +/** + * Allocate stable, document-unique HTML `id`s for Portable Text headings. + * + * The primary id is a slug of the heading text. Existing block ids and the + * Portable Text `_key` are kept as extra fragment targets so old anchors keep + * resolving after renames or edits. + */ + +import { slugify } from "../utils/slugify.js"; + +const HEADING_STYLES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); + +/** Safe HTML id: letter/underscore start, then alnum/hyphen/underscore. */ +const SAFE_ID_PATTERN = /^[A-Za-z_][\w-]*$/; + +export type HeadingIdAttrs = { + /** Primary `id` on the heading element (slug of current text when available). */ + id: string; + /** + * Additional fragment targets rendered as nested empty elements so existing + * ids and stable block keys keep working alongside the text slug. + */ + extraIds: string[]; +}; + +type SpanLike = { + _type?: string; + text?: string; + children?: SpanLike[]; +}; + +type BlockLike = { + _type?: string; + _key?: string; + style?: string; + id?: string; + children?: unknown; +}; + +/** + * Collect plain text from a Portable Text block's children. + * Works on both raw PT spans and the marks-tree nodes produced at render time. + */ +export function headingPlainText(children: unknown): string { + if (!Array.isArray(children)) return ""; + const parts: string[] = []; + const walk = (nodes: SpanLike[]) => { + for (const node of nodes) { + if (!node || typeof node !== "object") continue; + if (typeof node.text === "string") { + parts.push(node.text); + } + if (Array.isArray(node.children)) { + walk(node.children); + } + } + }; + walk(children as SpanLike[]); + return parts.join(""); +} + +export function isHeadingStyle(style: string | undefined): boolean { + return style !== undefined && HEADING_STYLES.has(style); +} + +/** + * True when `value` is safe to emit as an HTML `id` attribute without + * encoding or injection risk. + */ +export function isSafeHtmlId(value: string): boolean { + return value.length > 0 && value.length <= 128 && SAFE_ID_PATTERN.test(value); +} + +/** + * Allocate heading id attributes for one block. + * + * @param usedIds — mutable set of ids already claimed in this document. + * Pass the same set for every heading in one render. + */ +export function allocateHeadingId(options: { + style: string | undefined; + children: unknown; + /** Portable Text block `_key` — stable across text edits when the editor preserves it. */ + blockKey?: string; + /** Explicit id already on the node (e.g. imported WP anchor). */ + existingId?: string; + usedIds: Set; +}): HeadingIdAttrs | undefined { + if (!isHeadingStyle(options.style)) return undefined; + + const plain = headingPlainText(options.children); + const fromText = slugify(plain); + const existing = + typeof options.existingId === "string" && isSafeHtmlId(options.existingId) + ? options.existingId + : undefined; + const key = + typeof options.blockKey === "string" && isSafeHtmlId(options.blockKey) + ? options.blockKey + : undefined; + + // Prefer the human-readable text slug as the primary id. + // `slugify` can yield digit-leading strings ("1st Post" → "1st-post"); + // those fail isSafeHtmlId, so prefix before uniqueness allocation. + let base: string; + if (fromText) { + base = isSafeHtmlId(fromText) ? fromText : `h-${fromText}`; + } else if (existing) { + base = existing; + } else if (key) { + base = key; + } else { + base = "heading"; + } + + const id = uniqueId(base, options.usedIds); + options.usedIds.add(id); + + const extraIds: string[] = []; + // Keep an author/import-provided id even when the slug is primary. + if (existing && existing !== id && !options.usedIds.has(existing)) { + extraIds.push(existing); + options.usedIds.add(existing); + } + // Stable block key so `#key` survives heading renames. + if (key && key !== id && !options.usedIds.has(key)) { + extraIds.push(key); + options.usedIds.add(key); + } + + return { id, extraIds }; +} + +/** + * Shallow-copy heading blocks in a Portable Text value, stamping each with + * `id` / `_headingExtraIds` for the Block renderer. Non-heading nodes are + * returned by reference. Safe to call on the render path — does not mutate + * the caller's array or block objects. + */ +export function assignHeadingIds(value: T): T { + if (!Array.isArray(value)) return value; + + const usedIds = new Set(); + let changed = false; + const next = value.map((item) => { + if (!item || typeof item !== "object") return item; + const block = item as BlockLike; + if (block._type !== "block" || !isHeadingStyle(block.style)) return item; + + const attrs = allocateHeadingId({ + style: block.style, + children: block.children, + blockKey: block._key, + existingId: typeof block.id === "string" ? block.id : undefined, + usedIds, + }); + if (!attrs) return item; + + changed = true; + const stamped: BlockLike & { _headingExtraIds?: string[] } = { + ...block, + id: attrs.id, + }; + if (attrs.extraIds.length > 0) { + stamped._headingExtraIds = attrs.extraIds; + } + return stamped; + }); + + return (changed ? next : value) as T; +} + +function uniqueId(base: string, used: Set): string { + if (!used.has(base)) return base; + let n = 2; + while (used.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +} diff --git a/packages/core/tests/repro/heading-slug-id.render.test.ts b/packages/core/tests/repro/heading-slug-id.render.test.ts new file mode 100644 index 0000000000..17be3d96a5 --- /dev/null +++ b/packages/core/tests/repro/heading-slug-id.render.test.ts @@ -0,0 +1,127 @@ +/** + * Portable Text headings must render with a slug `id` from their text so + * same-page `#section` links work. An existing id and the block `_key` stay + * available as additional fragment targets when the heading is renamed. + */ +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { describe, expect, test } from "vitest"; + +import Block from "../../src/components/Block.astro"; +import PortableText from "../../src/components/PortableText.astro"; + +function span(text: string, key = "s1") { + return { _type: "span" as const, _key: key, text, marks: [] as string[] }; +} + +function headingBlock( + text: string, + opts: { key?: string; id?: string; style?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" } = {}, +) { + return { + _type: "block" as const, + _key: opts.key ?? "hk1", + style: opts.style ?? ("h2" as const), + ...(opts.id ? { id: opts.id } : {}), + children: [span(text)], + }; +} + +const headingTag = (html: string, level = 2) => + html.match(new RegExp(`]*>[\\s\\S]*?`))?.[0] ?? ""; + +const allIds = (html: string) => + Array.from(html.matchAll(/\bid="([^"]*)"/g), (m) => m[1]).filter((id): id is string => !!id); + +describe("heading slug ids", () => { + test("Block.astro slugs heading text onto id", async () => { + const c = await AstroContainer.create(); + const html = await c.renderToString(Block, { + props: { + node: headingBlock("This is a new heading", { key: "blk_abc" }), + index: 0, + isInline: false, + }, + slots: { default: "This is a new heading" }, + }); + const tag = headingTag(html); + expect(tag).toContain('id="this-is-a-new-heading"'); + expect(tag).toContain('id="blk_abc"'); + expect(tag).toContain("This is a new heading"); + }); + + test("PortableText assigns unique slugs across headings", async () => { + const c = await AstroContainer.create(); + const html = await c.renderToString(PortableText, { + props: { + value: [ + headingBlock("Overview", { key: "a" }), + headingBlock("Overview", { key: "b" }), + { + _type: "block", + _key: "p", + style: "normal", + children: [span("body")], + }, + ], + }, + }); + const ids = allIds(html); + expect(ids).toContain("overview"); + expect(ids).toContain("overview-2"); + expect(ids).toContain("a"); + expect(ids).toContain("b"); + // Paragraphs must not pick up an id. + expect(html).toMatch(/]*\bid=)/); + }); + + test("keeps an existing id in addition to the text slug", async () => { + const c = await AstroContainer.create(); + const html = await c.renderToString(PortableText, { + props: { + value: [headingBlock("Current Title", { key: "stable_key", id: "legacy-anchor" })], + }, + }); + const ids = allIds(html); + expect(ids).toContain("current-title"); + expect(ids).toContain("legacy-anchor"); + expect(ids).toContain("stable_key"); + // Primary id on the heading element is the text slug. + expect(headingTag(html)).toMatch(/]*\bid="current-title"/); + }); + + test("h1–h6 all receive ids; blockquote does not", async () => { + const c = await AstroContainer.create(); + const html = await c.renderToString(PortableText, { + props: { + value: [ + headingBlock("One", { key: "h1k", style: "h1" }), + headingBlock("Two", { key: "h3k", style: "h3" }), + { + _type: "block", + _key: "q", + style: "blockquote", + children: [span("quoted")], + }, + ], + }, + }); + expect(headingTag(html, 1)).toContain('id="one"'); + expect(headingTag(html, 3)).toContain('id="two"'); + expect(html).toMatch(/]*\bid=)/); + }); + + test("digit-leading heading text gets a safe unique id", async () => { + const c = await AstroContainer.create(); + const html = await c.renderToString(PortableText, { + props: { + value: [headingBlock("1st Post", { key: "d1" }), headingBlock("1st Post", { key: "d2" })], + }, + }); + const ids = allIds(html); + expect(ids).toContain("h-1st-post"); + expect(ids).toContain("h-1st-post-2"); + expect(ids).toContain("d1"); + expect(ids).toContain("d2"); + expect(headingTag(html)).toMatch(/]*\bid="h-1st-post"/); + }); +}); diff --git a/packages/core/tests/unit/components/portable-text-heading-id.test.ts b/packages/core/tests/unit/components/portable-text-heading-id.test.ts new file mode 100644 index 0000000000..ca1741d093 --- /dev/null +++ b/packages/core/tests/unit/components/portable-text-heading-id.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import { + allocateHeadingId, + assignHeadingIds, + headingPlainText, + isSafeHtmlId, +} from "../../../src/components/portable-text-heading-id.js"; + +function span(text: string) { + return { _type: "span" as const, _key: "s", text, marks: [] as string[] }; +} + +function heading(text: string, opts: { key?: string; id?: string; style?: string } = {}) { + return { + _type: "block" as const, + _key: opts.key ?? "k1", + style: opts.style ?? "h2", + ...(opts.id ? { id: opts.id } : {}), + children: [span(text)], + }; +} + +describe("headingPlainText", () => { + it("joins span text", () => { + expect(headingPlainText([span("Hello "), span("World")])).toBe("Hello World"); + }); + + it("walks nested mark-tree children", () => { + expect( + headingPlainText([ + { + _type: "span", + markType: "strong", + children: [{ _type: "span", text: "Bold" }], + }, + { _type: "span", text: " plain" }, + ]), + ).toBe("Bold plain"); + }); + + it("returns empty for non-arrays", () => { + expect(headingPlainText(undefined)).toBe(""); + expect(headingPlainText(null)).toBe(""); + }); +}); + +describe("isSafeHtmlId", () => { + it("accepts typical slugs and keys", () => { + expect(isSafeHtmlId("this-is-a-new-heading")).toBe(true); + expect(isSafeHtmlId("block_abc123")).toBe(true); + expect(isSafeHtmlId("_private")).toBe(true); + }); + + it("rejects empty, spaces, and injection-shaped values", () => { + expect(isSafeHtmlId("")).toBe(false); + expect(isSafeHtmlId("has space")).toBe(false); + expect(isSafeHtmlId("1starts-with-digit")).toBe(false); + expect(isSafeHtmlId('x" onload="alert(1)')).toBe(false); + expect(isSafeHtmlId("a".repeat(129))).toBe(false); + }); +}); + +describe("allocateHeadingId", () => { + it("slugs heading text", () => { + const used = new Set(); + const result = allocateHeadingId({ + style: "h2", + children: [span("This is a new heading")], + blockKey: "blk1", + usedIds: used, + }); + expect(result).toEqual({ + id: "this-is-a-new-heading", + extraIds: ["blk1"], + }); + expect(used.has("this-is-a-new-heading")).toBe(true); + expect(used.has("blk1")).toBe(true); + }); + + it("keeps an existing id as an extra target alongside the text slug", () => { + const used = new Set(); + const result = allocateHeadingId({ + style: "h1", + children: [span("Introduction")], + blockKey: "k9", + existingId: "custom-anchor", + usedIds: used, + }); + expect(result?.id).toBe("introduction"); + expect(result?.extraIds).toEqual(["custom-anchor", "k9"]); + }); + + it("disambiguates duplicate slugs within a document", () => { + const used = new Set(); + const first = allocateHeadingId({ + style: "h2", + children: [span("Overview")], + blockKey: "a", + usedIds: used, + }); + const second = allocateHeadingId({ + style: "h2", + children: [span("Overview")], + blockKey: "b", + usedIds: used, + }); + expect(first?.id).toBe("overview"); + expect(second?.id).toBe("overview-2"); + }); + + it("does not emit heading ids for non-heading styles", () => { + expect( + allocateHeadingId({ + style: "normal", + children: [span("Not a heading")], + usedIds: new Set(), + }), + ).toBeUndefined(); + }); + + it("falls back when text slugifies to empty", () => { + const result = allocateHeadingId({ + style: "h3", + children: [span("!!!")], + blockKey: "empty-text-key", + usedIds: new Set(), + }); + expect(result).toEqual({ id: "empty-text-key", extraIds: [] }); + }); + + it("ignores unsafe existing ids and keys", () => { + const result = allocateHeadingId({ + style: "h2", + children: [span("Safe Title")], + blockKey: "bad key", + existingId: 'x" onclick="evil', + usedIds: new Set(), + }); + expect(result).toEqual({ id: "safe-title", extraIds: [] }); + }); + + it("prefixes digit-leading slugs so the id stays isSafeHtmlId", () => { + const used = new Set(); + const result = allocateHeadingId({ + style: "h2", + children: [span("1st Post")], + blockKey: "k1", + usedIds: used, + }); + expect(result?.id).toBe("h-1st-post"); + expect(isSafeHtmlId(result!.id)).toBe(true); + expect(result?.extraIds).toEqual(["k1"]); + }); +}); + +describe("assignHeadingIds", () => { + it("stamps headings without mutating the input array", () => { + const input = [ + heading("First Section", { key: "k1" }), + { + _type: "block" as const, + _key: "p1", + style: "normal", + children: [span("body")], + }, + heading("First Section", { key: "k2" }), + ]; + const frozen = structuredClone(input); + const out = assignHeadingIds(input); + + expect(input).toEqual(frozen); + expect(out).not.toBe(input); + + const h1 = out[0] as { id?: string; _headingExtraIds?: string[] }; + const p = out[1] as { id?: string }; + const h2 = out[2] as { id?: string; _headingExtraIds?: string[] }; + + expect(h1.id).toBe("first-section"); + expect(h1._headingExtraIds).toEqual(["k1"]); + expect(p.id).toBeUndefined(); + expect(h2.id).toBe("first-section-2"); + expect(h2._headingExtraIds).toEqual(["k2"]); + }); + + it("preserves an existing id in addition to the text slug", () => { + const out = assignHeadingIds([heading("Renamed Later", { key: "stable", id: "old-name" })]); + const h = out[0] as { id?: string; _headingExtraIds?: string[] }; + expect(h.id).toBe("renamed-later"); + expect(h._headingExtraIds).toEqual(["old-name", "stable"]); + }); + + it("returns non-arrays unchanged", () => { + const single = heading("Alone"); + expect(assignHeadingIds(single)).toBe(single); + }); + + it("keeps digit-leading heading slugs unique across a document", () => { + const out = assignHeadingIds([ + heading("1st Post", { key: "a" }), + heading("1st Post", { key: "b" }), + ]); + const h1 = out[0] as { id?: string; _headingExtraIds?: string[] }; + const h2 = out[1] as { id?: string; _headingExtraIds?: string[] }; + expect(h1.id).toBe("h-1st-post"); + expect(h2.id).toBe("h-1st-post-2"); + expect(isSafeHtmlId(h1.id!)).toBe(true); + expect(isSafeHtmlId(h2.id!)).toBe(true); + expect(h1._headingExtraIds).toEqual(["a"]); + expect(h2._headingExtraIds).toEqual(["b"]); + }); +});