-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprerender.mjs
More file actions
155 lines (136 loc) · 5.67 KB
/
Copy pathprerender.mjs
File metadata and controls
155 lines (136 loc) · 5.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import fs from 'node:fs/promises';
import path from 'node:path';
import { loadConfig, ROOT } from './lib/config.mjs';
import { collectDocs } from './lib/collect.mjs';
import { dim, green, red, yellow } from './lib/log.mjs';
/**
* Post-build prerender: writes one static index.html per page into the build
* output, with the article HTML baked in and full SEO metadata (title,
* description, canonical, Open Graph, Twitter card), plus sitemap.xml and
* robots.txt.
*
* No SSR involved — the SPA shell is reused as-is and the content is placed
* inside <app-root>, so crawlers and no-JS readers get the real page while
* Angular replaces it with the interactive app the moment it boots.
*
* Runs as part of `npm run build`, after `ng build`.
*/
const DIST = path.join(ROOT, 'dist', 'feastdocs', 'browser');
const config = await loadConfig();
const shellPath = path.join(DIST, 'index.html');
let shell;
try {
shell = await fs.readFile(shellPath, 'utf8');
} catch {
console.error(
`${red('✗')} prerender: ${path.relative(ROOT, shellPath)} not found — run ng build first.`,
);
process.exit(1);
}
if (!config.siteUrl) {
console.log(
`${yellow('!')} prerender skipped: set ${dim('siteUrl')} in feastdocs.config.mjs ` +
`(e.g. 'https://docs.example.com') to enable SEO output.`,
);
process.exit(0);
}
const siteUrl = config.siteUrl.replace(/\/+$/, '');
const { docs } = await collectDocs(config);
let pages = 0;
for (const doc of docs) {
const html = renderPage(shell, doc, config, siteUrl);
const target =
doc.slug === '' ? shellPath : path.join(DIST, ...doc.slug.split('/'), 'index.html');
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, html, 'utf8');
pages += 1;
}
// Sitemap: public, navigable pages only — hidden pages stay reachable but unlisted.
const sitemapEntries = docs
.filter((doc) => !doc.hidden)
.map(
(doc) =>
` <url><loc>${escapeXml(pageurl(/sitelet?url=https%3A%2F%2Fgithub.com%2FMindfeast%2Ffeastdocs%2Fblob%2Fmain%2Ftools%2FsiteUrl%2C%2520doc.slug))}</loc>` +
`<lastmod>${doc.lastUpdated.slice(0, 10)}</lastmod></url>`,
)
.join('\n');
await fs.writeFile(
path.join(DIST, 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapEntries}\n</urlset>\n`,
'utf8',
);
await fs.writeFile(
path.join(DIST, 'robots.txt'),
`User-agent: *\nAllow: /\nDisallow: /_editor\n\nSitemap: ${siteUrl}/sitemap.xml\n`,
'utf8',
);
console.log(
`${green('✓')} prerendered ${pages} pages, sitemap.xml, robots.txt ${dim(`→ ${path.relative(ROOT, DIST)}`)}`,
);
/**
* The URL a page is actually served at.
*
* Pages are written as `<slug>/index.html`, so a host that serves directories
* — Cloudflare Pages, GitHub Pages — answers `/slug` with a 308 to `/slug/`.
* Declaring the slash-less form pointed the sitemap and the canonical at that
* redirect instead of at the page: every sitemap URL came back from Search
* Console as "Discovered – currently not indexed", and the page served at
* `/slug/` claimed `/slug` as canonical, which redirects back to it.
*
* nginx (deploy/nginx.conf) answers both forms with a 200, so the trailing
* slash is the single form that is correct on every host we deploy to.
*/
function pageurl(/sitelet?url=https%3A%2F%2Fgithub.com%2FMindfeast%2Ffeastdocs%2Fblob%2Fmain%2Ftools%2FsiteUrl%2C%2520slug) {
return slug === '' ? `${siteUrl}/` : `${siteUrl}/${slug}/`;
}
function renderPage(shell, doc, config, siteUrl) {
const title = doc.slug === '' ? config.title : `${doc.title} · ${config.title}`;
const description = doc.description || config.tagline;
const url = pageurl(/sitelet?url=https%3A%2F%2Fgithub.com%2FMindfeast%2Ffeastdocs%2Fblob%2Fmain%2Ftools%2FsiteUrl%2C%2520doc.slug);
const meta = [
`<link rel="canonical" href="${escapeAttr(url)}" />`,
`<meta property="og:title" content="${escapeAttr(title)}" />`,
`<meta property="og:description" content="${escapeAttr(description)}" />`,
`<meta property="og:type" content="article" />`,
`<meta property="og:url" content="${escapeAttr(url)}" />`,
`<meta property="og:site_name" content="${escapeAttr(config.title)}" />`,
];
// Without an image, a shared link renders as a bare text card on LinkedIn,
// Slack and X. With one, the large-summary layout applies.
if (config.socialImage) {
const image = `${siteUrl}/${String(config.socialImage).replace(/^\/+/, '')}`;
meta.push(
`<meta property="og:image" content="${escapeAttr(image)}" />`,
`<meta property="og:image:alt" content="${escapeAttr(config.title)}" />`,
`<meta name="twitter:image" content="${escapeAttr(image)}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
);
} else {
meta.push(`<meta name="twitter:card" content="summary" />`);
}
const metaTags = meta.join('\n ');
// The static article inherits the shipped stylesheet (fd-markdown etc.), so
// even the no-JS rendering is presentable. Angular clears it on bootstrap.
const article =
`<main class="fd-prerender" style="max-width:820px;margin:0 auto;padding:2rem 1.5rem">` +
`<article class="fd-doc"><h1>${escapeHtml(doc.title)}</h1>` +
(doc.description ? `<p>${escapeHtml(doc.description)}</p>` : '') +
`<div class="fd-markdown">${doc.html}</div></article></main>`;
return shell
.replace(/<title>[\s\S]*?<\/title>/, `<title>${escapeHtml(title)}</title>`)
.replace(
/<meta name="description"[^>]*\/?>/,
`<meta name="description" content="${escapeAttr(description)}" />`,
)
.replace('</head>', ` ${metaTags}\n </head>`)
.replace(/<app-root><\/app-root>/, `<app-root>${article}</app-root>`);
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function escapeAttr(value) {
return escapeHtml(value).replace(/"/g, '"');
}
function escapeXml(value) {
return escapeAttr(value).replace(/'/g, ''');
}