Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ 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/<slug>.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: Summarize build output
if: always()
run: |
Expand Down
37 changes: 37 additions & 0 deletions apps/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,42 @@ 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

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

[ogs]: https://github.com/jshemas/openGraphScraper
266 changes: 266 additions & 0 deletions apps/guides/__tests__/og-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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$`),
Comment on lines +180 to +181
);

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(/\/$/, '');

Comment on lines +216 to +218
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);
});
3 changes: 3 additions & 0 deletions apps/guides/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"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"
},
Expand Down Expand Up @@ -96,6 +97,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:",
Expand Down
Loading
Loading