diff --git a/documentation/ag-grid-docs/project.json b/documentation/ag-grid-docs/project.json index 6580d0a346e..c03c2686567 100644 --- a/documentation/ag-grid-docs/project.json +++ b/documentation/ag-grid-docs/project.json @@ -19,7 +19,7 @@ "!{projectRoot}/vitest.config.ts", "{workspaceRoot}/external/ag-website-shared/**", "charts", - "{projectRoot}/.astro/cache/sitemap/**", + "{projectRoot}/.astro/cache/sitemap/sitemap-0.xml", { "env": "PUBLIC_PACKAGE_VERSION" } ], "cache": true, diff --git a/documentation/ag-grid-docs/src/components/demos/demosData.ts b/documentation/ag-grid-docs/src/components/demos/demosData.ts index af3b398a2e4..495304d5952 100644 --- a/documentation/ag-grid-docs/src/components/demos/demosData.ts +++ b/documentation/ag-grid-docs/src/components/demos/demosData.ts @@ -13,6 +13,11 @@ export interface DemoTab { altDark: string; } +// The video tour of the demos. Linked from the header of every demo page and from the markdown +// twins, so the label and URL live here rather than being repeated per page. +export const VIDEO_TOUR_URL = 'https://youtu.be/bcMvTUVbMvI'; +export const VIDEO_TOUR_TEXT = 'Video Tour'; + export const demoTabs: DemoTab[] = [ { key: 'complete', diff --git a/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx b/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx new file mode 100644 index 00000000000..e9d6b5b7e5a --- /dev/null +++ b/documentation/ag-grid-docs/src/components/landing-pages/FrameworkCtaLink.tsx @@ -0,0 +1,30 @@ +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { gridUrlWithPrefix } from '@ag-website-shared/utils/gridUrlWithPrefix'; +import { useFramework } from '@utils/hooks/useFramework'; +import type { FunctionComponent, ReactNode } from 'react'; + +interface Props { + /** A './' path, resolved against the visitor's selected framework. */ + url: string; + id?: string; + className?: string; + /** Append a chevron, which the caller's styles animate on hover. */ + withChevron?: boolean; + children: ReactNode; +} + +/** + * A CTA link pointing at a framework-prefixed docs page, for use in the server-rendered parts of a + * landing page. Renders against the default framework on the server and follows the visitor's + * selection once hydrated, so it stays in step with the framework CTAs further down the page. + */ +export const FrameworkCtaLink: FunctionComponent = ({ url, id, className, withChevron, children }) => { + const { framework } = useFramework(); + + return ( + + {children} + {withChevron && } + + ); +}; diff --git a/documentation/ag-grid-docs/src/content.config.ts b/documentation/ag-grid-docs/src/content.config.ts index 323b582386e..63aa9e4529c 100644 --- a/documentation/ag-grid-docs/src/content.config.ts +++ b/documentation/ag-grid-docs/src/content.config.ts @@ -530,6 +530,11 @@ const homepage = defineCollection({ seeDemosUrl: z.string(), githubText: z.string(), githubUrl: z.string(), + freeTrialText: z.string(), + // A './' path, resolved against the visitor's selected framework at render. + freeTrialUrl: z.string(), + buyNowText: z.string(), + buyNowUrl: z.string(), }), sections: z.array( z.object({ @@ -545,6 +550,16 @@ const homepage = defineCollection({ // per-section behaviour); framework CTAs instead pass the raw './' path through. ctaUrlIsBaseUrl: z.boolean().optional(), ctaId: z.string().optional(), + // An extra CTA rendered after the section's main CTA. `isFramework` resolves its + // './' url against the visitor's selected framework. + secondaryCta: z + .object({ + title: z.string(), + url: z.string(), + id: z.string().optional(), + isFramework: z.boolean().optional(), + }) + .optional(), isFramework: z.boolean().optional(), showBackgroundGradient: z.boolean().optional(), sectionClass: z.string().optional(), diff --git a/documentation/ag-grid-docs/src/content/homepage/homepage.json b/documentation/ag-grid-docs/src/content/homepage/homepage.json index 8486e1bcbe9..54a39293205 100644 --- a/documentation/ag-grid-docs/src/content/homepage/homepage.json +++ b/documentation/ag-grid-docs/src/content/homepage/homepage.json @@ -6,7 +6,11 @@ "seeDemosText": "See demos", "seeDemosUrl": "./example/", "githubText": "View on GitHub", - "githubUrl": "https://github.com/ag-grid/ag-grid-demos/tree/main/finance" + "githubUrl": "https://github.com/ag-grid/ag-grid-demos/tree/main/finance", + "freeTrialText": "Free Trial", + "freeTrialUrl": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "buyNowText": "Buy Now", + "buyNowUrl": "/license-pricing/" }, "sections": [ { @@ -14,9 +18,15 @@ "tag": "Unbeatable Speed & Performance", "heading": "The Fastest Data Grid In The World", "subHeading": "Handle millions of rows, and thousands of updates per second out of the box, without compromising on performance", - "ctaTitle": "Get Started For Free", + "ctaTitle": "Explore the Docs", "ctaUrl": "./getting-started", "ctaId": "get-started-for-free", + "secondaryCta": { + "title": "Try Enterprise Free", + "url": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "id": "try-enterprise-free-cta", + "isFramework": true + }, "isFramework": true, "showBackgroundGradient": true }, @@ -38,6 +48,12 @@ "ctaTitle": "Explore Integrated Charts", "ctaUrl": "./integrated-charts/", "ctaId": "integrated-charting-cta", + "secondaryCta": { + "title": "Free Trial", + "url": "./community-vs-enterprise/#request-a-30-day-enterprise-bundle-trial-licence", + "id": "integrated-charting-free-trial-cta", + "isFramework": true + }, "isFramework": true, "showBackgroundGradient": true }, diff --git a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss index 51aca2c2416..3758f575149 100644 --- a/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss +++ b/documentation/ag-grid-docs/src/pages-styles/homepage.module.scss @@ -163,6 +163,9 @@ body { .heroLinks { position: relative; display: flex; + // Wraps because the row now carries four CTAs — without it the trial and pricing buttons + // push the row past the viewport on narrow screens. + flex-wrap: wrap; gap: $spacing-size-2; align-items: center; margin: $spacing-size-4 0 0; diff --git a/documentation/ag-grid-docs/src/pages/example-finance.astro b/documentation/ag-grid-docs/src/pages/example-finance.astro index be7224d03ca..dec3417e606 100644 --- a/documentation/ag-grid-docs/src/pages/example-finance.astro +++ b/documentation/ag-grid-docs/src/pages/example-finance.astro @@ -2,6 +2,7 @@ import styles from '@pages-styles/example.module.scss'; import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { Finance } from 'src/components/demos/examples/finance'; import LogoMark from '@components/logo/LogoMark'; @@ -21,6 +22,10 @@ const content = demoContent('finance'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example-hr.astro b/documentation/ag-grid-docs/src/pages/example-hr.astro index a581ae04330..9d2cc90cf5c 100644 --- a/documentation/ag-grid-docs/src/pages/example-hr.astro +++ b/documentation/ag-grid-docs/src/pages/example-hr.astro @@ -1,6 +1,7 @@ --- import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import styles from '@pages-styles/example.module.scss'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { HR } from '@components/demos/examples/hr'; @@ -21,6 +22,10 @@ const content = demoContent('hr'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example-inventory.astro b/documentation/ag-grid-docs/src/pages/example-inventory.astro index 73a9dc8d397..b6b72f0f5c6 100644 --- a/documentation/ag-grid-docs/src/pages/example-inventory.astro +++ b/documentation/ag-grid-docs/src/pages/example-inventory.astro @@ -2,6 +2,7 @@ import styles from '@pages-styles/example.module.scss'; import DemosLayout from '@components/demos/DemosLayout.astro'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { Inventory } from '@components/demos/examples/inventory'; import LogoMark from '@components/logo/LogoMark'; @@ -21,6 +22,10 @@ const content = demoContent('inventory'); See On GitHub + + + {VIDEO_TOUR_TEXT} + Contact Us diff --git a/documentation/ag-grid-docs/src/pages/example.astro b/documentation/ag-grid-docs/src/pages/example.astro index 78e50d22a19..2dc218c7572 100644 --- a/documentation/ag-grid-docs/src/pages/example.astro +++ b/documentation/ag-grid-docs/src/pages/example.astro @@ -4,6 +4,7 @@ import Example from '@components/example-grid/Example'; import styles from '@pages-styles/example.module.scss'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import DemoTabs from '@components/demos/DemosTabs.astro'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import LogoMark from '@components/logo/LogoMark'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; import { DISABLE_MARKDOWN_DOCS } from '@constants'; @@ -31,14 +32,9 @@ const markdownUrl = DISABLE_MARKDOWN_DOCS ? undefined : urlWithBaseUrl('/example See On GitHub - + - See The Video Tour + {VIDEO_TOUR_TEXT} Contact Us diff --git a/documentation/ag-grid-docs/src/pages/index.astro b/documentation/ag-grid-docs/src/pages/index.astro index 2f619e88c0d..41555554c7e 100644 --- a/documentation/ag-grid-docs/src/pages/index.astro +++ b/documentation/ag-grid-docs/src/pages/index.astro @@ -5,6 +5,7 @@ import styles from '@pages-styles/homepage.module.scss'; import { Quotes } from '@components/quotes/Quotes'; import { quotesData } from '@components/quotes/quotesData'; import { LandingPageFWSelector } from '@ag-website-shared/components/landing-pages/LandingPageFWSelector'; +import { FrameworkCtaLink } from '@components/landing-pages/FrameworkCtaLink'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; import { Version } from '@ag-website-shared/components/whats-new/components/Version'; import { Icon } from '@ag-website-shared/components/icon/Icon'; @@ -163,6 +164,22 @@ const markdownUrl = DISABLE_MARKDOWN_DOCS ? undefined : urlWithBaseUrl('/index.m
+ + {hero.freeTrialText} + + + {hero.buyNowText} + { iframe.contentWindow?.postMessage(darkModeEvent); }); - // Send on event on page for charts that are embeded on the page - window.dispatchEvent(new CustomEvent('message', { detail: darkModeEvent })); + // No `message` CustomEvent on `window` here. A CustomEvent has no `origin`, so a third-party + // `message` listener that expects a real postMessage throws on it: reCAPTCHA's api.js parses + // `event.origin` as a URL, which breaks the captcha on any page carrying the contact form. }; $darkmode.listen(updateHtml); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildDemoMarkdown.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildDemoMarkdown.ts index 896053e1b4e..1008f04231f 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildDemoMarkdown.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildDemoMarkdown.ts @@ -1,4 +1,5 @@ import { toAbsoluteUrl } from '@ag-website-shared/markdoc/toAbsoluteUrl'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL } from '@components/demos/demosData'; import { urlWithBaseUrl } from '@utils/urlWithBaseUrl'; import demosData from '../../content/demos/demos.json'; @@ -32,6 +33,7 @@ export function buildDemoMarkdown({ demo, siteRoot }: { demo: DemoName; siteRoot [ `[See on GitHub](${content.githubUrl})`, `[View the demo](${toAbsoluteUrl(urlWithBaseUrl(`/example-${demo}/`), siteRoot)})`, + `[${VIDEO_TOUR_TEXT}](${VIDEO_TOUR_URL})`, `[Contact us](${toAbsoluteUrl(urlWithBaseUrl('/contact/'), siteRoot)})`, ].join(' | '), ]; diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.test.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.test.ts index 3c1015b2cf3..fc09efda8d7 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.test.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.test.ts @@ -27,7 +27,7 @@ describe('buildExampleMarkdown', () => { }); it('includes the video and contact resources', () => { - expect(output).toContain('[See the video tour](https://youtu.be/bcMvTUVbMvI)'); + expect(output).toContain('[Video Tour](https://youtu.be/bcMvTUVbMvI)'); expect(output).toContain('[Contact Us](https://www.ag-grid.com/contact/)'); }); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts index d011e6d8c62..def131688cb 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildExampleMarkdown.ts @@ -1,8 +1,5 @@ import { toAbsoluteUrl } from '@ag-website-shared/markdoc/toAbsoluteUrl'; -import { demoTabs } from '@components/demos/demosData'; - -// The video-tour link shown on the /example page header (example.astro). -const VIDEO_TOUR_URL = 'https://youtu.be/bcMvTUVbMvI'; +import { VIDEO_TOUR_TEXT, VIDEO_TOUR_URL, demoTabs } from '@components/demos/demosData'; /** * Build the markdown twin of the /example (demo) page. The page is almost entirely a live @@ -22,7 +19,7 @@ export function buildExampleMarkdown({ siteRoot }: { siteRoot?: string } = {}): .join('\n'); const resources = [ - `- [See the video tour](${VIDEO_TOUR_URL})`, + `- [${VIDEO_TOUR_TEXT}](${VIDEO_TOUR_URL})`, `- [Contact Us](${toAbsoluteUrl('/contact/', siteRoot)})`, ].join('\n'); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts index 79577abcafc..1ac0949d0f8 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.test.ts @@ -26,10 +26,22 @@ describe('buildHomepageMarkdown', () => { it('resolves CTA links absolutely', () => { // Framework CTA (./getting-started) and base-url CTA (/theme-builder/) both absolute. - expect(output).toMatch(/\[Get Started For Free\]\(https:\/\/www\.ag-grid\.com\/[^)]*getting-started/); + expect(output).toMatch(/\[Explore the Docs\]\(https:\/\/www\.ag-grid\.com\/[^)]*getting-started/); expect(output).toContain('[Create a Custom Theme](https://www.ag-grid.com/theme-builder/)'); }); + it('renders the hero trial and pricing CTAs', () => { + expect(output).toMatch( + /\[Free Trial\]\(https:\/\/www\.ag-grid\.com\/[^)]*community-vs-enterprise\/#request-a-30-day-enterprise-bundle-trial-licence\)/ + ); + expect(output).toContain('[Buy Now](https://www.ag-grid.com/license-pricing/)'); + }); + + it('renders a section secondary CTA after its main CTA', () => { + expect(output).toMatch(/\[Explore the Docs\]\([^)]*\) \| \[Try Enterprise Free\]\([^)]*\)/); + expect(output).toMatch(/\[Explore Integrated Charts\]\([^)]*\) \| \[Free Trial\]\([^)]*\)/); + }); + it('renders each section eyebrow headline as a kicker above its heading', () => { expect(output).toContain('*Unbeatable Speed & Performance*\n\n## The Fastest Data Grid In The World'); expect(output).toContain('*JavaScript Data Grid FAQs*\n\n## Frequently Asked Questions'); diff --git a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts index 34bf7a69466..5d80a88665a 100644 --- a/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts +++ b/documentation/ag-grid-docs/src/utils/markdown-pages/buildHomepageMarkdown.ts @@ -12,6 +12,11 @@ import versionsData from '../../content/versions/ag-grid-versions.json'; const FRAMEWORK = 'javascript'; const NUM_WHATS_NEW = 3; +interface SecondaryCta { + title: string; + url: string; + isFramework?: boolean; +} interface HomepageSection { id: string; tag?: string; @@ -22,6 +27,7 @@ interface HomepageSection { ctaTitle?: string; ctaUrl?: string; ctaUrlIsBaseUrl?: boolean; + secondaryCta?: SecondaryCta; } interface FaqItem { question: string; @@ -36,18 +42,27 @@ interface VersionEntry { highlights?: VersionHighlight[]; } -function resolveCtaUrl(section: HomepageSection, siteRoot?: string): string | undefined { - if (!section.ctaUrl) { +function resolveCtaUrl(ctaUrl: string | undefined, siteRoot?: string): string | undefined { + if (!ctaUrl) { return undefined; } // Framework CTAs store a './'-relative path resolved by the grid framework prefix at render; // the rest are already root-relative. - const url = section.ctaUrl.startsWith('./') - ? urlWithPrefix({ framework: FRAMEWORK, url: section.ctaUrl }) - : section.ctaUrl; + const url = ctaUrl.startsWith('./') ? urlWithPrefix({ framework: FRAMEWORK, url: ctaUrl }) : ctaUrl; return toAbsoluteUrl(url, siteRoot); } +/** The section's CTA links in the order the page renders them: main CTA, then any secondary CTA. */ +function ctaLinks(section: HomepageSection, siteRoot?: string): string[] { + const mainUrl = resolveCtaUrl(section.ctaUrl, siteRoot); + const { secondaryCta } = section; + + return [ + ...(mainUrl && section.ctaTitle ? [`[${section.ctaTitle}](${mainUrl})`] : []), + ...(secondaryCta ? [`[${secondaryCta.title}](${resolveCtaUrl(secondaryCta.url, siteRoot)})`] : []), + ]; +} + function whatsNewSection(): string { const versions = (versionsData as VersionEntry[]) .filter((version) => version.version.endsWith('.0')) @@ -90,9 +105,9 @@ function sectionBlock(section: HomepageSection, siteRoot?: string): string { if (subHeading) { parts.push(subHeading); } - const ctaUrl = resolveCtaUrl(section, siteRoot); - if (ctaUrl && section.ctaTitle) { - parts.push(`[${section.ctaTitle}](${ctaUrl})`); + const ctas = ctaLinks(section, siteRoot); + if (ctas.length) { + parts.push(ctas.join(' | ')); } if (section.id === 'whats-new') { parts.push(whatsNewSection()); @@ -119,6 +134,8 @@ export function buildHomepageMarkdown({ siteRoot }: { siteRoot?: string } = {}): ].join('\n'); const heroLinks = [ + `[${hero.freeTrialText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.freeTrialUrl }), siteRoot)})`, + `[${hero.buyNowText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.buyNowUrl }), siteRoot)})`, `[${hero.seeDemosText}](${toAbsoluteUrl(urlWithPrefix({ framework: FRAMEWORK, url: hero.seeDemosUrl }), siteRoot)})`, `[${hero.githubText}](${hero.githubUrl})`, ].join(' | '); diff --git a/documentation/ag-grid-docs/src/utils/sitemap.ts b/documentation/ag-grid-docs/src/utils/sitemap.ts index a90a019d746..d6ddaf18815 100644 --- a/documentation/ag-grid-docs/src/utils/sitemap.ts +++ b/documentation/ag-grid-docs/src/utils/sitemap.ts @@ -73,12 +73,14 @@ const filterIgnoredPages = (page: string) => { * * There are 2 locations where the sitemap is generated: * - * 1. Sitemap xml (`sitemap-0.xml`) - after a complete build, the sitemap xml file is generated in the astro `dist` folder. It is also cached in `[documentation]/.astro/cache/sitemap/sitemap-0.xml` (from the `ag-cache-sitemap` astro plugin). The cache also stores the git hash of the build, so it can be used to determine whether to cache again + * 1. Sitemap xml (`sitemap-0.xml`) - after a complete build, the sitemap xml file is generated in the astro `dist` folder. It is also cached in `[documentation]/.astro/cache/sitemap/sitemap-0.xml` (from the `ag-cache-sitemap` astro plugin), which refreshes the cache whenever the page list changed * 2. Sitemap page (`/sitemap`) - this page is generated from the sitemap xml, however since the page cannot be generated until the build is complete, it either uses what is in the cache (from a previous build), or pulls it from `LIVE_SITEMAP_URL` * + * Because of (2), a build may need to run twice for the sitemap page to list the pages the same build generated. `buildWithSitemapCache` only does that when the page list actually moved - see that script for the comparison it makes. + * * To generate the sitemap locally: * - * 1. With localhost links - run `nx build ag-grid-docs --clean-cache=true --run-second-build=true` to clear out the cache and run the build twice, so the sitemap page is updated. Preview with `nx preview ag-grid-docs` + * 1. With localhost links - run `nx build ag-grid-docs --clean-cache=true --run-second-build=true` to clear out the cache and allow the second build, so the sitemap page is updated. Preview with `nx preview ag-grid-docs` * 2. With production links - run the production preview with `nx preview ag-grid-docs -c production` * * Check the sitemap locally at `http://localhost:4611/sitemap-0.xml` and `http://localhost:4611/sitemap` diff --git a/external/ag-website-shared/plugins/agCacheSitemap.ts b/external/ag-website-shared/plugins/agCacheSitemap.ts index 0cdf9810c55..a82cd27161e 100644 --- a/external/ag-website-shared/plugins/agCacheSitemap.ts +++ b/external/ag-website-shared/plugins/agCacheSitemap.ts @@ -3,18 +3,29 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getGitHash } from '../src/utils/gitUtils'; +import { diffSitemapLocs, getSitemapLocs } from '../src/utils/sitemapLocs'; type Options = { cacheFolder: string; }; +const readFileOrNull = async (filePath: string) => { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + throw error; + } +}; + export default function createPlugin({ cacheFolder }: Options): AstroIntegration { return { name: 'ag-cache-sitemap', hooks: { 'astro:build:done': async ({ dir, logger }) => { - const currentHash = getGitHash(); const outputDir = fileURLToPath(dir); const sitemapSourcePath = path.join(outputDir, 'sitemap-0.xml'); const metaSourcePath = path.join(outputDir, 'debug', 'meta.json'); @@ -23,30 +34,33 @@ export default function createPlugin({ cacheFolder }: Options): AstroIntegration const cacheSitemapPath = path.join(cacheRoot, 'sitemap-0.xml'); const cacheMetaPath = path.join(cacheRoot, 'debug', 'meta.json'); - const readCachedHash = async () => { - try { - const raw = await fs.readFile(cacheMetaPath, 'utf8'); - const cachedMeta = JSON.parse(raw); - return cachedMeta?.git?.hash ?? null; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - throw error; - } - }; - - const cachedHash = await readCachedHash(); - if (cachedHash === currentHash) { - logger.info(`Sitemap cache already up to date for ${currentHash}.`); + const generatedXml = await readFileOrNull(sitemapSourcePath); + if (generatedXml == null) { + logger.warn('sitemap-0.xml not found — nothing to cache.'); return; } + // Cached on the page list rather than the git hash: a commit that touches no page + // leaves the sitemap identical, and a rebuild at the same commit can still change it + // (uncommitted content edits), so the hash answers neither question. The page list is + // also all the sitemap page renders, so an unchanged list means an unchanged page. + const cachedXml = await readFileOrNull(cacheSitemapPath); + const sitemapUnchanged = + cachedXml != null && + diffSitemapLocs(getSitemapLocs(cachedXml), getSitemapLocs(generatedXml)).matches; + await fs.mkdir(path.dirname(cacheMetaPath), { recursive: true }); + if (sitemapUnchanged) { + // Only the meta, so the recorded git hash tracks the build that last confirmed + // this sitemap and `getSitemapXml` does not report the cache as stale. + await fs.copyFile(metaSourcePath, cacheMetaPath); + logger.info('Cached sitemap already lists the same pages — left in place.'); + return; + } + await fs.copyFile(sitemapSourcePath, cacheSitemapPath); await fs.copyFile(metaSourcePath, cacheMetaPath); - logger.info(`Cached sitemap and meta.json for ${currentHash}.`); + logger.info(`Cached sitemap with ${getSitemapLocs(generatedXml).length} page(s).`); }, }, }; diff --git a/external/ag-website-shared/scripts/buildWithSitemapCache.ts b/external/ag-website-shared/scripts/buildWithSitemapCache.ts index 338d3b99661..c7af5250277 100644 --- a/external/ag-website-shared/scripts/buildWithSitemapCache.ts +++ b/external/ag-website-shared/scripts/buildWithSitemapCache.ts @@ -1,20 +1,38 @@ #!/usr/bin/env tsx /** - * Build helper for AG Charts website. + * Build helper for the AG websites. * - * Runs a full Astro build to generate the sitemap cache, then optionally runs a - * second build that uses the sitemap cache to generate the sitemap page. + * The sitemap page is a chicken-and-egg case: it lists the pages in the sitemap, but the sitemap is + * only generated once every page — including this one — has been built. So the page renders from a + * *previous* sitemap (the on-disk cache, or the live site) and the build has to run again to catch + * the page up whenever the page list has actually moved. + * + * "Whenever it has actually moved" is the point: a second full build of the whole site is expensive, + * and most builds do not add or remove a page. So the first build records which sitemap its sitemap + * pages rendered from (see `consumedSitemapRecord`), and this script only builds again when that + * differs from the sitemap the build went on to generate. Builds that generate no sitemap at all + * (archives — see the `astro.config.mjs` integrations) can never need the second pass. * * Flags: * - --run-second-build / --no-run-second-build / --run-second-build=false + * Allow a second build when the sitemap pages are out of date. Without it, they are left as-is. * - --clean-cache / --no-clean-cache / --clean-cache=false + * Discard the cached sitemap first, so the first build renders from the live site instead. * - All other params are passed through to Astro */ import { spawnSync } from 'node:child_process'; -import { rmSync } from 'node:fs'; +import { readFileSync, rmSync } from 'node:fs'; import path from 'node:path'; -import { SITEMAP_CACHE_DIR } from '../src/constants'; +import { SITEMAP_BUILD_DIR, SITEMAP_CACHE_DIR } from '../src/constants'; +import { + decideSecondBuild, + getConsumedSitemapRecordPath, + readConsumedSitemapRecord, +} from '../src/utils/consumedSitemapRecord'; + +// Astro's default `outDir`, which none of the websites override. +const ASTRO_OUT_DIR = 'dist'; const rawArgs = process.argv.slice(2); const normaliseFlag = (flag: string) => flag.replace(/^--/, ''); @@ -41,16 +59,22 @@ const getFlagValue = (flag: string) => { return value; }; const hasFlag = (flag: string) => getFlagValue(flag) ?? false; +const OWN_FLAGS = ['--run-second-build', '--clean-cache']; const runSecondBuild = hasFlag('--run-second-build'); const cleanCache = hasFlag('--clean-cache'); -const astroArgs = [ - 'build', - ...rawArgs.filter((arg) => !arg.startsWith('--run-second-build') && !arg.startsWith('--clean-cache')), -]; +const astroArgs = ['build', ...rawArgs.filter((arg) => !OWN_FLAGS.some((flag) => arg.startsWith(flag)))]; + +/** Null when this build generated no sitemap at all, as archive builds do. */ +const readGeneratedSitemap = () => { + try { + return readFileSync(path.join(ASTRO_OUT_DIR, 'sitemap-0.xml'), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } -const cleanSitemapCache = async () => { - const cacheFolder = path.resolve(SITEMAP_CACHE_DIR); - rmSync(cacheFolder, { recursive: true, force: true }); + throw error; + } }; const runBuild = () => { @@ -62,13 +86,24 @@ const runBuild = () => { if (cleanCache) { console.log('✨ Cleaning sitemap cache'); - cleanSitemapCache(); + rmSync(path.resolve(SITEMAP_CACHE_DIR), { recursive: true, force: true }); } +// A record left by an earlier build would otherwise be read as this build's. +rmSync(getConsumedSitemapRecordPath(SITEMAP_BUILD_DIR), { force: true }); + runBuild(); if (runSecondBuild) { - console.log('♻️ Building again to use latest sitemap'); + const { needed, reason } = decideSecondBuild({ + generatedXml: readGeneratedSitemap(), + record: readConsumedSitemapRecord(SITEMAP_BUILD_DIR), + }); - runBuild(); + if (needed) { + console.log(`♻️ Building again to update the sitemap page: ${reason}`); + runBuild(); + } else { + console.log(`✅ Skipping the second build — ${reason}.`); + } } diff --git a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx index 518ce9d0efe..3095c9f5f20 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx +++ b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx @@ -3,6 +3,7 @@ import { CONSENT_LABELS, DATA_PROCESSING_CONSENT_REQUIRED, } from '@ag-website-shared/components/consent-fields/consentMessages'; +import type { CaptchaTicker } from '@ag-website-shared/components/contact-form/initCaptcha'; import { initCaptcha } from '@ag-website-shared/components/contact-form/initCaptcha'; import { Icon } from '@ag-website-shared/components/icon/Icon'; import { CONSENT_FIELD_IDS, CONTACT_FORM_DATA, RECAPTCHA_URL, STUDIO_FORM_DATA } from '@ag-website-shared/constants'; @@ -57,27 +58,42 @@ interface Props { submitLabel?: string; } -function loadRecaptchaScript(): Promise { - return new Promise((resolve, reject) => { - if ((window as any).grecaptcha) { - return resolve(); - } - const id = 'grecaptcha-script'; - const existing = document.getElementById(id) as HTMLScriptElement | null; - if (existing) { - existing.addEventListener('load', () => resolve(), { once: true }); - existing.addEventListener('error', reject, { once: true }); +const RECAPTCHA_READY_CALLBACK = 'agOnRecaptchaReady'; +const RECAPTCHA_READY_PROMISE = 'agRecaptchaReady'; + +/** + * Loads reCAPTCHA once per page, in explicit-render mode. + * + * `api.js` auto-renders every `.g-recaptcha` container exactly once, when it first executes. + * The Astro client router swaps pages without a reload, so `window.grecaptcha` outlives the + * container it rendered into and a later visit to this page is left with a fresh, empty one. + * Explicit mode hands rendering to the caller, which renders on each mount instead. + * + * The in-flight promise lives on `window` rather than in a module variable because what it + * guards is global: the injected script tag and the `grecaptcha` it defines. A second module + * instance has to join the load already under way instead of appending a duplicate script. + */ +function loadRecaptcha(): Promise { + const globals = window as any; + globals[RECAPTCHA_READY_PROMISE] ??= new Promise((resolve, reject) => { + if (globals.grecaptcha?.render != null) { + resolve(); return; } - const s = document.createElement('script'); - s.id = id; - s.src = RECAPTCHA_URL; - s.async = true; - s.defer = true; - s.onload = () => resolve(); - s.onerror = reject; - document.head.appendChild(s); + globals[RECAPTCHA_READY_CALLBACK] = resolve; + const script = document.createElement('script'); + script.id = 'grecaptcha-script'; + script.src = `${RECAPTCHA_URL}?render=explicit&onload=${RECAPTCHA_READY_CALLBACK}`; + script.async = true; + script.defer = true; + script.onerror = (error) => { + // Drop the cached promise so a later mount retries the load. + globals[RECAPTCHA_READY_PROMISE] = undefined; + reject(error); + }; + document.head.appendChild(script); }); + return globals[RECAPTCHA_READY_PROMISE]; } export const ContactForm: FunctionComponent = ({ @@ -86,6 +102,8 @@ export const ContactForm: FunctionComponent = ({ submitLabel, }: Props) => { const formRef = useRef(null); + const captchaRef = useRef(null); + const captchaWidgetId = useRef(null); const captchaTimestamp = useRef(''); const reapplyCaptchaTimestamp = useRef<(() => void) | null>(null); const [isDebug, setIsDebug] = useState(isDev); @@ -140,18 +158,37 @@ export const ContactForm: FunctionComponent = ({ setReturnUrl(urlWithCurrentPath.toString()); } - loadRecaptchaScript().then(() => { - reapplyCaptchaTimestamp.current = initCaptcha((ts) => { + let unmounted = false; + let captcha: CaptchaTicker | undefined; + + loadRecaptcha().then(() => { + const container = captchaRef.current; + if (unmounted || container == null) { + return; + } + captchaWidgetId.current = (globalThis as any).grecaptcha.render(container, { + sitekey: captchaSiteKey, + }); + captcha = initCaptcha(container, (ts) => { captchaTimestamp.current = ts; }); + reapplyCaptchaTimestamp.current = captcha.reapply; }); + + return () => { + unmounted = true; + captcha?.stop(); + }; }, []); const onValidSubmit = useCallback(() => { setIsDisabled(true); setCaptchaError(false); - const captchaPassed = (globalThis as any).grecaptcha.getResponse(); + // Widget ids increment across client-side navigations, and a bare `getResponse()` reads + // widget 0, which belongs to a container an earlier page swap already destroyed. + const widgetId = captchaWidgetId.current; + const captchaPassed = widgetId != null && (globalThis as any).grecaptcha.getResponse(widgetId); if (captchaPassed) { reapplyCaptchaTimestamp.current?.(); formRef.current?.submit(); @@ -318,7 +355,7 @@ export const ContactForm: FunctionComponent = ({
-
+
{captchaError &&

Please click on the reCAPTCHA checkbox

}
diff --git a/external/ag-website-shared/src/components/contact-form/initCaptcha.ts b/external/ag-website-shared/src/components/contact-form/initCaptcha.ts index 8315eb5bae1..8f2a3d47d87 100644 --- a/external/ag-website-shared/src/components/contact-form/initCaptcha.ts +++ b/external/ag-website-shared/src/components/contact-form/initCaptcha.ts @@ -1,5 +1,12 @@ +export interface CaptchaTicker { + /** Writes the frozen timestamp back into the hidden input, just before submitting. */ + reapply: () => void; + /** Stops the ticker. Call on unmount, or it keeps running for the life of the page. */ + stop: () => void; +} + /** - * Starts Salesforce's captcha timestamp ticker. + * Starts Salesforce's captcha timestamp ticker for one rendered reCAPTCHA widget. * * `onTimestamp` receives every value written, so the caller can render it back into the * hidden input: React resets a controlled input on re-render, and `form.submit()` only @@ -10,7 +17,7 @@ * The ticker stops once the captcha is solved, freezing `ts` at solve time, which is the * elapsed-time signal Salesforce validates. */ -export function initCaptcha(onTimestamp?: (ts: string) => void): () => void { +export function initCaptcha(container: HTMLElement, onTimestamp?: (ts: string) => void): CaptchaTicker { let latest = ''; function write(ts: string) { @@ -24,14 +31,21 @@ export function initCaptcha(onTimestamp?: (ts: string) => void): () => void { } function timestamp() { - const response = document.getElementById('g-recaptcha-response') as HTMLInputElement; - if (response == null || response.value.trim() == '') { + // Scoped to this widget's own container: reCAPTCHA only names the first widget's field + // `g-recaptcha-response`, and suffixes every later one (`g-recaptcha-response-1` and so + // on), so a lookup by bare id silently misses any widget rendered after the first. + const response = container.querySelector('textarea[name="g-recaptcha-response"]'); + if (response == null || response.value.trim() === '') { latest = JSON.stringify(new Date().getTime()); write(latest); onTimestamp?.(latest); } } - setInterval(timestamp, 500); - return () => write(latest); + const ticker = setInterval(timestamp, 500); + + return { + reapply: () => write(latest), + stop: () => clearInterval(ticker), + }; } diff --git a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss index f1a7aec6614..3cb227f4900 100644 --- a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss +++ b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.module.scss @@ -85,6 +85,15 @@ gap: $spacing-size-2; } +// Lays out the section's CTAs in a row. A section with a single CTA is unaffected; sections that +// add a secondary CTA wrap onto a second line rather than overflowing on narrow viewports. +.ctaGroup { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: $spacing-size-3; +} + .ctaButton { display: inline-flex; align-items: center; diff --git a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx index 4d4a8cfac8d..a0f1e34befc 100644 --- a/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx +++ b/external/ag-website-shared/src/components/landing-pages/LandingPageSection.tsx @@ -31,6 +31,15 @@ const FRAMEWORK_CONFIGS: Record = { }, }; +/** An additional CTA rendered alongside the section's main CTA. */ +export interface SecondaryCta { + title: string; + url: string; + id?: string; + /** Resolve `url` against the visitor's selected framework, as the main CTA does when `isFramework`. */ + isFramework?: boolean; +} + interface Props { id: string; tag: string; @@ -42,6 +51,7 @@ interface Props { ctaTitle?: string; ctaUrl?: string; ctaId?: string; + secondaryCta?: SecondaryCta; sectionClass?: string; showBackgroundGradient?: boolean; children: ReactNode; @@ -160,6 +170,21 @@ const CTAWithFrameworks: FunctionComponent<{ ctaId: string; ctaTitle: string; ct ); }; +/** + * A secondary CTA. Carries no framework picker of its own — when `isFramework` is set it follows + * whichever framework the visitor has already selected, so it stays in step with the main CTA. + */ +const SecondaryCtaLink: FunctionComponent<{ cta: SecondaryCta }> = ({ cta }) => { + const { framework } = useFrameworkSelector(); + const href = cta.isFramework ? gridUrlWithPrefix({ framework, url: cta.url }) : cta.url; + + return ( +
+ {cta.title} + + ); +}; + export const LandingPageSection: FunctionComponent = ({ id, tag, @@ -170,6 +195,7 @@ export const LandingPageSection: FunctionComponent = ({ ctaTitle = 'Learn more', ctaUrl, ctaId, + secondaryCta, isFramework = false, sectionClass, showBackgroundGradient, @@ -180,7 +206,7 @@ export const LandingPageSection: FunctionComponent = ({ // strip pass no tag/heading/subHeading and must not render empty

/

/

tags. const hasHeading = Boolean(heading || headingHtml); const hasSubHeading = Boolean(subHeading || subHeadingHtml); - const hasHeader = Boolean(tag) || hasHeading || hasSubHeading || Boolean(ctaUrl); + const hasHeader = Boolean(tag) || hasHeading || hasSubHeading || Boolean(ctaUrl) || Boolean(secondaryCta); return (
= ({

{subHeading}

))} - {ctaUrl && isFramework && } + {(ctaUrl || secondaryCta) && ( +
+ {ctaUrl && isFramework && ( + + )} + + {ctaUrl && !isFramework && ( + + {ctaTitle} + + )} - {ctaUrl && !isFramework && ( - - {ctaTitle} - + {secondaryCta && } +
)} )} diff --git a/external/ag-website-shared/src/constants.ts b/external/ag-website-shared/src/constants.ts index 9ea92a63f8d..f7763f2c3c8 100644 --- a/external/ag-website-shared/src/constants.ts +++ b/external/ag-website-shared/src/constants.ts @@ -78,6 +78,16 @@ export const STUDIO_FORM_DATA = { // Relative to website folder export const SITEMAP_CACHE_DIR = '.astro/cache/sitemap'; +/** + * Scratch folder for the sitemap bookkeeping `buildWithSitemapCache` needs across the two builds it + * may run — currently the record of which sitemap the first build's sitemap pages rendered from. + * + * Deliberately outside `SITEMAP_CACHE_DIR`: the cached sitemap is a declared nx input for the docs + * `build` target (a change to it has to re-run the build), and this bookkeeping changes on every + * build without changing what the site renders, so it must not feed that hash. + */ +export const SITEMAP_BUILD_DIR = '.astro/cache/sitemap-build'; + /** * `User-Agent` identifying build-time fetches against the live AG sites (sitemaps, robots disallow * lists), which are not served to the default agent. diff --git a/external/ag-website-shared/src/utils/consumedSitemapRecord.test.ts b/external/ag-website-shared/src/utils/consumedSitemapRecord.test.ts new file mode 100644 index 00000000000..e25ba8bf7cc --- /dev/null +++ b/external/ag-website-shared/src/utils/consumedSitemapRecord.test.ts @@ -0,0 +1,61 @@ +import { type ConsumedSitemapRecord, decideSecondBuild } from './consumedSitemapRecord'; + +const HOME = 'https://www.ag-grid.com/'; +const ABOUT = 'https://www.ag-grid.com/about/'; +const PIPELINE = 'https://www.ag-grid.com/pipeline/'; + +const sitemapXml = (...locs: string[]) => + `${locs + .map((loc) => `${loc}2026-08-21T00:00:00.000Z`) + .join('')}`; + +const cacheRecord = (...locs: string[]): ConsumedSitemapRecord => ({ source: 'cache', locs }); +const liveRecord = (...locs: string[]): ConsumedSitemapRecord => ({ + source: 'live', + sitemapUrl: 'https://www.ag-grid.com/sitemap-0.xml', + locs, +}); + +describe('decideSecondBuild', () => { + test('skips the second build when the generated sitemap lists the pages already rendered', () => { + const decision = decideSecondBuild({ + generatedXml: sitemapXml(HOME, ABOUT), + record: cacheRecord(HOME, ABOUT), + }); + + expect(decision).toEqual({ needed: false, reason: 'sitemap unchanged since the cached sitemap — 2 page(s)' }); + }); + + test('skips the second build when the live sitemap the page rendered from is still current', () => { + const decision = decideSecondBuild({ + generatedXml: sitemapXml(HOME, ABOUT), + record: liveRecord(HOME, ABOUT), + }); + + expect(decision.needed).toBe(false); + expect(decision.reason).toContain('the live sitemap (https://www.ag-grid.com/sitemap-0.xml)'); + }); + + test('builds again when a page was added', () => { + const decision = decideSecondBuild({ + generatedXml: sitemapXml(HOME, ABOUT, PIPELINE), + record: cacheRecord(HOME, ABOUT), + }); + + expect(decision).toEqual({ needed: true, reason: `1 added (${PIPELINE})` }); + }); + + test('builds again when the first build had no sitemap to render from', () => { + const decision = decideSecondBuild({ generatedXml: sitemapXml(HOME), record: null }); + + expect(decision.needed).toBe(true); + }); + + test('skips the second build when this build generates no sitemap at all', () => { + // Archive builds: the sitemap integrations are not registered and the /sitemap page is + // removed from the output, so no amount of rebuilding can change it. + const decision = decideSecondBuild({ generatedXml: null, record: null }); + + expect(decision.needed).toBe(false); + }); +}); diff --git a/external/ag-website-shared/src/utils/consumedSitemapRecord.ts b/external/ag-website-shared/src/utils/consumedSitemapRecord.ts new file mode 100644 index 00000000000..7b6cac36a02 --- /dev/null +++ b/external/ag-website-shared/src/utils/consumedSitemapRecord.ts @@ -0,0 +1,109 @@ +import { promises as fs, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { describeSitemapLocsDiff, diffSitemapLocs, getSitemapLocs } from './sitemapLocs'; + +/** + * The sitemap page is a chicken-and-egg case: it lists the pages in the sitemap, but the sitemap is + * only generated once every page — including this one — has been built. So the page renders from a + * *previous* sitemap (the on-disk cache, or the live site) and the build may have to run a second + * time to re-render it against the sitemap it just generated. + * + * This record is how the build finds out whether that second build is actually needed: the page + * writes down which sitemap it rendered from, and `buildWithSitemapCache` compares that against the + * sitemap the build went on to generate. + */ +export type ConsumedSitemapRecord = { + /** Where the sitemap came from — the on-disk cache, or a fetch from the live site. */ + source: 'cache' | 'live'; + /** Set when `source` is `live`. */ + sitemapUrl?: string; + locs: string[]; +}; + +const RECORD_FILE_NAME = 'consumed-sitemap.json'; + +export const getConsumedSitemapRecordPath = (recordDir: string) => path.join(path.resolve(recordDir), RECORD_FILE_NAME); + +// Every page that renders the sitemap writes the same record, and Astro builds pages concurrently, +// so write to a private path and rename it into place — a plain write can interleave into a torn +// file that then reads back as no record at all. +let writeCount = 0; + +export const writeConsumedSitemapRecord = async ({ + recordDir, + xmlSitemap, + source, + sitemapUrl, +}: { + recordDir: string; + xmlSitemap: string; + source: ConsumedSitemapRecord['source']; + sitemapUrl?: string; +}) => { + const recordPath = getConsumedSitemapRecordPath(recordDir); + const record: ConsumedSitemapRecord = { source, sitemapUrl, locs: getSitemapLocs(xmlSitemap) }; + const tempPath = `${recordPath}.${process.pid}-${++writeCount}.tmp`; + + await fs.mkdir(path.dirname(recordPath), { recursive: true }); + await fs.writeFile(tempPath, JSON.stringify(record), 'utf8'); + await fs.rename(tempPath, recordPath); +}; + +/** Null when no usable record was written, which is treated as "assume the pages are out of date". */ +export const readConsumedSitemapRecord = (recordDir: string): ConsumedSitemapRecord | null => { + let raw: string; + try { + raw = readFileSync(getConsumedSitemapRecordPath(recordDir), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + throw error; + } + + try { + const record = JSON.parse(raw); + return Array.isArray(record?.locs) ? record : null; + } catch { + return null; + } +}; + +export type SecondBuildDecision = { + needed: boolean; + /** Why, for the build log. */ + reason: string; +}; + +/** + * Whether the sitemap pages the first build produced are already correct. + * + * Anything unexpected resolves to "build again": a redundant build only costs time, whereas a stale + * sitemap page ships. + */ +export const decideSecondBuild = ({ + generatedXml, + record, +}: { + /** The sitemap this build generated, or null if it generated none (archive builds). */ + generatedXml: string | null; + record: ConsumedSitemapRecord | null; +}): SecondBuildDecision => { + if (generatedXml == null) { + return { needed: false, reason: 'this build generates no sitemap, so the sitemap page cannot be out of date' }; + } + + if (record == null) { + return { needed: true, reason: 'no record of which sitemap the first build rendered from' }; + } + + const diff = diffSitemapLocs(record.locs, getSitemapLocs(generatedXml)); + if (diff.matches) { + const from = record.source === 'live' ? `the live sitemap (${record.sitemapUrl})` : 'the cached sitemap'; + return { needed: false, reason: `sitemap unchanged since ${from} — ${record.locs.length} page(s)` }; + } + + return { needed: true, reason: describeSitemapLocsDiff(diff) }; +}; diff --git a/external/ag-website-shared/src/utils/getSitemapXml.test.ts b/external/ag-website-shared/src/utils/getSitemapXml.test.ts index ff4095fc438..48624f2df62 100644 --- a/external/ag-website-shared/src/utils/getSitemapXml.test.ts +++ b/external/ag-website-shared/src/utils/getSitemapXml.test.ts @@ -1,6 +1,10 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { vi } from 'vitest'; import { BUILD_USER_AGENT } from '../constants'; +import { getConsumedSitemapRecordPath } from './consumedSitemapRecord'; import { getSitemapXml } from './getSitemapXml'; const SITEMAP_URL = 'https://www.ag-grid.com/sitemap-0.xml'; @@ -41,4 +45,62 @@ describe('getSitemapXml', () => { await expect(fetchSitemap()).rejects.toThrow(`Failed to fetch sitemap ${SITEMAP_URL}: 503 Service Unavailable`); }); + + describe('recording which sitemap was used', () => { + let tempDir: string; + + const readRecord = async (recordDir: string) => + JSON.parse(await fs.readFile(getConsumedSitemapRecordPath(recordDir), 'utf8')); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ag-sitemap-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('records a sitemap fetched from the live site', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => SITEMAP_XML })); + const recordDir = path.join(tempDir, 'sitemap-build'); + + await getSitemapXml({ + cacheDir: MISSING_CACHE_DIR, + sitemapUrl: SITEMAP_URL, + logger, + gitHash: 'test-hash', + recordDir, + }); + + expect(await readRecord(recordDir)).toEqual({ + source: 'live', + sitemapUrl: SITEMAP_URL, + locs: ['https://www.ag-grid.com/'], + }); + }); + + test('records a sitemap read from the cache', async () => { + const cacheDir = path.join(tempDir, 'sitemap'); + const recordDir = path.join(tempDir, 'sitemap-build'); + await fs.mkdir(path.join(cacheDir, 'debug'), { recursive: true }); + await fs.writeFile(path.join(cacheDir, 'sitemap-0.xml'), SITEMAP_XML, 'utf8'); + await fs.writeFile( + path.join(cacheDir, 'debug', 'meta.json'), + JSON.stringify({ git: { hash: 'test-hash' } }), + 'utf8' + ); + + await getSitemapXml({ cacheDir, sitemapUrl: SITEMAP_URL, logger, gitHash: 'test-hash', recordDir }); + + expect(await readRecord(recordDir)).toEqual({ source: 'cache', locs: ['https://www.ag-grid.com/'] }); + }); + + test('records nothing when no record folder is given', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => SITEMAP_XML })); + + await fetchSitemap(); + + await expect(fs.readdir(tempDir)).resolves.toEqual([]); + }); + }); }); diff --git a/external/ag-website-shared/src/utils/getSitemapXml.ts b/external/ag-website-shared/src/utils/getSitemapXml.ts index 985718ec4e7..3a0889044ca 100644 --- a/external/ag-website-shared/src/utils/getSitemapXml.ts +++ b/external/ag-website-shared/src/utils/getSitemapXml.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { BUILD_USER_AGENT } from '../constants'; +import { type ConsumedSitemapRecord, writeConsumedSitemapRecord } from './consumedSitemapRecord'; import { getGitHash } from './gitUtils'; type Logger = Pick; @@ -11,6 +12,12 @@ type GetSitemapXmlOptions = { sitemapUrl: string; logger?: Logger; gitHash?: string; + /** + * When set, the resolved sitemap is recorded here so `buildWithSitemapCache` can tell whether + * the sitemap this page rendered from matches the one the build then generated, and skip the + * second build when it does. Omit to record nothing. + */ + recordDir?: string; }; const readCachedHash = async (cachedMetaPath: string) => { @@ -32,6 +39,7 @@ export const getSitemapXml = async ({ sitemapUrl, logger = console, gitHash, + recordDir, }: GetSitemapXmlOptions): Promise => { const cacheFolder = path.resolve(cacheDir); const cachedSitemapPath = path.join(cacheFolder, 'sitemap-0.xml'); @@ -39,6 +47,7 @@ export const getSitemapXml = async ({ const currentHash = gitHash ?? getGitHash(); let xmlSitemap: string | null = null; + let source: ConsumedSitemapRecord['source'] = 'cache'; try { await fs.access(cachedSitemapPath); const cachedHash = await readCachedHash(cachedMetaPath); @@ -63,8 +72,23 @@ export const getSitemapXml = async ({ throw new Error(`Failed to fetch sitemap ${sitemapUrl}: ${response.status} ${response.statusText}`); } xmlSitemap = await response.text(); + source = 'live'; logger.log(`⚠️ No cached sitemap found, fetched from live site: ${sitemapUrl}`); } + if (recordDir) { + try { + await writeConsumedSitemapRecord({ + recordDir, + xmlSitemap, + source, + sitemapUrl: source === 'live' ? sitemapUrl : undefined, + }); + } catch (error) { + // Only costs a redundant second build, so never fail the page over it. + logger.warn(`⚠️ Could not record the sitemap this build rendered from: ${error}`); + } + } + return xmlSitemap; }; diff --git a/external/ag-website-shared/src/utils/sitemapLocs.test.ts b/external/ag-website-shared/src/utils/sitemapLocs.test.ts new file mode 100644 index 00000000000..c3b309368b8 --- /dev/null +++ b/external/ag-website-shared/src/utils/sitemapLocs.test.ts @@ -0,0 +1,79 @@ +import { describeSitemapLocsDiff, diffSitemapLocs, getSitemapLocs } from './sitemapLocs'; + +const sitemapXml = (...locs: string[]) => + `${locs + .map((loc) => `${loc}2026-08-21T00:00:00.000Z`) + .join('')}`; + +const HOME = 'https://www.ag-grid.com/'; +const ABOUT = 'https://www.ag-grid.com/about/'; +const PIPELINE = 'https://www.ag-grid.com/pipeline/'; + +describe('getSitemapLocs', () => { + test('reads the locs in sitemap order', () => { + expect(getSitemapLocs(sitemapXml(HOME, ABOUT))).toEqual([HOME, ABOUT]); + }); + + test('returns nothing for a sitemap with no urls', () => { + expect(getSitemapLocs(sitemapXml())).toEqual([]); + }); +}); + +describe('diffSitemapLocs', () => { + test('matches sitemaps whose lastmod differs but whose pages do not', () => { + const before = getSitemapLocs(sitemapXml(HOME, ABOUT)); + const after = getSitemapLocs(sitemapXml(HOME, ABOUT).replace('2026-08-21', '2026-08-22')); + + expect(diffSitemapLocs(before, after).matches).toBe(true); + }); + + test('reports an added page', () => { + const diff = diffSitemapLocs([HOME, ABOUT], [HOME, ABOUT, PIPELINE]); + + expect(diff).toEqual({ matches: false, added: [PIPELINE], removed: [], reordered: false }); + }); + + test('reports a removed page', () => { + const diff = diffSitemapLocs([HOME, ABOUT, PIPELINE], [HOME, ABOUT]); + + expect(diff).toEqual({ matches: false, added: [], removed: [PIPELINE], reordered: false }); + }); + + test('treats a reorder as a change, because the page lists pages in sitemap order', () => { + const diff = diffSitemapLocs([HOME, ABOUT], [ABOUT, HOME]); + + expect(diff).toEqual({ matches: false, added: [], removed: [], reordered: true }); + }); + + test('treats a repeated page as a change, so a duplicate is not lost to set comparison', () => { + const diff = diffSitemapLocs([HOME], [HOME, HOME]); + + expect(diff).toEqual({ matches: false, added: [], removed: [], reordered: false }); + expect(describeSitemapLocsDiff(diff)).toBe('the page list changed'); + }); + + test('does not match an empty sitemap against a populated one', () => { + expect(diffSitemapLocs([], [HOME]).matches).toBe(false); + }); +}); + +describe('describeSitemapLocsDiff', () => { + test('summarises additions and removals', () => { + const diff = diffSitemapLocs([HOME, ABOUT], [HOME, PIPELINE]); + + expect(describeSitemapLocsDiff(diff)).toBe(`1 added (${PIPELINE}), 1 removed (${ABOUT})`); + }); + + test('truncates a long list of changes', () => { + const added = ['/a/', '/b/', '/c/', '/d/']; + const diff = diffSitemapLocs([], added); + + expect(describeSitemapLocsDiff(diff)).toBe('4 added (/a/, /b/, /c/, …)'); + }); + + test('calls out a reorder', () => { + expect(describeSitemapLocsDiff(diffSitemapLocs([HOME, ABOUT], [ABOUT, HOME]))).toBe( + 'same pages in a different order' + ); + }); +}); diff --git a/external/ag-website-shared/src/utils/sitemapLocs.ts b/external/ag-website-shared/src/utils/sitemapLocs.ts new file mode 100644 index 00000000000..c0ea4ce40c1 --- /dev/null +++ b/external/ag-website-shared/src/utils/sitemapLocs.ts @@ -0,0 +1,58 @@ +/** + * The `/sitemap` page and its `/sitemap.md` twin render nothing but the `` list of the sitemap + * XML, in sitemap order (see `parseSitemap`). `` is rewritten on every build and never + * reaches either page, so two sitemaps with the same locs in the same order produce byte-identical + * sitemap pages. + * + * That makes the loc list — not the raw XML, and not the git hash — the right cache key for both the + * sitemap cache and the build's decision on whether the sitemap pages need re-rendering. + */ + +const LOC_REGEX = /([^<]+)<\/loc>/g; + +export const getSitemapLocs = (xml: string): string[] => [...xml.matchAll(LOC_REGEX)].map(([, loc]) => loc.trim()); + +export type SitemapLocsDiff = { + /** The two loc lists render the same sitemap page. */ + matches: boolean; + added: string[]; + removed: string[]; + /** The same URLs in a different order. The page lists them in sitemap order, so still a change. */ + reordered: boolean; +}; + +export const diffSitemapLocs = (before: string[], after: string[]): SitemapLocsDiff => { + const matches = before.length === after.length && before.every((loc, index) => loc === after[index]); + const beforeSet = new Set(before); + const afterSet = new Set(after); + const added = after.filter((loc) => !beforeSet.has(loc)); + const removed = before.filter((loc) => !afterSet.has(loc)); + + return { + matches, + added, + removed, + reordered: !matches && before.length === after.length && added.length === 0 && removed.length === 0, + }; +}; + +/** One-line summary of a diff, for build logs. */ +export const describeSitemapLocsDiff = ({ matches, added, removed, reordered }: SitemapLocsDiff): string => { + if (matches) { + return 'no change'; + } + if (reordered) { + return 'same pages in a different order'; + } + + const summarise = (label: string, locs: string[]) => + locs.length === 0 + ? null + : `${locs.length} ${label} (${locs.slice(0, 3).join(', ')}${locs.length > 3 ? ', …' : ''})`; + + // The fallback covers a page listed a different number of times, which shows up as neither an + // addition nor a removal. + return ( + [summarise('added', added), summarise('removed', removed)].filter(Boolean).join(', ') || 'the page list changed' + ); +}; diff --git a/packages/ag-grid-community/src/export/gridSerializer.ts b/packages/ag-grid-community/src/export/gridSerializer.ts index d115946d1c7..9fb5d00be40 100644 --- a/packages/ag-grid-community/src/export/gridSerializer.ts +++ b/packages/ag-grid-community/src/export/gridSerializer.ts @@ -10,8 +10,8 @@ import type { AgColumnGroup } from '../entities/agColumnGroup'; import type { RowNode } from '../entities/rowNode'; import { _addGridCommonParams, - _canSkipShowingRowGroup, _isClientSideRowModel, + _isHiddenSingleChildGroup, _isServerSideRowModel, } from '../gridOptionsUtils'; import type { ExportParams, ShouldRowBeSkippedParams } from '../interfaces/exportParams'; @@ -72,10 +72,7 @@ export class GridSerializer extends BeanStub implements NamedBean { const hideOpenParents = this.gos.get('groupHideOpenParents') && !isExplicitExportSelection; const isLeafNode = this.colModel.pivotMode ? node.leafGroup : !node.group; const isFooter = !!node.footer; - const shouldSkipCurrentGroup = - node.allChildrenCount === 1 && - node.childrenAfterGroup?.length === 1 && - _canSkipShowingRowGroup(this.gos, node); + const shouldSkipCurrentGroup = _isHiddenSingleChildGroup(this.gos, node); if ( (!isLeafNode && !isFooter && (params.skipRowGroups || shouldSkipCurrentGroup || hideOpenParents)) || @@ -389,7 +386,9 @@ export class GridSerializer extends BeanStub implements NamedBean { } private withCollapsibleGroupRanges(cell: GridHeaderCell, columnsToExport: AgColumn[]): GridHeaderCell { - if (cell.type !== 'group' && cell.type !== 'padding') { + // only real group cells contribute ranges: padding cells wrapping an expandable chain + // would re-emit the same range once per padded row, inflating the outline nesting + if (cell.type !== 'group') { return cell; } if (!cell.column?.isExpandable()) { diff --git a/packages/ag-grid-community/src/gridOptionsUtils.ts b/packages/ag-grid-community/src/gridOptionsUtils.ts index 58e471e5a8f..113693b48a3 100644 --- a/packages/ag-grid-community/src/gridOptionsUtils.ts +++ b/packages/ag-grid-community/src/gridOptionsUtils.ts @@ -338,6 +338,15 @@ export function _canSkipShowingRowGroup(gos: GridOptionsService, node: RowNode): return false; } +/** + * Returns true when a single-child group row is hidden from the UI (and from exports) + * due to `groupHideParentOfSingleChild` (or its deprecated equivalents). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export function _isHiddenSingleChildGroup(gos: GridOptionsService, node: RowNode): boolean { + return node.allChildrenCount === 1 && node.childrenAfterGroup?.length === 1 && _canSkipShowingRowGroup(gos, node); +} + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _getMaxConcurrentDatasourceRequests(gos: GridOptionsService): number | undefined { const res = gos.get('maxConcurrentDatasourceRequests'); diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 1594d16c414..41076eead5a 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -204,6 +204,7 @@ export { _isGroupMultiAutoColumn, _isGroupRowsSticky, _isGroupUseEntireRow, + _isHiddenSingleChildGroup, _isLegacyMenuEnabled, _isMultiRowSelection, _isRowNumbers, diff --git a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts index ab6753e82d2..19a976cbc75 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelSerializingSession.ts @@ -25,7 +25,12 @@ import type { RowHeightCallbackParams, RowNode, } from 'ag-grid-community'; -import { BaseGridSerializingSession, _addGridCommonParams, _mergeDeep } from 'ag-grid-community'; +import { + BaseGridSerializingSession, + _addGridCommonParams, + _isHiddenSingleChildGroup, + _mergeDeep, +} from 'ag-grid-community'; import type { InternalExcelCell } from './assets/excelInterfaces'; import { getHeightFromProperty } from './assets/excelUtils'; @@ -338,16 +343,12 @@ export class ExcelSerializingSession extends BaseGridSerializingSession