@duffcloudservices/cms 0.3.16 → 0.4.0

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.
@@ -1,7 +1,33 @@
1
1
  import { Plugin } from 'vite';
2
+ import { S as SeoConfiguration } from '../seo-DsJjfI1p.js';
2
3
  import { Component, Plugin as Plugin$1 } from 'vue';
3
4
  import MarkdownIt from 'markdown-it';
4
5
 
6
+ /**
7
+ * Loader for the `.dcs/pages.yaml` route manifest.
8
+ *
9
+ * `pages.yaml` is the canonical page registry maintained by the DCS portal and
10
+ * by Copilot when scaffolding pages. For the build-time SEO emitter we only
11
+ * need each route's `slug` and `path` (e.g. `{ slug: 'home', path: '/' }`).
12
+ *
13
+ * The parser is intentionally defensive: any missing/unparseable file or
14
+ * malformed entry yields `null` (caller logs + no-ops) so a bad manifest can
15
+ * never break a production build.
16
+ */
17
+ /** A single route extracted from `.dcs/pages.yaml`. */
18
+ interface PageRouteEntry {
19
+ /** Page slug, matching an entry in `seo.yaml` `pages.<slug>` (may be absent). */
20
+ slug: string;
21
+ /** Route path, e.g. `/`, `/services`, `/blog/my-post`. */
22
+ path: string;
23
+ /**
24
+ * Human title from the manifest (e.g. "Kitchen Cabinet Refresh"). Used as the
25
+ * per-route title fallback when `seo.yaml` has no entry for this page, so
26
+ * un-configured routes (e.g. blog posts) get unique titles. Optional.
27
+ */
28
+ title?: string;
29
+ }
30
+
5
31
  /**
6
32
  * DCS Content Plugin for Vite
7
33
  *
@@ -53,33 +79,45 @@ declare function dcsContentPlugin(options?: DcsContentPluginOptions): Plugin;
53
79
  /**
54
80
  * DCS SEO Plugin for Vite
55
81
  *
56
- * Reads `.dcs/seo.yaml` at build time and injects content
57
- * as `__DCS_SEO__` global variable for use by useSEO.
82
+ * Two responsibilities, both driven by `.dcs/seo.yaml`:
58
83
  *
59
- * @example
84
+ * 1. **Build-time define** (always on): reads `.dcs/seo.yaml` and injects it as
85
+ * the `__DCS_SEO__` global for the runtime `useSEO` composable.
86
+ *
87
+ * 2. **Static `<head>` emitter** (opt-in via `emitStaticHtml: true`, default
88
+ * OFF): after the bundle is written, reads the SPA's built `index.html`,
89
+ * and for every route in `.dcs/pages.yaml` writes a per-route
90
+ * `dist/<path>/index.html` whose `<head>` carries the resolved title, meta,
91
+ * canonical, Open Graph, Twitter, and JSON-LD tags. This gives a Vue SPA
92
+ * per-route static SEO **without** vite-ssg.
93
+ *
94
+ * VitePress sites already bake SEO via their own config, so they leave this
95
+ * option OFF and are completely unaffected.
96
+ *
97
+ * @example Runtime define only (default — safe for VitePress)
60
98
  * ```typescript
61
99
  * // vite.config.ts
62
100
  * import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'
63
101
  *
64
102
  * export default defineConfig({
65
- * plugins: [
66
- * dcsSeoPlugin({ debug: true })
67
- * ]
103
+ * plugins: [dcsSeoPlugin({ debug: true })]
68
104
  * })
69
105
  * ```
70
106
  *
71
- * For VitePress:
107
+ * @example Vue SPA with per-route static <head> emission
72
108
  * ```typescript
73
- * // .vitepress/config.ts
74
- * import { defineConfig } from 'vitepress'
109
+ * // vite.config.ts
75
110
  * import { dcsSeoPlugin } from '@duffcloudservices/cms/plugins'
76
111
  *
77
112
  * export default defineConfig({
78
- * vite: {
79
- * plugins: [
80
- * dcsSeoPlugin()
81
- * ]
82
- * }
113
+ * plugins: [
114
+ * dcsSeoPlugin({
115
+ * emitStaticHtml: true, // turn the emitter ON
116
+ * pagesPath: '.dcs/pages.yaml', // route manifest (default)
117
+ * noindex: ['account', 'projects'], // robots: noindex,nofollow
118
+ * exclude: ['/preview'], // skip these routes entirely
119
+ * })
120
+ * ]
83
121
  * })
84
122
  * ```
85
123
  */
