tfrere HF Staff commited on
Commit
2ea3987
·
1 Parent(s): 1c0ee53

fix: authors, bibliography and CSS in published article

Browse files

- Read authors from Y.Array("frontmatter.authors") instead of
Y.Map("frontmatter") which only holds scalar fields
- Read date from "published" key (matching frontend convention)
- Render authors with links and affiliations in the article header
- Fix bibliography: renderHTML was outputting raw HTML as a text node
(escaped); now uses postProcess to decode the renderedhtml attribute
- Fix CSS loading: try multiple paths so it works both in dev and
in Docker production (/app/frontend-styles/)

Made-with: Cursor

backend/src/publisher/extensions.ts CHANGED
@@ -61,18 +61,15 @@ const BibliographyServer = Node.create({
61
  parseHTML() {
62
  return [{ tag: 'div[data-type="bibliography"]' }];
63
  },
64
- renderHTML({ HTMLAttributes, node }) {
65
- const content = node.attrs.renderedHtml || "";
66
- const inner = content
67
- ? `<h2 class="bibliography-title">References</h2><div class="bibliography-content">${content}</div>`
68
- : '<p class="bibliography-empty">No citations</p>';
69
  return [
70
  "div",
71
  mergeAttributes(HTMLAttributes, {
72
  "data-type": "bibliography",
73
  class: "bibliography-block",
74
  }),
75
- inner,
76
  ];
77
  },
78
  });
 
61
  parseHTML() {
62
  return [{ tag: 'div[data-type="bibliography"]' }];
63
  },
64
+ renderHTML({ HTMLAttributes }) {
65
+ // Render an empty placeholder; postProcess in html-renderer will inject
66
+ // the actual bibliography HTML from the renderedhtml attribute.
 
 
67
  return [
68
  "div",
69
  mergeAttributes(HTMLAttributes, {
70
  "data-type": "bibliography",
71
  class: "bibliography-block",
72
  }),
 
73
  ];
74
  },
75
  });
backend/src/publisher/html-renderer.ts CHANGED
@@ -8,10 +8,16 @@
8
  import { generateHTML } from "@tiptap/html";
9
  import { getServerExtensions } from "./extensions.js";
10
 
 
 
 
 
 
 
11
  export interface PublishMeta {
12
  title: string;
13
  description: string;
14
- authors: string[];
15
  date: string;
16
  ogImage?: string;
17
  }
