@griddo/cx 11.13.3 → 11.14.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.
Files changed (37) hide show
  1. package/build/commands/end-render.js +31 -31
  2. package/build/commands/end-render.js.map +4 -4
  3. package/build/commands/notify-indexnow.d.ts +1 -0
  4. package/build/commands/notify-indexnow.js +161 -0
  5. package/build/commands/notify-indexnow.js.map +7 -0
  6. package/build/commands/prepare-assets-directory.js +12 -12
  7. package/build/commands/prepare-assets-directory.js.map +3 -3
  8. package/build/commands/prepare-domains-render.js +12 -12
  9. package/build/commands/prepare-domains-render.js.map +3 -3
  10. package/build/commands/reset-render.js +23 -23
  11. package/build/commands/reset-render.js.map +3 -3
  12. package/build/commands/start-render.js +51 -51
  13. package/build/commands/start-render.js.map +4 -4
  14. package/build/commands/upload-search-content.js +12 -12
  15. package/build/commands/upload-search-content.js.map +3 -3
  16. package/build/index.js +5 -3
  17. package/build/services/indexnow-key-file.d.ts +22 -0
  18. package/build/services/indexnow-notify.d.ts +29 -0
  19. package/build/services/indexnow-payload.d.ts +54 -0
  20. package/build/services/render.d.ts +2 -1
  21. package/build/shared/envs.d.ts +2 -1
  22. package/cli.mjs +9 -0
  23. package/exporter/build.sh +1 -0
  24. package/exporter/commands/end-render.ts +11 -0
  25. package/exporter/commands/notify-indexnow.ts +19 -0
  26. package/exporter/services/generate-md.ts +7 -10
  27. package/exporter/services/indexnow-key-file.ts +58 -0
  28. package/exporter/services/indexnow-notify.ts +196 -0
  29. package/exporter/services/indexnow-payload.ts +195 -0
  30. package/exporter/services/llms.ts +14 -1
  31. package/exporter/services/render.ts +6 -0
  32. package/exporter/services/store.ts +24 -0
  33. package/exporter/shared/envs.ts +2 -0
  34. package/exporter/ssg-adapters/gatsby/actions/meta.ts +2 -0
  35. package/exporter/ssg-adapters/gatsby/actions/sync.ts +6 -1
  36. package/exporter/ssg-adapters/gatsby/shared/sync-render.ts +1 -5
  37. package/package.json +4 -3
