Skip to content

Commit 0102495

Browse files
committed
feat(docs): add deep-link anchors to prerequisites and steps
ELSPrerequisites and ELSSteps are Vue component templates, so VuePress's markdown anchor system (which only slugs h2-h5 headings) never gave them ids - there was nothing to deep-link to. This adds hover-revealed anchors: - Each step gets a stable id slugified from its title text (e.g. #download-the-install-script), deduped page-wide so multiple steps and multiple ELSSteps blocks never collide. - Each Prerequisites section gets a single #prerequisites section anchor. - A '#' link (styled like the site's h2 header-anchors) reveals on hover and sits to the left of the wording; clicking it - or the step's number badge - updates the URL and scrolls below the fixed navbar. - The step number badge changes color on hover. - Step body content and the prerequisites list are indented to line up with the title/heading wording; anchors are vertically aligned with the text. - A shared slugify helper (utils/slugify.ts) strips punctuation/signs from anchor urls; markdown heading anchors (config.ts) now use it too, so the no-signs rule applies site-wide.
1 parent 0a11a53 commit 0102495

4 files changed

Lines changed: 226 additions & 11 deletions

File tree

docs/.vuepress/components/ELSPrerequisites.vue

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,54 @@
11
<template>
2-
<div class="prereqs">
2+
<div ref="rootRef" class="prereqs">
33
<div class="prereqs-header">
4-
<h4><slot name="title">Prerequisites</slot></h4>
4+
<h4 ref="headingRef"><slot name="title">Prerequisites</slot></h4>
55
</div>
66
<div class="prereqs-body">
77
<slot />
88
</div>
99
</div>
1010
</template>
1111

