Skip to content

Commit cf48248

Browse files
committed
fix(copy-markdown): 🐛 place blog copy button below post header
On blog posts the copy button was wedged between the title and the author/date profile because it was inserted right after the <h1>. Insert it after the whole post <header> instead, so the order reads title -> profile -> button. Docs pages keep the button directly under the title. Extract the insertion logic into a dependency-free `dom` module and add DOM regression tests covering blog post, blog list, and docs pages.
1 parent 26e1413 commit cf48248

7 files changed

Lines changed: 243 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@gracefullight/docusaurus-plugin-copy-markdown": patch
3+
---
4+
5+
Place the blog copy button below the post header (after the author/date metadata) instead of wedging it between the title and the profile. Docs pages keep the button directly under the title. The insertion logic is now split into a dependency-free `dom` module with DOM regression tests covering blog post, blog list, and docs pages.

bun.lock

Lines changed: 20 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/docusaurus-plugin-copy-markdown/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ At runtime the client reads `@generated/codeTranslations` for the active locale.
104104

105105
- Reads `.md` / `.mdx` source files at build time through Docusaurus content plugins.
106106
- Strips frontmatter and lightly cleans MDX imports and JSX comments from copied text.
107-
- Finds the main page title (`<h1>`) and inserts the button **immediately after it**. This works reliably on both docs and blog posts even when the theme wraps the title in a larger `<header>` containing author/date metadata.
107+
- Finds the main page title (`<h1>`) and inserts the button next to it:
108+
- **Docs**: immediately after the `<h1>`.
109+
- **Blog**: after the whole post `<header>`, so the button sits below the author/date metadata (title → profile → button) instead of being wedged between the title and the profile.
108110
- The button uses self-contained inline styles + a small injected stylesheet so it renders as a clean outlined button with the copy icon on the left, regardless of how heavily the host site customizes or resets native `<button>` styles.
109111
- `buttonClassName` can still be used for additional theme-specific classes.
110112
- Supports `buttonAlignment` (`left` | `center` | `right`) to control horizontal placement below the title.

