diff --git a/.github/scripts/check-seo-images.cjs b/.github/scripts/check-seo-images.cjs new file mode 100755 index 00000000000000..969a868b6d789d --- /dev/null +++ b/.github/scripts/check-seo-images.cjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx", ".mdx"]); + +function walk(directory) { + if (!fs.existsSync(directory)) return []; + + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = path.join(directory, entry.name); + return entry.isDirectory() ? walk(absolutePath) : [absolutePath]; + }); +} + +function lineNumberAt(source, index) { + return source.slice(0, index).split(/\r?\n/).length; +} + +function findSeoOpeningTags(source) { + const tags = []; + const startPattern = /])/g; + + for (const startMatch of source.matchAll(startPattern)) { + const start = startMatch.index || 0; + let quote = null; + let escaped = false; + let braceDepth = 0; + + for (let index = start + startMatch[0].length; index < source.length; index++) { + const character = source[index]; + + if (quote) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + quote = null; + } + continue; + } + + if (character === '"' || character === "'" || character === "`") { + quote = character; + } else if (character === "{") { + braceDepth++; + } else if (character === "}" && braceDepth > 0) { + braceDepth--; + } else if (character === ">" && braceDepth === 0) { + tags.push({ + index: start, + text: source.slice(start, index + 1), + }); + break; + } + } + } + + return tags; +} + +function findLocalSeoImages(source) { + const references = []; + const imagePropPattern = + /\bimage\s*=\s*(?:"([^"]+)"|'([^']+)'|\{\s*["']([^"']+)["']\s*\})/; + + for (const tag of findSeoOpeningTags(source)) { + const imageMatch = tag.text.match(imagePropPattern); + if (!imageMatch) continue; + + const imagePath = imageMatch[1] || imageMatch[2] || imageMatch[3]; + if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(imagePath)) continue; + + references.push({ + imagePath, + line: lineNumberAt(source, tag.index), + }); + } + + return references; +} + +function validateSeoImages({ repoRoot, sourceRoot, assetRoots }) { + const failures = []; + + for (const sourceFile of walk(sourceRoot)) { + if (!sourceExtensions.has(path.extname(sourceFile))) continue; + + const source = fs.readFileSync(sourceFile, "utf8"); + const relativeSource = path.relative(repoRoot, sourceFile); + + for (const reference of findLocalSeoImages(source)) { + if (!reference.imagePath.startsWith("/")) { + failures.push( + `${relativeSource}:${reference.line} invalid local SEO image ${reference.imagePath} (use a root-relative path backed by static or public)`, + ); + continue; + } + + const pathname = reference.imagePath.split(/[?#]/, 1)[0]; + const relativeAsset = path.posix + .normalize(pathname) + .replace(/^\/+/, ""); + + if (relativeAsset.startsWith("../") || path.isAbsolute(relativeAsset)) { + failures.push( + `${relativeSource}:${reference.line} invalid local SEO image path ${reference.imagePath}`, + ); + continue; + } + + const candidates = assetRoots.map((root) => path.join(root, relativeAsset)); + if (!candidates.some((candidate) => fs.existsSync(candidate))) { + failures.push( + `${relativeSource}:${reference.line} missing local SEO image ${reference.imagePath} (expected ${path.relative(repoRoot, candidates[0])} or ${path.relative(repoRoot, candidates[1])})`, + ); + } + } + } + + return failures; +} + +function main() { + const repoRoot = path.resolve(__dirname, "..", ".."); + const sourceRoot = path.join(repoRoot, "src"); + const assetRoots = [ + path.join(repoRoot, "static"), + path.join(repoRoot, "public"), + ]; + const failures = validateSeoImages({ repoRoot, sourceRoot, assetRoots }); + + if (failures.length > 0) { + console.error("Local SEO image validation failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exitCode = 1; + return; + } + + console.log("All local SEO image references resolve."); +} + +if (require.main === module) { + main(); +} + +module.exports = { + findLocalSeoImages, + findSeoOpeningTags, + validateSeoImages, +}; diff --git a/.github/scripts/check-seo-images.test.cjs b/.github/scripts/check-seo-images.test.cjs new file mode 100644 index 00000000000000..780a2bd963a600 --- /dev/null +++ b/.github/scripts/check-seo-images.test.cjs @@ -0,0 +1,88 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + findLocalSeoImages, + validateSeoImages, +} = require("./check-seo-images.cjs"); + +function createFixture() { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "seo-images-")); + const sourceRoot = path.join(repoRoot, "src"); + const staticRoot = path.join(repoRoot, "static"); + const publicRoot = path.join(repoRoot, "public"); + fs.mkdirSync(sourceRoot, { recursive: true }); + fs.mkdirSync(staticRoot, { recursive: true }); + fs.mkdirSync(publicRoot, { recursive: true }); + + return { + repoRoot, + sourceRoot, + assetRoots: [staticRoot, publicRoot], + }; +} + +function writeFile(root, relativePath, contents = "") { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +test("extracts literal image props from multiline SEO tags", () => { + const source = ` +export const Head = () => ( + +);`; + + assert.deepEqual(findLocalSeoImages(source), [ + { imagePath: "/images/platform.webp", line: 3 }, + ]); +}); + +test("ignores external and dynamic SEO image props", () => { + const source = ` + +`; + + assert.deepEqual(findLocalSeoImages(source), []); +}); + +test("accepts an existing root-relative asset", (context) => { + const fixture = createFixture(); + context.after(() => fs.rmSync(fixture.repoRoot, { recursive: true, force: true })); + writeFile(fixture.sourceRoot, "page.js", ''); + writeFile(fixture.assetRoots[0], "images/card.webp", "image"); + + assert.deepEqual(validateSeoImages(fixture), []); +}); + +test("reports a missing local asset with its source line", (context) => { + const fixture = createFixture(); + context.after(() => fs.rmSync(fixture.repoRoot, { recursive: true, force: true })); + writeFile( + fixture.sourceRoot, + "pages/missing.js", + 'const Head = () => (\n \n);', + ); + + const failures = validateSeoImages(fixture); + assert.equal(failures.length, 1); + assert.match(failures[0], /src[\\/]pages[\\/]missing\.js:2/); + assert.match(failures[0], /missing local SEO image \/images\/missing\.webp/); +}); + +test("rejects a relative local image path", (context) => { + const fixture = createFixture(); + context.after(() => fs.rmSync(fixture.repoRoot, { recursive: true, force: true })); + writeFile(fixture.sourceRoot, "page.jsx", ''); + + const failures = validateSeoImages(fixture); + assert.equal(failures.length, 1); + assert.match(failures[0], /invalid local SEO image images\/card\.webp/); +}); diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3fd7e8199af1e4..94b2eac1e9d6d1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,6 +21,12 @@ jobs: - name: Check CONTRIBUTING versions run: npm run check:contributing-versions + - name: Check local SEO image references + run: npm run check:seo-images + + - name: Test local SEO image validation + run: npm run test:seo-images + - name: Build run: npm run build diff --git a/package.json b/package.json index f865572768e3a9..35f14595e8ad26 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "lint": "eslint --fix .", "checklint": "eslint .", "check:contributing-versions": "node .github/scripts/check-contributing-versions.cjs", + "check:seo-images": "node .github/scripts/check-seo-images.cjs", + "test:seo-images": "node --test .github/scripts/check-seo-images.test.cjs", "pretest": "eslint --ignore-path .gitignore .", "preload-fonts": "gatsby-preload-fonts", "deploy": "gatsby build && gh-pages -d public -b master", diff --git a/src/pages/community/adventures-of-five-and-friends/index.js b/src/pages/community/adventures-of-five-and-friends/index.js index d08f9a0615fc75..22cf6d9d345d11 100644 --- a/src/pages/community/adventures-of-five-and-friends/index.js +++ b/src/pages/community/adventures-of-five-and-friends/index.js @@ -13,5 +13,5 @@ const Adventures = () => { }; export default Adventures; export const Head = () => { - return ; + return ; }; \ No newline at end of file diff --git a/src/pages/solutions/platform-engineering/index.js b/src/pages/solutions/platform-engineering/index.js index b07f92f25d0322..ad03882b07b3a8 100644 --- a/src/pages/solutions/platform-engineering/index.js +++ b/src/pages/solutions/platform-engineering/index.js @@ -15,6 +15,6 @@ export default PlatformEngineeringSolutions; export const Head = () => { return ; }; \ No newline at end of file diff --git a/static/images/mascot-five-adventure-github-stars.webp b/static/images/mascot-five-adventure-github-stars.webp new file mode 100644 index 00000000000000..0f1912fa7a646c Binary files /dev/null and b/static/images/mascot-five-adventure-github-stars.webp differ diff --git a/static/images/meshery-operator.webp b/static/images/meshery-operator.webp new file mode 100644 index 00000000000000..7e888313f3e55c Binary files /dev/null and b/static/images/meshery-operator.webp differ diff --git a/static/images/solutions-collaborate.webp b/static/images/solutions-collaborate.webp new file mode 100644 index 00000000000000..41a5f36f7a9d65 Binary files /dev/null and b/static/images/solutions-collaborate.webp differ diff --git a/static/images/solutions-devrel.webp b/static/images/solutions-devrel.webp new file mode 100644 index 00000000000000..0bc9841f6f1185 Binary files /dev/null and b/static/images/solutions-devrel.webp differ diff --git a/static/images/solutions-diagram.webp b/static/images/solutions-diagram.webp new file mode 100644 index 00000000000000..defa434b4dbf8d Binary files /dev/null and b/static/images/solutions-diagram.webp differ diff --git a/static/images/solutions-gitops.webp b/static/images/solutions-gitops.webp new file mode 100644 index 00000000000000..5461c5d09fa77c Binary files /dev/null and b/static/images/solutions-gitops.webp differ diff --git a/static/images/solutions-infrastructure.webp b/static/images/solutions-infrastructure.webp new file mode 100644 index 00000000000000..cfe9808334cce9 Binary files /dev/null and b/static/images/solutions-infrastructure.webp differ diff --git a/static/images/solutions-operation.webp b/static/images/solutions-operation.webp new file mode 100644 index 00000000000000..6d7983c8d2af94 Binary files /dev/null and b/static/images/solutions-operation.webp differ diff --git a/static/images/solutions-platform-engineering.webp b/static/images/solutions-platform-engineering.webp new file mode 100644 index 00000000000000..1bf272883300b2 Binary files /dev/null and b/static/images/solutions-platform-engineering.webp differ