@karaoke-cms/module-seo 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -0
- package/src/css-contract.ts +4 -0
- package/src/fonts/inter-latin-400-normal.woff +0 -0
- package/src/generate-og-images.ts +265 -0
- package/src/index.ts +38 -0
- package/src/pages/robots.txt.ts +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karaoke-cms/module-seo",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.10.3",
|
|
5
|
+
"description": "SEO module for karaoke-cms — OG images, JSON-LD, robots.txt",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./pages/robots.txt": "./src/pages/robots.txt.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"astro",
|
|
16
|
+
"cms",
|
|
17
|
+
"seo",
|
|
18
|
+
"og-image",
|
|
19
|
+
"karaoke-cms"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
23
|
+
"satori": "^0.10.14"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"astro": ">=6.0.0",
|
|
27
|
+
"@karaoke-cms/contracts": "^0.10.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"astro": "^6.0.8",
|
|
31
|
+
"vitest": "^4.1.1",
|
|
32
|
+
"@karaoke-cms/contracts": "0.10.3"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "vitest run test/seo-utils.test.js"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import satori from 'satori';
|
|
5
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
6
|
+
import type { AstroIntegrationLogger } from 'astro';
|
|
7
|
+
|
|
8
|
+
// Load Inter font bundled with the package — resolved relative to this file's location.
|
|
9
|
+
const _fontData = readFileSync(
|
|
10
|
+
fileURLToPath(new URL('./fonts/inter-latin-400-normal.woff', import.meta.url)),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
// ── HTML helpers ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function getMeta(html: string, prop: string): string | null {
|
|
16
|
+
const a = html.match(
|
|
17
|
+
new RegExp(`<meta[^>]*(?:property|name)=["']${prop}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
|
18
|
+
);
|
|
19
|
+
if (a) return a[1];
|
|
20
|
+
const b = html.match(
|
|
21
|
+
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${prop}["']`, 'i'),
|
|
22
|
+
);
|
|
23
|
+
return b ? b[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hasMeta(html: string, prop: string): boolean {
|
|
27
|
+
return getMeta(html, prop) !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive the OG image path from an og:url value.
|
|
32
|
+
* https://example.com/blog/my-post/ → /og/blog/my-post.png
|
|
33
|
+
*/
|
|
34
|
+
export function ogImagePath(ogUrl: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const pathname = new URL(ogUrl).pathname.replace(/\/$/, '');
|
|
37
|
+
return pathname && pathname !== '/' ? `/og${pathname}.png` : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Inject `og:image`, `twitter:image`, `twitter:card`, and JSON-LD into HTML.
|
|
45
|
+
* Replaces the existing `twitter:card` if already `summary` (upgrades to large image).
|
|
46
|
+
* Returns the modified HTML string.
|
|
47
|
+
*/
|
|
48
|
+
export function injectSeoTags(
|
|
49
|
+
html: string,
|
|
50
|
+
ogImage: string,
|
|
51
|
+
ogUrl: string,
|
|
52
|
+
ogType: string | null,
|
|
53
|
+
title: string | null,
|
|
54
|
+
description: string | null,
|
|
55
|
+
siteName: string | null,
|
|
56
|
+
): string {
|
|
57
|
+
const metaTags = [
|
|
58
|
+
`<meta property="og:image" content="${ogImage}">`,
|
|
59
|
+
`<meta name="twitter:image" content="${ogImage}">`,
|
|
60
|
+
].join('');
|
|
61
|
+
|
|
62
|
+
// Build JSON-LD schema
|
|
63
|
+
const isArticle = ogType === 'article';
|
|
64
|
+
const schema: Record<string, unknown> = {
|
|
65
|
+
'@context': 'https://schema.org',
|
|
66
|
+
'@type': isArticle ? 'BlogPosting' : 'WebSite',
|
|
67
|
+
url: ogUrl,
|
|
68
|
+
...(title ? { [isArticle ? 'headline' : 'name']: title } : {}),
|
|
69
|
+
...(description ? { description } : {}),
|
|
70
|
+
...(siteName && !isArticle ? { name: siteName } : {}),
|
|
71
|
+
};
|
|
72
|
+
const jsonLd = `<script type="application/ld+json">${JSON.stringify(schema)}</script>`;
|
|
73
|
+
|
|
74
|
+
// Upgrade twitter:card from summary → summary_large_image
|
|
75
|
+
let modified = html.replace(
|
|
76
|
+
/(<meta[^>]*name=["']twitter:card["'][^>]*content=["'])summary(["'][^>]*>)/gi,
|
|
77
|
+
'$1summary_large_image$2',
|
|
78
|
+
).replace(
|
|
79
|
+
/(<meta[^>]*content=["'])summary(["'][^>]*name=["']twitter:card["'][^>]*>)/gi,
|
|
80
|
+
'$1summary_large_image$2',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Inject before </head>
|
|
84
|
+
modified = modified.replace('</head>', `${metaTags}${jsonLd}</head>`);
|
|
85
|
+
return modified;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── OG image renderer ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function escapeHtml(str: string): string {
|
|
91
|
+
return str
|
|
92
|
+
.replace(/&/g, '&')
|
|
93
|
+
.replace(/</g, '<')
|
|
94
|
+
.replace(/>/g, '>')
|
|
95
|
+
.replace(/"/g, '"');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ogTemplate(title: string, description: string | undefined, siteName: string | undefined): object {
|
|
99
|
+
const safeTitle = escapeHtml(title);
|
|
100
|
+
const safeDesc = description
|
|
101
|
+
? escapeHtml(description.length > 140 ? description.slice(0, 137) + '…' : description)
|
|
102
|
+
: '';
|
|
103
|
+
const safeSite = escapeHtml(siteName ?? '');
|
|
104
|
+
const fontSize = title.length > 60 ? 44 : title.length > 40 ? 52 : 60;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
type: 'div',
|
|
108
|
+
key: null,
|
|
109
|
+
props: {
|
|
110
|
+
style: {
|
|
111
|
+
display: 'flex',
|
|
112
|
+
flexDirection: 'column',
|
|
113
|
+
justifyContent: 'space-between',
|
|
114
|
+
width: '100%',
|
|
115
|
+
height: '100%',
|
|
116
|
+
padding: '72px 80px',
|
|
117
|
+
backgroundColor: '#ffffff',
|
|
118
|
+
fontFamily: 'Inter',
|
|
119
|
+
},
|
|
120
|
+
children: [
|
|
121
|
+
{
|
|
122
|
+
type: 'div',
|
|
123
|
+
key: null,
|
|
124
|
+
props: {
|
|
125
|
+
style: {
|
|
126
|
+
display: 'flex',
|
|
127
|
+
fontSize,
|
|
128
|
+
fontWeight: 700,
|
|
129
|
+
color: '#111111',
|
|
130
|
+
maxWidth: 900,
|
|
131
|
+
lineHeight: 1.25,
|
|
132
|
+
},
|
|
133
|
+
children: safeTitle,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: 'div',
|
|
138
|
+
key: null,
|
|
139
|
+
props: {
|
|
140
|
+
style: {
|
|
141
|
+
display: 'flex',
|
|
142
|
+
justifyContent: 'space-between',
|
|
143
|
+
alignItems: 'flex-end',
|
|
144
|
+
gap: 40,
|
|
145
|
+
},
|
|
146
|
+
children: [
|
|
147
|
+
safeDesc
|
|
148
|
+
? {
|
|
149
|
+
type: 'div',
|
|
150
|
+
key: null,
|
|
151
|
+
props: {
|
|
152
|
+
style: {
|
|
153
|
+
display: 'flex',
|
|
154
|
+
fontSize: 22,
|
|
155
|
+
color: '#555555',
|
|
156
|
+
maxWidth: 750,
|
|
157
|
+
lineHeight: 1.4,
|
|
158
|
+
flex: 1,
|
|
159
|
+
},
|
|
160
|
+
children: safeDesc,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: { type: 'div', key: null, props: { style: { display: 'flex', flex: 1 }, children: '' } },
|
|
164
|
+
safeSite
|
|
165
|
+
? {
|
|
166
|
+
type: 'div',
|
|
167
|
+
key: null,
|
|
168
|
+
props: {
|
|
169
|
+
style: { display: 'flex', fontSize: 20, color: '#aaaaaa', flexShrink: 0 },
|
|
170
|
+
children: safeSite,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
: { type: 'div', key: null, props: { style: { display: 'flex', flexShrink: 0 }, children: '' } },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function renderPng(title: string, description?: string, siteName?: string): Promise<Buffer> {
|
|
183
|
+
const svg = await satori(ogTemplate(title, description, siteName) as any, {
|
|
184
|
+
width: 1200,
|
|
185
|
+
height: 630,
|
|
186
|
+
fonts: [
|
|
187
|
+
{
|
|
188
|
+
name: 'Inter',
|
|
189
|
+
data: _fontData.buffer as ArrayBuffer,
|
|
190
|
+
weight: 400,
|
|
191
|
+
style: 'normal',
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
return Buffer.from(new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function* walkHtmlFiles(dir: string): Generator<string> {
|
|
201
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
202
|
+
const full = join(dir, entry.name);
|
|
203
|
+
if (entry.isDirectory()) yield* walkHtmlFiles(full);
|
|
204
|
+
else if (entry.name.endsWith('.html')) yield full;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Post-build SEO enrichment pass.
|
|
212
|
+
*
|
|
213
|
+
* For every HTML page that has `og:url` set (blog posts, docs pages, etc.):
|
|
214
|
+
* 1. Derives the OG image path from the URL (e.g. /blog/my-post → /og/blog/my-post.png)
|
|
215
|
+
* 2. Injects `og:image`, `twitter:image`, `twitter:card` (summary_large_image), and JSON-LD
|
|
216
|
+
* into the HTML and writes it back to disk.
|
|
217
|
+
* 3. Renders a 1200×630 PNG using satori + resvg-js and writes it to `dist/og/…`.
|
|
218
|
+
*
|
|
219
|
+
* Runs in the `astro:build:done` hook — plain Node.js, no Vite/Rollup bundling —
|
|
220
|
+
* so native binaries work without any special configuration.
|
|
221
|
+
*
|
|
222
|
+
* Pages that already have `og:image` are skipped (user-provided takes precedence).
|
|
223
|
+
*/
|
|
224
|
+
export async function generateOgImages(dir: URL, logger: AstroIntegrationLogger): Promise<void> {
|
|
225
|
+
const distPath = fileURLToPath(dir);
|
|
226
|
+
let generated = 0;
|
|
227
|
+
|
|
228
|
+
for (const htmlFile of walkHtmlFiles(distPath)) {
|
|
229
|
+
let html = readFileSync(htmlFile, 'utf-8');
|
|
230
|
+
|
|
231
|
+
// Skip if og:image already set (user-managed)
|
|
232
|
+
if (hasMeta(html, 'og:image')) continue;
|
|
233
|
+
|
|
234
|
+
const ogUrl = getMeta(html, 'og:url');
|
|
235
|
+
if (!ogUrl) continue;
|
|
236
|
+
|
|
237
|
+
const imagePath = ogImagePath(ogUrl);
|
|
238
|
+
if (!imagePath) continue; // homepage or unparseable URL
|
|
239
|
+
|
|
240
|
+
const title = getMeta(html, 'og:title');
|
|
241
|
+
const description = getMeta(html, 'og:description') ?? undefined;
|
|
242
|
+
const ogType = getMeta(html, 'og:type');
|
|
243
|
+
const siteName = getMeta(html, 'og:site_name') ?? undefined;
|
|
244
|
+
|
|
245
|
+
if (!title) continue;
|
|
246
|
+
|
|
247
|
+
// 1. Inject SEO tags into HTML and write back
|
|
248
|
+
html = injectSeoTags(html, imagePath, ogUrl, ogType, title, description ?? null, siteName ?? null);
|
|
249
|
+
writeFileSync(htmlFile, html);
|
|
250
|
+
|
|
251
|
+
// 2. Generate PNG (skip if already exists from a previous incremental build)
|
|
252
|
+
const pngPath = join(distPath, imagePath.slice(1)); // /og/… → dist/og/…
|
|
253
|
+
if (!existsSync(pngPath)) {
|
|
254
|
+
const png = await renderPng(title, description, siteName);
|
|
255
|
+
mkdirSync(dirname(pngPath), { recursive: true });
|
|
256
|
+
writeFileSync(pngPath, png);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
generated++;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (generated > 0) {
|
|
263
|
+
logger.info(`Generated ${generated} OG image${generated === 1 ? '' : 's'}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineModule } from '@karaoke-cms/contracts';
|
|
2
|
+
import { generateOgImages } from './generate-og-images.js';
|
|
3
|
+
import { cssContract } from './css-contract.js';
|
|
4
|
+
|
|
5
|
+
export { cssContract } from './css-contract.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SEO module — zero-config OG images, JSON-LD structured data, and robots.txt.
|
|
9
|
+
*
|
|
10
|
+
* Add to `modules[]` in `karaoke.config.ts` and every published blog post and docs
|
|
11
|
+
* page will automatically get a social card image, structured data, and a robots.txt.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // karaoke.config.ts
|
|
15
|
+
* import { seo } from '@karaoke-cms/module-seo';
|
|
16
|
+
* export default defineConfig({
|
|
17
|
+
* modules: [seo()],
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
export const seo = defineModule({
|
|
21
|
+
id: 'seo',
|
|
22
|
+
cssContract,
|
|
23
|
+
|
|
24
|
+
routes: () => [
|
|
25
|
+
{ pattern: '/robots.txt', entrypoint: '@karaoke-cms/module-seo/pages/robots.txt' },
|
|
26
|
+
],
|
|
27
|
+
|
|
28
|
+
menuEntries: () => [],
|
|
29
|
+
|
|
30
|
+
integration: {
|
|
31
|
+
name: '@karaoke-cms/module-seo',
|
|
32
|
+
hooks: {
|
|
33
|
+
'astro:build:done': async ({ dir, logger }: { dir: URL; logger: any }) => {
|
|
34
|
+
await generateOgImages(dir, logger);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robots.txt endpoint.
|
|
3
|
+
* Disallows the /karaoke-cms/ handbook routes (dev-only, should not be indexed).
|
|
4
|
+
* Everything else is allowed.
|
|
5
|
+
*/
|
|
6
|
+
export async function GET(): Promise<Response> {
|
|
7
|
+
const content = [
|
|
8
|
+
'User-agent: *',
|
|
9
|
+
'Allow: /',
|
|
10
|
+
'Disallow: /karaoke-cms/',
|
|
11
|
+
'',
|
|
12
|
+
].join('\n');
|
|
13
|
+
|
|
14
|
+
return new Response(content, {
|
|
15
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
16
|
+
});
|
|
17
|
+
}
|