@@ -0,0 +1,195 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import pLimit from "p-limit";
5
+
6
+ import { GriddoLog } from "../core/GriddoLog";
7
+ import { GRIDDO_API_CONCURRENCY_COUNT, GRIDDO_INDEXNOW_ENABLED } from "../shared/envs";
8
+ import type { GriddoPageObject } from "../shared/types/pages";
9
+ import type { Site } from "../shared/types/sites";
10
+ import { getRenderPathsHydratedWithDomainFromDB } from "./render";
11
+ import { getPage } from "./sites";
12
+
13
+ export interface IndexNowUpsertEntry {
14
+ id: number;
15
+ url: string;
16
+ siteId: number;
17
+ }
18
+
19
+ export interface IndexNowDeleteEntry {
20
+ id: number;
21
+ url: string;
22
+ siteId: number;
23
+ reason: "offlinePending" | "siteUnpublished";
24
+ }
25
+
26
+ export interface IndexNowPayloadFile {
27
+ domain: string;
28
+ renderedAt: string;
29
+ upsert: IndexNowUpsertEntry[];
30
+ delete: IndexNowDeleteEntry[];
31
+ skipped: {
32
+ noindex: number;
33
+ noUrlResolved: number;
34
+ paginatedSecondary: number;
35
+ };
36
+ }
37
+
38
+ interface FlushOptions {
39
+ domain: string;
40
+ sitesToPublish: Site[];
41
+ sitesToUnpublish: Site[];
42
+ }
43
+
44
+ interface CollectOptions {
45
+ siteId: number;
46
+ pageId: number;
47
+ pageObjects: GriddoPageObject[];
48
+ uploadPending: number[];
49
+ }
50
+
51
+ /**
52
+ * Collects URLs that should be notified to IndexNow during `createStore`.
53
+ * Intended usage:
54
+ * - `collect()` during the per-page loop (reuses the already-fetched page
55
+ * objects, so upserts cost 0 extra API calls).
56
+ * - `flush()` once per render to resolve delete URLs via `getPage` and write
57
+ * the payload file to `.griddo/cache/<domain>/indexnow-payload.json`.
58
+ */
59
+ class IndexNowCollector {
60
+ public readonly enabled: boolean;
61
+ private upserts: IndexNowUpsertEntry[] = [];
62
+ private skipped = { noindex: 0, noUrlResolved: 0, paginatedSecondary: 0 };
63
+
64
+ constructor(enabled: boolean = GRIDDO_INDEXNOW_ENABLED) {
65
+ this.enabled = enabled;
66
+ }
67
+
68
+ collect(options: CollectOptions): void {
69
+ if (!this.enabled) return;
70
+ const { siteId, pageId, pageObjects, uploadPending } = options;
71
+ if (!uploadPending.includes(pageId)) return;
72
+
73
+ for (const obj of pageObjects) {
74
+ const entry = this.extractCanonical(obj, siteId, pageId);
75
+ if (entry) this.upserts.push(entry);
76
+ }
77
+ }
78
+
79
+ async flush(options: FlushOptions): Promise<void> {
80
+ if (!this.enabled) return;
81
+ const { domain, sitesToPublish, sitesToUnpublish } = options;
82
+
83
+ const { __cache } = await getRenderPathsHydratedWithDomainFromDB({ domain });
84
+ const payloadPath = path.join(__cache, "indexnow-payload.json");
85
+
86
+ // Always drop the previous payload first so a failure leaves no stale file.
87
+ await safeUnlink(payloadPath);
88
+
89
+ const deletes: IndexNowDeleteEntry[] = [];
90
+ const limit = pLimit(GRIDDO_API_CONCURRENCY_COUNT);
91
+ const jobs: Promise<void>[] = [];
92
+
93
+ for (const site of sitesToPublish) {
94
+ for (const id of site.pagesStatus.offlinePending) {
95
+ jobs.push(
96
+ limit(async () => {
97
+ const url = await this.resolveUrlForId(id);
98
+ if (url) {
99
+ deletes.push({ id, url, siteId: site.id, reason: "offlinePending" });
100
+ } else {
101
+ this.skipped.noUrlResolved++;
102
+ }
103
+ }),
104
+ );
105
+ }
106
+ }
107
+
108
+ for (const site of sitesToUnpublish) {
109
+ const ids = new Set<number>([
110
+ ...site.pagesStatus.active,
111
+ ...site.pagesStatus.offlinePending,
112
+ ]);
113
+ for (const id of ids) {
114
+ jobs.push(
115
+ limit(async () => {
116
+ const url = await this.resolveUrlForId(id);
117
+ if (url) {
118
+ deletes.push({ id, url, siteId: site.id, reason: "siteUnpublished" });
119
+ } else {
120
+ this.skipped.noUrlResolved++;
121
+ }
122
+ }),
123
+ );
124
+ }
125
+ }
126
+
127
+ await Promise.all(jobs);
128
+
129
+ const payload: IndexNowPayloadFile = {
130
+ domain,
131
+ renderedAt: new Date().toISOString(),
132
+ upsert: this.upserts,
133
+ delete: deletes,
134
+ skipped: this.skipped,
135
+ };
136
+
137
+ await fsp.mkdir(__cache, { recursive: true });
138
+ await fsp.writeFile(payloadPath, JSON.stringify(payload, null, 2), "utf-8");
139
+ GriddoLog.verbose(
140
+ `indexnow payload written (upsert:${payload.upsert.length} delete:${payload.delete.length} skipped:${JSON.stringify(payload.skipped)})`,
141
+ );
142
+ }
143
+
144
+ private extractCanonical(
145
+ obj: GriddoPageObject,
146
+ siteId: number,
147
+ pageId: number,
148
+ ): IndexNowUpsertEntry | null {
149
+ const page = obj.context.page as { fullUrl?: string; template?: { pageNumber?: number } };
150
+ const meta = obj.context.pageMetadata;
151
+
152
+ // Skip paginated secondary pages (/2/, /3/...). Page 1 is the canonical entry.
153
+ const pageNumber = page?.template?.pageNumber;
154
+ if (typeof pageNumber === "number" && pageNumber > 1) {
155
+ this.skipped.paginatedSecondary++;
156
+ return null;
157
+ }
158
+
159
+ const url = page?.fullUrl;
160
+ if (!url) {
161
+ this.skipped.noUrlResolved++;
162
+ return null;
163
+ }
164
+
165
+ if (meta?.index === "noindex") {
166
+ this.skipped.noindex++;
167
+ return null;
168
+ }
169
+
170
+ return { id: pageId, url, siteId };
171
+ }
172
+
173
+ private async resolveUrlForId(id: number): Promise<string | null> {
174
+ try {
175
+ const page = await getPage(id, "indexnow-payload");
176
+ const url = (page as { fullUrl?: string } | null)?.fullUrl;
177
+ return url ?? null;
178
+ } catch (err) {
179
+ GriddoLog.warn(
180
+ `indexnow: getPage(${id}) failed: ${(err as Error)?.message ?? String(err)}`,
181
+ );
182
+ return null;
183
+ }
184
+ }
185
+ }
186
+
187
+ async function safeUnlink(p: string): Promise<void> {
188
+ try {
189
+ await fsp.unlink(p);
190
+ } catch {
191
+ // Missing file is fine; nothing to clear.
192
+ }
193
+ }
194
+
195
+ export { IndexNowCollector };
@@ -71,7 +71,20 @@ async function generateLlmsTxt(domain: string): Promise<void> {
71
71
  const pageLinks = llmsResponse
72
72
  .map(({ title, url, socialDescription }) => {
73
73
  const description = socialDescription ? `: ${socialDescription}` : "";
74
- return `- [${title}](${removeTrailingSlash(url)}${pageIndexName})${description}`;
74
+ const strippedUrl = removeTrailingSlash(url);
75
+ // Home: pathname "/" → /index.md evita que quede dominio.com.md
76
+ const isHome = (() => {
77
+ try {
78
+ return new URL(url).pathname === "/";
79
+ } catch {
80
+ return url === "/" || url === "";
81
+ }
82
+ })();
83
+ const href =
84
+ GRIDDO_RENDER_ENABLED_LLM_MD && isHome
85
+ ? `${strippedUrl}/index.md`
86
+ : `${strippedUrl}${pageIndexName}`;
87
+ return `- [${title}](${href})${description}`;
75
88
  })
76
89
  .join("\n");
77
90
 
@@ -181,6 +181,11 @@ async function getRenderPathsHydratedWithDomainFromDB(options?: {
181
181
  };
182
182
  }
183
183
 
184
+ async function getDomainDryRunFromDB(domain: string): Promise<boolean> {
185
+ const db = await readDB();
186
+ return !!db.domains[domain]?.dryRun;
187
+ }
188
+
184
189
  async function getRenderMetadataFromDB() {
185
190
  const db = await readDB();
186
191
  return {
@@ -228,6 +233,7 @@ async function generateBuildReport(domain: string) {
228
233
  export {
229
234
  assertRenderIsValid,
230
235
  generateBuildReport,
236
+ getDomainDryRunFromDB,
231
237
  getRenderMetadataFromDB,
232
238
  getRenderModeFromDB,
233
239
  getRenderPathsHydratedWithDomainFromDB,
@@ -35,6 +35,7 @@ import {
35
35
  getMultiPageElements,
36
36
  getPaginatedPages,
37
37
  } from "./pages";
38
+ import { IndexNowCollector } from "./indexnow-payload";
38
39
  import { getReferenceFieldData } from "./reference-fields";
39
40
  import { getRenderPathsHydratedWithDomainFromDB } from "./render";
40
41
  import { getPage } from "./sites";
@@ -80,6 +81,9 @@ async function createStore(options: {
80
81
  const createdPages: number[] = [];
81
82
  const buildProcessData: BuildProcessData = {};
82
83
 
84
+ // IndexNow delta collector. No-op when the feature flag is off.
85
+ const indexNowCollector = new IndexNowCollector();
86
+
83
87
  // Get sites objects to publish and unpublish from this domain
84
88
  const { sitesToPublish, sitesToUnpublish } = await getSitesToRender(domain);
85
89
 
@@ -115,6 +119,11 @@ async function createStore(options: {
115
119
  const { id: siteId, slug: siteSlug, theme, favicon, pagesStatus } = site;
116
120
  const siteDirName = siteId.toString();
117
121
 
122
+ // Snapshot of real pending-to-upload IDs before FROM_SCRATCH merges
123
+ // `active` into `pagesStatus.uploadPending` below. Used by IndexNow so
124
+ // unchanged `active` pages are not reported as upserts on FROM_SCRATCH.
125
+ const uploadPendingForIndexNow = [...pagesStatus.uploadPending];
126
+
118
127
  allPagesToRemoveFromBuild.push(...pagesStatus.offlinePending, ...pagesStatus.deleted);
119
128
 
120
129
  const {
@@ -312,6 +321,13 @@ async function createStore(options: {
312
321
  // Save build data to store
313
322
  await saveSitePagesInStore(siteIdName, griddoPageObjects);
314
323
 
324
+ indexNowCollector.collect({
325
+ siteId,
326
+ pageId: page.id,
327
+ pageObjects: griddoPageObjects,
328
+ uploadPending: uploadPendingForIndexNow,
329
+ });
330
+
315
331
  // Update progress counter and log
316
332
  progressCounter.current += 1;
317
333
  progress.update(progressCounter.current);
@@ -355,6 +371,14 @@ async function createStore(options: {
355
371
  // infor del render normalmente.
356
372
  await saveRenderInfoInStore({ buildProcessData, createdPages, sitesToPublish }, domain);
357
373
 
374
+ try {
375
+ await indexNowCollector.flush({ domain, sitesToPublish, sitesToUnpublish });
376
+ } catch (err) {
377
+ GriddoLog.warn(
378
+ `indexnow: failed to write delta for ${domain}: ${(err as Error)?.message ?? String(err)}`,
379
+ );
380
+ }
381
+
358
382
  return {
359
383
  pagesToCreate: createdPages,
360
384
  pagesToDelete: allPagesToRemoveFromBuild,
@@ -35,6 +35,7 @@ function safeParseInt(value: string, fallback: number): number {
35
35
 
36
36
  // Rendering
37
37
  const GRIDDO_AI_EMBEDDINGS = envIsTruthy(env.GRIDDO_AI_EMBEDDINGS);
38
+ const GRIDDO_INDEXNOW_ENABLED = envIsTruthy(env.GRIDDO_INDEXNOW_ENABLED);
38
39
  const GRIDDO_API_CONCURRENCY_COUNT = safeParseInt(env.GRIDDO_API_CONCURRENCY_COUNT || env.GRIDDO_RENDER_CONCURRENCY_COUNT || "10", 10);
39
40
  const GRIDDO_ASSET_PREFIX = env.GRIDDO_ASSET_PREFIX || env.ASSET_PREFIX || env.GRIDDO_RENDER_ASSET_PREFIX;
40
41
  const GRIDDO_BUILD_LOGS = envIsTruthy(env.GRIDDO_BUILD_LOGS || env.GRIDDO_RENDER_BUILD_LOGS);
@@ -64,6 +65,7 @@ export {
64
65
  GRIDDO_BOT_USER,
65
66
  GRIDDO_BUILD_LOGS,
66
67
  GRIDDO_BUILD_LOGS_BUFFER_SIZE,
68
+ GRIDDO_INDEXNOW_ENABLED,
67
69
  GRIDDO_PUBLIC_API_URL,
68
70
  GRIDDO_REACT_APP_INSTANCE,
69
71
  GRIDDO_RENDER_API_FETCH_RETRY_ATTEMPTS,
@@ -1,5 +1,6 @@
1
1
  import type { RenderContext } from "@shared/context";
2
2
 
3
+ import { generateIndexNowKeyFile } from "@services/indexnow-key-file";
3
4
  import { generateRobots } from "@services/robots";
4
5
  import { generateSitemaps } from "@services/sitemaps";
5
6
 
@@ -8,4 +9,5 @@ export async function metaAction(context: RenderContext) {
8
9
 
9
10
  await generateSitemaps(domain);
10
11
  await generateRobots(domain);
12
+ await generateIndexNowKeyFile(domain);
11
13
  }
@@ -5,6 +5,7 @@ import fsp from "node:fs/promises";
5
5
  import path from "node:path";
6
6
 
7
7
  import { deleteDisposableSiteDirs, deleteEmptyDirectories, mvDirs } from "../../../core/fs";
8
+ import { getIndexNowKeyFilename } from "../../../services/indexnow-key-file";
8
9
  import { RENDER_MODE } from "../../../shared/types/render";
9
10
  import { extractAssetsFromDist } from "../shared/extract-assets";
10
11
  import { SyncRender } from "../shared/sync-render";
@@ -42,12 +43,16 @@ export async function syncAction(context: RenderContext<SSG>) {
42
43
  if (renderMode === RENDER_MODE.INCREMENTAL) {
43
44
  await deleteDisposableSiteDirs(previousDist);
44
45
 
46
+ const artifactsToCopyToExports = ["build-report.json", "llms.txt", "robots.txt"];
47
+ const indexNowKeyFile = getIndexNowKeyFilename(domain);
48
+ if (indexNowKeyFile) artifactsToCopyToExports.push(indexNowKeyFile);
49
+
45
50
  const syncRender = new SyncRender({
46
51
  src: currentDist,
47
52
  dst: previousDist,
48
53
  pagesToCreate,
49
54
  pagesToDelete,
50
- artifactsToCopyToExports: ["build-report.json", "llms.txt", "robots.txt"],
55
+ artifactsToCopyToExports,
51
56
  });
52
57
 
53
58
  await syncRender.execute();
@@ -144,11 +144,7 @@ class SyncRender {
144
144
  // ../page-data/about-us/page-data.json // página con slug
145
145
  // ../page-data/programs/page-data.json // página con slug
146
146
  // ../page-data/index/page-data.json // <---- ¡página root index!
147
- //
148
- // Ojo: `composePath` viene normalizado por `removeTrailingSlash`
149
- // (ver `scanPages`), así que la home llega como "" y no como "/".
150
- const normalizedCompose =
151
- page.composePath === "" || page.composePath === "/" ? "index" : page.composePath;
147
+ const normalizedCompose = page.composePath === "/" ? "index" : page.composePath;
152
148
  const jsonTo = path.join(this.bundleDir, "page-data", normalizedCompose, "page-data.json");
153
149
 
154
150
  this.state.htmlToAdd.push({ from: page.htmlPath, to: htmlTo });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/cx",
3
3
  "description": "Griddo SSG based on Gatsby",
4
- "version": "11.13.3",
4
+ "version": "11.14.0",
5
5
  "authors": [
6
6
  "Hisco <francis.vega@griddo.io>"
7
7
  ],
@@ -40,6 +40,7 @@
40
40
  "start-render": "node ./build/commands/start-render",
41
41
  "end-render": "node ./build/commands/end-render",
42
42
  "upload-search-content": "node ./build/commands/upload-search-content",
43
+ "notify-indexnow": "node ./build/commands/notify-indexnow",
43
44
  "reset-render": "node ./build/commands/reset-render",
44
45
  "// ONLY LOCAL SCRIPTS": "",
45
46
  "prepare-assets-directory": "node ./build/commands/prepare-assets-directory",
@@ -61,7 +62,7 @@
61
62
  },
62
63
  "devDependencies": {
63
64
  "@biomejs/biome": "2.3.4",
64
- "@griddo/core": "11.13.3",
65
+ "@griddo/core": "11.14.0",
65
66
  "@types/node": "20.19.4",
66
67
  "@typescript/native-preview": "7.0.0-dev.20260401.1",
67
68
  "cheerio": "1.1.2",
@@ -95,5 +96,5 @@
95
96
  "publishConfig": {
96
97
  "access": "public"
97
98
  },
98
- "gitHead": "6dd412b5408e057baa6c1c89f4815c85c7e16a45"
99
+ "gitHead": "d2c96b30e53ce16a9d06df7fd512308ae6fff64c"
99
100
  }