@@ -89,9 +127,48 @@ interface DcsSeoPluginOptions {
89
127
  seoPath?: string;
90
128
  /** Enable debug logging */
91
129
  debug?: boolean;
130
+ /**
131
+ * Opt-in: emit per-route static `<head>` (meta + JSON-LD) into the built
132
+ * `dist/` at the end of the build. Default `false` — VitePress sites and any
133
+ * site that bakes its own SEO are unaffected when this is off.
134
+ */
135
+ emitStaticHtml?: boolean;
136
+ /**
137
+ * Path to the route manifest relative to project root, used only when
138
+ * `emitStaticHtml` is true. Default `'.dcs/pages.yaml'`.
139
+ */
140
+ pagesPath?: string;
141
+ /**
142
+ * Routes to skip entirely (no per-route HTML written). Matched against the
143
+ * route `path` (e.g. `'/preview'`) OR the route `slug` (e.g. `'account'`).
144
+ */
145
+ exclude?: string[];
146
+ /**
147
+ * Routes that should receive `robots: noindex, nofollow`. Matched against the
148
+ * route `path` OR `slug`. The home route (`/`) still overwrites
149
+ * `dist/index.html`.
150
+ */
151
+ noindex?: string[];
92
152
  }
93
153
  /**
94
- * Vite plugin that injects .dcs/seo.yaml at build time.
154
+ * Core emitter, separated from Vite so it is unit-testable.
155
+ *
156
+ * For each route: build the head tags from the shared resolver, splice them
157
+ * into the shell HTML, and write `dist/<path>/index.html`. Returns the number
158
+ * of files written. Pure aside from `fs` writes — deterministic + idempotent.
159
+ */
160
+ declare function emitStaticSeoHtml(params: {
161
+ outDir: string;
162
+ shellHtml: string;
163
+ routes: PageRouteEntry[];
164
+ seoConfig: SeoConfiguration | undefined;
165
+ exclude?: string[];
166
+ noindex?: string[];
167
+ debug?: boolean;
168
+ }): number;
169
+ /**
170
+ * Vite plugin that injects .dcs/seo.yaml at build time and, optionally, emits
171
+ * per-route static `<head>` HTML for SPA SEO.
95
172
  *
96
173
  * @param options - Plugin configuration
97
174
  * @returns Vite plugin
@@ -345,4 +422,4 @@ declare function dcsCdnBuildEnd(options?: DcsCdnBuildEndOptions): (siteConfig: {
345
422
 
346
423
  declare function responsiveImagePlugin(md: MarkdownIt): void;
347
424
 
348
- export { type DcsCdnBuildEndOptions, type DcsCdnImagePluginOptions, type DcsContentPluginOptions, type DcsEditorPluginOptions, type DcsPreviewPluginOptions, type DcsSeoPluginOptions, dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsPreviewPlugin, dcsSeoPlugin, responsiveImagePlugin };
425
+ export { type DcsCdnBuildEndOptions, type DcsCdnImagePluginOptions, type DcsContentPluginOptions, type DcsEditorPluginOptions, type DcsPreviewPluginOptions, type DcsSeoPluginOptions, dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsPreviewPlugin, dcsSeoPlugin, emitStaticSeoHtml, responsiveImagePlugin };
@@ -1,9 +1,9 @@
1
- import fs3 from 'fs';
2
- import path from 'path';
1
+ import { buildHeadTags, spliceHeadHtml, loadPagesManifest } from '../chunk-RDYVYYTC.js';
2
+ import fs2 from 'fs';
3
+ import path2 from 'path';
3
4
  import yaml from 'js-yaml';
4
5
  import { defineComponent, h } from 'vue';
5
6
 
6
- // src/plugins/dcsContentPlugin.ts
7
7
  function dcsContentPlugin(options = {}) {
8
8
  const { contentPath = ".dcs/content.yaml", debug = false } = options;
9
9
  let resolvedConfig;
@@ -15,13 +15,13 @@ function dcsContentPlugin(options = {}) {
15
15
  config(config) {
16
16
  const projectRoot = config.root || process.cwd();
17
17
  const possiblePaths = [
18
- path.resolve(projectRoot, contentPath),
19
- path.resolve(projectRoot, "..", contentPath),
20
- path.resolve(process.cwd(), contentPath)
18
+ path2.resolve(projectRoot, contentPath),
19
+ path2.resolve(projectRoot, "..", contentPath),
20
+ path2.resolve(process.cwd(), contentPath)
21
21
  ];
22
22
  let foundPath;
23
23
  for (const testPath of possiblePaths) {
24
- if (fs3.existsSync(testPath)) {
24
+ if (fs2.existsSync(testPath)) {
25
25
  foundPath = testPath;
26
26
  break;
27
27
  }
@@ -39,7 +39,7 @@ function dcsContentPlugin(options = {}) {
39
39
  };
40
40
  }
41
41
  try {
42
- const fileContent = fs3.readFileSync(foundPath, "utf8");
42
+ const fileContent = fs2.readFileSync(foundPath, "utf8");
43
43
  const content = yaml.load(fileContent);
44
44
  if (debug) {
45
45
  console.log(`[dcs-content] Loaded ${foundPath}`);
@@ -65,11 +65,11 @@ function dcsContentPlugin(options = {}) {
65
65
  configureServer(server) {
66
66
  const projectRoot = resolvedConfig?.root || process.cwd();
67
67
  const watchPaths = [
68
- path.resolve(projectRoot, contentPath),
69
- path.resolve(projectRoot, "..", contentPath)
68
+ path2.resolve(projectRoot, contentPath),
69
+ path2.resolve(projectRoot, "..", contentPath)
70
70
  ];
71
71
  watchPaths.forEach((watchPath) => {
72
- if (fs3.existsSync(watchPath)) {
72
+ if (fs2.existsSync(watchPath)) {
73
73
  server.watcher.add(watchPath);
74
74
  server.watcher.on("change", (changedPath) => {
75
75
  if (changedPath === watchPath) {
@@ -84,8 +84,83 @@ function dcsContentPlugin(options = {}) {
84
84
  }
85
85
  };
86
86
  }
87
+ function resolveOutDir(config) {
88
+ const out = config.build?.outDir || "dist";
89
+ return path2.isAbsolute(out) ? out : path2.resolve(config.root || process.cwd(), out);
90
+ }
91
+ function loadSeoConfig(projectRoot, seoPath, debug) {
92
+ const possiblePaths = [
93
+ path2.resolve(projectRoot, seoPath),
94
+ path2.resolve(projectRoot, "..", seoPath),
95
+ path2.resolve(process.cwd(), seoPath)
96
+ ];
97
+ let foundPath;
98
+ for (const testPath of possiblePaths) {
99
+ if (fs2.existsSync(testPath)) {
100
+ foundPath = testPath;
101
+ break;
102
+ }
103
+ }
104
+ if (!foundPath) {
105
+ if (debug) {
106
+ console.log("[dcs-seo] No seo.yaml found at:");
107
+ possiblePaths.forEach((p) => console.log(` - ${p}`));
108
+ console.log("[dcs-seo] Using defaults only");
109
+ }
110
+ return null;
111
+ }
112
+ try {
113
+ const fileContent = fs2.readFileSync(foundPath, "utf8");
114
+ const config = yaml.load(fileContent);
115
+ return { config, foundPath };
116
+ } catch (error) {
117
+ console.warn("[dcs-seo] Failed to parse seo.yaml:", error);
118
+ return null;
119
+ }
120
+ }
121
+ function routeToOutputFile(outDir, routePath) {
122
+ const trimmed = routePath.replace(/^\/+/, "").replace(/\/+$/, "");
123
+ if (trimmed === "") return path2.join(outDir, "index.html");
124
+ return path2.join(outDir, ...trimmed.split("/"), "index.html");
125
+ }
126
+ function emitStaticSeoHtml(params) {
127
+ const { outDir, shellHtml, routes, seoConfig, exclude = [], noindex = [], debug = false } = params;
128
+ const excludeSet = new Set(exclude);
129
+ const noindexSet = new Set(noindex);
130
+ let written = 0;
131
+ for (const route of routes) {
132
+ if (excludeSet.has(route.path) || route.slug && excludeSet.has(route.slug)) {
133
+ if (debug) console.log(`[dcs-seo] skip (excluded): ${route.path}`);
134
+ continue;
135
+ }
136
+ const forceNoindex = noindexSet.has(route.path) || route.slug && noindexSet.has(route.slug);
137
+ const tags = buildHeadTags(route.slug, route.path, seoConfig, {
138
+ includeKeywords: true,
139
+ fallbackTitle: route.title,
140
+ robots: forceNoindex ? "noindex, nofollow" : void 0
141
+ });
142
+ const html = spliceHeadHtml(shellHtml, tags);
143
+ const outFile = routeToOutputFile(outDir, route.path);
144
+ fs2.mkdirSync(path2.dirname(outFile), { recursive: true });
145
+ fs2.writeFileSync(outFile, html, "utf8");
146
+ written++;
147
+ if (debug) {
148
+ console.log(
149
+ `[dcs-seo] wrote ${path2.relative(outDir, outFile)} (title="${tags.title}"${forceNoindex ? ", noindex" : ""})`
150
+ );
151
+ }
152
+ }
153
+ return written;
154
+ }
87
155
  function dcsSeoPlugin(options = {}) {
88
- const { seoPath = ".dcs/seo.yaml", debug = false } = options;
156
+ const {
157
+ seoPath = ".dcs/seo.yaml",
158
+ debug = false,
159
+ emitStaticHtml = false,
160
+ pagesPath = ".dcs/pages.yaml",
161
+ exclude = [],
162
+ noindex = []
163
+ } = options;
89
164
  let resolvedConfig;
90
165
  return {
91
166
  name: "dcs-seo",
@@ -94,62 +169,93 @@ function dcsSeoPlugin(options = {}) {
94
169
  },
95
170
  config(config) {
96
171
  const projectRoot = config.root || process.cwd();
97
- const possiblePaths = [
98
- path.resolve(projectRoot, seoPath),
99
- path.resolve(projectRoot, "..", seoPath),
100
- path.resolve(process.cwd(), seoPath)
101
- ];
102
- let foundPath;
103
- for (const testPath of possiblePaths) {
104
- if (fs3.existsSync(testPath)) {
105
- foundPath = testPath;
106
- break;
107
- }
108
- }
109
- if (!foundPath) {
110
- if (debug) {
111
- console.log("[dcs-seo] No seo.yaml found at:");
112
- possiblePaths.forEach((p) => console.log(` - ${p}`));
113
- console.log("[dcs-seo] Using defaults only");
114
- }
172
+ const loaded = loadSeoConfig(projectRoot, seoPath, debug);
173
+ if (!loaded) {
115
174
  return {
116
175
  define: {
117
176
  __DCS_SEO__: "undefined"
118
177
  }
119
178
  };
120
179
  }
180
+ if (debug) {
181
+ console.log(`[dcs-seo] Loaded ${loaded.foundPath}`);
182
+ console.log(`[dcs-seo] Version: ${loaded.config.version}`);
183
+ console.log(`[dcs-seo] Site Name: ${loaded.config.global?.siteName || "(not set)"}`);
184
+ console.log(
185
+ `[dcs-seo] Pages: ${Object.keys(loaded.config.pages ?? {}).join(", ") || "(none)"}`
186
+ );
187
+ }
188
+ return {
189
+ define: {
190
+ __DCS_SEO__: JSON.stringify(loaded.config)
191
+ }
192
+ };
193
+ },
194
+ /**
195
+ * Build-time static `<head>` emitter (opt-in via `emitStaticHtml`).
196
+ *
197
+ * Runs after the bundle is written so the built `index.html` shell exists
198
+ * on disk. For an SPA, Vite emits a single `index.html`; we fan it out to
199
+ * per-route files with route-specific `<head>` content.
200
+ *
201
+ * Fully defensive: any missing/unparseable input logs a warning and
202
+ * no-ops. Never throws (so it can never break a production build).
203
+ */
204
+ writeBundle() {
205
+ if (!emitStaticHtml) return;
121
206
  try {
122
- const fileContent = fs3.readFileSync(foundPath, "utf8");
123
- const seoConfig = yaml.load(fileContent);
207
+ const projectRoot = resolvedConfig?.root || process.cwd();
208
+ const outDir = resolveOutDir(resolvedConfig);
209
+ if (!fs2.existsSync(outDir)) {
210
+ console.warn(`[dcs-seo] emitStaticHtml: outDir not found (${outDir}); skipping`);
211
+ return;
212
+ }
213
+ const shellPath = path2.join(outDir, "index.html");
214
+ if (!fs2.existsSync(shellPath)) {
215
+ console.warn(
216
+ `[dcs-seo] emitStaticHtml: no index.html in ${outDir}; nothing to emit`
217
+ );
218
+ return;
219
+ }
220
+ const routes = loadPagesManifest(projectRoot, pagesPath, debug);
221
+ if (!routes) {
222
+ console.warn(
223
+ `[dcs-seo] emitStaticHtml: ${pagesPath} missing or empty; skipping per-route emission`
224
+ );
225
+ return;
226
+ }
227
+ const loaded = loadSeoConfig(projectRoot, seoPath, debug);
228
+ if (!loaded) {
229
+ console.warn(
230
+ `[dcs-seo] emitStaticHtml: ${seoPath} missing or unparseable; emitting with defaults only`
231
+ );
232
+ }
233
+ const shellHtml = fs2.readFileSync(shellPath, "utf8");
234
+ const written = emitStaticSeoHtml({
235
+ outDir,
236
+ shellHtml,
237
+ routes,
238
+ seoConfig: loaded?.config,
239
+ exclude,
240
+ noindex,
241
+ debug
242
+ });
124
243
  if (debug) {
125
- console.log(`[dcs-seo] Loaded ${foundPath}`);
126
- console.log(`[dcs-seo] Version: ${seoConfig.version}`);
127
- console.log(`[dcs-seo] Site Name: ${seoConfig.global?.siteName || "(not set)"}`);
128
- console.log(`[dcs-seo] Pages: ${Object.keys(seoConfig.pages ?? {}).join(", ") || "(none)"}`);
244
+ console.log(`[dcs-seo] emitStaticHtml: wrote ${written} per-route HTML file(s)`);
129
245
  }
130
- return {
131
- define: {
132
- __DCS_SEO__: JSON.stringify(seoConfig)
133
- }
134
- };
135
246
  } catch (error) {
136
- console.warn("[dcs-seo] Failed to parse seo.yaml:", error);
137
- return {
138
- define: {
139
- __DCS_SEO__: "undefined"
140
- }
141
- };
247
+ console.warn("[dcs-seo] emitStaticHtml failed; build output left unchanged:", error);
142
248
  }
143
249
  },
