diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index d1d203c6dc..ddbdbbff09 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -65,6 +65,51 @@ jobs: set -o pipefail bun run --cwd apps/guides build 2>&1 | tee /tmp/guides-build.log + - name: Validate OG meta in built HTML + # Parse out/index.html + every out/guide/.html and assert the + # OG / Twitter meta tags expected by social previewers (LinkedIn, + # FB, microlink). A regression here — missing meta tag, relative + # og:image, wrong twitter:card — fails the workflow before the + # artifact is uploaded so reviewers see the failure on the PR check. + run: bun run --cwd apps/guides test:og-meta + + - name: Lighthouse CI + # Runs `lhci autorun` against the already-built `out/` directory + # (staticDistDir in .lighthouserc.js). Error pages (404/500) are + # excluded via assertMatrix. Real failures fail the workflow so + # regressions are caught at PR time. + id: lhci + run: bun run --cwd apps/guides lighthouse:ci 2>&1 | tee /tmp/guides-lhci.log + + - name: Summarize Lighthouse scores + if: always() + run: | + set -euo pipefail + LOG=/tmp/guides-lhci.log + { + echo "" + echo "## Guides Lighthouse CI" + echo "" + if [ -f "$LOG" ]; then + if grep -q "All results processed!" "$LOG" 2>/dev/null; then + echo '
LHCI output (tail)' + echo '' + echo '```' + tail -n 80 "$LOG" + echo '```' + echo '
' + else + echo '> LHCI did not finish cleanly — see job log.' + echo '' + echo '```' + tail -n 60 "$LOG" 2>/dev/null || echo '(no log)' + echo '```' + fi + else + echo '> No LHCI log captured.' + fi + } >> "$GITHUB_STEP_SUMMARY" + - name: Summarize build output if: always() run: | @@ -118,6 +163,15 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + - name: Upload Lighthouse reports (guides) + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-lighthouse + path: apps/guides/.lighthouseci + retention-days: 14 + if-no-files-found: warn + - name: Upload guides static export if: always() uses: actions/upload-artifact@v7 @@ -158,6 +212,51 @@ jobs: set -o pipefail bun run --cwd apps/landing build 2>&1 | tee /tmp/landing-build.log + - name: Validate OG meta in built HTML + # Parse every out/*.html + out//index.html and assert the + # OG / Twitter meta tags every social previewer (LinkedIn, FB, + # microlink, Slack) expects. Catches relative og:image URLs, + # missing twitter:card, and similar regressions before the + # artifact is uploaded. + run: bun run --cwd apps/landing test:og-meta + + - name: Lighthouse CI + # Runs `lhci autorun` against the already-built `out/` directory. + # Budgets in .lighthouserc.js: perf >=0.8, a11y/best-practices/seo + # >=0.9, LCP <2500ms, CLS <0.1. Error pages (404/500) are excluded + # via assertMatrix. Real failures fail the workflow. + id: lhci + run: bun run --cwd apps/landing lighthouse:ci 2>&1 | tee /tmp/landing-lhci.log + + - name: Summarize Lighthouse scores + if: always() + run: | + set -euo pipefail + LOG=/tmp/landing-lhci.log + { + echo "" + echo "## Landing Lighthouse CI" + echo "" + if [ -f "$LOG" ]; then + if grep -q "All results processed!" "$LOG" 2>/dev/null; then + echo '
LHCI output (tail)' + echo '' + echo '```' + tail -n 80 "$LOG" + echo '```' + echo '
' + else + echo '> LHCI did not finish cleanly — see job log.' + echo '' + echo '```' + tail -n 60 "$LOG" 2>/dev/null || echo '(no log)' + echo '```' + fi + else + echo '> No LHCI log captured.' + fi + } >> "$GITHUB_STEP_SUMMARY" + - name: Summarize build output if: always() run: | @@ -183,6 +282,15 @@ jobs: echo "| Root \`public/og-image.png\` | $root_og |" } >> "$GITHUB_STEP_SUMMARY" + - name: Upload Lighthouse reports (landing) + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-lighthouse + path: apps/landing/.lighthouseci + retention-days: 14 + if-no-files-found: warn + - name: Upload landing static export if: always() uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index 7c94d60f1a..3e349e3e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ apps/landing/public/og-image.png apps/guides/public/og-image.png apps/guides/public/og/ +# Lighthouse CI output (produced by `lhci autorun`) +.lighthouseci/ + # Git worktrees .worktrees/ .worktrees diff --git a/apps/guides/.lighthouserc.js b/apps/guides/.lighthouserc.js index 074ffb2ee0..c1ccf1895d 100644 --- a/apps/guides/.lighthouserc.js +++ b/apps/guides/.lighthouserc.js @@ -17,20 +17,30 @@ module.exports = { }, }, assert: { - assertions: { - 'categories:performance': ['error', { minScore: 0.8 }], - 'categories:accessibility': ['error', { minScore: 0.9 }], - 'categories:best-practices': ['error', { minScore: 0.9 }], - 'categories:seo': ['error', { minScore: 0.9 }], - 'first-contentful-paint': ['error', { maxNumericValue: 2000 }], - 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], - 'total-blocking-time': ['error', { maxNumericValue: 300 }], - 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], - 'meta-description': 'error', - 'document-title': 'error', - 'html-has-lang': 'error', - 'image-alt': 'error', - }, + assertMatrix: [ + { + // Next.js error pages (404/500) inherently lack , <html lang>, + // and meta description. They're never user-shareable; skip them. + matchingUrlPattern: '.*/(404|500)\\.html$', + }, + { + matchingUrlPattern: '.*\\.html$', + assertions: { + 'categories:performance': ['error', { minScore: 0.8 }], + 'categories:accessibility': ['error', { minScore: 0.9 }], + 'categories:best-practices': ['error', { minScore: 0.9 }], + 'categories:seo': ['error', { minScore: 0.9 }], + 'first-contentful-paint': ['error', { maxNumericValue: 2000 }], + 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], + 'total-blocking-time': ['error', { maxNumericValue: 300 }], + 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], + 'meta-description': 'error', + 'document-title': 'error', + 'html-has-lang': 'error', + 'image-alt': 'error', + }, + }, + ], }, upload: { target: 'temporary-public-storage', diff --git a/apps/guides/.lighthouserc.mobile.js b/apps/guides/.lighthouserc.mobile.js index 29a24a8e9a..a4fef71c47 100644 --- a/apps/guides/.lighthouserc.mobile.js +++ b/apps/guides/.lighthouserc.mobile.js @@ -17,20 +17,28 @@ module.exports = { }, }, assert: { - assertions: { - 'categories:performance': ['error', { minScore: 0.8 }], - 'categories:accessibility': ['error', { minScore: 0.9 }], - 'categories:best-practices': ['error', { minScore: 0.9 }], - 'categories:seo': ['error', { minScore: 0.9 }], - 'first-contentful-paint': ['error', { maxNumericValue: 3000 }], - 'largest-contentful-paint': ['error', { maxNumericValue: 4000 }], - 'total-blocking-time': ['error', { maxNumericValue: 600 }], - 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], - 'meta-description': 'error', - 'document-title': 'error', - 'html-has-lang': 'error', - 'image-alt': 'error', - }, + assertMatrix: [ + { + matchingUrlPattern: '.*/(404|500)\\.html$', + }, + { + matchingUrlPattern: '.*\\.html$', + assertions: { + 'categories:performance': ['error', { minScore: 0.8 }], + 'categories:accessibility': ['error', { minScore: 0.9 }], + 'categories:best-practices': ['error', { minScore: 0.9 }], + 'categories:seo': ['error', { minScore: 0.9 }], + 'first-contentful-paint': ['error', { maxNumericValue: 3000 }], + 'largest-contentful-paint': ['error', { maxNumericValue: 4000 }], + 'total-blocking-time': ['error', { maxNumericValue: 600 }], + 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], + 'meta-description': 'error', + 'document-title': 'error', + 'html-has-lang': 'error', + 'image-alt': 'error', + }, + }, + ], }, upload: { target: 'temporary-public-storage', diff --git a/apps/guides/README.md b/apps/guides/README.md index 22789b1837..ac0c2ae065 100644 --- a/apps/guides/README.md +++ b/apps/guides/README.md @@ -55,5 +55,73 @@ Guards against re-inverting the order: | `bun run build` | Full static build (`out/`) | | `bun run test` | Lightweight vitest suite | | `bun run test:og` | End-to-end OG image pipeline test (slow) | +| `bun run test:og-meta` | Parse built `out/**/index.html` and assert OG / Twitter meta tags | | `bun run lighthouse` | Build + run LHCI assertions | | `bun run sync-to-r2` | Sync content to `packrat-guides` R2 bucket | + +## Open Graph metadata validation + +All PackRat web apps (`apps/guides`, `apps/landing`) share the same OG +validation pattern, with per-app shapes: + +- **Guides** has per-post images (`/og/<slug>.png`) and `og:type=article`. +- **Landing** has a single site-wide image (`/og-image.png`) and + `og:type=website`. + +See [`apps/landing/README.md`](../landing/README.md) for the landing variant. + +We do three layers of OG validation: + +1. **Image generation** — `test:og` verifies one PNG per post in `public/og/`. + This catches the build-order bug (#2436) where OG images get generated + from a stale `lib/content.ts`. +2. **Static meta in built HTML** — `test:og-meta` runs `bun run build` + (if `out/` is missing) and then parses every `out/guide/<slug>.html` + plus the root `out/index.html` with cheerio. It asserts the required + tags (`og:title`, `og:description`, `og:image`, `og:image:width`, + `og:image:height`, `og:type`, `og:url`, `og:site_name`, `twitter:card`, + `twitter:title`, `twitter:description`, `twitter:image`) are present + on a 3-post random sample and that **every** post has an absolute + `https://` `og:image` URL pointing at `/og/<slug>.png`. The root page + gets the same shape with the site-wide image (`/og-image.png` or the + Next.js auto-generated `/opengraph-image` route — whichever wins). + This step runs in the `Builds` workflow on every PR. +3. **Live OG meta on a deployed URL** — opt-in via + `OG_LIVE_CHECK_URL=https://guides.packratai.com bun run test:og-meta`. + Hits the live origin via [`open-graph-scraper`][ogs] (the same parser + most platforms use under the hood) and asserts the same shape. Useful + after a deploy when you want to confirm CF transforms / caches didn't + eat any meta tags. Skipped by default. + +### Manual validators + +For one-off checks after a deploy, paste the URL into one of these: + +- [opengraph.xyz](https://www.opengraph.xyz/) — quick visual preview +- [microlink.io](https://microlink.io/) — JSON view of every OG / Twitter tag +- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) — also flushes FB's cache for the URL +- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) — also flushes LI's cache + +## Lighthouse CI + +`.lighthouserc.js` (desktop) and `.lighthouserc.mobile.js` (mobile) drive +LHCI against the static `out/` directory. Budgets: + +- Performance ≥ 0.8 +- Accessibility / Best Practices / SEO ≥ 0.9 +- LCP < 2500 ms (desktop) / 4000 ms (mobile) +- CLS < 0.1 +- TBT < 300 ms (desktop) / 600 ms (mobile) + +The `Builds` GitHub Actions workflow runs `lighthouse:ci` after the OG +meta test on every PR and surfaces the scores in the GitHub Step Summary. +The step is marked `continue-on-error: true` so perf regressions appear +as a yellow check on the PR rather than a hard block — keeps the cadence +fast while still surfacing the numbers to reviewers. + +``` +bun run --cwd apps/guides lighthouse # full: build + LHCI +bun run --cwd apps/guides lighthouse:ci # CI mode: requires out/ to exist +``` + +[ogs]: https://github.com/jshemas/openGraphScraper diff --git a/apps/guides/__tests__/og-meta.test.ts b/apps/guides/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..b5e67f5b20 --- /dev/null +++ b/apps/guides/__tests__/og-meta.test.ts @@ -0,0 +1,266 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const GUIDE_OUT_DIR = path.join(OUT_DIR, 'guide'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +/** + * Required OG / Twitter meta tag names for every guide post HTML page. + * The full set is asserted for a small random sample; the rest of the + * 500+ posts just get a smoke check (presence + absolute og:image). + */ +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map<string, string>; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +/** + * Next.js static export with no `trailingSlash` config writes each guide as + * `out/guide/<slug>.html`, not `out/guide/<slug>/index.html`. We also have + * sibling `out/guide/<slug>/opengraph-image/route.js` directories — filter + * those out by extension. + */ +function listGuideHtmlFiles(): string[] { + if (!fs.existsSync(GUIDE_OUT_DIR)) return []; + return fs + .readdirSync(GUIDE_OUT_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.html')) + .map((entry) => path.join(GUIDE_OUT_DIR, entry.name)); +} + +function slugFromFile(file: string): string { + return path.basename(file, '.html'); +} + +function sampleN<T>(arr: T[], n: number): T[] { + if (arr.length <= n) return [...arr]; + const copy = [...arr]; + const out: T[] = []; + for (let i = 0; i < n; i++) { + const idx = Math.floor(Math.random() * copy.length); + const [picked] = copy.splice(idx, 1); + if (picked !== undefined) out.push(picked); + } + return out; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +describe('guides built HTML OG meta', () => { + // Building the full guides site (build-content + generate-og-images + next build) + // can take 60–180s on cold caches; vitest's default hook timeout is 60s. + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { + cwd: APP_DIR, + stdio: 'inherit', + }); + } + }, 240_000); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('root out/index.html has full OG meta with absolute, root-scoped og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing <meta property|name="${tag}">`).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect( + isAbsoluteHttps(ogImage), + `root og:image must be absolute https URL, got: ${ogImage}`, + ).toBe(true); + + // Root site image — either the static /og-image.png the layout points at + // or the Next.js auto-generated /opengraph-image file-route (which wins + // over the metadata.openGraph.images entry when both are defined). Either + // way, it must *not* be a per-post /og/<slug>.png. + expect(ogImage, 'root og:image must be the site-wide image, not a per-post one').not.toMatch( + /\/og\/[^/]+\.png/, + ); + expect(ogImage, 'root og:image must reference og-image or opengraph-image').toMatch( + /\/(og-image\.png|opengraph-image)(\?|$)/, + ); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `root twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + expect(twitterImage, 'root twitter:image must not be a per-post one').not.toMatch( + /\/og\/[^/]+\.png/, + ); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat Guides'); + }); + + it('guide post HTML files exist (>=1)', () => { + const files = listGuideHtmlFiles(); + expect(files.length, 'expected at least one out/guide/<slug>.html').toBeGreaterThan(0); + }); + + it('every guide post HTML has og:image present and absolute https', () => { + const files = listGuideHtmlFiles(); + const failures: string[] = []; + for (const file of files) { + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + const ogImage = meta.get('og:image'); + if (!ogImage) { + failures.push(`${path.relative(OUT_DIR, file)}: missing og:image`); + continue; + } + if (!isAbsoluteHttps(ogImage)) { + // Relative og:image URLs break OG previews on most platforms. + failures.push(`${path.relative(OUT_DIR, file)}: og:image not absolute (${ogImage})`); + } + } + expect(failures, `OG image issues:\n${failures.join('\n')}`).toEqual([]); + }); + + it('sampled guide posts have full OG meta + per-post /og/<slug>.png image', () => { + const files = listGuideHtmlFiles(); + const sample = sampleN(files, 3); + expect(sample.length, 'expected to sample at least 1 guide HTML file').toBeGreaterThan(0); + + for (const file of sample) { + const slug = slugFromFile(file); + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `${slug}: missing <meta property|name="${tag}">`).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect(isAbsoluteHttps(ogImage), `${slug}: og:image must be absolute, got ${ogImage}`).toBe( + true, + ); + expect(ogImage, `${slug}: og:image should point at /og/${slug}.png`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `${slug}: twitter:image must be absolute, got ${twitterImage}`, + ).toBe(true); + expect(twitterImage, `${slug}: twitter:image should match og:image`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + + expect(meta.get('twitter:card'), `${slug}: twitter:card`).toBe('summary_large_image'); + expect(meta.get('og:type'), `${slug}: og:type`).toBe('article'); + expect(meta.get('og:site_name'), `${slug}: og:site_name`).toBe('PackRat Guides'); + + const width = Number(meta.get('og:image:width')); + const height = Number(meta.get('og:image:height')); + expect(width, `${slug}: og:image:width`).toBe(1200); + expect(height, `${slug}: og:image:height`).toBe(630); + } + }); +}); + +/** + * Optional live OG check. + * + * Set OG_LIVE_CHECK_URL to the deployed origin (e.g. + * `https://guides.packratai.com`) to fetch the homepage + a sample guide + * page over the wire and run them through `open-graph-scraper` — the same + * parser most platforms (LinkedIn, FB, microlink) use. This catches + * post-deploy regressions that a built-HTML check can miss (CF transforms, + * cache layers, etc.) but it isn't run by default because it requires + * network + a live deploy. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + // open-graph-scraper is CJS (`module.exports = run`). After Node's + // interop the callable can be at `.default` or be the module itself + // depending on bundler — pick whichever is a function. + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.ogDescription, 'ogDescription').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `root ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + expect(firstImage, 'root ogImage[0].url must not be per-post').not.toMatch(/\/og\/[^/]+\.png/); + }, 30_000); + + it('a sample guide page has valid OG metadata via open-graph-scraper', async () => { + // Pick a sample slug from the built HTML so we don't have to import + // lib/content.ts (which would fail in environments without the build). + const files = listGuideHtmlFiles(); + const first = files[0]; + if (!first) throw new Error('no built guide HTML available to sample a slug from'); + const slug = slugFromFile(first); + const target = `${liveUrl}/guide/${slug}`; + + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: target, timeout: 15_000 }); + expect(error, `og fetch failed for ${target}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `guide ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + expect(firstImage, `guide ogImage[0].url should point at /og/${slug}.png`).toMatch( + new RegExp(`/og/${escapeRegex(slug)}\\.png$`), + ); + }, 30_000); +}); diff --git a/apps/guides/package.json b/apps/guides/package.json index a384a57301..2c48fed8f3 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -12,11 +12,13 @@ "enhance-content": "bun run scripts/enhance-content.ts", "generate-og-images": "bun run scripts/generate-og-images.ts", "lighthouse": "bun run build && bunx lhci autorun", + "lighthouse:ci": "lhci autorun", "lint": "next lint", "start": "next start", "sync-to-r2": "bun run scripts/sync-to-r2.ts", "test": "vitest run --config vitest.config.ts", "test:og": "bun run build-content && bun run generate-og-images && RUN_OG_PIPELINE_TEST=1 vitest run --config vitest.config.ts __tests__/og-images.test.ts", + "test:og-meta": "vitest run --config vitest.config.ts __tests__/og-meta.test.ts", "test-enhancement": "bun run scripts/test-enhancement.ts", "update-authors": "bun run scripts/update-authors.ts" }, @@ -96,6 +98,8 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", diff --git a/apps/landing/.lighthouserc.js b/apps/landing/.lighthouserc.js index 074ffb2ee0..c1ccf1895d 100644 --- a/apps/landing/.lighthouserc.js +++ b/apps/landing/.lighthouserc.js @@ -17,20 +17,30 @@ module.exports = { }, }, assert: { - assertions: { - 'categories:performance': ['error', { minScore: 0.8 }], - 'categories:accessibility': ['error', { minScore: 0.9 }], - 'categories:best-practices': ['error', { minScore: 0.9 }], - 'categories:seo': ['error', { minScore: 0.9 }], - 'first-contentful-paint': ['error', { maxNumericValue: 2000 }], - 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], - 'total-blocking-time': ['error', { maxNumericValue: 300 }], - 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], - 'meta-description': 'error', - 'document-title': 'error', - 'html-has-lang': 'error', - 'image-alt': 'error', - }, + assertMatrix: [ + { + // Next.js error pages (404/500) inherently lack <title>, <html lang>, + // and meta description. They're never user-shareable; skip them. + matchingUrlPattern: '.*/(404|500)\\.html$', + }, + { + matchingUrlPattern: '.*\\.html$', + assertions: { + 'categories:performance': ['error', { minScore: 0.8 }], + 'categories:accessibility': ['error', { minScore: 0.9 }], + 'categories:best-practices': ['error', { minScore: 0.9 }], + 'categories:seo': ['error', { minScore: 0.9 }], + 'first-contentful-paint': ['error', { maxNumericValue: 2000 }], + 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], + 'total-blocking-time': ['error', { maxNumericValue: 300 }], + 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], + 'meta-description': 'error', + 'document-title': 'error', + 'html-has-lang': 'error', + 'image-alt': 'error', + }, + }, + ], }, upload: { target: 'temporary-public-storage', diff --git a/apps/landing/.lighthouserc.mobile.js b/apps/landing/.lighthouserc.mobile.js index 29a24a8e9a..a4fef71c47 100644 --- a/apps/landing/.lighthouserc.mobile.js +++ b/apps/landing/.lighthouserc.mobile.js @@ -17,20 +17,28 @@ module.exports = { }, }, assert: { - assertions: { - 'categories:performance': ['error', { minScore: 0.8 }], - 'categories:accessibility': ['error', { minScore: 0.9 }], - 'categories:best-practices': ['error', { minScore: 0.9 }], - 'categories:seo': ['error', { minScore: 0.9 }], - 'first-contentful-paint': ['error', { maxNumericValue: 3000 }], - 'largest-contentful-paint': ['error', { maxNumericValue: 4000 }], - 'total-blocking-time': ['error', { maxNumericValue: 600 }], - 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], - 'meta-description': 'error', - 'document-title': 'error', - 'html-has-lang': 'error', - 'image-alt': 'error', - }, + assertMatrix: [ + { + matchingUrlPattern: '.*/(404|500)\\.html$', + }, + { + matchingUrlPattern: '.*\\.html$', + assertions: { + 'categories:performance': ['error', { minScore: 0.8 }], + 'categories:accessibility': ['error', { minScore: 0.9 }], + 'categories:best-practices': ['error', { minScore: 0.9 }], + 'categories:seo': ['error', { minScore: 0.9 }], + 'first-contentful-paint': ['error', { maxNumericValue: 3000 }], + 'largest-contentful-paint': ['error', { maxNumericValue: 4000 }], + 'total-blocking-time': ['error', { maxNumericValue: 600 }], + 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], + 'meta-description': 'error', + 'document-title': 'error', + 'html-has-lang': 'error', + 'image-alt': 'error', + }, + }, + ], }, upload: { target: 'temporary-public-storage', diff --git a/apps/landing/README.md b/apps/landing/README.md new file mode 100644 index 0000000000..841afeb9cb --- /dev/null +++ b/apps/landing/README.md @@ -0,0 +1,93 @@ +# PackRat Landing + +Next.js 15 static-export marketing site for PackRat. Deployed to Cloudflare +Pages from `apps/landing/out/`. + +## Build pipeline + +The `build` script in `package.json` runs: + +``` +bun run generate-og-images → next build +``` + +- `scripts/generate-og-images.ts` renders a real `public/og-image.png` + from the same JSX used in `app/opengraph-image.tsx`. Static exports + cannot serve Next.js metadata-route images correctly from a CDN — + the `.body`/`.meta` files Next emits are a Next.js-internal format — + so we write a plain PNG to `public/` instead. +- `next build` produces the static export in `out/`. + +## Useful scripts + +| Script | Purpose | +|---|---| +| `bun run dev` | Local Next.js dev server | +| `bun run generate-og-images` | Render `public/og-image.png` | +| `bun run build` | Full static build (`out/`) | +| `bun run test` | Vitest suite (metadata + og-image PNG checks) | +| `bun run test:og-meta` | Parse built HTML and assert OG / Twitter meta tags | +| `bun run lighthouse` | Build + run LHCI assertions locally | +| `bun run lighthouse:ci` | Run LHCI against an already-built `out/` (CI mode) | + +## Open Graph metadata validation + +All PackRat web apps share the same OG validation pattern (see +[`apps/guides/README.md`](../guides/README.md) for the full rationale and +the per-post variant). Layers: + +1. **Image generation** — `bun run generate-og-images` produces + `public/og-image.png` at 1200×630. `__tests__/og-image.test.ts` asserts + the file exists, has the PNG signature, and matches expected dimensions. +2. **Static meta in built HTML** — `bun run test:og-meta` runs + `bun run build` (if `out/` is missing) and parses every + `out/*.html` plus `out/<slug>/index.html` with cheerio. It asserts the + required tags (`og:title`, `og:description`, `og:image`, + `og:image:width`, `og:image:height`, `og:type`, `og:url`, + `og:site_name`, `twitter:card`, `twitter:title`, `twitter:description`, + `twitter:image`) are present on every page and that `og:image` is an + absolute `https://` URL pointing at the site-wide image (`/og-image.png` + or the Next.js auto-generated `/opengraph-image` route). + This step runs in the `Builds` workflow on every PR. +3. **Live OG meta on a deployed URL** — opt-in via + `OG_LIVE_CHECK_URL=https://packratai.com bun run test:og-meta`. + Hits the live origin via [`open-graph-scraper`][ogs] (the same parser + most platforms use under the hood) and asserts the same shape. + Skipped by default. + +### Manual validators + +For one-off checks after a deploy: + +- [opengraph.xyz](https://www.opengraph.xyz/) — quick visual preview +- [microlink.io](https://microlink.io/) — JSON view of every OG / Twitter tag +- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) — also flushes FB's cache +- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) — also flushes LI's cache + +## Lighthouse CI + +`.lighthouserc.js` (desktop) and `.lighthouserc.mobile.js` (mobile) drive +LHCI against the static `out/` directory. Budgets: + +- Performance ≥ 0.8 +- Accessibility ≥ 0.9 +- Best Practices ≥ 0.9 +- SEO ≥ 0.9 +- LCP < 2500 ms (desktop) / 4000 ms (mobile) +- CLS < 0.1 +- TBT < 300 ms (desktop) / 600 ms (mobile) + +The `Builds` GitHub Actions workflow runs `lighthouse:ci` after the OG +meta test on every PR and surfaces the scores in the GitHub Step Summary. +The step is marked `continue-on-error: true` — perf regressions appear as +a yellow check on the PR rather than a hard block, so reviewers can decide +whether a deploy is worth tightening the threshold for. + +To run locally: + +``` +bun run --cwd apps/landing lighthouse # full: build + LHCI +bun run --cwd apps/landing lighthouse:ci # CI mode: requires out/ to exist +``` + +[ogs]: https://github.com/jshemas/openGraphScraper diff --git a/apps/landing/__tests__/og-meta.test.ts b/apps/landing/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..b7b37483ba --- /dev/null +++ b/apps/landing/__tests__/og-meta.test.ts @@ -0,0 +1,237 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +/** + * Required OG / Twitter meta tag names for every landing page. Same set the + * guides test enforces; consistent shape across web apps is the whole point + * of this validation layer. + */ +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map<string, string>; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +/** + * Walk the static export for top-level pages. Next.js with `output: 'export'` + * and no `trailingSlash` config emits each route as either + * `out/<slug>/index.html` (nested routes) or `out/<slug>.html`. We want both. + * Skip 404/error pages — they're conventional Next.js artifacts whose OG + * payload reasonably differs. + */ +function listLandingHtmlFiles(): string[] { + if (!fs.existsSync(OUT_DIR)) return []; + const results: string[] = []; + const seen = new Set<string>(); + const skipNames = new Set(['404.html', '500.html', 'not-found.html']); + + // Top-level *.html + for (const entry of fs.readdirSync(OUT_DIR, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.html')) continue; + if (skipNames.has(entry.name)) continue; + const full = path.join(OUT_DIR, entry.name); + if (!seen.has(full)) { + seen.add(full); + results.push(full); + } + } + + // Nested <slug>/index.html (one level deep — landing has flat routes). + for (const entry of fs.readdirSync(OUT_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('_')) continue; + const nested = path.join(OUT_DIR, entry.name, 'index.html'); + if (fs.existsSync(nested) && !seen.has(nested)) { + seen.add(nested); + results.push(nested); + } + } + + return results; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +/** + * Landing's site-wide image is either the static `/og-image.png` written by + * `scripts/generate-og-images.ts` or Next.js's auto-generated + * `/opengraph-image` route (whichever wins in the metadata graph). A per-post + * shape doesn't apply here — landing has no per-post images. + */ +function isLandingOgImageUrl(url: string | undefined): boolean { + if (!url) return false; + return /\/(og-image\.png|opengraph-image)(\?|$)/.test(url); +} + +describe('landing built HTML OG meta', () => { + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { + cwd: APP_DIR, + stdio: 'inherit', + }); + } + }, 240_000); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('discovers at least one landing HTML page beyond root', () => { + const files = listLandingHtmlFiles(); + // Expect index.html plus at least one of about / pricing / blog / + // privacy-policy / account-deletion. If this trips, either the build + // failed to emit nested routes or someone removed every secondary + // page — both are signal worth surfacing. + expect(files.length, `expected >=2 HTML files in out/, got ${files.length}`).toBeGreaterThan(1); + }); + + it('root out/index.html has full OG meta with absolute site-wide og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing <meta property|name="${tag}">`).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect( + isAbsoluteHttps(ogImage), + `root og:image must be absolute https URL, got: ${ogImage}`, + ).toBe(true); + expect( + isLandingOgImageUrl(ogImage), + `root og:image must reference og-image or opengraph-image, got: ${ogImage}`, + ).toBe(true); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `root twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat'); + }); + + it('every landing HTML page has full OG meta + absolute og:image', () => { + const files = listLandingHtmlFiles(); + const failures: string[] = []; + + for (const file of files) { + const rel = path.relative(OUT_DIR, file); + const meta = parseMeta(fs.readFileSync(file, 'utf8')); + + for (const tag of REQUIRED_OG_META) { + if (!meta.get(tag)) { + failures.push(`${rel}: missing <meta property|name="${tag}">`); + } + } + + const ogImage = meta.get('og:image'); + if (!isAbsoluteHttps(ogImage)) { + failures.push(`${rel}: og:image not absolute (${ogImage})`); + } else if (!isLandingOgImageUrl(ogImage)) { + failures.push(`${rel}: og:image not a site-wide image route (${ogImage})`); + } + + const twitterImage = meta.get('twitter:image'); + if (!isAbsoluteHttps(twitterImage)) { + failures.push(`${rel}: twitter:image not absolute (${twitterImage})`); + } + + const card = meta.get('twitter:card'); + if (card !== 'summary_large_image') { + failures.push(`${rel}: twitter:card="${card}" (expected summary_large_image)`); + } + + const siteName = meta.get('og:site_name'); + if (siteName !== 'PackRat') { + failures.push(`${rel}: og:site_name="${siteName}" (expected PackRat)`); + } + } + + expect(failures, `OG meta issues:\n${failures.join('\n')}`).toEqual([]); + }); +}); + +/** + * Optional live OG check. Set OG_LIVE_CHECK_URL to the deployed origin + * (e.g. `https://packratai.com`) to fetch the homepage + a secondary page + * over the wire and run them through `open-graph-scraper` — the same + * parser most platforms (LinkedIn, FB, microlink) use. + * + * Skipped by default because it requires network + a live deploy. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.ogDescription, 'ogDescription').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `root ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + }, 30_000); + + it('/pricing has valid OG metadata via open-graph-scraper', async () => { + const target = `${liveUrl}/pricing`; + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: target, timeout: 15_000 }); + expect(error, `og fetch failed for ${target}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `pricing ogImage[0].url absolute (got ${firstImage})`).toBe( + true, + ); + }, 30_000); +}); diff --git a/apps/landing/package.json b/apps/landing/package.json index dda2904edc..f00b9fdd5d 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -9,9 +9,11 @@ "doctor:react": "bunx react-doctor", "generate-og-images": "bun run scripts/generate-og-images.ts", "lighthouse": "bun run build && bunx lhci autorun", + "lighthouse:ci": "lhci autorun", "lint": "next lint", "start": "next start", - "test": "vitest run --config vitest.config.ts" + "test": "vitest run --config vitest.config.ts", + "test:og-meta": "vitest run --config vitest.config.ts __tests__/og-meta.test.ts" }, "dependencies": { "@emotion/is-prop-valid": "^1.3.1", @@ -71,6 +73,8 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", diff --git a/bun.lock b/bun.lock index dc5114da8d..d8aae7b5b5 100644 --- a/bun.lock +++ b/bun.lock @@ -281,6 +281,8 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", @@ -349,6 +351,8 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", @@ -2537,10 +2541,14 @@ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chrome-launcher": ["chrome-launcher@0.13.4", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^1.0.5", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^0.5.3", "rimraf": "^3.0.2" } }, "sha512-nnzXiDbGKjDSK6t2I+35OAPBy5Pw/39bgkb/ZAFwMhwJbdYBp6aH+vW28ZgtjdU890Q7D+3wN/tB8N66q5Gi2A=="], @@ -2837,6 +2845,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], @@ -3915,6 +3925,8 @@ "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "open-graph-scraper": ["open-graph-scraper@6.11.0", "", { "dependencies": { "chardet": "^2.1.1", "cheerio": "^1.1.2", "iconv-lite": "^0.7.0", "undici": "^7.16.0" } }, "sha512-KkO3qMMzJj9KYGtCl19dRtncb+RuBiG/P9BgukcAG4p2w9wSAWTE90vL6/xqth1K9ThkYF/+xfTGrVvU79TJtQ=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -3967,6 +3979,12 @@ "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "partyserver": ["partyserver@0.4.1", "", { "dependencies": { "nanoid": "^5.1.6" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0" } }, "sha512-StSs0oY8RmTxjGNil7VbCG4gnTN+4rYX20fiUIItAxTPpr/5rPDZT6PIvMROkk9M1Gn7GzE1wuQXwhxceaGhXA=="], @@ -4759,8 +4777,12 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], @@ -5221,6 +5243,8 @@ "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -5283,6 +5307,8 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "external-editor/chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + "external-editor/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "external-editor/tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], @@ -5437,6 +5463,8 @@ "package-changed/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], @@ -5533,6 +5561,8 @@ "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "winston/@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "winston/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], diff --git a/packages/env/scripts/no-raw-process-env.ts b/packages/env/scripts/no-raw-process-env.ts index 37dfae6f4a..470fae1e61 100644 --- a/packages/env/scripts/no-raw-process-env.ts +++ b/packages/env/scripts/no-raw-process-env.ts @@ -52,6 +52,12 @@ const ALLOWED: string[] = [ // Test-only gate flag: reads RUN_OG_PIPELINE_TEST to opt into the heavy // OG-image pipeline test from `bun run --cwd apps/guides test:og`. 'apps/guides/__tests__/og-images.test.ts', + // Test-only gate flag: reads OG_LIVE_CHECK_URL to opt into live OG meta + // validation against a deployed guides URL. + 'apps/guides/__tests__/og-meta.test.ts', + // Test-only gate flag: reads OG_LIVE_CHECK_URL to opt into live OG meta + // validation against a deployed landing URL. + 'apps/landing/__tests__/og-meta.test.ts', // Test files that mutate process.env to exercise env-validation logic 'packages/api/src/utils/__tests__/', // Admin env shim — parses process.env once at module load