Skip to content

Commit b193621

Browse files
authored
Merge pull request #577 from sboldyreva/anchors
feat(docs): add deep-link anchors to prerequisites and steps
2 parents 0e7114c + 360f10e commit b193621

6 files changed

Lines changed: 357 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" :id="headingId"><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+
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';
19+
20+
const rootRef = ref<HTMLElement | null>(null);
21+
const headingRef = ref<HTMLElement | null>(null);
22+
23+
function anchorPrerequisites() {
24+
const heading = headingRef.value;
25+
if (!heading || heading.dataset.anchored) return;
26+
heading.dataset.anchored = '1';
27+
28+
const anchor = document.createElement('a');
29+
anchor.className = 'header-anchor prereq-anchor';
30+
anchor.setAttribute('href', `#${heading.id}`);
31+
anchor.setAttribute('aria-hidden', 'true');
32+
anchor.setAttribute('tabindex', '-1');
33+
anchor.textContent = '#';
34+
// On the left of the heading wording, vertically aligned with it.
35+
heading.insertBefore(anchor, heading.firstChild);
36+
}
37+
38+
onMounted(() => {
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: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,66 @@
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+
// 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.
22+
const used = collectUsedIds();
23+
24+
// Top-level steps only — leave nested sub-steps (ol ol > li) un-anchored.
25+
body.querySelectorAll<HTMLLIElement>(':scope > ol > li').forEach((li) => {
26+
if (li.dataset.anchored) return; // already decorated
27+
li.dataset.anchored = '1';
28+
29+
const titleEl = li.querySelector('p');
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+
}
36+
37+
const anchor = document.createElement('a');
38+
anchor.className = 'header-anchor els-step-anchor';
39+
anchor.setAttribute('href', `#${id}`);
40+
anchor.setAttribute('aria-hidden', 'true');
41+
anchor.setAttribute('tabindex', '-1');
42+
anchor.textContent = '#';
43+
// On the left of the step title (prepended), vertically aligned with it.
44+
const target = titleEl ?? li;
45+
target.insertBefore(anchor, target.firstChild);
46+
47+
// Transparent overlay over the number badge so clicking the number also
48+
// navigates to the step's anchor and updates the URL.
49+
const numberLink = document.createElement('a');
50+
numberLink.className = 'els-step-number';
51+
numberLink.setAttribute('href', `#${id}`);
52+
numberLink.setAttribute('aria-hidden', 'true');
53+
numberLink.setAttribute('tabindex', '-1');
54+
li.appendChild(numberLink);
55+
});
56+
}
57+
58+
onMounted(() => {
59+
// Run after VuePress has assigned heading ids.
60+
setTimeout(anchorSteps, 0);
61+
});
62+
</script>
63+
964
<style scoped>
1065
.els-steps {
1166
margin: 1.5rem 0;
@@ -22,11 +77,52 @@
2277
.els-steps-body :deep(ol > li) {
2378
counter-increment: step-counter;
2479
position: relative;
25-
padding-left: 1.5rem;
80+
padding-left: 1.2rem;
2681
padding-bottom: 1.25rem;
2782
margin-bottom: 0;
2883
border-left: 2px solid #e0e3e8;
2984
margin-left: 0.9rem;
85+
/* Land below the fixed navbar when navigated to via a step anchor. */
86+
scroll-margin-top: 6rem;
87+
}
88+
89+
/* The anchor floats left at its natural position (no negative margin, so it
90+
never overlaps the number badge), with a fixed width and a minimal gap.
91+
font-size in rem (not em) keeps it identical to the prerequisites anchor. */
92+
.els-steps-body :deep(ol > li > p:first-child > a.els-step-anchor) {
93+
opacity: 0;
94+
font-size: 1rem;
95+
width: 0.7rem;
96+
padding-right: 0;
97+
margin-left: 0;
98+
/* Inherit the title's 2rem line-height so the floated # centers at the same
99+
level as the (vertically centered) wording. No extra margin-top needed. */
100+
line-height: inherit;
101+
margin-top: 0;
102+
transition: opacity 0.15s ease;
103+
}
104+
105+
.els-steps-body :deep(ol > li:hover > p:first-child > a.els-step-anchor) {
106+
opacity: 1;
107+
}
108+
109+
/* Indent the step's body content (everything below the title) by the anchor's
110+
reserved width so it aligns with the title wording. margin-left (not padding)
111+
so code blocks move as a whole and their background aligns too. Exclude the
112+
absolutely positioned number-overlay link. */
113+
.els-steps-body :deep(ol > li > *:not(:first-child):not(a)) {
114+
margin-left: 0.7rem;
115+
}
116+
117+
/* Transparent overlay over the number badge — makes the number clickable. */
118+
.els-steps-body :deep(ol > li > a.els-step-number) {
119+
position: absolute;
120+
left: -1rem;
121+
top: 0;
122+
width: 2rem;
123+
height: 2rem;
124+
border-radius: 50%;
125+
cursor: pointer;
30126
}
31127
32128
.els-steps-body :deep(ol > li:last-child) {
@@ -50,12 +146,22 @@
50146
align-items: center;
51147
justify-content: center;
52148
line-height: 1;
149+
transition: background 0.15s ease;
150+
}
151+
152+
/* Hover hint on the step: the number badge changes color. */
153+
.els-steps-body :deep(ol > li:hover::before) {
154+
background: #0B5CAD;
53155
}
54156
55157
.els-steps-body :deep(ol > li > p:first-child) {
56158
font-weight: 600;
57159
color: #0d1a26;
58160
margin-top: 0;
161+
/* Match the number badge height so the wording sits at the badge's vertical
162+
middle (where the number is) instead of at its top. */
163+
min-height: 2rem;
164+
line-height: 2rem;
59165
}
60166
61167
.els-steps-body :deep(ol > li > p) {
@@ -83,7 +189,10 @@
83189
84190
.els-steps-body :deep(div[class*="language-"]),
85191
.els-steps-body :deep(.code-with-copy) {
86-
margin: 0.5rem 0;
192+
/* top/bottom only — left indent comes from the body-content rule above so
193+
the code block aligns with the step wording. */
194+
margin-top: 0.5rem;
195+
margin-bottom: 0.5rem;
87196
}
88197
89198
.els-steps-body :deep(:not(pre) > code) {

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>`,

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],

0 commit comments

Comments
 (0)