12+
<script setup lang="ts">
13+
import { onMounted, ref } from 'vue';
14+
import { slugify, uniqueId, collectUsedIds } from '../utils/slugify';
15+
16+
const rootRef = ref<HTMLElement | null>(null);
17+
const headingRef = ref<HTMLElement | null>(null);
18+
19+
function anchorPrerequisites() {
20+
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;
26+
27+
const anchor = document.createElement('a');
28+
anchor.className = 'header-anchor prereq-anchor';
29+
anchor.setAttribute('href', `#${id}`);
30+
anchor.setAttribute('aria-hidden', 'true');
31+
anchor.setAttribute('tabindex', '-1');
32+
anchor.textContent = '#';
33+
// On the left of the heading wording, vertically aligned with it.
34+
heading.insertBefore(anchor, heading.firstChild);
35+
}
36+
37+
onMounted(() => {
38+
// Run after VuePress has assigned heading ids.
39+
setTimeout(anchorPrerequisites, 0);
40+
});
41+
</script>
42+
1243
<style scoped>
1344
.prereqs {
1445
border-radius: 10px;
1546
border: 1px solid #D9EDFF;
1647
background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
1748
padding: 1.25rem 1.5rem;
1849
margin: 1.5rem 0;
50+
/* Land below the fixed navbar when navigated to via the section anchor. */
51+
scroll-margin-top: 6rem;
1952
}
2053
2154
.prereqs-header h4 {
@@ -25,6 +58,36 @@
2558
color: #1b1f27;
2659
}
2760
61+
/* The anchor floats left at its natural position with a fixed width and a
62+
minimal gap; the body list below is indented to match (see .prereqs-body).
63+
font-size in rem (not em) keeps it identical to the ELSSteps anchor. */
64+
.prereqs-header h4 :deep(a.prereq-anchor) {
65+
opacity: 0;
66+
font-size: 1rem;
67+
width: 0.7rem;
68+
padding-right: 0;
69+
margin-left: 0;
70+
/* Match the heading's line-height and nudge down so the floated # starts at
71+
the same level as the wording instead of riding above it. */
72+
line-height: inherit;
73+
margin-top: 0.1rem;
74+
transition: opacity 0.15s ease;
75+
}
76+
77+
.prereqs-header h4:hover {
78+
cursor: pointer;
79+
}
80+
81+
.prereqs-header h4:hover :deep(a.prereq-anchor) {
82+
opacity: 1;
83+
}
84+
85+
/* Indent the body by the anchor's reserved width so the list aligns with the
86+
"Prerequisites" wording, which the floated anchor pushes right. */
87+
.prereqs-body {
88+
padding-left: 0.7rem;
89+
}
90+
2891
.prereqs-body :deep(ul) {
2992
list-style: none;
3093
padding: 0;

docs/.vuepress/components/ELSSteps.vue

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,61 @@
11
<template>
22
<div class="els-steps">
3-
<div class="els-steps-body">
3+
<div ref="bodyRef" class="els-steps-body">
44
<slot />
55
</div>
66
</div>
77
</template>
88

9+
<script setup lang="ts">
10+
import { onMounted, ref } from 'vue';
11+
import { slugify, uniqueId, collectUsedIds } from '../utils/slugify';
12+
13+
const bodyRef = ref<HTMLElement | null>(null);
14+
15+
function anchorSteps() {
16+
const body = bodyRef.value;
17+
if (!body) return;
18+
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.
21+
const used = collectUsedIds();
22+
23+
// Top-level steps only — leave nested sub-steps (ol ol > li) un-anchored.
24+
body.querySelectorAll<HTMLLIElement>(':scope > ol > li').forEach((li) => {
25+
if (li.id) return; // already processed
26+
27+
const titleEl = li.querySelector('p');
28+
const title = titleEl?.textContent ?? li.textContent ?? '';
29+
const id = uniqueId(slugify(title) || 'step', used);
30+
li.id = id;
31+
32+
const anchor = document.createElement('a');
33+
anchor.className = 'header-anchor els-step-anchor';
34+
anchor.setAttribute('href', `#${id}`);
35+
anchor.setAttribute('aria-hidden', 'true');
36+
anchor.setAttribute('tabindex', '-1');
37+
anchor.textContent = '#';
38+
// On the left of the step title (prepended), vertically aligned with it.
39+
const target = titleEl ?? li;
40+
target.insertBefore(anchor, target.firstChild);
41+
42+
// Transparent overlay over the number badge so clicking the number also
43+
// navigates to the step's anchor and updates the URL.
44+
const numberLink = document.createElement('a');
45+
numberLink.className = 'els-step-number';
46+
numberLink.setAttribute('href', `#${id}`);
47+
numberLink.setAttribute('aria-hidden', 'true');
48+
numberLink.setAttribute('tabindex', '-1');
49+
li.appendChild(numberLink);
50+
});
51+
}
52+
53+
onMounted(() => {
54+
// Run after VuePress has assigned heading ids.
55+
setTimeout(anchorSteps, 0);
56+
});
57+
</script>
58+
959
<style scoped>
1060
.els-steps {
1161
margin: 1.5rem 0;
@@ -22,11 +72,52 @@
2272
.els-steps-body :deep(ol > li) {
2373
counter-increment: step-counter;
2474
position: relative;
25-
padding-left: 1.5rem;
75+
padding-left: 1.2rem;
2676
padding-bottom: 1.25rem;
2777
margin-bottom: 0;
2878
border-left: 2px solid #e0e3e8;
2979
margin-left: 0.9rem;
80+
/* Land below the fixed navbar when navigated to via a step anchor. */
81+
scroll-margin-top: 6rem;
82+
}
83+
84+
/* The anchor floats left at its natural position (no negative margin, so it
85+
never overlaps the number badge), with a fixed width and a minimal gap.
86+
font-size in rem (not em) keeps it identical to the prerequisites anchor. */
87+
.els-steps-body :deep(ol > li > p:first-child > a.els-step-anchor) {
88+
opacity: 0;
89+
font-size: 1rem;
90+
width: 0.7rem;
91+
padding-right: 0;
92+
margin-left: 0;
93+
/* Inherit the title's 2rem line-height so the floated # centers at the same
94+
level as the (vertically centered) wording. No extra margin-top needed. */
95+
line-height: inherit;
96+
margin-top: 0;
97+
transition: opacity 0.15s ease;
98+
}
99+
100+
.els-steps-body :deep(ol > li:hover > p:first-child > a.els-step-anchor) {
101+
opacity: 1;
102+
}
103+
104+
/* Indent the step's body content (everything below the title) by the anchor's
105+
reserved width so it aligns with the title wording. margin-left (not padding)
106+
so code blocks move as a whole and their background aligns too. Exclude the
107+
absolutely positioned number-overlay link. */
108+
.els-steps-body :deep(ol > li > *:not(:first-child):not(a)) {
109+
margin-left: 0.7rem;
110+
}
111+
112+
/* Transparent overlay over the number badge — makes the number clickable. */
113+
.els-steps-body :deep(ol > li > a.els-step-number) {
114+
position: absolute;
115+
left: -1rem;
116+
top: 0;
117+
width: 2rem;
118+
height: 2rem;
119+
border-radius: 50%;
120+
cursor: pointer;
30121
}
31122
32123
.els-steps-body :deep(ol > li:last-child) {
@@ -50,12 +141,22 @@
50141
align-items: center;
51142
justify-content: center;
52143
line-height: 1;
144+
transition: background 0.15s ease;
145+
}
146+
147+
/* Hover hint on the step: the number badge changes color. */
148+
.els-steps-body :deep(ol > li:hover::before) {
149+
background: #0B5CAD;
53150
}
54151
55152
.els-steps-body :deep(ol > li > p:first-child) {
56153
font-weight: 600;
57154
color: #0d1a26;
58155
margin-top: 0;
156+
/* Match the number badge height so the wording sits at the badge's vertical
157+
middle (where the number is) instead of at its top. */
158+
min-height: 2rem;
159+
line-height: 2rem;
59160
}
60161
61162
.els-steps-body :deep(ol > li > p) {
@@ -83,7 +184,10 @@
83184
84185
.els-steps-body :deep(div[class*="language-"]),
85186
.els-steps-body :deep(.code-with-copy) {
86-
margin: 0.5rem 0;
187+
/* top/bottom only — left indent comes from the body-content rule above so
188+
the code block aligns with the step wording. */
189+
margin-top: 0.5rem;
190+
margin-bottom: 0.5rem;
87191
}
88192
89193
.els-steps-body :deep(:not(pre) > code) {

docs/.vuepress/config.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@ import { defineUserConfig, viteBundler } from "vuepress";
22
import theme from "./theme";
33
import plugins from "./config-user/plugins";
44
import headFunctions from "./headFunctions";
5+
import { slugify } from "./utils/slugify";
56

67
export default defineUserConfig({
78
theme,
89
markdown: {
910
anchor: {
10-
slugify: str =>
11-
str
12-
.replace(/®/g, '')
13-
.replace(//g, '')
14-
.toLowerCase()
15-
.replace(/\s+/g, '-')
11+
// Shared rule: strip punctuation/signs (, . ? ! ' etc.) from anchor urls.
12+
slugify,
1613
},
1714
headers: {
1815
level: [2, 3, 4, 5],

docs/.vuepress/utils/slugify.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Shared slug helper for anchor ids.
3+
*
4+
* Mirrors the rule used by VuePress markdown anchors in `config.ts`
5+
* (strip ® / ™, lowercase, spaces -> "-") so component-generated anchors
6+
* (steps, prerequisites) behave exactly like heading anchors. Additionally
7+
* strips characters that aren't url/id-safe and collapses repeated hyphens.
8+
*/
9+
export function slugify(str: string): string {
10+
return str
11+
.replace(/®/g, '')
12+
.replace(//g, '')
13+
.toLowerCase()
14+
.trim()
15+
.replace(/[^a-z0-9\s-]/g, '') // drop non url-safe chars
16+
.replace(/\s+/g, '-') // spaces -> hyphens
17+
.replace(/-+/g, '-') // collapse repeats
18+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
19+
}
20+
21+
/**
22+
* Returns `base` if unused, otherwise appends -1, -2, ... until unique.
23+
* Records the chosen id in `used` (mutates the set). Same dedupe behavior
24+
* as markdown-it-anchor so step/prereq ids never collide with each other
25+
* or with heading anchors already present on the page.
26+
*/
27+
export function uniqueId(base: string, used: Set<string>): string {
28+
const safeBase = base || 'section';
29+
let id = safeBase;
30+
let i = 0;
31+
while (used.has(id)) {
32+
i += 1;
33+
id = `${safeBase}-${i}`;
34+
}
35+
used.add(id);
36+
return id;
37+
}
38+
39+
/**
40+
* Seeds a "used ids" set from every element already carrying an id within
41+
* the given root (defaults to the whole document). Lets runtime-generated
42+
* anchors avoid colliding with VuePress heading anchors and other blocks.
43+
*/
44+
export function collectUsedIds(root: ParentNode = document): Set<string> {
45+
const used = new Set<string>();
46+
root.querySelectorAll('[id]').forEach((el) => {
47+
const id = el.getAttribute('id');
48+
if (id) used.add(id);
49+
});
50+
return used;
51+
}

0 commit comments

Comments
 (0)