diff --git a/astro.config.ts b/astro.config.ts index c7a240ef31..a5ceebb6a4 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -6,6 +6,7 @@ import { redirects } from './astro.redirects'; import { sidebar } from './astro.sidebar'; import { devServerFileWatcher } from './config/integrations/dev-server-file-watcher'; import { sitemap } from './config/integrations/sitemap'; +import { rehypeBlogImages } from './config/plugins/rehype-blog-images'; import { rehypeMdxIncludeHeadings } from './config/plugins/rehype-mdx-include-headings'; import { rehypeTasklistEnhancer } from './config/plugins/rehype-tasklist-enhancer'; import { PROD_ORIGIN } from './src/consts'; @@ -150,7 +151,7 @@ export default defineConfig({ // @ts-expect-error — `remark-smartypants` type is not matching Astro's for some reason even though they both use unified's `Plugin` type [remarkSmartypants, { dashes: false }], ], - rehypePlugins: [rehypeSlug, rehypeTasklistEnhancer(), rehypeMdxIncludeHeadings()], + rehypePlugins: [rehypeSlug, rehypeTasklistEnhancer(), rehypeMdxIncludeHeadings(), rehypeBlogImages()], }, image: { domains: ['avatars.githubusercontent.com'], diff --git a/config/plugins/rehype-blog-images.ts b/config/plugins/rehype-blog-images.ts new file mode 100644 index 0000000000..cab9d80abe --- /dev/null +++ b/config/plugins/rehype-blog-images.ts @@ -0,0 +1,97 @@ +import { join } from 'node:path'; +import type { Root } from 'hast'; +import sharp from 'sharp'; +import type { Plugin, Transformer } from 'unified'; +import { visit } from 'unist-util-visit'; + +/** + * Rehype plugin for blog-post body images (scoped to `src/content/blog/`). + * + * Markdown images render as bare `` tags with no dimensions and eager + * loading. This plugin: + * + * 1. Injects intrinsic `width`/`height` (read from the file in `public/`) so + * the browser reserves layout space before the image downloads. + * 2. Keeps the first body image eager (it is the usual LCP candidate) and + * lazy-loads every following image; all get `decoding="async"`. + * + * Images that already declare `loading`, `width`, or `height` are left as + * authored. External URLs and unresolvable files are skipped gracefully. + */ + +interface Dims { + width: number; + height: number; +} + +// Module-level cache: many posts reuse images, and metadata reads repeat +// across dozens of posts on every build. `null` marks a failed read so broken +// paths aren't retried per occurrence. The cache is never invalidated, so in +// `pnpm dev` an edited image serves stale width/height until a server restart. +const dimsCache = new Map(); + +async function readDims(absPath: string): Promise { + const cached = dimsCache.get(absPath); + if (cached !== undefined) return cached; + let dims: Dims | null = null; + try { + const meta = await sharp(absPath).metadata(); + if (meta.width && meta.height) dims = { width: meta.width, height: meta.height }; + } catch { + // Missing or unreadable file — leave dimensions off, keep the attrs. + } + dimsCache.set(absPath, dims); + return dims; +} + +export function rehypeBlogImages(): Plugin<[], Root> { + const transformer: Transformer = async (tree, file) => { + const sourcePath = (file.path ?? file.history?.[0] ?? '').replaceAll('\\', '/'); + if (!sourcePath.includes('/src/content/blog/')) return; + + // Collect in document order first; async work inside a visitor is unsafe. + const images: { properties: Record }[] = []; + visit(tree, 'element', (node) => { + if (node.tagName !== 'img') return; + node.properties ??= {}; + images.push(node as { properties: Record }); + }); + + let isFirst = true; + for (const img of images) { + const props = img.properties; + const first = isFirst; + isFirst = false; + + // Respect explicitly authored loading behavior. + if (props.loading == null) { + props.loading = first ? 'eager' : 'lazy'; + props.decoding ??= 'async'; + } + + const src = typeof props.src === 'string' ? props.src : ''; + // Only local, root-relative assets ('/images/…', not '//host/…'). + if (!src.startsWith('/') || src.startsWith('//')) continue; + if (props.width != null || props.height != null) continue; + + // Malformed percent-encoding (a literal `%` in a filename) makes + // decodeURIComponent throw — degrade to skipping dimensions, like + // every other failure path here. + let cleanPath: string; + try { + cleanPath = decodeURIComponent(src.split(/[?#]/)[0] ?? ''); + } catch { + continue; + } + const dims = await readDims(join(process.cwd(), 'public', cleanPath)); + if (dims) { + props.width = dims.width; + props.height = dims.height; + } + } + }; + + return function attacher() { + return transformer; + }; +} diff --git a/package.json b/package.json index 2284abda92..fe35c4b6ab 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "NODE_OPTIONS=--max-old-space-size=8192 astro build", - "build:fast": "SKIP_OG=true NODE_OPTIONS=--max-old-space-size=8192 astro build", - "build:linkcheck": "SKIP_OG=true SKIP_LLMS=true SKIP_IMG=true NODE_OPTIONS=--max-old-space-size=8192 astro build", + "build": "node ./scripts/generate-nav-sprite.mjs && NODE_OPTIONS=--max-old-space-size=8192 astro build", + "build:fast": "node ./scripts/generate-nav-sprite.mjs && SKIP_OG=true NODE_OPTIONS=--max-old-space-size=8192 astro build", + "build:linkcheck": "node ./scripts/generate-nav-sprite.mjs && SKIP_OG=true SKIP_LLMS=true SKIP_IMG=true NODE_OPTIONS=--max-old-space-size=8192 astro build", "preview": "astro preview", "check": "astro check", "format": "pnpm run format:code", @@ -23,7 +23,8 @@ "lint:redirects": "node --experimental-transform-types ./scripts/check-redirect-chains.ts", "netlify:build": "pnpm ${NETLIFY_BUILD_SCRIPT:-build}", "lunaria:build": "node --experimental-transform-types ./scripts/lunaria.mts", - "generate:redirects": "node --experimental-transform-types ./scripts/generate-redirects.ts" + "generate:redirects": "node --experimental-transform-types ./scripts/generate-redirects.ts", + "generate:nav-sprite": "node ./scripts/generate-nav-sprite.mjs" }, "devDependencies": { "@actions/core": "^1.11.1", diff --git a/public/images/blog/1-million-reasons-to-choose-tbmq-as-high-performance-mqtt-broker/migration_from_jedis_to_lettuce.webp b/public/images/blog/1-million-reasons-to-choose-tbmq-as-high-performance-mqtt-broker/migration_from_jedis_to_lettuce.webp index e06a321eb8..b0205eb694 100644 Binary files a/public/images/blog/1-million-reasons-to-choose-tbmq-as-high-performance-mqtt-broker/migration_from_jedis_to_lettuce.webp and b/public/images/blog/1-million-reasons-to-choose-tbmq-as-high-performance-mqtt-broker/migration_from_jedis_to_lettuce.webp differ diff --git a/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-1.webp b/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-1.webp index b618171a6a..4905f4480d 100644 Binary files a/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-1.webp and b/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-1.webp differ diff --git a/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-2.webp b/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-2.webp index 0ce0f8665f..3282218854 100644 Binary files a/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-2.webp and b/public/images/blog/breakthrough-agricultural-solutions-with-thingsboard/dashboard-img-2.webp differ diff --git a/public/images/blog/from-architecture-to-results-why-working-with-the-platform-team-makes-a-difference/cover.webp b/public/images/blog/from-architecture-to-results-why-working-with-the-platform-team-makes-a-difference/cover.webp index 61bed1d0c2..0e7af731bd 100644 Binary files a/public/images/blog/from-architecture-to-results-why-working-with-the-platform-team-makes-a-difference/cover.webp and b/public/images/blog/from-architecture-to-results-why-working-with-the-platform-team-makes-a-difference/cover.webp differ diff --git a/public/images/blog/improve-your-business-with-mobile-app/cover.webp b/public/images/blog/improve-your-business-with-mobile-app/cover.webp index 013888e59e..cec025aa5b 100644 Binary files a/public/images/blog/improve-your-business-with-mobile-app/cover.webp and b/public/images/blog/improve-your-business-with-mobile-app/cover.webp differ diff --git a/public/images/blog/improve-your-business-with-mobile-app/desktop-to-mobile.webp b/public/images/blog/improve-your-business-with-mobile-app/desktop-to-mobile.webp index f53c908487..84aa604166 100644 Binary files a/public/images/blog/improve-your-business-with-mobile-app/desktop-to-mobile.webp and b/public/images/blog/improve-your-business-with-mobile-app/desktop-to-mobile.webp differ diff --git a/public/images/blog/new-thingsboard-tbmq-pricing-modular-add-ons-top-ups-and-total-cost-clarity/public_cloud-2.webp b/public/images/blog/new-thingsboard-tbmq-pricing-modular-add-ons-top-ups-and-total-cost-clarity/public_cloud-2.webp index 62ff14fa23..92406c49e0 100644 Binary files a/public/images/blog/new-thingsboard-tbmq-pricing-modular-add-ons-top-ups-and-total-cost-clarity/public_cloud-2.webp and b/public/images/blog/new-thingsboard-tbmq-pricing-modular-add-ons-top-ups-and-total-cost-clarity/public_cloud-2.webp differ diff --git a/public/images/blog/tbmq-2-1-new-chapter-in-mqtt-messaging-with-embedded-integrations/helm-tbmq2.webp b/public/images/blog/tbmq-2-1-new-chapter-in-mqtt-messaging-with-embedded-integrations/helm-tbmq2.webp index 9e408970e3..9194057a2d 100644 Binary files a/public/images/blog/tbmq-2-1-new-chapter-in-mqtt-messaging-with-embedded-integrations/helm-tbmq2.webp and b/public/images/blog/tbmq-2-1-new-chapter-in-mqtt-messaging-with-embedded-integrations/helm-tbmq2.webp differ diff --git a/public/images/blog/thingsboard-3-9-0-release/debug.webp b/public/images/blog/thingsboard-3-9-0-release/debug.webp index be19447bd3..607f26c4d8 100644 Binary files a/public/images/blog/thingsboard-3-9-0-release/debug.webp and b/public/images/blog/thingsboard-3-9-0-release/debug.webp differ diff --git a/public/images/blog/thingsboard-3-9-0-release/image-7.png b/public/images/blog/thingsboard-3-9-0-release/image-7.png index bef2645827..d1e45f8cc5 100644 Binary files a/public/images/blog/thingsboard-3-9-0-release/image-7.png and b/public/images/blog/thingsboard-3-9-0-release/image-7.png differ diff --git a/public/images/blog/thingsboard-3-9-0-release/image-8.png b/public/images/blog/thingsboard-3-9-0-release/image-8.png index bef2645827..d1e45f8cc5 100644 Binary files a/public/images/blog/thingsboard-3-9-0-release/image-8.png and b/public/images/blog/thingsboard-3-9-0-release/image-8.png differ diff --git a/public/images/blog/thingsboard-3-9-0-release/image.png b/public/images/blog/thingsboard-3-9-0-release/image.png index bef2645827..d1e45f8cc5 100644 Binary files a/public/images/blog/thingsboard-3-9-0-release/image.png and b/public/images/blog/thingsboard-3-9-0-release/image.png differ diff --git a/public/images/blog/thingsboard-4-0-release/converters-2-0.webp b/public/images/blog/thingsboard-4-0-release/converters-2-0.webp index e9663ac7ce..1fb8d2db04 100644 Binary files a/public/images/blog/thingsboard-4-0-release/converters-2-0.webp and b/public/images/blog/thingsboard-4-0-release/converters-2-0.webp differ diff --git a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_12-1.webp b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_12-1.webp index 3daa74a2ba..2d7e58858b 100644 Binary files a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_12-1.webp and b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_12-1.webp differ diff --git a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_2.webp b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_2.webp index 02d4382336..3bb1c552c7 100644 Binary files a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_2.webp and b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/reporting_2.webp differ diff --git a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/secrets.webp b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/secrets.webp index fc37eb3136..4bdb4b71a8 100644 Binary files a/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/secrets.webp and b/public/images/blog/thingsboard-4-2-release-reporting-2-0-ai-integration-secrets-management-and-more/secrets.webp differ diff --git a/public/images/blog/thingsboard-cli/cover.webp b/public/images/blog/thingsboard-cli/cover.webp index 576db6dd2c..0bf0ecab93 100644 Binary files a/public/images/blog/thingsboard-cli/cover.webp and b/public/images/blog/thingsboard-cli/cover.webp differ diff --git a/public/images/blog/thingsboard-edge-4-0-1-release/edge-cluster.webp b/public/images/blog/thingsboard-edge-4-0-1-release/edge-cluster.webp index 8c07e56b7e..01e1863fa4 100644 Binary files a/public/images/blog/thingsboard-edge-4-0-1-release/edge-cluster.webp and b/public/images/blog/thingsboard-edge-4-0-1-release/edge-cluster.webp differ diff --git a/public/images/blog/thingsboard-edge-4-0-1-release/edge-rule-chains.webp b/public/images/blog/thingsboard-edge-4-0-1-release/edge-rule-chains.webp index b4452c35ec..8fa15a8a8a 100644 Binary files a/public/images/blog/thingsboard-edge-4-0-1-release/edge-rule-chains.webp and b/public/images/blog/thingsboard-edge-4-0-1-release/edge-rule-chains.webp differ diff --git a/public/images/development-services/hero-1.webp b/public/images/development-services/hero-1.webp index 49f53db334..a5e5d5a82d 100644 Binary files a/public/images/development-services/hero-1.webp and b/public/images/development-services/hero-1.webp differ diff --git a/public/images/partners/mclimate-logo.png b/public/images/partners/mclimate-logo.png index e45481a6e3..bde5f81fc3 100644 Binary files a/public/images/partners/mclimate-logo.png and b/public/images/partners/mclimate-logo.png differ diff --git a/public/nav-sprite.svg b/public/nav-sprite.svg new file mode 100644 index 0000000000..871a0e5e60 --- /dev/null +++ b/public/nav-sprite.svg @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/generate-nav-sprite.mjs b/scripts/generate-nav-sprite.mjs new file mode 100644 index 0000000000..6906b973d1 --- /dev/null +++ b/scripts/generate-nav-sprite.mjs @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; + +// Builds the navigation icon sprite + its manifest from +// src/assets/images/landings/nav/*.svg: +// +// public/nav-sprite.svg — per icon +// src/assets/images/landings/nav-sprite.manifest.json — name → { id, viewBox } +// +// The mega-menu references icons via (see +// NavIcon.astro), which keeps ~100KB of icon markup out of every page's HTML +// stream. NavIcon reads the manifest, so geometry and viewBox always come +// from the same generation pass. Runs automatically before builds (see +// package.json build scripts); after editing an icon in dev, re-run: +// +// pnpm generate:nav-sprite + +const SRC_DIR = resolve('src/assets/images/landings/nav'); +const OUT_SPRITE = resolve('public/nav-sprite.svg'); +const OUT_MANIFEST = resolve('src/assets/images/landings/nav-sprite.manifest.json'); +const MARKER = 'generated by scripts/generate-nav-sprite.mjs — do not edit'; + +// Attributes that must not be copied from a source root onto its +// : sizing is controlled by the referencing , and identity / +// namespace attributes don't belong on symbols. +const DROP_ATTRS = new Set(['width', 'height', 'id', 'class', 'xmlns', 'xmlns:xlink', 'version']); + +const files = readdirSync(SRC_DIR) + .filter((f) => f.endsWith('.svg')) + .sort(); + +const symbols = []; +const manifest = {}; +for (const file of files) { + const name = basename(file, '.svg'); + let raw = readFileSync(join(SRC_DIR, file), 'utf8').replace(/<\?xml[^>]*\?>\s*/, ''); + + const match = raw.match(/]*)>([\s\S]*)<\/svg>\s*$/); + if (!match) throw new Error(`${file}: could not parse root`); + const [, rootAttrs, inner] = match; + + // Prefix internal ids per icon so defs (gradients, clip paths) from + // different icons can't collide inside the combined sprite. + const prefixed = inner + .replace(/\bid="([^"]+)"/g, `id="${name}--$1"`) + .replace(/url\(#([^)]+)\)/g, `url(#${name}--$1)`) + .replace(/\b(xlink:href|href)="#([^"]+)"/g, `$1="#${name}--$2"`); + + // Carry presentation attributes (fill="none", stroke rules, viewBox…) + // onto the ; they inherit into its content when instantiated. + const kept = []; + let viewBox = ''; + for (const attrMatch of rootAttrs.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"/g)) { + const [, attrName, value] = attrMatch; + if (DROP_ATTRS.has(attrName)) continue; + if (attrName === 'viewBox') viewBox = value; + kept.push(`${attrName}="${value}"`); + } + if (!viewBox) { + throw new Error(`${file}: missing viewBox — the sprite needs it for scaling`); + } + + const id = `nav-${name}`; + symbols.push(`${prefixed.trim()}`); + manifest[name] = { id, viewBox }; +} + +const sprite = `\n${symbols.join('\n')}\n`; +writeFileSync(OUT_SPRITE, sprite); +writeFileSync( + OUT_MANIFEST, + `${JSON.stringify({ '//': MARKER, icons: manifest }, null, '\t')}\n` +); +console.log( + `✓ nav-sprite.svg (${(sprite.length / 1024).toFixed(0)} KB) + manifest — ${symbols.length} icons` +); diff --git a/src/assets/images/landings/nav-sprite.manifest.json b/src/assets/images/landings/nav-sprite.manifest.json new file mode 100644 index 0000000000..0d8f51556c --- /dev/null +++ b/src/assets/images/landings/nav-sprite.manifest.json @@ -0,0 +1,153 @@ +{ + "//": "generated by scripts/generate-nav-sprite.mjs — do not edit", + "icons": { + "about-s-icon": { + "id": "nav-about-s-icon", + "viewBox": "0 0 24 24" + }, + "affiliate-s-icon": { + "id": "nav-affiliate-s-icon", + "viewBox": "0 0 24 24" + }, + "careers-s-icon": { + "id": "nav-careers-s-icon", + "viewBox": "0 0 24 24" + }, + "case-aq-icon": { + "id": "nav-case-aq-icon", + "viewBox": "0 0 34 34" + }, + "case-eng-icon": { + "id": "nav-case-eng-icon", + "viewBox": "0 0 34 44" + }, + "case-env-icon": { + "id": "nav-case-env-icon", + "viewBox": "0 0 34 44" + }, + "case-fam-icon": { + "id": "nav-case-fam-icon", + "viewBox": "0 0 34 44" + }, + "case-health-care-icon": { + "id": "nav-case-health-care-icon", + "viewBox": "0 0 24 24" + }, + "case-level-icon": { + "id": "nav-case-level-icon", + "viewBox": "0 0 34 34" + }, + "case-met-icon": { + "id": "nav-case-met-icon", + "viewBox": "0 0 34 44" + }, + "case-off-icon": { + "id": "nav-case-off-icon", + "viewBox": "0 0 34 34" + }, + "case-ret-icon": { + "id": "nav-case-ret-icon", + "viewBox": "0 0 34 44" + }, + "case-scada-drilling-system-icon": { + "id": "nav-case-scada-drilling-system-icon", + "viewBox": "0 0 20 20" + }, + "case-scada-energy-management": { + "id": "nav-case-scada-energy-management", + "viewBox": "0 0 20 20" + }, + "case-scada-icon": { + "id": "nav-case-scada-icon", + "viewBox": "0 0 24 24" + }, + "case-studies": { + "id": "nav-case-studies", + "viewBox": "0 0 24 24" + }, + "case-waste-icon": { + "id": "nav-case-waste-icon", + "viewBox": "0 0 34 34" + }, + "case-wat-icon": { + "id": "nav-case-wat-icon", + "viewBox": "0 0 34 34" + }, + "contact-s-icon": { + "id": "nav-contact-s-icon", + "viewBox": "0 0 24 24" + }, + "development-services": { + "id": "nav-development-services", + "viewBox": "0 0 34 44" + }, + "dis-s-icon": { + "id": "nav-dis-s-icon", + "viewBox": "0 0 20 18" + }, + "feedback": { + "id": "nav-feedback", + "viewBox": "0 0 23 25" + }, + "gateway-icon": { + "id": "nav-gateway-icon", + "viewBox": "0 0 54 54" + }, + "hard-s-icon": { + "id": "nav-hard-s-icon", + "viewBox": "0 0 24 24" + }, + "media-s-icon": { + "id": "nav-media-s-icon", + "viewBox": "0 0 24 24" + }, + "site-fleet-tracking-icon": { + "id": "nav-site-fleet-tracking-icon", + "viewBox": "0 0 24 24" + }, + "smart-irrigation": { + "id": "nav-smart-irrigation", + "viewBox": "0 0 32 32" + }, + "support-icon": { + "id": "nav-support-icon", + "viewBox": "0 0 34 44" + }, + "tb-mobile-icon": { + "id": "nav-tb-mobile-icon", + "viewBox": "0 0 54 54" + }, + "tb-pe-mobile-icon": { + "id": "nav-tb-pe-mobile-icon", + "viewBox": "0 0 54 54" + }, + "tbmq-icon": { + "id": "nav-tbmq-icon", + "viewBox": "0 0 100 100" + }, + "thingsboard-c-icon": { + "id": "nav-thingsboard-c-icon", + "viewBox": "0 0 54 54" + }, + "thingsboard-cm-icon": { + "id": "nav-thingsboard-cm-icon", + "viewBox": "0 0 54 54" + }, + "thingsboard-e-icon": { + "id": "nav-thingsboard-e-icon", + "viewBox": "0 0 54 54" + }, + "thingsboard-p-icon": { + "id": "nav-thingsboard-p-icon", + "viewBox": "0 0 32 32" + }, + "train-icon": { + "id": "nav-train-icon", + "viewBox": "0 0 34 44" + }, + "trendz-icon": { + "id": "nav-trendz-icon", + "viewBox": "0 0 54 54" + } + } +} diff --git a/src/components/DeferredLoadTrigger.astro b/src/components/DeferredLoadTrigger.astro index b7de2ebd8b..359a1591be 100644 --- a/src/components/DeferredLoadTrigger.astro +++ b/src/components/DeferredLoadTrigger.astro @@ -28,6 +28,13 @@ // Default API: if the user already interacted earlier in the session, // run immediately; otherwise wait for interaction on this page or the // fallback timer. + // + // Idle-fallback schedule — the authoritative table for every caller; + // keep the ordering when tuning any tier: + // 2500 (default) — MailerLite demo-form embed (DemoRequestForm.astro) + // 5000 — GTM + injected tags (GtmHead.astro) + // 5500 — GitHub star-count fetch, after GTM (Landing/GitHubButton.astro) + // 10000 — YourGPT chat widget (YourGptWidget.astro) window.__deferOnInteraction = function (cb, fallbackMs) { if (fired) return runCallback(cb); const ms = typeof fallbackMs === 'number' ? fallbackMs : 2500; diff --git a/src/components/DemoRequestForm.astro b/src/components/DemoRequestForm.astro index a41efd33fb..82457950ab 100644 --- a/src/components/DemoRequestForm.astro +++ b/src/components/DemoRequestForm.astro @@ -32,36 +32,50 @@ const { title, description, features, gtmFormId } = Astro.props; {alt} + {alt} ) : ( - {alt} + {alt} ) } diff --git a/src/components/GifVideo.astro b/src/components/GifVideo.astro index 79884d3dad..2a92b54cef 100644 --- a/src/components/GifVideo.astro +++ b/src/components/GifVideo.astro @@ -111,9 +111,5 @@ const { }); }; - // Run on a normal page load document.addEventListener('DOMContentLoaded', initLazyGifs); - - // Run on Astro View Transitions (client-side navigation) - document.addEventListener('astro:page-load', initLazyGifs); diff --git a/src/components/GtmHead.astro b/src/components/GtmHead.astro index 718023e2d6..68f4685c51 100644 --- a/src/components/GtmHead.astro +++ b/src/components/GtmHead.astro @@ -24,7 +24,10 @@ if (new URL(window.location.href).searchParams.has('gtm_debug')) { loadGtm(); } else { - (window.__deferOnInteraction || ((cb) => cb()))(loadGtm); + // 5s idle fallback (vs the 2.5s default) so tag-manager JS lands + // after the LCP paint window for visitors who never interact. + // Full tier schedule: DeferredLoadTrigger.astro. + (window.__deferOnInteraction || ((cb) => cb()))(loadGtm, 5000); } } diff --git a/src/components/ImageGallery.astro b/src/components/ImageGallery.astro index 95a7e74145..8daa0d90b4 100644 --- a/src/components/ImageGallery.astro +++ b/src/components/ImageGallery.astro @@ -1,6 +1,7 @@ --- import { getImage } from 'astro:assets'; import type { ImageMetadata } from 'astro'; +import { claimFirstContentImage } from '@util/first-content-image'; import { Products } from '~/models/site.models'; import 'photoswipe/style.css'; @@ -261,6 +262,13 @@ const processed = await Promise.all( ); const isSingle = processed.length === 1; + +// This gallery's leading thumb is the page's likely LCP element when it is +// the first content image on the page — load it eagerly (a `hasDark` pair +// eager-loads both variants; a display:none is still fetched when +// eager). Deliberately no fetchpriority boost — see DocImage for why. +const isFirstContentImage = processed.length > 0 && claimFirstContentImage(Astro.locals); +const thumbLoading = (i: number) => (i === 0 && isFirstContentImage ? 'eager' : 'lazy'); const useCardMode = cardMode && isSingle; --- @@ -302,7 +310,7 @@ const useCardMode = cardMode && isSingle; alt={processed[0].alt} width={processed[0].fullWidth} height={processed[0].fullHeight} - loading="lazy" + loading={thumbLoading(0)} decoding="async" /> {processed[0].hasDark && ( @@ -312,7 +320,7 @@ const useCardMode = cardMode && isSingle; alt={processed[0].alt} width={processed[0].fullWidth} height={processed[0].fullHeight} - loading="lazy" + loading={thumbLoading(0)} decoding="async" /> )} @@ -321,7 +329,7 @@ const useCardMode = cardMode && isSingle; ) : (