Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heading-slug-ids.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 73 additions & 15 deletions packages/core/src/components/Block.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,33 @@
* EmDash custom block override for `astro-portabletext`.
*
* Renders the same HTML as the upstream Block component (h1..h6, blockquote,
* `<p>` 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.
* `<p>` 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<BlockProps, "node"> & { node: TextAlignedNode };

const props = Astro.props as Props;
Expand All @@ -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<string, unknown> & { id?: unknown };
const restAttrs = isHeadingStyle(node.style) ? headingAttrs : attrs;
---

{
styleIs("h1") ? (
<h1 class={alignClass} {...attrs}>
<h1 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h1>
) : styleIs("h2") ? (
<h2 class={alignClass} {...attrs}>
<h2 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h2>
) : styleIs("h3") ? (
<h3 class={alignClass} {...attrs}>
<h3 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h3>
) : styleIs("h4") ? (
<h4 class={alignClass} {...attrs}>
<h4 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h4>
) : styleIs("h5") ? (
<h5 class={alignClass} {...attrs}>
<h5 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h5>
) : styleIs("h6") ? (
<h6 class={alignClass} {...attrs}>
<h6 class={alignClass} id={headingId} {...restAttrs}>
{headingExtraIds.map((extra) => (
<span id={extra} />
))}
<slot />
</h6>
) : styleIs("blockquote") ? (
<blockquote class={alignClass} {...attrs}>
<blockquote class={alignClass} {...restAttrs}>
<slot />
</blockquote>
) : styleIs("normal") ? (
<p class={alignClass} {...attrs}>
<p class={alignClass} {...restAttrs}>
<slot />
</p>
) : (
<p class={alignClass} {...attrs}>
<p class={alignClass} {...restAttrs}>
<slot />
</p>
)
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/components/PortableText.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<PortableTextProps, "value"> {
value: PortableTextProps["value"];
Expand All @@ -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 <blockquote> 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 ? (
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 178 additions & 0 deletions packages/core/src/components/portable-text-heading-id.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Comment thread
scottbuscemi marked this conversation as resolved.

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[]);

Check warning on line 58 in packages/core/src/components/portable-text-heading-id.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe assertion from `any` detected: consider using type guards or a safer assertion.
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<string>;
}): 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);
Comment thread
scottbuscemi marked this conversation as resolved.
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<T>(value: T): T {
if (!Array.isArray(value)) return value;

const usedIds = new Set<string>();
let changed = false;
const next = value.map((item) => {
if (!item || typeof item !== "object") return item;
const block = item as BlockLike;

Check warning on line 147 in packages/core/src/components/portable-text-heading-id.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe assertion from `any` detected: consider using type guards or a safer assertion.
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;

Check warning on line 170 in packages/core/src/components/portable-text-heading-id.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: 'T' could be instantiated with an arbitrary type which could be unrelated to the original type.
}

function uniqueId(base: string, used: Set<string>): string {
if (!used.has(base)) return base;
let n = 2;
while (used.has(`${base}-${n}`)) n += 1;
return `${base}-${n}`;
}
Loading
Loading