@koehler8/cms 1.0.0-beta.20 → 1.0.0-beta.22
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/bin/cms-generate-image-variants.js +33 -0
- package/package.json +13 -3
- package/scripts/generate-image-variants.js +251 -0
- package/src/composables/usePageConfig.js +3 -0
- package/src/composables/usePageMeta.js +57 -0
- package/src/utils/breadcrumbs.js +118 -0
- package/src/utils/jsonLd.js +58 -0
- package/src/utils/webAppManifest.js +82 -0
- package/templates/index.html +5 -4
- package/vite-plugin.js +22 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Wrapper that runs the framework's generate-image-variants.js from the
|
|
3
|
+
// consuming site repo's CWD. Accepts --site-dir <path> to locate site assets;
|
|
4
|
+
// defaults to ./site.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let siteDir = './site';
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
if (args[i] === '--site-dir' && args[i + 1]) {
|
|
16
|
+
siteDir = args[i + 1];
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolvedSiteDir = path.resolve(process.cwd(), siteDir);
|
|
22
|
+
process.env.CMS_SITE_DIR = resolvedSiteDir;
|
|
23
|
+
|
|
24
|
+
const { generateVariants } = await import(
|
|
25
|
+
path.resolve(__dirname, '..', 'scripts', 'generate-image-variants.js')
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await generateVariants({ siteDir: resolvedSiteDir });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`❌ ${err.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koehler8/cms",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.22",
|
|
4
4
|
"description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/koehler8/cms/issues"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"vue",
|
|
18
|
+
"cms",
|
|
19
|
+
"vite",
|
|
20
|
+
"ssg",
|
|
21
|
+
"theming",
|
|
22
|
+
"extensions",
|
|
23
|
+
"config-driven"
|
|
24
|
+
],
|
|
17
25
|
"scripts": {
|
|
18
26
|
"postinstall": "node scripts/patch-lru-cache-tla.js",
|
|
19
27
|
"test": "vitest run",
|
|
@@ -46,6 +54,7 @@
|
|
|
46
54
|
},
|
|
47
55
|
"bin": {
|
|
48
56
|
"cms-generate-public-assets": "./bin/cms-generate-public-assets.js",
|
|
57
|
+
"cms-generate-image-variants": "./bin/cms-generate-image-variants.js",
|
|
49
58
|
"cms-validate-themes": "./bin/cms-validate-themes.js",
|
|
50
59
|
"cms-validate-extensions": "./bin/cms-validate-extensions.js",
|
|
51
60
|
"cms-create-theme": "./bin/cms-create-theme.js",
|
|
@@ -77,7 +86,8 @@
|
|
|
77
86
|
"canvas": "^3.2.0",
|
|
78
87
|
"dotenv": "^17.2.3",
|
|
79
88
|
"pinia": "^3.0.1",
|
|
80
|
-
"png-to-ico": "^3.0.1"
|
|
89
|
+
"png-to-ico": "^3.0.1",
|
|
90
|
+
"sharp": "^0.33.5"
|
|
81
91
|
},
|
|
82
92
|
"devDependencies": {
|
|
83
93
|
"@vue/test-utils": "^2.4.6",
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate responsive image variants from `{siteDir}/assets/img/_source/**`
|
|
3
|
+
* into `{siteDir}/assets/img/`.
|
|
4
|
+
*
|
|
5
|
+
* For each source image (jpg/jpeg/png/webp/avif/tiff), produces:
|
|
6
|
+
* {name}-{width}.avif
|
|
7
|
+
* {name}-{width}.webp
|
|
8
|
+
* {name}-{width}.jpg
|
|
9
|
+
* across the configured widths. Subdirectory layout is preserved.
|
|
10
|
+
*
|
|
11
|
+
* Skips variants whose mtime is newer than the source's mtime — re-running
|
|
12
|
+
* the script after a `git pull` only regenerates the images that changed.
|
|
13
|
+
*
|
|
14
|
+
* Configuration is read from `{siteDir}/content/{baseLocale}/site.json`'s
|
|
15
|
+
* optional `imageVariants` block; defaults are sensible for marketing sites.
|
|
16
|
+
*
|
|
17
|
+
* Usage from a consuming site:
|
|
18
|
+
* CMS_SITE_DIR=./site node node_modules/@koehler8/cms/scripts/generate-image-variants.js
|
|
19
|
+
*
|
|
20
|
+
* Or via the CLI wrapper:
|
|
21
|
+
* npx cms-generate-image-variants --site-dir ./site
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import sharp from 'sharp';
|
|
27
|
+
|
|
28
|
+
import { inflateFlatConfig } from '../src/utils/inflateFlatConfig.js';
|
|
29
|
+
|
|
30
|
+
// ---- Configuration ----
|
|
31
|
+
|
|
32
|
+
const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 2560];
|
|
33
|
+
const DEFAULT_FORMATS = ['avif', 'webp', 'jpg'];
|
|
34
|
+
const DEFAULT_QUALITY = { avif: 60, webp: 75, jpg: 80 };
|
|
35
|
+
const SOURCE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.tiff', '.tif']);
|
|
36
|
+
|
|
37
|
+
const FORMAT_OUTPUT_EXT = {
|
|
38
|
+
avif: 'avif',
|
|
39
|
+
webp: 'webp',
|
|
40
|
+
jpg: 'jpg',
|
|
41
|
+
jpeg: 'jpg',
|
|
42
|
+
png: 'png',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function readJsonIfExists(filePath) {
|
|
46
|
+
if (!fs.existsSync(filePath)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveSiteConfig(siteDir) {
|
|
55
|
+
const contentDir = path.join(siteDir, 'content');
|
|
56
|
+
const contentConfig = readJsonIfExists(path.join(contentDir, 'content.config.json')) || {};
|
|
57
|
+
const baseLocale = contentConfig.baseLocale || 'en';
|
|
58
|
+
const sitePath = path.join(contentDir, baseLocale, 'site.json');
|
|
59
|
+
const raw = readJsonIfExists(sitePath) || {};
|
|
60
|
+
return inflateFlatConfig(raw);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeWidths(input) {
|
|
64
|
+
if (!Array.isArray(input)) return [...DEFAULT_WIDTHS];
|
|
65
|
+
const filtered = input
|
|
66
|
+
.map((v) => Number.parseInt(v, 10))
|
|
67
|
+
.filter((v) => Number.isFinite(v) && v > 0);
|
|
68
|
+
if (!filtered.length) return [...DEFAULT_WIDTHS];
|
|
69
|
+
return [...new Set(filtered)].sort((a, b) => a - b);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeFormats(input) {
|
|
73
|
+
if (!Array.isArray(input)) return [...DEFAULT_FORMATS];
|
|
74
|
+
const filtered = input
|
|
75
|
+
.map((v) => (typeof v === 'string' ? v.trim().toLowerCase() : ''))
|
|
76
|
+
.filter((v) => v && FORMAT_OUTPUT_EXT[v]);
|
|
77
|
+
if (!filtered.length) return [...DEFAULT_FORMATS];
|
|
78
|
+
return [...new Set(filtered)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeQuality(input) {
|
|
82
|
+
const merged = { ...DEFAULT_QUALITY };
|
|
83
|
+
if (input && typeof input === 'object') {
|
|
84
|
+
for (const [k, v] of Object.entries(input)) {
|
|
85
|
+
const n = Number.parseInt(v, 10);
|
|
86
|
+
if (Number.isFinite(n) && n >= 1 && n <= 100) merged[k] = n;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Resolve `imageVariants` config block, with safe defaults.
|
|
93
|
+
export function resolveImageVariantConfig(siteConfig) {
|
|
94
|
+
const block = (siteConfig?.imageVariants && typeof siteConfig.imageVariants === 'object')
|
|
95
|
+
? siteConfig.imageVariants
|
|
96
|
+
: {};
|
|
97
|
+
return {
|
|
98
|
+
widths: normalizeWidths(block.widths),
|
|
99
|
+
formats: normalizeFormats(block.formats),
|
|
100
|
+
quality: normalizeQuality(block.quality),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- File walking ----
|
|
105
|
+
|
|
106
|
+
function* walkSourceImages(sourceDir) {
|
|
107
|
+
if (!fs.existsSync(sourceDir)) return;
|
|
108
|
+
const stack = [sourceDir];
|
|
109
|
+
while (stack.length) {
|
|
110
|
+
const dir = stack.pop();
|
|
111
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
112
|
+
const full = path.join(dir, entry.name);
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
stack.push(full);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!entry.isFile()) continue;
|
|
118
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
119
|
+
if (!SOURCE_EXTENSIONS.has(ext)) continue;
|
|
120
|
+
yield full;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build the list of (sourcePath, outputs[]) jobs without doing IO yet.
|
|
126
|
+
export function planVariantJobs({ siteDir, config }) {
|
|
127
|
+
const imgDir = path.join(siteDir, 'assets', 'img');
|
|
128
|
+
const sourceDir = path.join(imgDir, '_source');
|
|
129
|
+
const jobs = [];
|
|
130
|
+
if (!fs.existsSync(sourceDir)) return jobs;
|
|
131
|
+
|
|
132
|
+
for (const sourcePath of walkSourceImages(sourceDir)) {
|
|
133
|
+
const relFromSource = path.relative(sourceDir, sourcePath);
|
|
134
|
+
const parsed = path.parse(relFromSource);
|
|
135
|
+
const subdir = parsed.dir;
|
|
136
|
+
const baseName = parsed.name;
|
|
137
|
+
|
|
138
|
+
const outputs = [];
|
|
139
|
+
for (const width of config.widths) {
|
|
140
|
+
for (const format of config.formats) {
|
|
141
|
+
const ext = FORMAT_OUTPUT_EXT[format];
|
|
142
|
+
if (!ext) continue;
|
|
143
|
+
const outName = `${baseName}-${width}.${ext}`;
|
|
144
|
+
const outPath = path.join(imgDir, subdir, outName);
|
|
145
|
+
outputs.push({ width, format, outPath });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
jobs.push({ sourcePath, outputs });
|
|
149
|
+
}
|
|
150
|
+
return jobs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Apply a per-output sharp transform pipeline.
|
|
154
|
+
async function renderOutput(sourcePath, output, config) {
|
|
155
|
+
const { width, format, outPath } = output;
|
|
156
|
+
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
|
|
157
|
+
|
|
158
|
+
let pipeline = sharp(sourcePath, { failOn: 'truncated' }).rotate();
|
|
159
|
+
pipeline = pipeline.resize({ width, withoutEnlargement: true });
|
|
160
|
+
switch (format) {
|
|
161
|
+
case 'avif':
|
|
162
|
+
pipeline = pipeline.avif({ quality: config.quality.avif });
|
|
163
|
+
break;
|
|
164
|
+
case 'webp':
|
|
165
|
+
pipeline = pipeline.webp({ quality: config.quality.webp });
|
|
166
|
+
break;
|
|
167
|
+
case 'jpg':
|
|
168
|
+
case 'jpeg':
|
|
169
|
+
pipeline = pipeline.jpeg({ quality: config.quality.jpg, mozjpeg: true });
|
|
170
|
+
break;
|
|
171
|
+
case 'png':
|
|
172
|
+
pipeline = pipeline.png();
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
throw new Error(`unsupported format: ${format}`);
|
|
176
|
+
}
|
|
177
|
+
await pipeline.toFile(outPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isUpToDate(sourcePath, outPath) {
|
|
181
|
+
if (!fs.existsSync(outPath)) return false;
|
|
182
|
+
try {
|
|
183
|
+
const src = fs.statSync(sourcePath).mtimeMs;
|
|
184
|
+
const dst = fs.statSync(outPath).mtimeMs;
|
|
185
|
+
return dst >= src;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Main ----
|
|
192
|
+
|
|
193
|
+
export async function generateVariants({ siteDir, log = console.log, warn = console.warn } = {}) {
|
|
194
|
+
if (!siteDir || !fs.existsSync(siteDir)) {
|
|
195
|
+
throw new Error(`siteDir does not exist: ${siteDir}`);
|
|
196
|
+
}
|
|
197
|
+
const siteConfig = resolveSiteConfig(siteDir);
|
|
198
|
+
const config = resolveImageVariantConfig(siteConfig);
|
|
199
|
+
const jobs = planVariantJobs({ siteDir, config });
|
|
200
|
+
if (jobs.length === 0) {
|
|
201
|
+
log(`[cms-image-variants] no sources found under ${path.join(siteDir, 'assets', 'img', '_source')}`);
|
|
202
|
+
return { generated: 0, skipped: 0, sources: 0 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let generated = 0;
|
|
206
|
+
let skipped = 0;
|
|
207
|
+
for (const { sourcePath, outputs } of jobs) {
|
|
208
|
+
for (const output of outputs) {
|
|
209
|
+
if (isUpToDate(sourcePath, output.outPath)) {
|
|
210
|
+
skipped += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await renderOutput(sourcePath, output, config);
|
|
215
|
+
generated += 1;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
warn(`[cms-image-variants] failed ${path.relative(siteDir, output.outPath)}: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
log(
|
|
222
|
+
`[cms-image-variants] sources: ${jobs.length}, generated: ${generated}, skipped (up-to-date): ${skipped}`,
|
|
223
|
+
);
|
|
224
|
+
return { generated, skipped, sources: jobs.length };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Allow invocation as `node scripts/generate-image-variants.js` (used by the
|
|
228
|
+
// bin wrapper). Reads CMS_SITE_DIR like generate-public-assets does.
|
|
229
|
+
const isMain = (() => {
|
|
230
|
+
try {
|
|
231
|
+
const __filename = new URL(import.meta.url).pathname;
|
|
232
|
+
return process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
|
|
238
|
+
if (isMain) {
|
|
239
|
+
const siteDir = process.env.CMS_SITE_DIR
|
|
240
|
+
? path.resolve(process.env.CMS_SITE_DIR)
|
|
241
|
+
: null;
|
|
242
|
+
if (!siteDir) {
|
|
243
|
+
console.error('❌ CMS_SITE_DIR environment variable is required.');
|
|
244
|
+
console.error(' Example: CMS_SITE_DIR=./site npx cms-generate-image-variants');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
generateVariants({ siteDir }).catch((err) => {
|
|
248
|
+
console.error(`❌ ${err.message}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -50,6 +50,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
50
50
|
content: data.content || {},
|
|
51
51
|
meta: data.meta || {},
|
|
52
52
|
draft: data.draft,
|
|
53
|
+
jsonld: data.jsonld,
|
|
53
54
|
};
|
|
54
55
|
};
|
|
55
56
|
|
|
@@ -72,6 +73,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
72
73
|
content: data.content || {},
|
|
73
74
|
meta: data.meta || {},
|
|
74
75
|
draft: data.draft,
|
|
76
|
+
jsonld: data.jsonld,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
}
|
|
@@ -113,6 +115,7 @@ export function usePageConfig({ pageId, pagePath, locale, onPageLoaded } = {}) {
|
|
|
113
115
|
content: firstData.content || {},
|
|
114
116
|
meta: firstData.meta || {},
|
|
115
117
|
draft: firstData.draft,
|
|
118
|
+
jsonld: firstData.jsonld,
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -3,8 +3,21 @@ import { useHead } from '@unhead/vue';
|
|
|
3
3
|
import { isPathDraft } from '../utils/draftMode.js';
|
|
4
4
|
import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
|
|
5
5
|
import { buildSocialMeta } from '../utils/socialMeta.js';
|
|
6
|
+
import { buildJsonLdScripts } from '../utils/jsonLd.js';
|
|
7
|
+
import { buildBreadcrumbList } from '../utils/breadcrumbs.js';
|
|
6
8
|
import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
|
|
7
9
|
|
|
10
|
+
// Site-verification meta-tag names per platform. Authors set
|
|
11
|
+
// `site.siteVerification.<platform>` in site.json with the token issued by
|
|
12
|
+
// the platform's webmaster console; we emit one <meta> per platform.
|
|
13
|
+
const SITE_VERIFICATION_META = {
|
|
14
|
+
google: 'google-site-verification',
|
|
15
|
+
bing: 'msvalidate.01',
|
|
16
|
+
yandex: 'yandex-verification',
|
|
17
|
+
pinterest: 'p:domain_verify',
|
|
18
|
+
facebook: 'facebook-domain-verification',
|
|
19
|
+
};
|
|
20
|
+
|
|
8
21
|
const SPECIAL_PAGE_LABELS = {
|
|
9
22
|
home: '',
|
|
10
23
|
terms: 'Terms of Service',
|
|
@@ -162,6 +175,22 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
162
175
|
isNotFound: isNotFound.value,
|
|
163
176
|
});
|
|
164
177
|
for (const entry of social) meta.push(entry);
|
|
178
|
+
|
|
179
|
+
// Site-verification meta tags. These are emitted on every page (not
|
|
180
|
+
// just home) — verification platforms typically only check homepage
|
|
181
|
+
// but emitting site-wide is harmless and matches what most CMSs do.
|
|
182
|
+
const verification = siteData.value?.site?.siteVerification;
|
|
183
|
+
if (verification && typeof verification === 'object') {
|
|
184
|
+
for (const [platform, token] of Object.entries(verification)) {
|
|
185
|
+
const metaName = SITE_VERIFICATION_META[platform];
|
|
186
|
+
if (!metaName) continue;
|
|
187
|
+
if (typeof token !== 'string') continue;
|
|
188
|
+
const trimmed = token.trim();
|
|
189
|
+
if (!trimmed) continue;
|
|
190
|
+
meta.push({ name: metaName, content: trimmed, key: `verify-${platform}` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
165
194
|
const link = [];
|
|
166
195
|
if (canonicalHref.value) {
|
|
167
196
|
link.push({ rel: 'canonical', href: canonicalHref.value, key: 'canonical' });
|
|
@@ -169,10 +198,38 @@ export function usePageMeta({ siteData, currentPage, locale }) {
|
|
|
169
198
|
for (const entry of hreflangLinks.value) {
|
|
170
199
|
link.push(entry);
|
|
171
200
|
}
|
|
201
|
+
|
|
202
|
+
const script = buildJsonLdScripts({
|
|
203
|
+
siteData: siteData.value,
|
|
204
|
+
currentPage: currentPage.value,
|
|
205
|
+
isDraft: isDraft.value,
|
|
206
|
+
isNotFound: isNotFound.value,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Auto-generated BreadcrumbList JSON-LD. Appended after any
|
|
210
|
+
// author-supplied jsonld blocks so authors can override by emitting a
|
|
211
|
+
// hand-authored BreadcrumbList of their own (Google takes the first
|
|
212
|
+
// valid one when multiple are present).
|
|
213
|
+
const breadcrumb = buildBreadcrumbList({
|
|
214
|
+
siteUrl: siteUrl.value,
|
|
215
|
+
currentPage: currentPage.value,
|
|
216
|
+
pages: siteData.value?.pages,
|
|
217
|
+
baseLocale: configBaseLocale,
|
|
218
|
+
locale: localeValue.value,
|
|
219
|
+
});
|
|
220
|
+
if (breadcrumb && !isDraft.value && !isNotFound.value) {
|
|
221
|
+
script.push({
|
|
222
|
+
type: 'application/ld+json',
|
|
223
|
+
innerHTML: JSON.stringify(breadcrumb),
|
|
224
|
+
key: 'jsonld-BreadcrumbList',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
172
228
|
return {
|
|
173
229
|
title: pageMetaTitle.value,
|
|
174
230
|
meta,
|
|
175
231
|
link,
|
|
232
|
+
script,
|
|
176
233
|
};
|
|
177
234
|
});
|
|
178
235
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a JSON-LD `BreadcrumbList` for the current page from URL path
|
|
3
|
+
* segments and the inflated pages map. Drives Google's breadcrumb display
|
|
4
|
+
* in search results without authors having to hand-author the schema.
|
|
5
|
+
*
|
|
6
|
+
* Returns `null` when breadcrumbs aren't applicable (home, draft, 404,
|
|
7
|
+
* single-segment path with no resolvable hierarchy, missing site.url, or
|
|
8
|
+
* per-page opt-out via `currentPage.meta.breadcrumbs === false`).
|
|
9
|
+
*
|
|
10
|
+
* Label resolution priority for each path prefix `/seg1/seg2/...`:
|
|
11
|
+
* 1. A page in the inflated config whose `path` matches → its
|
|
12
|
+
* `meta.title`, falling back to a slug-derived title-case label.
|
|
13
|
+
* 2. Otherwise → the last segment formatted (`my-post-title` →
|
|
14
|
+
* `My Post Title`).
|
|
15
|
+
*
|
|
16
|
+
* The first entry is always `Home` linked at the canonical home URL. The
|
|
17
|
+
* last entry is the current page; its `item` URL is included (Google
|
|
18
|
+
* accepts both forms; including is more interoperable).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { buildCanonicalUrl } from './canonicalUrl.js';
|
|
22
|
+
|
|
23
|
+
function normalizePath(value) {
|
|
24
|
+
if (typeof value !== 'string') return '/';
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
if (!trimmed) return '/';
|
|
27
|
+
let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
28
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
29
|
+
return p || '/';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function titleCaseSlug(slug) {
|
|
33
|
+
if (typeof slug !== 'string') return '';
|
|
34
|
+
return slug
|
|
35
|
+
.replace(/[-_]+/g, ' ')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findPageByPath(pages, targetPath) {
|
|
42
|
+
if (!pages || typeof pages !== 'object') return null;
|
|
43
|
+
const norm = normalizePath(targetPath);
|
|
44
|
+
for (const data of Object.values(pages)) {
|
|
45
|
+
if (!data || typeof data !== 'object') continue;
|
|
46
|
+
if (normalizePath(data.path) === norm) return data;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveLabel(pages, prefixPath, lastSegment) {
|
|
52
|
+
const page = findPageByPath(pages, prefixPath);
|
|
53
|
+
const explicitTitle = page?.meta?.title;
|
|
54
|
+
if (typeof explicitTitle === 'string' && explicitTitle.trim()) {
|
|
55
|
+
return explicitTitle.trim();
|
|
56
|
+
}
|
|
57
|
+
return titleCaseSlug(lastSegment) || lastSegment || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildBreadcrumbList({
|
|
61
|
+
siteUrl,
|
|
62
|
+
currentPage,
|
|
63
|
+
pages,
|
|
64
|
+
baseLocale,
|
|
65
|
+
locale,
|
|
66
|
+
} = {}) {
|
|
67
|
+
if (!currentPage || typeof currentPage !== 'object') return null;
|
|
68
|
+
if (currentPage.isNotFound) return null;
|
|
69
|
+
if (currentPage.draft === true) return null;
|
|
70
|
+
if (currentPage.meta && currentPage.meta.breadcrumbs === false) return null;
|
|
71
|
+
|
|
72
|
+
const host = (typeof siteUrl === 'string' ? siteUrl : '').trim().replace(/\/+$/, '');
|
|
73
|
+
if (!host) return null;
|
|
74
|
+
|
|
75
|
+
const targetPath = normalizePath(currentPage.path);
|
|
76
|
+
if (targetPath === '/' || targetPath === '') return null;
|
|
77
|
+
|
|
78
|
+
const segments = targetPath.split('/').filter(Boolean);
|
|
79
|
+
if (segments.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const homeHref = buildCanonicalUrl({
|
|
82
|
+
siteUrl: host,
|
|
83
|
+
locale,
|
|
84
|
+
baseLocale,
|
|
85
|
+
path: '/',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const items = [
|
|
89
|
+
{
|
|
90
|
+
'@type': 'ListItem',
|
|
91
|
+
position: 1,
|
|
92
|
+
name: 'Home',
|
|
93
|
+
item: homeHref,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < segments.length; i++) {
|
|
98
|
+
const prefixPath = `/${segments.slice(0, i + 1).join('/')}`;
|
|
99
|
+
const href = buildCanonicalUrl({
|
|
100
|
+
siteUrl: host,
|
|
101
|
+
locale,
|
|
102
|
+
baseLocale,
|
|
103
|
+
path: prefixPath,
|
|
104
|
+
});
|
|
105
|
+
items.push({
|
|
106
|
+
'@type': 'ListItem',
|
|
107
|
+
position: items.length + 1,
|
|
108
|
+
name: resolveLabel(pages, prefixPath, segments[i]),
|
|
109
|
+
item: href,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
'@context': 'https://schema.org',
|
|
115
|
+
'@type': 'BreadcrumbList',
|
|
116
|
+
itemListElement: items,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize per-page + site-wide JSON-LD blocks into a flat list of
|
|
3
|
+
* @unhead/vue script descriptors ready for `useHead({ script: [...] })`.
|
|
4
|
+
*
|
|
5
|
+
* Authoring conventions in `pages/{id}.json` and `site.json`:
|
|
6
|
+
*
|
|
7
|
+
* "jsonld": { "@context": "https://schema.org", "@type": "Article", ... }
|
|
8
|
+
*
|
|
9
|
+
* or for multiple blocks on a page:
|
|
10
|
+
*
|
|
11
|
+
* "jsonld": [
|
|
12
|
+
* { "@type": "Article", ... },
|
|
13
|
+
* { "@type": "BreadcrumbList", ... }
|
|
14
|
+
* ]
|
|
15
|
+
*
|
|
16
|
+
* Page blocks ALWAYS append to site blocks (there's no "override" semantic
|
|
17
|
+
* — each schema.org block is an independent assertion, so listing both is
|
|
18
|
+
* the right behavior for a sitewide Organization + a page-specific Article).
|
|
19
|
+
*
|
|
20
|
+
* Skipped entirely on drafts and 404s; structured data on those would
|
|
21
|
+
* mislead crawlers (drafts shouldn't be indexed; 404s aren't authoritative).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function normalizeBlock(block) {
|
|
25
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) return null;
|
|
26
|
+
// Inject the schema.org @context if the author omitted it. JSON-LD blocks
|
|
27
|
+
// without @context are ignored by all major consumers, so adding it
|
|
28
|
+
// defensively is the right default.
|
|
29
|
+
if (!block['@context']) {
|
|
30
|
+
return { '@context': 'https://schema.org', ...block };
|
|
31
|
+
}
|
|
32
|
+
return block;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectBlocks(input) {
|
|
36
|
+
if (!input) return [];
|
|
37
|
+
if (Array.isArray(input)) {
|
|
38
|
+
return input.map(normalizeBlock).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
const single = normalizeBlock(input);
|
|
41
|
+
return single ? [single] : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildJsonLdScripts({ siteData, currentPage, isDraft, isNotFound } = {}) {
|
|
45
|
+
if (isDraft || isNotFound) return [];
|
|
46
|
+
|
|
47
|
+
const siteBlocks = collectBlocks(siteData?.site?.jsonld);
|
|
48
|
+
const pageBlocks = collectBlocks(currentPage?.jsonld);
|
|
49
|
+
|
|
50
|
+
const all = [...siteBlocks, ...pageBlocks];
|
|
51
|
+
if (all.length === 0) return [];
|
|
52
|
+
|
|
53
|
+
return all.map((block, i) => ({
|
|
54
|
+
type: 'application/ld+json',
|
|
55
|
+
innerHTML: JSON.stringify(block),
|
|
56
|
+
key: `jsonld-${block['@type'] || 'block'}-${i}`,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Web App Manifest (manifest.json) from the inflated site config.
|
|
3
|
+
*
|
|
4
|
+
* Returns the manifest object — the plugin JSON-stringifies and writes it
|
|
5
|
+
* to `siteRoot/public/manifest.json`. Browsers consume the file when the
|
|
6
|
+
* page emits `<link rel="manifest" href="/manifest.json">`.
|
|
7
|
+
*
|
|
8
|
+
* Authoring conventions in `site.json`:
|
|
9
|
+
*
|
|
10
|
+
* "manifest.name": "...", // overrides site.title
|
|
11
|
+
* "manifest.shortName": "...", // overrides truncated site.title
|
|
12
|
+
* "manifest.themeColor": "#...", // mobile chrome / splash bar color
|
|
13
|
+
* "manifest.backgroundColor": "#...", // splash background
|
|
14
|
+
* "manifest.startUrl": "/", // landing path on launch
|
|
15
|
+
* "manifest.display": "standalone",// browser, standalone, fullscreen, minimal-ui
|
|
16
|
+
* "manifest.icons[0].src": "...", // override default icon set
|
|
17
|
+
* "manifest.icons[0].sizes": "256x256",
|
|
18
|
+
* "manifest.icons[0].type": "image/png"
|
|
19
|
+
*
|
|
20
|
+
* Defaults are derived from existing site config and the icons that
|
|
21
|
+
* cms-generate-public-assets writes (favicon-256.png + favicon.ico).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_DISPLAY = 'standalone';
|
|
25
|
+
const DEFAULT_BG = '#ffffff';
|
|
26
|
+
const DEFAULT_THEME = '#ffffff';
|
|
27
|
+
const DEFAULT_START = '/';
|
|
28
|
+
|
|
29
|
+
function trim(value, fallback = '') {
|
|
30
|
+
if (typeof value !== 'string') return fallback;
|
|
31
|
+
const t = value.trim();
|
|
32
|
+
return t || fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultIcons() {
|
|
36
|
+
// Mirrors what cms-generate-public-assets writes into public/.
|
|
37
|
+
// Browsers pick the best size; the 256x256 PNG is the primary, the
|
|
38
|
+
// .ico is the legacy fallback for older Android.
|
|
39
|
+
return [
|
|
40
|
+
{ src: '/favicon-256.png', sizes: '256x256', type: 'image/png', purpose: 'any maskable' },
|
|
41
|
+
{ src: '/favicon.ico', sizes: '64x64 32x32 16x16', type: 'image/x-icon' },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildWebAppManifest(siteConfig) {
|
|
46
|
+
const site = (siteConfig && siteConfig.site) || {};
|
|
47
|
+
const m = (site.manifest && typeof site.manifest === 'object') ? site.manifest : {};
|
|
48
|
+
|
|
49
|
+
const siteTitle = trim(site.title);
|
|
50
|
+
const name = trim(m.name, siteTitle) || 'Site';
|
|
51
|
+
const shortName = trim(m.shortName, name.length <= 12 ? name : name.slice(0, 12));
|
|
52
|
+
|
|
53
|
+
const description = trim(m.description, trim(site.description));
|
|
54
|
+
|
|
55
|
+
const themeColor = trim(m.themeColor, DEFAULT_THEME);
|
|
56
|
+
const backgroundColor = trim(m.backgroundColor, DEFAULT_BG);
|
|
57
|
+
const startUrl = trim(m.startUrl, DEFAULT_START);
|
|
58
|
+
const display = trim(m.display, DEFAULT_DISPLAY);
|
|
59
|
+
const lang = trim(m.lang);
|
|
60
|
+
|
|
61
|
+
const customIcons = Array.isArray(m.icons)
|
|
62
|
+
? m.icons
|
|
63
|
+
.filter((icon) => icon && typeof icon === 'object' && typeof icon.src === 'string')
|
|
64
|
+
.map((icon) => ({ ...icon }))
|
|
65
|
+
: null;
|
|
66
|
+
const icons = customIcons && customIcons.length ? customIcons : defaultIcons();
|
|
67
|
+
|
|
68
|
+
const manifest = {
|
|
69
|
+
name,
|
|
70
|
+
short_name: shortName,
|
|
71
|
+
start_url: startUrl,
|
|
72
|
+
display,
|
|
73
|
+
theme_color: themeColor,
|
|
74
|
+
background_color: backgroundColor,
|
|
75
|
+
icons,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (description) manifest.description = description;
|
|
79
|
+
if (lang) manifest.lang = lang;
|
|
80
|
+
|
|
81
|
+
return manifest;
|
|
82
|
+
}
|
package/templates/index.html
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<!--
|
|
7
7
|
Per-page <title>, <meta name="description">, og:*, twitter:*, canonical,
|
|
8
|
-
and
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
vary per page.
|
|
8
|
+
hreflang, and JSON-LD blocks are emitted by usePageMeta at SSG render
|
|
9
|
+
time. Site-wide invariants (favicon, web-app manifest, the JSON-LD
|
|
10
|
+
Organization snippet, mobile-chrome theme color) live here.
|
|
12
11
|
-->
|
|
13
12
|
<link rel="icon" href="/favicon.ico">
|
|
13
|
+
<link rel="manifest" href="/manifest.json">
|
|
14
|
+
<meta name="theme-color" content="<%= manifestThemeColor %>">
|
|
14
15
|
<script type="application/ld+json">
|
|
15
16
|
{
|
|
16
17
|
"@context": "https://schema.org",
|
package/vite-plugin.js
CHANGED
|
@@ -15,6 +15,7 @@ import vue from '@vitejs/plugin-vue';
|
|
|
15
15
|
import { inflateFlatConfig } from './src/utils/inflateFlatConfig.js';
|
|
16
16
|
import { buildRobotsTxt } from './src/utils/robotsGenerator.js';
|
|
17
17
|
import { buildSitemap, getSitemapUrl } from './src/utils/sitemapGenerator.js';
|
|
18
|
+
import { buildWebAppManifest } from './src/utils/webAppManifest.js';
|
|
18
19
|
import { sha256Hex } from './src/utils/sha256.js';
|
|
19
20
|
|
|
20
21
|
// ---- Helpers ----
|
|
@@ -117,6 +118,7 @@ function extractSiteMetadata(siteConfig) {
|
|
|
117
118
|
url: siteUrl = '',
|
|
118
119
|
googleId: siteGoogleId = '',
|
|
119
120
|
sameAs: siteSameAs = [],
|
|
121
|
+
manifest: siteManifest = {},
|
|
120
122
|
} = siteConfig.site || {};
|
|
121
123
|
|
|
122
124
|
const siteSameAsList = Array.isArray(siteSameAs)
|
|
@@ -124,7 +126,14 @@ function extractSiteMetadata(siteConfig) {
|
|
|
124
126
|
: [];
|
|
125
127
|
const siteSameAsJson = JSON.stringify(siteSameAsList);
|
|
126
128
|
|
|
127
|
-
|
|
129
|
+
// Mobile chrome bar / PWA splash color. Defaults match buildWebAppManifest's
|
|
130
|
+
// default so the rendered <meta name="theme-color"> matches whatever
|
|
131
|
+
// manifest.json advertises.
|
|
132
|
+
const manifestThemeColor = (typeof siteManifest.themeColor === 'string'
|
|
133
|
+
? siteManifest.themeColor.trim()
|
|
134
|
+
: '') || '#ffffff';
|
|
135
|
+
|
|
136
|
+
return { siteTitle, siteDescription, siteUrl, siteGoogleId, siteSameAsJson, manifestThemeColor };
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -354,6 +363,7 @@ export default function cmsPlugin(options = {}) {
|
|
|
354
363
|
siteUrl: metadata.siteUrl,
|
|
355
364
|
siteGoogleId: metadata.siteGoogleId,
|
|
356
365
|
siteSameAsJson: metadata.siteSameAsJson,
|
|
366
|
+
manifestThemeColor: metadata.manifestThemeColor,
|
|
357
367
|
});
|
|
358
368
|
|
|
359
369
|
const processed = html.replace(
|
|
@@ -418,6 +428,16 @@ export default function cmsPlugin(options = {}) {
|
|
|
418
428
|
const sitemapUrl = sitemapXml ? getSitemapUrl(siteConfig) : '';
|
|
419
429
|
const robotsBody = buildRobotsTxt(siteConfig, sitemapUrl);
|
|
420
430
|
fs.writeFileSync(path.join(sitePublicDir, 'robots.txt'), robotsBody, 'utf-8');
|
|
431
|
+
|
|
432
|
+
// Web App Manifest. The template emits <link rel="manifest"> on every
|
|
433
|
+
// page; browsers use this to enable Add-to-Home-Screen + control
|
|
434
|
+
// splash colors when the site is launched from a homescreen icon.
|
|
435
|
+
const manifest = buildWebAppManifest(siteConfig);
|
|
436
|
+
fs.writeFileSync(
|
|
437
|
+
path.join(sitePublicDir, 'manifest.json'),
|
|
438
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
439
|
+
'utf-8',
|
|
440
|
+
);
|
|
421
441
|
};
|
|
422
442
|
|
|
423
443
|
const cmsCore = {
|
|
@@ -832,6 +852,7 @@ export const assetUrlMap = resolver.assetUrlMap;
|
|
|
832
852
|
siteUrl: metadata.siteUrl,
|
|
833
853
|
siteGoogleId: metadata.siteGoogleId,
|
|
834
854
|
siteSameAsJson: metadata.siteSameAsJson,
|
|
855
|
+
manifestThemeColor: metadata.manifestThemeColor,
|
|
835
856
|
});
|
|
836
857
|
|
|
837
858
|
html = html.replace(
|