tfrere HF Staff commited on
Commit
f621e1d
·
1 Parent(s): aba5856

fix(publisher): render HF user card via dedicated transformer

Browse files

The hfUser atomic node was serialized to an empty
`<div data-component="hfUser">` placeholder with no transformer to
turn it into the actual profile card, so it rendered as nothing in
published articles. Add `hfUserTransformer` mirroring the upstream
`HfUser.astro` (avatar + name + @handle ), register it in the pipeline,
ship the matching `.hf-user` styles in `_publisher.css`, and cover the
three cases (full attrs, fallback to username, missing username) with
unit tests.

Made-with: Cursor

backend/src/publisher/transformers/hf-user.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ /**
4
+ * Transforms `<div data-component="hfUser" username="..." name="..." url="...">`
5
+ * (the placeholder emitted by the TipTap atomic node) into the actual
6
+ * Hugging Face user profile card.
7
+ *
8
+ * Mirrors `app/src/components/HfUser.astro` from the upstream
9
+ * `research-article-template`:
10
+ * <div class="hf-user">
11
+ * <div class="hf-user__left">
12
+ * <img class="hf-user__avatar" src="https://huggingface.co/api/users/<u>/avatar" .../>
13
+ * <span class="hf-user__text">
14
+ * <a class="hf-user__name" href="...">Display Name</a>
15
+ * <span class="hf-user__row">
16
+ * <a class="hf-user__username" href="...">@username</a>
17
+ * </span>
18
+ * </span>
19
+ * </div>
20
+ * </div>
21
+ *
22
+ * Falls back gracefully when only `username` is provided (no `name`/`url`):
23
+ * - `name` defaults to the username
24
+ * - `url` defaults to `https://huggingface.co/<username>`
25
+ *
26
+ * If `username` is missing entirely the placeholder div is removed - we don't
27
+ * want a broken empty card in the published page.
28
+ */
29
+ export const hfUserTransformer: Transformer = {
30
+ name: "hfUser",
31
+ apply(document) {
32
+ for (const div of [...document.querySelectorAll('div[data-component="hfUser"]')]) {
33
+ const username = (div.getAttribute("username") || div.getAttribute("data-username") || "").trim();
34
+ const rawName = (div.getAttribute("name") || div.getAttribute("data-name") || "").trim();
35
+ const rawUrl = (div.getAttribute("url") || div.getAttribute("data-url") || "").trim();
36
+
37
+ if (!username) {
38
+ div.remove();
39
+ continue;
40
+ }
41
+
42
+ const displayName = rawName || username;
43
+ const profileUrl = rawUrl || `https://huggingface.co/${encodeURIComponent(username)}`;
44
+ const avatarSrc = `https://huggingface.co/api/users/${encodeURIComponent(username)}/avatar`;
45
+
46
+ const card = document.createElement("div");
47
+ card.className = "hf-user";
48
+
49
+ const left = document.createElement("div");
50
+ left.className = "hf-user__left";
51
+
52
+ const img = document.createElement("img");
53
+ img.className = "hf-user__avatar";
54
+ img.setAttribute("src", avatarSrc);
55
+ img.setAttribute("alt", `${displayName} avatar`);
56
+ img.setAttribute("width", "44");
57
+ img.setAttribute("height", "44");
58
+ img.setAttribute("loading", "lazy");
59
+ img.setAttribute("decoding", "async");
60
+ img.setAttribute("referrerpolicy", "no-referrer");
61
+
62
+ const text = document.createElement("span");
63
+ text.className = "hf-user__text";
64
+
65
+ const nameLink = document.createElement("a");
66
+ nameLink.className = "hf-user__name";
67
+ nameLink.setAttribute("href", profileUrl);
68
+ nameLink.setAttribute("target", "_blank");
69
+ nameLink.setAttribute("rel", "noopener noreferrer");
70
+ nameLink.textContent = displayName;
71
+
72
+ const row = document.createElement("span");
73
+ row.className = "hf-user__row";
74
+
75
+ const userLink = document.createElement("a");
76
+ userLink.className = "hf-user__username";
77
+ userLink.setAttribute("href", profileUrl);
78
+ userLink.setAttribute("target", "_blank");
79
+ userLink.setAttribute("rel", "noopener noreferrer");
80
+ userLink.textContent = `@${username}`;
81
+
82
+ row.appendChild(userLink);
83
+ text.appendChild(nameLink);
84
+ text.appendChild(row);
85
+ left.appendChild(img);
86
+ left.appendChild(text);
87
+ card.appendChild(left);
88
+
89
+ div.replaceWith(card);
90
+ }
91
+ },
92
+ };
backend/src/publisher/transformers/index.ts CHANGED
@@ -14,7 +14,9 @@
14
  * the mermaid source.
