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
|
| 65 |
-
|
| 66 |
-
|
| 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:
|
| 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
|
|
|
|
| 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 |
-
${
|
| 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 |
-
${
|
| 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, "'");
|
| 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(/&/g, "&")
|
| 299 |
+
.replace(/</g, "<")
|
| 300 |
+
.replace(/>/g, ">")
|
| 301 |
+
.replace(/"/g, '"')
|
| 302 |
+
.replace(/'/g, "'")
|
| 303 |
+
.replace(/'/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, "'");
|
| 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
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
| 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:
|
| 130 |
-
|
| 131 |
-
:
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|