Skip to content

Commit 360f10e

Browse files
committed
feat(docs): build-time step ids + HowTo JSON-LD for AI-readable docs
Make the step/prerequisite anchors machine-addressable in the static output, not just in the client-rendered DOM: - els-structured-data plugin (extendsMarkdown): assigns a stable, page-unique slug id to each top-level <li> inside <ELSSteps>, so <li id=...> is in the SSR HTML. - Same plugin (extendsPage): emits a schema.org HowTo/HowToStep JSON-LD script into the page <head>, with urls matching the step ids. - ELSPrerequisites: render the section id (#prerequisites) server-side via a bound :id (overridable with <ELSPrerequisites id=...>). - ELSSteps/ELSPrerequisites onMounted now read the build-time id and only attach the visual # / clickable number, with slugify kept as a fallback. Reuses the shared utils/slugify helper so build-time and runtime ids match.
1 parent 0102495 commit 360f10e

4 files changed

Lines changed: 146 additions & 15 deletions

File tree

docs/.vuepress/components/ELSPrerequisites.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<div ref="rootRef" class="prereqs">
33
<div class="prereqs-header">
4-
<h4 ref="headingRef"><slot name="title">Prerequisites</slot></h4>
4+
<h4 ref="headingRef" :id="headingId"><slot name="title">Prerequisites</slot></h4>
55
</div>
66
<div class="prereqs-body">
77
<slot />
@@ -11,22 +11,23 @@
1111

1212
<script setup lang="ts">
1313
import { onMounted, ref } from 'vue';
14-
import { slugify, uniqueId, collectUsedIds } from '../utils/slugify';
14+
15+
// id is rendered server-side so the section anchor exists in the static HTML.
16+
// Override with <ELSPrerequisites id="..."> if a page has more than one block.
17+
const props = defineProps<{ id?: string }>();
18+
const headingId = props.id ?? 'prerequisites';
1519
1620
const rootRef = ref<HTMLElement | null>(null);
1721
const headingRef = ref<HTMLElement | null>(null);
1822
1923
function anchorPrerequisites() {
2024
const heading = headingRef.value;
21-
if (!heading || heading.id) return;
22-
23-
const used = collectUsedIds();
24-
const id = uniqueId(slugify(heading.textContent ?? '') || 'prerequisites', used);
25-
heading.id = id;
25+
if (!heading || heading.dataset.anchored) return;
26+
heading.dataset.anchored = '1';
2627
2728
const anchor = document.createElement('a');
2829
anchor.className = 'header-anchor prereq-anchor';
29-
anchor.setAttribute('href', `#${id}`);
30+
anchor.setAttribute('href', `#${heading.id}`);
3031
anchor.setAttribute('aria-hidden', 'true');
3132
anchor.setAttribute('tabindex', '-1');
3233
anchor.textContent = '#';
@@ -35,7 +36,6 @@ function anchorPrerequisites() {
3536
}
3637
3738
onMounted(() => {
38-
// Run after VuePress has assigned heading ids.
3939
setTimeout(anchorPrerequisites, 0);
4040
});
4141
</script>

docs/.vuepress/components/ELSSteps.vue

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,23 @@ function anchorSteps() {
1616
const body = bodyRef.value;
1717
if (!body) return;
1818
19-
// Seed from ids already on the page so step anchors never collide with
20-
// heading anchors or other <ELSSteps> blocks on the same page.
19+
// Step ids are assigned at build time (els-structured-data markdown plugin).
20+
// We only read them here to attach the visual affordances; slugify is a
21+
// fallback for the rare case a build-time id is missing.
2122
const used = collectUsedIds();
2223
2324
// Top-level steps only — leave nested sub-steps (ol ol > li) un-anchored.
2425
body.querySelectorAll<HTMLLIElement>(':scope > ol > li').forEach((li) => {
25-
if (li.id) return; // already processed
26+
if (li.dataset.anchored) return; // already decorated
27+
li.dataset.anchored = '1';
2628
2729
const titleEl = li.querySelector('p');
28-
const title = titleEl?.textContent ?? li.textContent ?? '';
29-
const id = uniqueId(slugify(title) || 'step', used);
30-
li.id = id;
30+
let id = li.id;
31+
if (!id) {
32+
const title = titleEl?.textContent ?? li.textContent ?? '';
33+
id = uniqueId(slugify(title) || 'step', used);
34+
li.id = id;
35+
}
3136
3237
const anchor = document.createElement('a');
3338
anchor.className = 'header-anchor els-step-anchor';

docs/.vuepress/config-user/plugins.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import {ContainerPluginOptions} from "@vuepress/plugin-container/lib/node/contai
33
import { registerComponentsPlugin } from '@vuepress/plugin-register-components'
44
import { prismjsPlugin } from '@vuepress/plugin-prismjs'
55
import { path } from '@vuepress/utils'
6+
import { elsStructuredDataPlugin } from '../plugins/elsStructuredData'
67

78
export default [
89
prismjsPlugin(),
10+
elsStructuredDataPlugin,
911
containerPlugin({
1012
type: 'warning',
1113
before: info => `<div class="warning custom-block"><p class="custom-block-title">${info}</p>`,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { slugify } from "../utils/slugify";
2+
3+
/**
4+
* Build-time structured data for <ELSSteps> blocks.
5+
*
6+
* 1. extendsMarkdown: assigns a stable, page-unique `id` (slug of the step
7+
* title) to each top-level step <li>, so the anchor is present in the SSR
8+
* HTML (machine-addressable), not only after the client-side script runs.
9+
* 2. extendsPage: emits a schema.org HowTo / HowToStep JSON-LD <script> into
10+
* the page <head> (the correct place for JSON-LD; injecting a <script>
11+
* into the page body breaks VuePress's SFC compilation).
12+
*
13+
* Both paths slug the same step titles with the same page-wide dedup, so the
14+
* JSON-LD `url` matches the `<li id>` in the HTML.
15+
*/
16+
17+
function clean(s: string): string {
18+
return (s || "")
19+
.replace(/`([^`]*)`/g, "$1")
20+
.replace(/\*\*([^*]*)\*\*/g, "$1")
21+
.replace(/\*([^*]*)\*/g, "$1")
22+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
23+
.replace(/\s+/g, " ")
24+
.trim();
25+
}
26+
27+
// First inline text inside a list item = the step title.
28+
function firstInlineText(tokens: any[], openIdx: number): string {
29+
for (let j = openIdx + 1; j < tokens.length; j++) {
30+
if (tokens[j].type === "inline") return tokens[j].content;
31+
if (tokens[j].type === "list_item_close") break;
32+
}
33+
return "";
34+
}
35+
36+
// ---- point 1: assign ids to step <li> at parse time ----
37+
function assignStepIds(md: any) {
38+
md.core.ruler.push("els_step_ids", (state: any) => {
39+
const tokens = state.tokens;
40+
const used = new Set<string>();
41+
let inSteps = 0;
42+
let listDepth = 0;
43+
44+
for (let i = 0; i < tokens.length; i++) {
45+
const t = tokens[i];
46+
if (t.type === "html_block" && /<ELSSteps[\s/>]/.test(t.content) && !/<\/ELSSteps>/.test(t.content)) {
47+
inSteps++;
48+
continue;
49+
}
50+
if (t.type === "html_block" && /<\/ELSSteps>/.test(t.content)) {
51+
inSteps = Math.max(0, inSteps - 1);
52+
continue;
53+
}
54+
if (!inSteps) continue;
55+
56+
if (t.type === "ordered_list_open") listDepth++;
57+
else if (t.type === "ordered_list_close") listDepth--;
58+
59+
if (t.type === "list_item_open" && listDepth === 1) {
60+
const base = slugify(clean(firstInlineText(tokens, i))) || "step";
61+
let id = base;
62+
let n = 0;
63+
while (used.has(id)) id = `${base}-${++n}`;
64+
used.add(id);
65+
t.attrSet("id", id);
66+
}
67+
}
68+
});
69+
}
70+
71+
// ---- point 2: build HowTo JSON-LD from the markdown source ----
72+
function howToScripts(page: any): any[] {
73+
const src: string = page?.content || "";
74+
if (!src.includes("<ELSSteps")) return [];
75+
76+
const pageTitle = clean(page?.title || "");
77+
const used = new Set<string>();
78+
const scripts: any[] = [];
79+
80+
const blockRe = /<ELSSteps[^>]*>([\s\S]*?)<\/ELSSteps>/g;
81+
let block: RegExpExecArray | null;
82+
while ((block = blockRe.exec(src))) {
83+
const steps: { id: string; name: string }[] = [];
84+
// top-level numbered items only (no leading whitespace -> not nested)
85+
const itemRe = /^(\d+)\.\s+(.+)$/gm;
86+
let item: RegExpExecArray | null;
87+
while ((item = itemRe.exec(block[1]))) {
88+
const name = clean(item[2]);
89+
const base = slugify(name) || "step";
90+
let id = base;
91+
let n = 0;
92+
while (used.has(id)) id = `${base}-${++n}`;
93+
used.add(id);
94+
steps.push({ id, name });
95+
}
96+
if (!steps.length) continue;
97+
98+
const howTo = {
99+
"@context": "https://schema.org",
100+
"@type": "HowTo",
101+
name: pageTitle || steps[0].name,
102+
step: steps.map((s, n) => ({
103+
"@type": "HowToStep",
104+
position: n + 1,
105+
name: s.name,
106+
text: s.name,
107+
url: `#${s.id}`,
108+
})),
109+
};
110+
scripts.push(["script", { type: "application/ld+json" }, JSON.stringify(howTo)]);
111+
}
112+
return scripts;
113+
}
114+
115+
export const elsStructuredDataPlugin = {
116+
name: "els-structured-data",
117+
extendsMarkdown: assignStepIds,
118+
extendsPage: (page: any) => {
119+
const scripts = howToScripts(page);
120+
if (!scripts.length) return;
121+
page.frontmatter.head = page.frontmatter.head || [];
122+
page.frontmatter.head.push(...scripts);
123+
},
124+
};

0 commit comments

Comments
 (0)