Skip to content

Commit 2ba4d27

Browse files
authored
Merge pull request #715 from dahlia/docs/markdown-button
Expose per-page Markdown actions on the docs site
2 parents c82ee90 + 71ac146 commit 2ba4d27

5 files changed

Lines changed: 393 additions & 8 deletions

File tree

CHANGES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ To be released.
202202

203203
### Docs
204204

205+
- Added a per-page Markdown action to the docs site so readers can open or
206+
copy the LLM-friendly Markdown for the current page without guessing the
207+
generated `*.md` path or starting from *llms.txt*. The action is now
208+
available directly from each documentation page while *llms.txt* and
209+
*llms-full.txt* continue to exclude high-noise pages such as the changelog,
210+
contribution guide, *README.md*, and sponsors page. [[#706], [#715]]
211+
205212
- Added [*Building a federated blog* tutorial] showing how to layer
206213
ActivityPub federation onto an [Astro] + [Bun] blog: actor setup,
207214
follower management, SQLite persistence, sending `Create`/`Update`/
@@ -226,6 +233,8 @@ To be released.
226233
[#691]: https://github.com/fedify-dev/fedify/issues/691
227234
[#695]: https://github.com/fedify-dev/fedify/pull/695
228235
[#704]: https://github.com/fedify-dev/fedify/issues/704
236+
[#706]: https://github.com/fedify-dev/fedify/issues/706
237+
[#715]: https://github.com/fedify-dev/fedify/pull/715
229238

230239

231240
Version 2.1.10

docs/.vitepress/config.mts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,20 @@ export default withMermaid(defineConfig({
311311
plugins: [
312312
groupIconVitePlugin(),
313313
llmstxt({
314-
ignoreFiles: [
315-
"changelog.md",
316-
"contribute.md",
317-
"README.md",
318-
"security.md",
319-
"sponsors.md",
320-
"tutorial.md",
321-
],
314+
ignoreFilesPerOutput: {
315+
llmsTxt: [
316+
"changelog.md",
317+
"contribute.md",
318+
"README.md",
319+
"sponsors.md",
320+
],
321+
llmsFullTxt: [
322+
"changelog.md",
323+
"contribute.md",
324+
"README.md",
325+
"sponsors.md",
326+
],
327+
},
322328
}),
323329
],
324330
},
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
<script setup lang="ts">
2+
import { useRoute } from "vitepress";
3+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
4+
5+
const copied = ref(false);
6+
const copyFailed = ref<"load" | "clipboard" | null>(null);
7+
const targetReady = ref(false);
8+
const isDev = import.meta.env.DEV;
9+
const devMessage =
10+
"Markdown actions are not available in the VitePress dev server. Please use the built docs instead.";
11+
const route = useRoute();
12+
let copiedResetTimeout: number | null = null;
13+
let copyFailedResetTimeout: number | null = null;
14+
let currentWrapper: HTMLElement | null = null;
15+
let currentTarget: HTMLElement | null = null;
16+
let targetUpdateVersion = 0;
17+
18+
const markdownPath = computed(() => {
19+
let path = route.path.replace(/\.html$/, "");
20+
if (path === "/") return "/index.md";
21+
if (path.endsWith("/")) return `${path}index.md`;
22+
return `${path.replace(/\/+$/, "")}.md`;
23+
});
24+
25+
function cleanupTarget(): void {
26+
if (
27+
currentWrapper == null ||
28+
currentTarget == null ||
29+
currentWrapper.parentNode == null
30+
) {
31+
currentWrapper = null;
32+
currentTarget = null;
33+
return;
34+
}
35+
36+
const heading = currentWrapper.querySelector(":scope > h1");
37+
if (heading instanceof HTMLElement) {
38+
currentWrapper.parentNode.insertBefore(heading, currentWrapper);
39+
}
40+
currentWrapper.remove();
41+
currentWrapper = null;
42+
currentTarget = null;
43+
}
44+
45+
function ensureTarget(heading: Element | null): void {
46+
const h1 = heading;
47+
if (!(h1 instanceof HTMLElement)) {
48+
targetReady.value = false;
49+
return;
50+
}
51+
52+
const existingWrapper = h1.parentElement;
53+
if (existingWrapper?.classList.contains("page-title-row")) {
54+
currentWrapper = existingWrapper;
55+
currentTarget = existingWrapper.querySelector(".page-title-actions-target");
56+
targetReady.value = currentTarget != null;
57+
return;
58+
}
59+
60+
const wrapper = document.createElement("div");
61+
wrapper.className = "page-title-row";
62+
const target = document.createElement("div");
63+
target.className = "page-title-actions-target";
64+
65+
h1.parentNode?.insertBefore(wrapper, h1);
66+
wrapper.appendChild(h1);
67+
wrapper.appendChild(target);
68+
currentWrapper = wrapper;
69+
currentTarget = target;
70+
targetReady.value = true;
71+
}
72+
73+
function waitForHeading(maxFrames = 8): Promise<Element | null> {
74+
return new Promise((resolve) => {
75+
let frame = 0;
76+
77+
const check = () => {
78+
const heading = document.querySelector(".vp-doc h1");
79+
if (heading != null || frame >= maxFrames) {
80+
resolve(heading);
81+
return;
82+
}
83+
frame++;
84+
window.requestAnimationFrame(check);
85+
};
86+
87+
check();
88+
});
89+
}
90+
91+
async function updateTarget(): Promise<void> {
92+
const version = ++targetUpdateVersion;
93+
targetReady.value = false;
94+
copied.value = false;
95+
copyFailed.value = null;
96+
cleanupTarget();
97+
await nextTick();
98+
const heading = await waitForHeading();
99+
if (version !== targetUpdateVersion) return;
100+
ensureTarget(heading);
101+
}
102+
103+
onMounted(() => {
104+
void updateTarget();
105+
watch(() => route.path, updateTarget);
106+
});
107+
108+
onBeforeUnmount(() => {
109+
targetUpdateVersion++;
110+
targetReady.value = false;
111+
cleanupTarget();
112+
if (copiedResetTimeout != null) window.clearTimeout(copiedResetTimeout);
113+
if (copyFailedResetTimeout != null) window.clearTimeout(copyFailedResetTimeout);
114+
});
115+
116+
function closeMenu(target: EventTarget | HTMLElement | null): void {
117+
const details = (target as HTMLElement | null)?.closest("details");
118+
if (details instanceof HTMLDetailsElement) details.open = false;
119+
}
120+
121+
function resetCopiedState(delay: number): void {
122+
if (copiedResetTimeout != null) window.clearTimeout(copiedResetTimeout);
123+
copiedResetTimeout = window.setTimeout(() => {
124+
copied.value = false;
125+
copiedResetTimeout = null;
126+
}, delay);
127+
}
128+
129+
function resetCopyFailedState(delay: number): void {
130+
if (copyFailedResetTimeout != null) window.clearTimeout(copyFailedResetTimeout);
131+
copyFailedResetTimeout = window.setTimeout(() => {
132+
copyFailed.value = null;
133+
copyFailedResetTimeout = null;
134+
}, delay);
135+
}
136+
137+
async function getMarkdown(): Promise<string> {
138+
const response = await fetch(markdownPath.value);
139+
if (!response.ok) {
140+
throw new Error(`Failed to load ${markdownPath.value}: ${response.status}`);
141+
}
142+
return await response.text();
143+
}
144+
145+
async function copyMarkdown(event: MouseEvent): Promise<void> {
146+
const trigger = event.currentTarget as HTMLElement | null;
147+
const version = targetUpdateVersion;
148+
if (isDev) {
149+
closeMenu(trigger);
150+
window.alert(devMessage);
151+
return;
152+
}
153+
try {
154+
const text = await getMarkdown();
155+
try {
156+
await navigator.clipboard.writeText(text);
157+
} catch {
158+
if (version !== targetUpdateVersion) return;
159+
copied.value = false;
160+
copyFailed.value = "clipboard";
161+
if (copiedResetTimeout != null) {
162+
window.clearTimeout(copiedResetTimeout);
163+
copiedResetTimeout = null;
164+
}
165+
resetCopyFailedState(2500);
166+
return;
167+
}
168+
if (version !== targetUpdateVersion) return;
169+
copied.value = true;
170+
copyFailed.value = null;
171+
if (copyFailedResetTimeout != null) {
172+
window.clearTimeout(copyFailedResetTimeout);
173+
copyFailedResetTimeout = null;
174+
}
175+
resetCopiedState(2000);
176+
} catch {
177+
if (version !== targetUpdateVersion) return;
178+
copied.value = false;
179+
copyFailed.value = "load";
180+
if (copiedResetTimeout != null) {
181+
window.clearTimeout(copiedResetTimeout);
182+
copiedResetTimeout = null;
183+
}
184+
resetCopyFailedState(2500);
185+
}
186+
}
187+
188+
function viewMarkdown(event: MouseEvent): void {
189+
closeMenu(event.currentTarget);
190+
if (!isDev) return;
191+
event.preventDefault();
192+
window.alert(devMessage);
193+
}
194+
</script>
195+
196+
<template>
197+
<Teleport v-if="targetReady" to=".page-title-actions-target">
198+
<details class="page-markdown-actions__menu">
199+
<summary class="page-markdown-actions__trigger">
200+
<span>Markdown</span>
201+
<svg
202+
class="page-markdown-actions__chevron"
203+
viewBox="0 0 16 16"
204+
fill="none"
205+
aria-hidden="true"
206+
>
207+
<path
208+
d="M4 6.5 8 10l4-3.5"
209+
stroke="currentColor"
210+
stroke-linecap="round"
211+
stroke-linejoin="round"
212+
stroke-width="1.6"
213+
/>
214+
</svg>
215+
</summary>
216+
<div class="page-markdown-actions__dropdown">
217+
<a
218+
class="page-markdown-actions__item"
219+
:href="markdownPath"
220+
target="_blank"
221+
rel="noreferrer"
222+
@click="viewMarkdown"
223+
>
224+
View as Markdown
225+
</a>
226+
<button class="page-markdown-actions__item" type="button" @click="copyMarkdown">
227+
{{
228+
copied
229+
? "Copied"
230+
: copyFailed === "load"
231+
? "Could not load Markdown"
232+
: copyFailed === "clipboard"
233+
? "Clipboard blocked"
234+
: "Copy Markdown"
235+
}}
236+
</button>
237+
</div>
238+
</details>
239+
</Teleport>
240+
</template>
241+
242+
<style scoped>
243+
.page-markdown-actions__menu {
244+
position: relative;
245+
}
246+
247+
.page-markdown-actions__trigger {
248+
display: inline-flex;
249+
align-items: center;
250+
justify-content: center;
251+
border: 1px solid var(--vp-c-divider);
252+
border-radius: 999px;
253+
background: color-mix(in srgb, var(--vp-c-bg-soft) 72%, transparent);
254+
color: var(--vp-c-text-1);
255+
font-size: 0.8125rem;
256+
font-weight: 600;
257+
line-height: 1.2;
258+
list-style: none;
259+
gap: 0.45rem;
260+
min-height: 2.1rem;
261+
padding: 0.45rem 0.85rem;
262+
cursor: pointer;
263+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.04);
264+
transition:
265+
border-color 0.2s ease,
266+
background-color 0.2s ease,
267+
color 0.2s ease,
268+
box-shadow 0.2s ease,
269+
transform 0.2s ease;
270+
}
271+
272+
.page-markdown-actions__trigger::-webkit-details-marker {
273+
display: none;
274+
}
275+
276+
.page-markdown-actions__trigger:hover,
277+
.page-markdown-actions__menu[open] > .page-markdown-actions__trigger {
278+
border-color: var(--vp-c-brand-1);
279+
background: var(--vp-c-bg);
280+
color: var(--vp-c-brand-1);
281+
box-shadow: 0 8px 20px rgb(0 0 0 / 0.08);
282+
}
283+
284+
.page-markdown-actions__chevron {
285+
flex: none;
286+
width: 0.95rem;
287+
height: 0.95rem;
288+
margin-right: -0.05rem;
289+
transition: transform 0.2s ease;
290+
}
291+
292+
.page-markdown-actions__menu[open] .page-markdown-actions__chevron {
293+
transform: rotate(180deg);
294+
}
295+
296+
.page-markdown-actions__dropdown {
297+
position: absolute;
298+
top: 100%;
299+
right: 0;
300+
z-index: 20;
301+
min-width: 13rem;
302+
padding: 0.35rem;
303+
border: 1px solid var(--vp-c-divider);
304+
border-radius: 0.75rem;
305+
background: var(--vp-c-bg);
306+
box-shadow: var(--vp-shadow-3);
307+
}
308+
309+
.page-markdown-actions__item {
310+
display: flex;
311+
width: 100%;
312+
align-items: center;
313+
border: 0;
314+
border-radius: 0.5rem;
315+
background: transparent;
316+
color: var(--vp-c-text-1);
317+
font: inherit;
318+
font-size: 0.875rem;
319+
line-height: 1.3;
320+
padding: 0.55rem 0.7rem;
321+
text-align: left;
322+
text-decoration: none;
323+
cursor: pointer;
324+
}
325+
326+
.page-markdown-actions__item:hover {
327+
background: var(--vp-c-bg-soft);
328+
color: var(--vp-c-brand-1);
329+
}
330+
</style>

0 commit comments

Comments
 (0)