@koehler8/cms 1.0.0-beta.21 → 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.
@@ -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.21",
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": ["vue", "cms", "vite", "ssg", "theming", "extensions", "config-driven"],
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
+ }
@@ -4,6 +4,7 @@ import { isPathDraft } from '../utils/draftMode.js';
4
4
  import { buildCanonicalUrl } from '../utils/canonicalUrl.js';
5
5
  import { buildSocialMeta } from '../utils/socialMeta.js';
6
6
  import { buildJsonLdScripts } from '../utils/jsonLd.js';
7
+ import { buildBreadcrumbList } from '../utils/breadcrumbs.js';
7
8
  import { availableLocales as configAvailableLocales, baseLocale as configBaseLocale } from '../utils/loadConfig.js';
8
9
 
9
10
  // Site-verification meta-tag names per platform. Authors set
@@ -205,6 +206,25 @@ export function usePageMeta({ siteData, currentPage, locale }) {
205
206
  isNotFound: isNotFound.value,
206
207
  });
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
+
208
228
  return {
209
229
  title: pageMetaTitle.value,
210
230
  meta,
@@ -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
+ }