packages/docusaurus-plugin-copy-markdown/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"devDependencies": {
1212
"@docusaurus/types": "^3",
1313
"@gracefullight-docusaurus/tsconfig": "workspace:^",
14+
"happy-dom": "^20.9.0",
1415
"tsup": "*",
1516
"typescript": "^5.9.2",
1617
"vitest": "^3.2.4"

packages/docusaurus-plugin-copy-markdown/src/client/copy-markdown-button.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,13 @@ import {
1111
DEFAULT_COPIED_LABEL,
1212
PLUGIN_NAME,
1313
} from "../constants";
14+
import { findTitleElement, insertButtonContainer } from "./dom";
1415

1516
const COPIED_RESET_MS = 2000;
1617
const CONTAINER_ATTR = "data-copy-markdown-button";
1718

1819
const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
1920

20-
// We now primarily target the main <h1> inside the article.
21-
// This gives the most reliable "right below the visible page title" behavior
22-
// across docs and blog pages, even on heavily customized themes.
23-
const TITLE_SELECTORS = [
24-
"article h1",
25-
"article .theme-doc-markdown h1",
26-
"article .markdown h1",
27-
];
28-
2921
type PluginGlobalData = CopyMarkdownGlobalData;
3022

3123
function getPluginData(): PluginGlobalData | undefined {
@@ -74,16 +66,6 @@ function lookupRoute(
7466
return;
7567
}
7668

77-
function findTitleElement(): HTMLElement | null {
78-
for (const selector of TITLE_SELECTORS) {
79-
const element = document.querySelector<HTMLElement>(selector);
80-
if (element) {
81-
return element;
82-
}
83-
}
84-
return null;
85-
}
86-
8769
function removeExistingButton(): void {
8870
for (const element of document.querySelectorAll(`[${CONTAINER_ATTR}]`)) {
8971
element.remove();
@@ -228,9 +210,10 @@ function injectButton(pluginData: PluginGlobalData, pathname: string): void {
228210
container.setAttribute(CONTAINER_ATTR, "true");
229211
container.className = "copy-markdown-button-container";
230212

231-
// Position right after the title (h1), before any author/date metadata on blogs.
232-
// This is the key behavioral improvement.
233-
titleEl.insertAdjacentElement("afterend", container);
213+
// Docs: right after the title (h1).
214+
// Blog: after the whole <header> so the button sits below the author/date
215+
// metadata (title -> profile -> button), not wedged between title and profile.
216+
insertButtonContainer(titleEl, route.contentType, container);
234217

235218
// Alignment control
236219
const justifyMap: Record<ButtonAlignment, string> = {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// @vitest-environment happy-dom
2+
3+
import { beforeEach, describe, expect, it } from "vitest";
4+
import {
5+
findTitleElement,
6+
insertButtonContainer,
7+
resolveInsertionAnchor,
8+
} from "./dom";
9+
10+
const CONTAINER_ATTR = "data-copy-markdown-button";
11+
12+
function makeContainer(): HTMLElement {
13+
const container = document.createElement("div");
14+
container.setAttribute(CONTAINER_ATTR, "true");
15+
return container;
16+
}
17+
18+
/** Mirrors a Docusaurus blog post page: <h1> lives inside a <header>
19+
* that also holds the date and author (profile) metadata. */
20+
function renderBlogPostPage(): void {
21+
document.body.innerHTML = `
22+
<article>
23+
<header>
24+
<h1>Logistic regression</h1>
25+
<div class="blog-date"><time datetime="2025-08-28">2025년 8월 28일</time> · 약 3분</div>
26+
<div class="blog-authors"><div class="author-name">Eunkwang Shin</div></div>
27+
</header>
28+
<div class="markdown"><h2>단변량 선형 회귀</h2><p>body</p></div>
29+
</article>
30+
`;
31+
}
32+
33+
/** Mirrors a Docusaurus docs page: <h1> sits inside .theme-doc-markdown,
34+
* with no surrounding <header>. */
35+
function renderDocsPage(): void {
36+
document.body.innerHTML = `
37+
<article>
38+
<div class="theme-doc-markdown markdown">
39+
<h1>Logistic regression</h1>
40+
<p>body</p>
41+
</div>
42+
</article>
43+
`;
44+
}
45+
46+
/** Mirrors a Docusaurus blog list page: post previews use <h2> links
47+
* (not <h1>), plus a "recent posts" sidebar of <h2> links. */
48+
function renderBlogListPage(): void {
49+
document.body.innerHTML = `
50+
<aside><nav><h2>최근 포스트</h2><ul><li><a href="/blog/a">A</a></li></ul></nav></aside>
51+
<main>
52+
<article><h2><a href="/blog/a">Post A</a></h2><p>excerpt</p></article>
53+
<article><h2><a href="/blog/b">Post B</a></h2><p>excerpt</p></article>
54+
</main>
55+
`;
56+
}
57+
58+
beforeEach(() => {
59+
document.body.innerHTML = "";
60+
});
61+
62+
describe("findTitleElement", () => {
63+
it("finds the main <h1> on a blog post page", () => {
64+
renderBlogPostPage();
65+
expect(findTitleElement()?.textContent).toBe("Logistic regression");
66+
});
67+
68+
it("finds the main <h1> on a docs page", () => {
69+
renderDocsPage();
70+
expect(findTitleElement()?.textContent).toBe("Logistic regression");
71+
});
72+
73+
it("returns null on a blog list page (previews are <h2>, not <h1>)", () => {
74+
renderBlogListPage();
75+
expect(findTitleElement()).toBeNull();
76+
});
77+
});
78+
79+
describe("resolveInsertionAnchor", () => {
80+
it("returns the enclosing <header> for blog posts", () => {
81+
renderBlogPostPage();
82+
const titleEl = findTitleElement();
83+
if (!titleEl) throw new Error("expected a title element");
84+
85+
const anchor = resolveInsertionAnchor(titleEl, "blog");
86+
expect(anchor.tagName).toBe("HEADER");
87+
});
88+
89+
it("returns the <h1> itself for docs", () => {
90+
renderDocsPage();
91+
const titleEl = findTitleElement();
92+
if (!titleEl) throw new Error("expected a title element");
93+
94+
const anchor = resolveInsertionAnchor(titleEl, "docs");
95+
expect(anchor).toBe(titleEl);
96+
});
97+
98+
it("falls back to the <h1> for blog posts without a <header>", () => {
99+
document.body.innerHTML = "<article><h1>No header here</h1></article>";
100+
const titleEl = findTitleElement();
101+
if (!titleEl) throw new Error("expected a title element");
102+
103+
const anchor = resolveInsertionAnchor(titleEl, "blog");
104+
expect(anchor).toBe(titleEl);
105+
});
106+
});
107+
108+
describe("insertButtonContainer — blog post (포스트 내부)", () => {
109+
it("inserts the button after the header, below the profile metadata", () => {
110+
renderBlogPostPage();
111+
const titleEl = findTitleElement();
112+
if (!titleEl) throw new Error("expected a title element");
113+
114+
const container = makeContainer();
115+
insertButtonContainer(titleEl, "blog", container);
116+
117+
const header = document.querySelector("header");
118+
const date = document.querySelector(".blog-date");
119+
const author = document.querySelector(".author-name");
120+
if (!(header && date && author)) throw new Error("fixture mismatch");
121+
122+
// The button container is a sibling placed right after the whole header.
123+
expect(header.nextElementSibling).toBe(container);
124+
125+
// It is NOT wedged between the title and the date/author profile block.
126+
expect(titleEl.nextElementSibling).not.toBe(container);
127+
128+
// Document order: title -> date -> author -> button.
129+
const following = Node.DOCUMENT_POSITION_FOLLOWING;
130+
expect(titleEl.compareDocumentPosition(container) & following).toBeTruthy();
131+
expect(date.compareDocumentPosition(container) & following).toBeTruthy();
132+
expect(author.compareDocumentPosition(container) & following).toBeTruthy();
133+
});
134+
});
135+
136+
describe("insertButtonContainer — docs page", () => {
137+
it("inserts the button immediately after the <h1>", () => {
138+
renderDocsPage();
139+
const titleEl = findTitleElement();
140+
if (!titleEl) throw new Error("expected a title element");
141+
142+
const container = makeContainer();
143+
insertButtonContainer(titleEl, "docs", container);
144+
145+
expect(titleEl.nextElementSibling).toBe(container);
146+
});
147+
});

0 commit comments

Comments
 (0)