Skip to content

Commit a0cd0c4

Browse files
authored
Blog heading anchors (#979)
* feat: add anchor links to blog post headings Blog posts are rendered from WordPress HTML and can cover multiple features in a single article. Add slugified ids and hover-visible anchor links to every heading so sections can be deep-linked, and introduce an optional blogFragment field on features so a feature can point at a specific section of a multi-topic post. * feat: link features to blog sections and add Network AI Prompts Wire features 15 (Professional recordings & screenshots) and 28 (Build Insights) to their matching sections in the AI prompts + recording metadata blog post, and add a new Network AI Prompts feature entry on the networking page (hidden from the homepage) that deep-links to the prompt export section.
1 parent 1331ad2 commit a0cd0c4

File tree

7 files changed

+90
-2
lines changed

7 files changed

+90
-2
lines changed

docs/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"aos": "^2.3.4",
2323
"astro": "^5.16.6",
2424
"date-fns": "^4.1.0",
25+
"github-slugger": "^2.0.0",
2526
"marked": "^16.4.1",
2627
"react": "^19.2.3",
2728
"react-dom": "^19.2.3",
1.2 MB
Loading
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
showOnHomepage: false
3+
name: "Network AI Prompts"
4+
blogId: 32
5+
blogFragment: "exporting-network-requests-ai-prompts"
6+
featurePage: "networking"
7+
asset:
8+
type: "image"
9+
path: "../../assets/features/network-ai-prompts.png"
10+
alt: "Exported network request prompt showing redacted request and response data, ready to paste into an AI assistant."
11+
alignment: "left"
12+
columnSpan: 8
13+
---
14+
15+
Export network requests as **privacy-redacted prompts** you can paste straight into ChatGPT, Claude, or any other AI assistant.

docs/src/content.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const feature = defineCollection({
3333
tagLine: z.string().optional(),
3434
docPath: z.string().optional(),
3535
blogId: z.number().optional(),
36+
blogFragment: z.string().optional(),
3637
youtubeLink: z.string().url().optional(),
3738
featurePage: z
3839
.enum([

docs/src/layouts/components/Feature.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface Props {
1010
}
1111
1212
const {
13-
data: { asset, tagLine, name, blogId, youtubeLink },
13+
data: { asset, tagLine, name, blogId, blogFragment, youtubeLink },
1414
} = Astro.props;
1515
const parsedCaption = asset.caption
1616
? marked.parseInline(asset.caption)
@@ -24,7 +24,7 @@ if (blogId) {
2424
`${blogBaseUrl}/wp-json/wp/v2/posts/${blogId}?_fields=slug`,
2525
);
2626
const { slug } = (await res.json()) as { slug: string };
27-
blogPath = `/blog/${slug}`;
27+
blogPath = `/blog/${slug}${blogFragment ? `#${blogFragment}` : ""}`;
2828
}
2929
3030
let imageSizes;

docs/src/pages/blog/[slug].astro

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
---
22
import { JSDOM } from "jsdom";
3+
import GithubSlugger from "github-slugger";
4+
import { createElement } from "react";
5+
import { renderToStaticMarkup } from "react-dom/server";
6+
import { FaLink } from "react-icons/fa6";
37
import { Image } from "astro:assets";
48
59
import Base from "@/layouts/Base.astro";
@@ -125,6 +129,27 @@ videos.forEach((video) => {
125129
video.setAttribute("controls", "true");
126130
});
127131
132+
const slugger = new GithubSlugger();
133+
const headings = doc.querySelectorAll("h1, h2, h3, h4, h5, h6");
134+
const linkIconMarkup = renderToStaticMarkup(
135+
createElement(FaLink, { "aria-hidden": true }),
136+
);
137+
138+
headings.forEach((heading) => {
139+
const text = heading.textContent?.trim() ?? "";
140+
if (!text) return;
141+
142+
const id = heading.id || slugger.slug(text);
143+
heading.id = id;
144+
145+
const anchor = doc.createElement("a");
146+
anchor.setAttribute("href", `#${id}`);
147+
anchor.setAttribute("class", "heading-anchor");
148+
anchor.setAttribute("aria-label", `Link to section: ${text}`);
149+
anchor.innerHTML = linkIconMarkup;
150+
heading.insertBefore(anchor, heading.firstChild);
151+
});
152+
128153
const manipulatedDom = dom.serialize();
129154
---
130155

@@ -191,3 +216,48 @@ const manipulatedDom = dom.serialize();
191216
</section>
192217
</article>
193218
</Base>
219+
220+
<style>
221+
.content :global(h1),
222+
.content :global(h2),
223+
.content :global(h3),
224+
.content :global(h4),
225+
.content :global(h5),
226+
.content :global(h6) {
227+
position: relative;
228+
229+
scroll-margin-top: 6rem;
230+
}
231+
232+
.content :global(.heading-anchor) {
233+
position: absolute;
234+
top: 56%;
235+
right: calc(100% + 0.5rem);
236+
237+
display: inline-flex;
238+
align-items: center;
239+
240+
color: inherit;
241+
text-decoration: none;
242+
243+
opacity: 0;
244+
245+
transform: translateY(-50%);
246+
transition: opacity 150ms ease;
247+
}
248+
249+
.content :global(.heading-anchor svg) {
250+
width: 0.5em;
251+
height: 0.5em;
252+
}
253+
254+
.content :global(h1:hover .heading-anchor),
255+
.content :global(h2:hover .heading-anchor),
256+
.content :global(h3:hover .heading-anchor),
257+
.content :global(h4:hover .heading-anchor),
258+
.content :global(h5:hover .heading-anchor),
259+
.content :global(h6:hover .heading-anchor),
260+
.content :global(.heading-anchor:focus-visible) {
261+
opacity: 1;
262+
}
263+
</style>

0 commit comments

Comments
 (0)