@@ -29,7 +35,8 @@ export function renderArticleHTML(
29
  const bodyHtml = generateHTML(json as any, extensions);
30
  const enrichedBody = postProcess(bodyHtml);
31
 
32
- const authorsStr = meta.authors.join(", ");
 
33
  const safeTitle = escapeHtml(meta.title);
34
  const safeDesc = escapeHtml(meta.description);
35
 
@@ -48,7 +55,7 @@ export function renderArticleHTML(
48
  <meta property="og:description" content="${safeDesc}">
49
  ${meta.ogImage ? `<meta property="og:image" content="${escapeHtml(meta.ogImage)}">` : ""}
50
  <meta property="article:published_time" content="${escapeHtml(meta.date)}">
51
- ${meta.authors.map((a) => `<meta property="article:author" content="${escapeHtml(a)}">`).join("\n ")}
52
 
53
  <!-- Twitter Card -->
54
  <meta name="twitter:card" content="summary_large_image">
@@ -102,6 +109,30 @@ body {
102
  color: var(--text-secondary);
103
  font-size: 0.9375rem;
104
  margin-bottom: 0.25rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
 
107
  .article-header .date {
@@ -187,7 +218,7 @@ dialog.lightbox img {
187
  <article class="article-container">
188
  <header class="article-header">
189
  <h1>${safeTitle}</h1>
190
- ${authorsStr ? `<p class="authors">${escapeHtml(authorsStr)}</p>` : ""}
191
  ${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
192
  </header>
193
 
@@ -254,6 +285,26 @@ function postProcess(html: string): string {
254
  }
255
  );
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  // HtmlEmbed: div[data-component="htmlEmbed"] → iframe with srcdoc
258
  result = result.replace(
259
  /<div[^>]*data-component="htmlEmbed"[^>]*data-src="([^"]*)"[^>]*><\/div>/g,
@@ -278,6 +329,18 @@ function escapeHtml(str: string): string {
278
  .replace(/'/g, "&#039;");
279
  }
280
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  function formatDate(dateStr: string): string {
282
  try {
283
  return new Date(dateStr).toLocaleDateString("en-US", {
 
8
  import { generateHTML } from "@tiptap/html";
9
  import { getServerExtensions } from "./extensions.js";
10
 
11
+ export interface PublishAuthor {
12
+ name: string;
13
+ url?: string;
14
+ affiliationNames: string[];
15
+ }
16
+
17
  export interface PublishMeta {
18
  title: string;
19
  description: string;
20
+ authors: PublishAuthor[];
21
  date: string;
22
  ogImage?: string;
23
  }
 
35
  const bodyHtml = generateHTML(json as any, extensions);
36
  const enrichedBody = postProcess(bodyHtml);
37
 
38
+ const authorNames = meta.authors.map((a) => a.name);
39
+ const authorsStr = authorNames.join(", ");
40
  const safeTitle = escapeHtml(meta.title);
41
  const safeDesc = escapeHtml(meta.description);
42
 
 
55
  <meta property="og:description" content="${safeDesc}">
56
  ${meta.ogImage ? `<meta property="og:image" content="${escapeHtml(meta.ogImage)}">` : ""}
57
  <meta property="article:published_time" content="${escapeHtml(meta.date)}">
58
+ ${authorNames.map((a) => `<meta property="article:author" content="${escapeHtml(a)}">`).join("\n ")}
59
 
60
  <!-- Twitter Card -->
61
  <meta name="twitter:card" content="summary_large_image">
 
109
  color: var(--text-secondary);
110
  font-size: 0.9375rem;
111
  margin-bottom: 0.25rem;
112
+ line-height: 1.6;
113
+ }
114
+
115
+ .article-header .author-link {
116
+ color: var(--accent-light);
117
+ text-decoration: none;
118
+ }
119
+
120
+ .article-header .author-link:hover {
121
+ text-decoration: underline;
122
+ }
123
+
124
+ .article-header .author-affiliation {
125
+ color: var(--text-tertiary);
126
+ font-size: 0.8125rem;
127
+ margin-left: 0.25rem;
128
+ }
129
+
130
+ .article-header .author-affiliation::before {
131
+ content: "(";
132
+ }
133
+
134
+ .article-header .author-affiliation::after {
135
+ content: ")";
136
  }
137
 
138
  .article-header .date {
 
218
  <article class="article-container">
219
  <header class="article-header">
220
  <h1>${safeTitle}</h1>
221
+ ${meta.authors.length > 0 ? `<div class="authors">${renderAuthors(meta.authors)}</div>` : ""}
222
  ${meta.date ? `<p class="date">${escapeHtml(formatDate(meta.date))}</p>` : ""}
223
  </header>
224
 
 
285
  }
286
  );
287
 
288
+ // Bibliography: inject renderedhtml attribute as actual HTML content
289
+ result = result.replace(
290
+ /<div([^>]*data-type="bibliography"[^>]*)><\/div>/gi,
291
+ (_match, attrs: string) => {
292
+ const htmlMatch = attrs.match(/renderedhtml="([^"]*)"/i);
293
+ const cleanAttrs = attrs.replace(/\s*renderedhtml="[^"]*"/i, "");
294
+ if (!htmlMatch || !htmlMatch[1]) {
295
+ return `<div${cleanAttrs}><p class="bibliography-empty">No citations</p></div>`;
296
+ }
297
+ const decoded = htmlMatch[1]
298
+ .replace(/&amp;/g, "&")
299
+ .replace(/&lt;/g, "<")
300
+ .replace(/&gt;/g, ">")
301
+ .replace(/&quot;/g, '"')
302
+ .replace(/&#039;/g, "'")
303
+ .replace(/&#x27;/g, "'");
304
+ return `<div${cleanAttrs}><h2 class="bibliography-title">References</h2><div class="bibliography-content">${decoded}</div></div>`;
305
+ }
306
+ );
307
+
308
  // HtmlEmbed: div[data-component="htmlEmbed"] → iframe with srcdoc
309
  result = result.replace(
310
  /<div[^>]*data-component="htmlEmbed"[^>]*data-src="([^"]*)"[^>]*><\/div>/g,
 
329
  .replace(/'/g, "&#039;");
330
  }
331
 
332
+ function renderAuthors(authors: PublishAuthor[]): string {
333
+ return authors.map((a) => {
334
+ const name = a.url
335
+ ? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
336
+ : `<span class="author-name">${escapeHtml(a.name)}</span>`;
337
+ const affil = a.affiliationNames.length > 0
338
+ ? `<span class="author-affiliation">${escapeHtml(a.affiliationNames.join(", "))}</span>`
339
+ : "";
340
+ return `<span class="author">${name}${affil}</span>`;
341
+ }).join('<span class="author-sep">, </span>');
342
+ }
343
+
344
  function formatDate(dateStr: string): string {
345
  try {
346
  return new Date(dateStr).toLocaleDateString("en-US", {
backend/src/publisher/index.ts CHANGED
@@ -26,33 +26,54 @@ function docPath(name: string) {
26
 
27
  /**
28
  * Load CSS files for injection into the published HTML.
 
29
  */
30
  function loadCSS(): { tokens: string; article: string } {
31
- const frontendStyles = join(__dirname, "..", "..", "..", "frontend", "src", "styles");
32
- const distStyles = join(__dirname, "..", "frontend-dist", "assets");
33
-
34
- let tokens = "";
35
- let article = "";
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- // Development: read source files directly
38
- const tokensPath = join(frontendStyles, "tokens.css");
39
- const articlePath = join(frontendStyles, "article.css");
40
 
41
- if (existsSync(tokensPath)) {
42
- tokens = readFileSync(tokensPath, "utf-8");
43
- article = readFileSync(articlePath, "utf-8");
44
- }
 
45
 
46
- return { tokens, article };
 
 
47
  }
48
 
49
  /**
50
  * Extract TipTap JSON and frontmatter from a Yjs document.
51
  * Uses @hocuspocus/transformer to convert the XmlFragment to ProseMirror JSON.
 
 
 
52
  */
53
  function extractFromYDoc(ydoc: Y.Doc): {
54
  json: Record<string, unknown>;
55
  frontmatter: Record<string, unknown>;
 
 
56
  } {
57
  const metaMap = ydoc.getMap("frontmatter");
58
  const frontmatter: Record<string, unknown> = {};
@@ -60,10 +81,17 @@ function extractFromYDoc(ydoc: Y.Doc): {
60
  frontmatter[key] = value;
61
  });
62
 
 
 
 
 
 
63
  const sharedTypes: string[] = [];
64
  ydoc.share.forEach((_value, key) => sharedTypes.push(key));
65
  console.log("[publish] Y.Doc shared types:", sharedTypes);
66
- console.log("[publish] frontmatter:", JSON.stringify(frontmatter));
 
 
67
 
68
  const fragment = ydoc.getXmlFragment("default");
69
  console.log("[publish] XmlFragment 'default' length:", fragment.length);
@@ -86,13 +114,15 @@ function extractFromYDoc(ydoc: Y.Doc): {
86
  console.log("[publish] Full JSON (truncated):", JSON.stringify(json).slice(0, 2000));
87
 
88
  if (json && typeof json === "object" && json.type === "doc") {
89
- return { json, frontmatter };
90
  }
91
 
92
  console.warn("[publish] TiptapTransformer returned unexpected format, using fallback");
93
  return {
94
  json: { type: "doc", content: [{ type: "paragraph" }] },
95
  frontmatter,
 
 
96
  };
97
  }
98
 
@@ -120,16 +150,20 @@ export async function publishDocument(docName: string, token?: string): Promise<
120
  Y.applyUpdate(ydoc, new Uint8Array(data));
121
 
122
  // Extract content
123
- const { json, frontmatter } = extractFromYDoc(ydoc);
124
 
125
- // Build metadata
126
  const meta: PublishMeta = {
127
  title: (frontmatter.title as string) || docName,
128
  description: (frontmatter.description as string) || "",
129
- authors: Array.isArray(frontmatter.authors)
130
- ? (frontmatter.authors as string[])
131
- : [],
132
- date: (frontmatter.date as string) || new Date().toISOString(),
 
 
 
 
133
  };
134
 
135
  const css = loadCSS();
 
26
 
27
  /**
28
  * Load CSS files for injection into the published HTML.
29
+ * Tries multiple paths: dev source, Docker copy, relative fallback.
30
  */
31
  function loadCSS(): { tokens: string; article: string } {
32
+ const candidates = [
33
+ join(__dirname, "..", "..", "..", "frontend", "src", "styles"),
34
+ join(__dirname, "..", "..", "frontend-styles"),
35
+ join(__dirname, "..", "frontend-styles"),
36
+ ];
37
+
38
+ for (const dir of candidates) {
39
+ const tokensPath = join(dir, "tokens.css");
40
+ const articlePath = join(dir, "article.css");
41
+ if (existsSync(tokensPath) && existsSync(articlePath)) {
42
+ console.log("[publish] CSS found at:", dir);
43
+ return {
44
+ tokens: readFileSync(tokensPath, "utf-8"),
45
+ article: readFileSync(articlePath, "utf-8"),
46
+ };
47
+ }
48
+ }
49
 
50
+ console.warn("[publish] CSS files not found, tried:", candidates);
51
+ return { tokens: "", article: "" };
52
+ }
53
 
54
+ interface AuthorObj {
55
+ name: string;
56
+ url?: string;
57
+ affiliations: number[];
58
+ }
59
 
60
+ interface AffiliationObj {
61
+ name: string;
62
+ url?: string;
63
  }
64
 
65
  /**
66
  * Extract TipTap JSON and frontmatter from a Yjs document.
67
  * Uses @hocuspocus/transformer to convert the XmlFragment to ProseMirror JSON.
68
+ *
69
+ * Scalar fields live in Y.Map("frontmatter"), while authors/affiliations/links
70
+ * each have their own Y.Array for concurrent-safe editing.
71
  */
72
  function extractFromYDoc(ydoc: Y.Doc): {
73
  json: Record<string, unknown>;
74
  frontmatter: Record<string, unknown>;
75
+ authors: AuthorObj[];
76
+ affiliations: AffiliationObj[];
77
  } {
78
  const metaMap = ydoc.getMap("frontmatter");
79
  const frontmatter: Record<string, unknown> = {};
 
81
  frontmatter[key] = value;
82
  });
83
 
84
+ const yAuthors = ydoc.getArray<AuthorObj>("frontmatter.authors");
85
+ const yAffiliations = ydoc.getArray<AffiliationObj>("frontmatter.affiliations");
86
+ const authors = yAuthors.toArray();
87
+ const affiliations = yAffiliations.toArray();
88
+
89
  const sharedTypes: string[] = [];
90
  ydoc.share.forEach((_value, key) => sharedTypes.push(key));
91
  console.log("[publish] Y.Doc shared types:", sharedTypes);
92
+ console.log("[publish] frontmatter scalars:", JSON.stringify(frontmatter));
93
+ console.log("[publish] authors:", JSON.stringify(authors));
94
+ console.log("[publish] affiliations:", JSON.stringify(affiliations));
95
 
96
  const fragment = ydoc.getXmlFragment("default");
97
  console.log("[publish] XmlFragment 'default' length:", fragment.length);
 
114
  console.log("[publish] Full JSON (truncated):", JSON.stringify(json).slice(0, 2000));
115
 
116
  if (json && typeof json === "object" && json.type === "doc") {
117
+ return { json, frontmatter, authors, affiliations };
118
  }
119
 
120
  console.warn("[publish] TiptapTransformer returned unexpected format, using fallback");
121
  return {
122
  json: { type: "doc", content: [{ type: "paragraph" }] },
123
  frontmatter,
124
+ authors,
125
+ affiliations,
126
  };
127
  }
128
 
 
150
  Y.applyUpdate(ydoc, new Uint8Array(data));
151
 
152
  // Extract content
153
+ const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
154
 
155
+ // Build metadata - authors come from Y.Array, date from "published" scalar
156
  const meta: PublishMeta = {
157
  title: (frontmatter.title as string) || docName,
158
  description: (frontmatter.description as string) || "",
159
+ authors: authors.map((a) => ({
160
+ name: a.name,
161
+ url: a.url,
162
+ affiliationNames: (a.affiliations || [])
163
+ .map((idx) => affiliations[idx - 1]?.name)
164
+ .filter(Boolean) as string[],
165
+ })),
166
+ date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
167
  };
168
 
169
  const css = loadCSS();