144
250
  // Watch for changes in development
145
251
  configureServer(server) {
146
252
  const projectRoot = resolvedConfig?.root || process.cwd();
147
253
  const watchPaths = [
148
- path.resolve(projectRoot, seoPath),
149
- path.resolve(projectRoot, "..", seoPath)
254
+ path2.resolve(projectRoot, seoPath),
255
+ path2.resolve(projectRoot, "..", seoPath)
150
256
  ];
151
257
  watchPaths.forEach((watchPath) => {
152
- if (fs3.existsSync(watchPath)) {
258
+ if (fs2.existsSync(watchPath)) {
153
259
  server.watcher.add(watchPath);
154
260
  server.watcher.on("change", (changedPath) => {
155
261
  if (changedPath === watchPath) {
@@ -290,14 +396,14 @@ function buildPictureElement(entry, alt, extraAttrs, sizes) {
290
396
  }
291
397
  function loadCdnImageMap(projectRoot, relativeMapPath, debug) {
292
398
  const possiblePaths = [
293
- path.resolve(projectRoot, relativeMapPath),
294
- path.resolve(projectRoot, "..", relativeMapPath),
295
- path.resolve(process.cwd(), relativeMapPath)
399
+ path2.resolve(projectRoot, relativeMapPath),
400
+ path2.resolve(projectRoot, "..", relativeMapPath),
401
+ path2.resolve(process.cwd(), relativeMapPath)
296
402
  ];
297
403
  for (const testPath of possiblePaths) {
298
- if (fs3.existsSync(testPath)) {
404
+ if (fs2.existsSync(testPath)) {
299
405
  try {
300
- const raw = fs3.readFileSync(testPath, "utf8");
406
+ const raw = fs2.readFileSync(testPath, "utf8");
301
407
  const data = JSON.parse(raw);
302
408
  const map = /* @__PURE__ */ new Map();
303
409
  for (const entry of data.images) {
@@ -458,13 +564,13 @@ function dcsCdnBuildEnd(options = {}) {
458
564
  return;
459
565
  }
460
566
  const outDir = siteConfig.outDir;
461
- if (!fs3.existsSync(outDir)) return;
567
+ if (!fs2.existsSync(outDir)) return;
462
568
  let filesProcessed = 0;
463
569
  let refsReplaced = 0;
464
570
  function walkDir(dir) {
465
- const entries = fs3.readdirSync(dir, { withFileTypes: true });
571
+ const entries = fs2.readdirSync(dir, { withFileTypes: true });
466
572
  for (const entry of entries) {
467
- const fullPath = path.join(dir, entry.name);
573
+ const fullPath = path2.join(dir, entry.name);
468
574
  if (entry.isDirectory()) {
469
575
  walkDir(fullPath);
470
576
  } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
@@ -473,7 +579,7 @@ function dcsCdnBuildEnd(options = {}) {
473
579
  }
474
580
  }
475
581
  function processFile(filePath) {
476
- let content = fs3.readFileSync(filePath, "utf8");
582
+ let content = fs2.readFileSync(filePath, "utf8");
477
583
  if (!pathPrefixes.some((p) => content.includes(p))) return;
478
584
  let changed = false;
479
585
  if (filePath.endsWith(".html")) {
@@ -508,7 +614,7 @@ function dcsCdnBuildEnd(options = {}) {
508
614
  }
509
615
  }
510
616
  if (changed) {
511
- fs3.writeFileSync(filePath, content, "utf8");
617
+ fs2.writeFileSync(filePath, content, "utf8");
512
618
  filesProcessed++;
513
619
  }
514
620
  }
@@ -555,6 +661,6 @@ function responsiveImagePlugin(md) {
555
661
  };
556
662
  }
557
663
 
558
- export { dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsPreviewPlugin, dcsSeoPlugin, responsiveImagePlugin };
664
+ export { dcsCdnBuildEnd, dcsCdnImagePlugin, dcsContentPlugin, dcsEditorPlugin, dcsPreviewPlugin, dcsSeoPlugin, emitStaticSeoHtml, responsiveImagePlugin };
559
665
  //# sourceMappingURL=index.js.map
560
666
  //# sourceMappingURL=index.js.map