freestruct 1.0.0 → 1.1.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.
package/engine/config.js CHANGED
@@ -20,7 +20,8 @@ export const KNOWN_CONFIG_KEYS = Object.freeze([
20
20
  'site', 'twitter', 'og', 'author', 'jsonLd', 'basePath', 'outputDir', 'minify', 'runHooks',
21
21
  'generateSitemap', 'generateFeed', 'generate404', 'generateRobots',
22
22
  'readingTime', 'lastModified', 'lazyLoad', 'linkCheck', 'searchIndex',
23
- 'cacheBusting',
23
+ 'cacheBusting', 'hooks', 'indexNow', 'ogImageGeneration',
24
+ 'search', 'breadcrumb', 'verification', 'llms', 'locales', 'article',
24
25
  ]);
25
26
 
26
27
  // Flags that EXISTED and no longer do anything. We warn loudly, never guess.
@@ -82,6 +83,211 @@ export function collectConfigDiagnostics(config, configName) {
82
83
  if (config.site.url === undefined) warnings.push('site.url missing - canonical/sitemap/feed urls will be broken');
83
84
  }
84
85
 
86
+ // og.image shape (issue #20): the back-compat string form is always fine;
87
+ // the object form accepts { src, width, height, alt, allowSvg }. Wrong
88
+ // shapes warn loudly here and the engine keeps its behavior honest (no
89
+ // image ships when src is not a usable string).
90
+ const ogImageShape = config.og?.image;
91
+ if (ogImageShape && typeof ogImageShape === 'string' && ogImageShape !== ''
92
+ && /\.svg$/i.test(ogImageShape.split(/[?#]/)[0])) {
93
+ warnings.push('og.image src "' + ogImageShape + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or switch to the object form { src: ..., allowSvg: true } to accept it)');
94
+ }
95
+ if (ogImageShape !== undefined && ogImageShape !== null && typeof ogImageShape === 'object') {
96
+ if (Array.isArray(ogImageShape)) {
97
+ warnings.push('og.image must be a string or a { src, width, height, alt } object, not an array - no image ships');
98
+ } else {
99
+ if (typeof ogImageShape.src !== 'string' || ogImageShape.src === '') {
100
+ warnings.push('og.image object is missing a string src - no image ships (the plain string form still works)');
101
+ } else if (/\.svg$/i.test(String(ogImageShape.src).split(/[?#]/)[0])
102
+ && ogImageShape.allowSvg !== true) {
103
+ warnings.push('og.image src "' + ogImageShape.src + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or set allowSvg: true to accept it)');
104
+ }
105
+ const KNOWN_OGIMAGE_KEYS = ['src', 'width', 'height', 'alt', 'allowSvg'];
106
+ const unknownOg = Object.keys(ogImageShape).filter(k => !KNOWN_OGIMAGE_KEYS.includes(k));
107
+ if (unknownOg.length > 0) {
108
+ warnings.push('unknown og.image companion key(s): "' + unknownOg.join('", "') + '" (known: ' + KNOWN_OGIMAGE_KEYS.join(', ') + ')');
109
+ }
110
+ if (ogImageShape.width !== undefined && ogImageShape.width !== '') {
111
+ const w = Number(ogImageShape.width);
112
+ if (!Number.isFinite(w) || w <= 0) warnings.push('og.image.width must be a positive number - it will be dropped');
113
+ }
114
+ if (ogImageShape.height !== undefined && ogImageShape.height !== '') {
115
+ const h = Number(ogImageShape.height);
116
+ if (!Number.isFinite(h) || h <= 0) warnings.push('og.image.height must be a positive number - it will be dropped');
117
+ }
118
+ if (ogImageShape.alt !== undefined && typeof ogImageShape.alt !== 'string' && typeof ogImageShape.alt !== 'number') {
119
+ warnings.push('og.image.alt must be a string or number - it will be dropped');
120
+ }
121
+ if (ogImageShape.allowSvg !== undefined && typeof ogImageShape.allowSvg !== 'boolean') {
122
+ warnings.push('og.image.allowSvg must be a boolean');
123
+ }
124
+ }
125
+ }
126
+
127
+ // hooks shape (issue #21): hooks is an object keyed by hook seam; each
128
+ // seam takes one string command or a list of them. Unknown seams and
129
+ // non-string entries warn loudly here, and the runner treats both
130
+ // honestly (non-strings are skipped, unknown seams have no runner).
131
+ if (config.hooks !== undefined && (typeof config.hooks !== 'object' || config.hooks === null || Array.isArray(config.hooks))) {
132
+ warnings.push('hooks must be a { postBuild: [...] } object, not ' + (Array.isArray(config.hooks) ? 'an array' : typeof config.hooks));
133
+ } else if (config.hooks) {
134
+ const KNOWN_HOOK_SEAMS = ['postBuild'];
135
+ for (const seam of Object.keys(config.hooks)) {
136
+ if (KNOWN_HOOK_SEAMS.includes(seam)) continue;
137
+ warnings.push('unknown hooks seam "' + seam + '" (known: ' + KNOWN_HOOK_SEAMS.join(', ') + ')');
138
+ }
139
+ const postBuild = config.hooks.postBuild;
140
+ if (postBuild !== undefined && !Array.isArray(postBuild) && typeof postBuild !== 'string') {
141
+ warnings.push('hooks.postBuild must be a string command or a list of string commands');
142
+ } else if (Array.isArray(postBuild)) {
143
+ for (const entry of postBuild) {
144
+ if (typeof entry !== 'string' || entry === '') {
145
+ warnings.push('every hooks.postBuild entry must be a non-empty string command');
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // indexNow shape (issue #19): true (generate-once-and-persist) or
153
+ // { key: '<32-hex>' }. A wrong-shaped key warns here and the indexnow
154
+ // subcommand refuses it too - no silently-dead submission command.
155
+ if (config.indexNow !== undefined && config.indexNow !== null && config.indexNow !== false) {
156
+ if (config.indexNow === true) {
157
+ // generate-once-and-persist path: shape is fine
158
+ } else if (typeof config.indexNow === 'string' || typeof config.indexNow === 'object') {
159
+ const key = typeof config.indexNow === 'string' ? config.indexNow : config.indexNow.key;
160
+ if (!/^[a-f0-9]{32}$/.test(String(key || ''))) {
161
+ warnings.push('indexNow key must be 32 lowercase hex chars (the IndexNow key contract) - the key file will not ship');
162
+ }
163
+ if (typeof config.indexNow === 'object') {
164
+ const unknownIdx = Object.keys(config.indexNow).filter(k => k !== 'key');
165
+ if (unknownIdx.length > 0) {
166
+ warnings.push('unknown indexNow key(s): "' + unknownIdx.join('", "') + '"');
167
+ }
168
+ }
169
+ } else {
170
+ warnings.push('indexNow must be true or { key: "<32-hex>" }');
171
+ }
172
+ }
173
+
174
+ // ogImageGeneration shape (issue #22): a { template, font?, width?,
175
+ // height? } object. Runtime resolution (engine/ogimage.js) warns on
176
+ // missing templates; here we catch the key-level shape mistakes.
177
+ if (config.ogImageGeneration !== undefined && (typeof config.ogImageGeneration !== 'object' || config.ogImageGeneration === null || Array.isArray(config.ogImageGeneration))) {
178
+ warnings.push('ogImageGeneration must be a { template, font, width, height } object, not ' + (Array.isArray(config.ogImageGeneration) ? 'an array' : typeof config.ogImageGeneration));
179
+ }
180
+
181
+ // jsonLd map shape (issue #25): the legacy forms are a single object or a
182
+ // list; the scoped map form keys every entry by a slug glob whose value is
183
+ // an LD+JSON object or a list of them. Wrong shapes warn loudly; the
184
+ // runner skips bad values per block rather than dropping the global set.
185
+ if (config.jsonLd !== undefined && !Array.isArray(config.jsonLd) && config.jsonLd !== null) {
186
+ if (typeof config.jsonLd !== 'object') {
187
+ warnings.push('jsonLd must be an LD+JSON object, a list of them, or a slug-glob map of them');
188
+ } else if (Object.keys(config.jsonLd).length > 0) {
189
+ const pathish = (k) => /^\//.test(k) || k === '*' || k === '**' || k.includes('*');
190
+ const keys = Object.keys(config.jsonLd);
191
+ const allPathish = keys.every(pathish);
192
+ const anyPathish = keys.some(pathish);
193
+ if (allPathish) {
194
+ // scoped map: validate each value's shape up front
195
+ for (const key of keys) {
196
+ const v = config.jsonLd[key];
197
+ const blocks = Array.isArray(v) ? v : [v];
198
+ if (blocks.length === 0 || blocks.some((b) => !b || typeof b !== 'object' || Array.isArray(b))) {
199
+ warnings.push('jsonLd "' + key + '" must map to an LD+JSON object or a list of them');
200
+ }
201
+ }
202
+ } else if (anyPathish) {
203
+ warnings.push('jsonLd mixes slug-glob keys with schema-object keys - either use the scoped map form (every key a glob) or the legacy object form; the run skips no block but reads it as a legacy object');
204
+ }
205
+ }
206
+ }
207
+
208
+ // og.scoped shape (issue #25 follow-on): a slug-glob map whose values take
209
+ // the og.image shapes (string, or the structured object form).
210
+ if (config.og?.scoped !== undefined && (typeof config.og.scoped !== 'object' || config.og.scoped === null || Array.isArray(config.og.scoped))) {
211
+ warnings.push('og.scoped must be a slug-glob map of og.image values (string or { src, width, height, alt, allowSvg }), not ' + (Array.isArray(config.og.scoped) ? 'an array' : typeof config.og.scoped));
212
+ } else if (config.og?.scoped && typeof config.og.scoped === 'object') {
213
+ for (const [key, v] of Object.entries(config.og.scoped)) {
214
+ if (!/^\//.test(key) && key !== '*' && !key.includes('*')) {
215
+ warnings.push('og.scoped keys must be slug globs (starting with "/", optionally with * / **) - skipping "' + key + '"');
216
+ }
217
+ if (v === null || Array.isArray(v) || (typeof v !== 'string' && typeof v !== 'object')) {
218
+ warnings.push('og.scoped "' + key + '" must be an og image string or a { src, width, height, alt, allowSvg } object (a list of images is not a variant - one page matches one image) - no image ships from this key');
219
+ }
220
+ }
221
+ }
222
+
223
+ // Per-page JSON-LD type override shape (SEO-gap pass): a slug-glob map
224
+ // keyed like og.scoped; the page-level value decides WHAT article type a
225
+ // page gets when it does not already carry LD+JSON.
226
+ if (config.article?.scoped !== undefined && (typeof config.article.scoped !== 'object' || config.article.scoped === null || Array.isArray(config.article.scoped))) {
227
+ warnings.push('article.scoped must be a slug-glob map of LD+JSON type strings ("Article", "TechArticle", "NewsArticle"), not ' + (Array.isArray(config.article.scoped) ? 'an array' : typeof config.article.scoped));
228
+ } else if (config.article?.scoped && typeof config.article.scoped === 'object') {
229
+ for (const [key, v] of Object.entries(config.article.scoped)) {
230
+ if (!/^\//.test(key) && !key.includes('*')) {
231
+ warnings.push('article.scoped keys must be slug globs (starting with "/", optionally with * / **) - skipping "' + key + '"');
232
+ }
233
+ if (!['Article', 'TechArticle', 'NewsArticle'].includes(String(v))) {
234
+ warnings.push('article.scoped "' + key + '" must be "Article", "TechArticle" or "NewsArticle" (got "' + String(v) + '") - the page gets no article type');
235
+ }
236
+ }
237
+ }
238
+
239
+ // search shape (SEO-gap pass): { url: ... } - the SearchAction target.
240
+ // The {search_term_string} placeholder in the URL is the schema.org
241
+ // contract; anything else turns the potentialAction block into a broken
242
+ // search target, so the shape diagnostic warns below.
243
+ if (config.search !== undefined && config.search !== null && (typeof config.search !== 'object' || Array.isArray(config.search))) {
244
+ warnings.push('search must be a { url: "..." } object');
245
+ } else if (config.search?.url !== undefined) {
246
+ if (typeof config.search.url !== 'string' || config.search.url === '') {
247
+ warnings.push('search.url must be a non-empty string URL with a {search_term_string} placeholder');
248
+ } else if (!config.search.url.includes('{search_term_string}')) {
249
+ warnings.push('search.url must contain the {search_term_string} placeholder or the SearchAction block is a broken search target - omitted');
250
+ }
251
+ }
252
+
253
+ // breadcrumb shape (SEO-gap pass): true opts the page into auto-built
254
+ // BreadcrumbList LD+JSON from the page's own position in the URL tree.
255
+ if (config.breadcrumb !== undefined && typeof config.breadcrumb !== 'boolean') {
256
+ warnings.push('breadcrumb must be true (auto BreadcrumbList from URL structure)');
257
+ }
258
+
259
+ // verification shape (SEO-gap pass): map of provider -> content value.
260
+ if (config.verification !== undefined && config.verification !== null && (typeof config.verification !== 'object' || Array.isArray(config.verification))) {
261
+ warnings.push('verification must be an object of provider -> verification-string (google, bing, yandex, pinterest)');
262
+ } else if (config.verification) {
263
+ const KNOWN_VERIFICATION_PROVIDERS = ['google', 'bing', 'yandex', 'pinterest'];
264
+ for (const [provider, v] of Object.entries(config.verification)) {
265
+ if (!KNOWN_VERIFICATION_PROVIDERS.includes(provider)) {
266
+ warnings.push('unknown verification provider "' + provider + '" (known: ' + KNOWN_VERIFICATION_PROVIDERS.join(', ') + ') - no meta ships for it');
267
+ } else if (typeof v !== 'string' || v === '') {
268
+ warnings.push('verification.' + provider + ' must be a non-empty string verification code - no meta ships for it');
269
+ }
270
+ }
271
+ }
272
+
273
+ // llms shape (SEO-gap pass): true opts the run into emitting llms.txt in
274
+ // the output dir (the AI-consumable docs summary convention).
275
+ if (config.llms !== undefined && typeof config.llms !== 'boolean') {
276
+ warnings.push('llms must be true (emit llms.txt in the output dir)');
277
+ }
278
+
279
+ // locales shape (SEO-gap pass): map of slug glob -> language code. Pages
280
+ // that match a pattern get hreflang alternates pointing at its siblings.
281
+ if (config.locales !== undefined && config.locales !== null && (typeof config.locales !== 'object' || Array.isArray(config.locales))) {
282
+ warnings.push('locales must be a map of slug glob -> language code (e.g. "/de/**: de"), not ' + (Array.isArray(config.locales) ? 'an array' : typeof config.locales));
283
+ } else if (config.locales && typeof config.locales === 'object') {
284
+ for (const [pattern, lang] of Object.entries(config.locales)) {
285
+ if (typeof lang !== 'string' || lang === '') {
286
+ warnings.push('locales "' + pattern + '" must map to a non-empty language code string');
287
+ }
288
+ }
289
+ }
290
+
85
291
  for (const w of warnings) {
86
292
  console.log('[freestruct] config warning: ' + w);
87
293
  }
package/engine/hooks.js CHANGED
@@ -1,8 +1,30 @@
1
- // engine/hooks.js - purge hooks (Phase 1.1 split)
1
+ // engine/hooks.js - purge hooks + general postBuild hooks (Phase 1.1 split)
2
2
  // Same security posture as 0.4.x: hooks run only with runHooks: true.
3
3
 
4
4
  import { execSync } from 'child_process';
5
5
 
6
+ // Issue #24: values ride FUNCTION replacers, never string-form replace
7
+ // strings. In string form a value carrying $& / $' / $1..9 is treated as
8
+ // replacement SYNTAX and corrupts the command that is about to run.
9
+ function substitutePlaceholders(command, { siteUrl, buildHash, outputDir }) {
10
+ return command
11
+ .replace(/\$\{?SITE_URL\}?/g, () => siteUrl)
12
+ .replace(/\$\{?BUILD_HASH\}?/g, () => buildHash)
13
+ .replace(/\$\{?OUTPUT_DIR\}?/g, () => outputDir);
14
+ }
15
+
16
+ // Hook children get the same facts as the placeholder substitution, as env
17
+ // vars, so a hook script does not have to do string surgery to learn where
18
+ // the build landed (issue #21: postBuild consumers chain scripts cleanly).
19
+ function hookEnv({ siteUrl, buildHash, outputDir }) {
20
+ return {
21
+ ...process.env,
22
+ FREESTRUCT_SITE_URL: siteUrl,
23
+ FREESTRUCT_BUILD_HASH: buildHash,
24
+ FREESTRUCT_OUTPUT_DIR: outputDir,
25
+ };
26
+ }
27
+
6
28
  export function runPurgeHooks(config, buildHash, outputDir) {
7
29
  const siteUrl = config.site.url;
8
30
  const purgeList = Array.isArray(config.cacheBusting.purge)
@@ -18,15 +40,7 @@ export function runPurgeHooks(config, buildHash, outputDir) {
18
40
  continue;
19
41
  }
20
42
 
21
- command = command
22
- .replace(/\$SITE_URL/g, siteUrl)
23
- .replace(/\$BUILD_HASH/g, buildHash)
24
- .replace(/\$OUTPUT_DIR/g, outputDir);
25
-
26
- command = command
27
- .replace(/\$\{SITE_URL\}/g, siteUrl)
28
- .replace(/\$\{BUILD_HASH\}/g, buildHash)
29
- .replace(/\$\{OUTPUT_DIR\}/g, outputDir);
43
+ command = substitutePlaceholders(command, { siteUrl, buildHash, outputDir });
30
44
 
31
45
  console.log('Purge hook ' + name + ': running...');
32
46
  try {
@@ -43,3 +57,47 @@ export function runPurgeHooks(config, buildHash, outputDir) {
43
57
  }
44
58
  }
45
59
  }
60
+
61
+ // ---------- general postBuild hooks (issue #21) ----------
62
+
63
+ // Generalize the already-reviewed purge runner into a first-class seam:
64
+ // hooks:
65
+ // postBuild:
66
+ // - node scripts/whatever.mjs
67
+ // Same contract as the purges: nothing executes without runHooks: true,
68
+ // execSync carries a timeout, stdio is inherited, the same $-placeholders
69
+ // (plus the FREESTRUCT_* env vars) reach the child. Unlike purge hooks, a
70
+ // nonzero hook exit is collected and the orchestrator turns the collection
71
+ // into a build failure (exit 4 - documented in the CLI contract).
72
+ export function runPostBuildHooks(config, buildHash, outputDir) {
73
+ const raw = config.hooks?.postBuild;
74
+ if (raw === undefined || raw === null) return [];
75
+ const commands = Array.isArray(raw) ? raw : [raw];
76
+ if (commands.length === 0) return [];
77
+
78
+ const siteUrl = config.site.url;
79
+ const failures = [];
80
+ for (const entry of commands) {
81
+ if (typeof entry !== 'string' || entry === '') {
82
+ console.log('[freestruct] hooks.postBuild entry is not a non-empty string command - skipped');
83
+ continue;
84
+ }
85
+ const command = substitutePlaceholders(entry, { siteUrl, buildHash, outputDir });
86
+
87
+ if (!config.runHooks) {
88
+ console.log('PostBuild hook: Command: ' + command.substring(0, 100) + '...');
89
+ console.log('PostBuild hooks disabled (set runHooks: true to enable)');
90
+ return [];
91
+ }
92
+
93
+ console.log('PostBuild hook: running ' + command.substring(0, 76) + '...');
94
+ try {
95
+ execSync(command, { stdio: 'inherit', timeout: 30000, env: hookEnv({ siteUrl, buildHash, outputDir }) });
96
+ console.log('PostBuild hook: done');
97
+ } catch (e) {
98
+ console.log(' PostBuild hook: FAILED (exit ' + e.status + ')');
99
+ failures.push(e);
100
+ }
101
+ }
102
+ return failures;
103
+ }
@@ -0,0 +1,140 @@
1
+ // engine/indexnow.js - IndexNow key-file emission + submission (issue #19)
2
+ // IndexNow is a one-POST notify protocol (Bing + Seznam + Yandex), and this
3
+ // engine is the component that already owns the sitemap, so the fit is
4
+ // natural: key file into the output dir (the keyLocation contract), a
5
+ // `freestruct indexnow` subcommand that reads the baked sitemap.xml and
6
+ // POSTs the URL list. Honesty contract: submission only makes sense AFTER
7
+ // the output is deployed live (keyLocation must resolve) - docs say so; the
8
+ // command surfaces HTTP status codes in its output; --dry-run prints the
9
+ // request body without touching the network for CI.
10
+
11
+ import fs from 'fs';
12
+ import crypto from 'crypto';
13
+ import path from 'path';
14
+
15
+ export const INDEXNOW_API = 'https://api.indexnow.org/indexnow';
16
+ const KEYFILE_HIDDEN = ['indexnow-key.txt'];
17
+
18
+ // ---------- key resolution ----------
19
+
20
+ /** 32 lowercase hex chars is the documented IndexNow key shape. */
21
+ export function isValidIndexNowKey(key) {
22
+ return typeof key === 'string' && /^[a-f0-9]{32}$/.test(key);
23
+ }
24
+
25
+ /**
26
+ * Resolve the IndexNow key for a run.
27
+ * indexNow.key (config, 32-hex) wins
28
+ * indexNow: true reads configDir/indexnow-key.txt, or
29
+ * generates one and PERSISTS it (generate-
30
+ * once: the same key across runs keeps the
31
+ * keyLocation stable at the search engines)
32
+ * Rotating? Change the config key or delete indexnow-key.txt - loudly logged.
33
+ * Returns { key, source } or null when IndexNow is not configured.
34
+ */
35
+ export function resolveIndexNowKey(config, configDir) {
36
+ const cfg = config.indexNow;
37
+ if (cfg === undefined || cfg === null || cfg === false) return null;
38
+ if (typeof cfg === 'string' || typeof cfg === 'object') {
39
+ const key = typeof cfg === 'string' ? cfg : cfg.key;
40
+ if (key !== undefined && key !== null) {
41
+ if (!isValidIndexNowKey(key)) return null; // config diagnostics already warned
42
+ return { key, source: 'config' };
43
+ }
44
+ }
45
+ const persistPath = path.join(configDir, 'indexnow-key.txt');
46
+ if (fs.existsSync(persistPath)) {
47
+ const key = fs.readFileSync(persistPath, 'utf8').trim();
48
+ if (isValidIndexNowKey(key)) return { key, source: 'persisted (' + KEYFILE_HIDDEN[0] + ')' };
49
+ }
50
+ const key = crypto.randomBytes(16).toString('hex');
51
+ fs.writeFileSync(persistPath, key + '\n', 'utf8');
52
+ console.log('[freestruct] indexnow: generated a new key and persisted it to ' + persistPath
53
+ + ' (generate-once: commit this file so the keyLocation stays stable across runs)');
54
+ return { key, source: 'generated' };
55
+ }
56
+
57
+ /** Emit <key>.txt into the output dir (the keyLocation file search engines probe). */
58
+ export function emitIndexNowKeyFile(key, outputDir) {
59
+ const keyFile = path.join(outputDir, key + '.txt');
60
+ fs.writeFileSync(keyFile, key + '\n', 'utf8');
61
+ console.log('[freestruct] indexnow: key file written: ' + key + '.txt (deploy it live - keyLocation must resolve)');
62
+ return keyFile;
63
+ }
64
+
65
+ // ---------- submission ----------
66
+
67
+ /** Read the baked sitemap.xml and return its URL list (null when absent/empty). */
68
+ export function readSitemapUrls(outputDir) {
69
+ const sitemapPath = path.join(outputDir, 'sitemap.xml');
70
+ if (!fs.existsSync(sitemapPath)) return null;
71
+ const sitemap = fs.readFileSync(sitemapPath, 'utf8');
72
+ const urls = (sitemap.match(/<loc>([\s\S]*?)<\/loc>/g) || [])
73
+ .map(e => e.replace(/<\/?loc>/g, '').trim())
74
+ .filter(Boolean);
75
+ return urls.length > 0 ? urls : null;
76
+ }
77
+
78
+ /**
79
+ * POST the sitemap URL list to api.indexnow.org. Returns the HTTP status so
80
+ * the caller can surface it (202 = accepted). `dryRun` skips the network.
81
+ */
82
+ export async function submitIndexNow({ config, configDir, outputDir, dryRun }) {
83
+ const resolved = resolveIndexNowKey(config, configDir);
84
+ if (!resolved) {
85
+ console.log('[freestruct] indexnow: not configured (set indexNow in the config)');
86
+ return { ok: false, status: null, reason: 'not-configured' };
87
+ }
88
+ if (!isValidIndexNowKey(config.indexNow?.key) && (config.indexNow === undefined)) {
89
+ console.log('[freestruct] indexnow: no key resolved');
90
+ return { ok: false, status: null, reason: 'no-key' };
91
+ }
92
+
93
+ const urls = readSitemapUrls(outputDir);
94
+ if (!urls) {
95
+ console.log('[freestruct] indexnow: no sitemap.xml with <loc> entries in ' + outputDir
96
+ + ' - run freestruct first (indexnow reads the baked sitemap)');
97
+ return { ok: false, status: null, reason: 'no-sitemap' };
98
+ }
99
+
100
+ let host;
101
+ try { host = new URL(config.site.url).host; } catch (e) {
102
+ console.log('[freestruct] indexnow: site.url is not parseable - cannot derive host');
103
+ return { ok: false, status: null, reason: 'bad-site-url' };
104
+ }
105
+
106
+ const payload = {
107
+ host,
108
+ key: resolved.key,
109
+ urlList: urls,
110
+ keyLocation: config.site.url.replace(/\/$/, '') + '/' + resolved.key + '.txt',
111
+ };
112
+
113
+ console.log('[freestruct] indexnow: submitting ' + urls.length + ' URL(s) to ' + INDEXNOW_API
114
+ + ' (key source: ' + resolved.source + ')');
115
+ if (dryRun) {
116
+ console.log('[freestruct] indexnow: --dry-run, request body:');
117
+ console.log(JSON.stringify(payload, null, 2));
118
+ return { ok: true, status: 'dry-run', reason: 'dry-run' };
119
+ }
120
+
121
+ let res;
122
+ try {
123
+ res = await fetch(INDEXNOW_API, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
126
+ body: JSON.stringify(payload),
127
+ });
128
+ } catch (e) {
129
+ console.log('[freestruct] indexnow: request failed: ' + e.message);
130
+ return { ok: false, status: null, reason: 'network-error' };
131
+ }
132
+
133
+ console.log('[freestruct] indexnow: HTTP ' + res.status
134
+ + (res.status === 200 || res.status === 202 ? ' (accepted)' : ' (NOT accepted - the engines surface failures; check key file live deployment)'));
135
+ if (res.status !== 200 && res.status !== 202) {
136
+ const body = await res.text().catch(() => '');
137
+ if (body) console.log('[freestruct] indexnow: response: ' + body.slice(0, 300));
138
+ }
139
+ return { ok: res.status === 200 || res.status === 202, status: res.status, reason: 'http' };
140
+ }