-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(core): slug heading ids from Portable Text content #2254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
scottbuscemi
wants to merge
3
commits into
main
Choose a base branch
from
feat/heading-slug-ids
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
178 changes: 178 additions & 0 deletions
178
packages/core/src/components/portable-text-heading-id.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| */ | ||
|
|
||
| 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<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); | ||
|
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; | ||
| 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
|
||
| } | ||
|
|
||
| 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}`; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.