15
  * 5. HighlightCode — runs Shiki over every remaining `<pre><code>`.
16
  * 6. HtmlEmbed — independent; converts placeholder divs into iframes.
17
- * 7. Footnote — runs last so collected texts include those inside every
 
 
18
  * other transformed block (tables, callouts, accordions...).
19
  */
20
  import type { Transformer } from "./types.js";
@@ -24,6 +26,7 @@ import { bibliographyTransformer } from "./bibliography.js";
24
  import { mermaidTransformer } from "./mermaid.js";
25
  import { highlightCodeTransformer } from "./highlight-code.js";
26
  import { htmlEmbedTransformer } from "./html-embed.js";
 
27
  import { footnoteTransformer } from "./footnote.js";
28
 
29
  export const transformers: Transformer[] = [
@@ -33,6 +36,7 @@ export const transformers: Transformer[] = [
33
  mermaidTransformer,
34
  highlightCodeTransformer,
35
  htmlEmbedTransformer,
 
36
  footnoteTransformer,
37
  ];
38
 
 
14
  * the mermaid source.
15
  * 5. HighlightCode — runs Shiki over every remaining `<pre><code>`.
16
  * 6. HtmlEmbed — independent; converts placeholder divs into iframes.
17
+ * 7. HfUser — independent; converts atomic placeholder divs into
18
+ * Hugging Face user profile cards.
19
+ * 8. Footnote — runs last so collected texts include those inside every
20
  * other transformed block (tables, callouts, accordions...).
21
  */
22
  import type { Transformer } from "./types.js";
 
26
  import { mermaidTransformer } from "./mermaid.js";
27
  import { highlightCodeTransformer } from "./highlight-code.js";
28
  import { htmlEmbedTransformer } from "./html-embed.js";
29
+ import { hfUserTransformer } from "./hf-user.js";
30
  import { footnoteTransformer } from "./footnote.js";
31
 
32
  export const transformers: Transformer[] = [
 
36
  mermaidTransformer,
37
  highlightCodeTransformer,
38
  htmlEmbedTransformer,
39
+ hfUserTransformer,
40
  footnoteTransformer,
41
  ];
42
 
backend/tests/html-renderer-snapshot.test.ts CHANGED
@@ -185,6 +185,44 @@ describe("postProcess - htmlEmbed", () => {
185
  });
186
  });
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  describe("postProcess - bibliography", () => {
189
  it("injects bibliography HTML into the placeholder", async () => {
190
  const json = minimalDoc([
 
185
  });
186
  });
187
 
188
+ describe("postProcess - hfUser", () => {
189
+ it("transforms hfUser div into the HF profile card", async () => {
190
+ const json = minimalDoc([{
191
+ type: "hfUser",
192
+ attrs: { username: "tfrere", name: "Thibaud Frere", url: "https://huggingface.co/tfrere" },
193
+ }]);
194
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
195
+ expect(html).toContain('class="hf-user"');
196
+ expect(html).toContain('class="hf-user__avatar"');
197
+ expect(html).toContain("https://huggingface.co/api/users/tfrere/avatar");
198
+ expect(html).toContain("Thibaud Frere");
199
+ expect(html).toContain("@tfrere");
200
+ expect(html).toContain('href="https://huggingface.co/tfrere"');
201
+ expect(html).not.toContain('data-component="hfUser"');
202
+ });
203
+
204
+ it("falls back to username when name and url are missing", async () => {
205
+ const json = minimalDoc([{
206
+ type: "hfUser",
207
+ attrs: { username: "alice", name: "", url: "" },
208
+ }]);
209
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
210
+ expect(html).toContain('class="hf-user"');
211
+ expect(html).toContain("@alice");
212
+ expect(html).toContain('href="https://huggingface.co/alice"');
213
+ });
214
+
215
+ it("removes the placeholder div when username is missing", async () => {
216
+ const json = minimalDoc([{
217
+ type: "hfUser",
218
+ attrs: { username: "", name: "Anon", url: "" },
219
+ }]);
220
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
221
+ expect(html).not.toContain('class="hf-user"');
222
+ expect(html).not.toContain('data-component="hfUser"');
223
+ });
224
+ });
225
+
226
  describe("postProcess - bibliography", () => {
227
  it("injects bibliography HTML into the placeholder", async () => {
228
  const json = minimalDoc([
frontend/src/styles/_publisher.css CHANGED
@@ -145,6 +145,75 @@ figure.html-embed > .html-embed__desc strong {
145
  font-weight: 600;
146
  }
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  /* PDF download button in the meta bar (inherits .button for gradient + padding) */
149
  a.button.pdf-link {
150
  display: inline-flex;
 
145
  font-weight: 600;
146
  }
147
 
148
+ /* ---------------------------------------------------------------------------
149
+ Hugging Face user profile card (publisher output)
150
+ Structure:
151
+ <div class="hf-user">
152
+ <div class="hf-user__left">
153
+ <img class="hf-user__avatar" .../>
154
+ <span class="hf-user__text">
155
+ <a class="hf-user__name">Display Name</a>
156
+ <span class="hf-user__row">
157
+ <a class="hf-user__username">@handle</a>
158
+ </span>
159
+ </span>
160
+ </div>
161
+ </div>
162
+ Mirrors `app/src/components/HfUser.astro` from the upstream
163
+ research-article-template so the same authored content renders
164
+ identically when republished from the collaborative editor.
165
+ --------------------------------------------------------------------------- */
166
+ .hf-user {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ gap: 10px;
170
+ padding: 10px 10px 10px 12px;
171
+ border-radius: 12px;
172
+ box-shadow: none;
173
+ }
174
+ .hf-user__left {
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ text-decoration: none;
179
+ color: inherit;
180
+ }
181
+ .hf-user__avatar {
182
+ display: block;
183
+ width: 44px;
184
+ height: 44px;
185
+ border-radius: 50%;
186
+ object-fit: cover;
187
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12) inset;
188
+ }
189
+ .hf-user__text {
190
+ display: flex;
191
+ flex-direction: column;
192
+ line-height: 1.1;
193
+ }
194
+ .hf-user__row {
195
+ display: inline-flex;
196
+ align-items: center;
197
+ white-space: nowrap;
198
+ }
199
+ .hf-user__name {
200
+ font-size: 14px;
201
+ font-weight: 700;
202
+ }
203
+ .hf-user__username {
204
+ font-size: 12px;
205
+ color: var(--muted-color);
206
+ text-decoration: underline !important;
207
+ text-underline-offset: 2px;
208
+ text-decoration-thickness: 0.06em;
209
+ text-decoration-color: var(--link-underline);
210
+ }
211
+ .hf-user a {
212
+ color: inherit;
213
+ text-decoration: none;
214
+ border-bottom: none;
215
+ }
216
+
217
  /* PDF download button in the meta bar (inherits .button for gradient + padding) */
218
  a.button.pdf-link {
219
  display: inline-flex;