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"]);
+ });
+});