Skip to content

Add screenshot validation script #544

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
50 changes: 50 additions & 0 deletions .github/workflows/validate-document.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Validate Document

on:
pull_request:
branches:
- master
paths:
- "website/**/.mdx"

workflow_dispatch:
inputs:
logLevel:
description: "Log level"
required: true
default: "warning"
type: choice
options:
- info
- warning
- debug

jobs:
validate_document:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: website/package-lock.json

- name: Install dependencies
run: npm install ignore

- name: Run validate document script
run: |
FILES=$(git diff --name-only origin/${{ github.base_ref }} HEAD | grep '\.mdx$')
echo "Changed document files: $FILES"
if [ -z "$FILES" ]; then
echo "No document files changed."
exit 0
fi
node website/validate-document.js $FILES
126 changes: 126 additions & 0 deletions website/.validationignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# DO NOT ADD NEW ENTRIES TO THIS FILE. New blog articles should be created
# according to the guidelines outlined in 2111-11-11-TEMPLATE.md. For older
# articles, we don't enforce every rule. However, when an article is updated,
# it should be removed from here and adjusted to conform to the new guidelines.

/docs/website/docs/advanced/caching.mdx
/docs/website/docs/advanced/cli.mdx
/docs/website/docs/advanced/code-references/_intro.mdx
/docs/website/docs/advanced/code-references/azure-devops.mdx
/docs/website/docs/advanced/code-references/bitbucket-pipe.mdx
/docs/website/docs/advanced/code-references/bitrise-step.mdx
/docs/website/docs/advanced/code-references/circleci-orb.mdx
/docs/website/docs/advanced/code-references/github-action.mdx
/docs/website/docs/advanced/code-references/gitlab-ci.mdx
/docs/website/docs/advanced/code-references/manual.mdx
/docs/website/docs/advanced/code-references/overview.mdx
/docs/website/docs/advanced/config-v2-migration-guide.mdx
/docs/website/docs/advanced/config-v2-sdk-compatibility.mdx
/docs/website/docs/advanced/config-v2.mdx
/docs/website/docs/advanced/data-governance.mdx
/docs/website/docs/advanced/migration-from-launchdarkly-translation.mdx
/docs/website/docs/advanced/migration-from-launchdarkly.mdx
/docs/website/docs/advanced/notifications-webhooks.mdx
/docs/website/docs/advanced/proxy/endpoints.mdx
/docs/website/docs/advanced/proxy/grpc.mdx
/docs/website/docs/advanced/proxy/monitoring.mdx
/docs/website/docs/advanced/proxy/overview.mdx
/docs/website/docs/advanced/team-management/auto-assign-users.mdx
/docs/website/docs/advanced/team-management/domain-verification.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/adfs.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/auth0.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/azure-ad.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/cloudflare.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/google.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/okta.mdx
/docs/website/docs/advanced/team-management/saml/identity-providers/onelogin.mdx
/docs/website/docs/advanced/team-management/saml/overview.mdx
/docs/website/docs/advanced/team-management/single-sign-on-sso.mdx
/docs/website/docs/advanced/team-management/team-management-basics.mdx
/docs/website/docs/advanced/troubleshooting.mdx
/docs/website/docs/faq.mdx
/docs/website/docs/getting-started.mdx
/docs/website/docs/glossary/alpha-testing.mdx
/docs/website/docs/glossary/beta-testing.mdx
/docs/website/docs/glossary/blue-green-deployment.mdx
/docs/website/docs/glossary/ci-cd-pipeline.mdx
/docs/website/docs/glossary/continuous-integration.mdx
/docs/website/docs/glossary/devops-engineer.mdx
/docs/website/docs/glossary/feature-testing.mdx
/docs/website/docs/glossary/multi-armed-bandit.mdx
/docs/website/docs/glossary/product-lifecycle-manager.mdx
/docs/website/docs/glossary/release-manager.mdx
/docs/website/docs/glossary/remote-configuration.mdx
/docs/website/docs/glossary/smoke-testing.mdx
/docs/website/docs/glossary/type-i-and-type-ii-errors.mdx
/docs/website/docs/glossary/version-control.mdx
/docs/website/docs/glossary/what-is-a-staging-environment.mdx
/docs/website/docs/glossary.mdx
/docs/website/docs/integrations/amplitude.mdx
/docs/website/docs/integrations/bitbucket.mdx
/docs/website/docs/integrations/bitrise.mdx
/docs/website/docs/integrations/circleci.mdx
/docs/website/docs/integrations/datadog.mdx
/docs/website/docs/integrations/github.mdx
/docs/website/docs/integrations/google-analytics.mdx
/docs/website/docs/integrations/intellij.mdx
/docs/website/docs/integrations/jira.mdx
/docs/website/docs/integrations/mixpanel.mdx
/docs/website/docs/integrations/monday.mdx
/docs/website/docs/integrations/overview.mdx
/docs/website/docs/integrations/pubnub.mdx
/docs/website/docs/integrations/segment.mdx
/docs/website/docs/integrations/slack.mdx
/docs/website/docs/integrations/terraform.mdx
/docs/website/docs/integrations/trello.mdx
/docs/website/docs/integrations/vscode.mdx
/docs/website/docs/integrations/zapier.mdx
/docs/website/docs/integrations/zoho-flow.mdx
/docs/website/docs/main-concepts.mdx
/docs/website/docs/network-traffic.mdx
/docs/website/docs/news.mdx
/docs/website/docs/organization.mdx
/docs/website/docs/purchase.mdx
/docs/website/docs/requests.mdx
/docs/website/docs/sdk-reference/android.mdx
/docs/website/docs/sdk-reference/community/deno.mdx
/docs/website/docs/sdk-reference/community/laravel.mdx
/docs/website/docs/sdk-reference/community/vue.mdx
/docs/website/docs/sdk-reference/cpp.mdx
/docs/website/docs/sdk-reference/dart.mdx
/docs/website/docs/sdk-reference/dotnet.mdx
/docs/website/docs/sdk-reference/elixir.mdx
/docs/website/docs/sdk-reference/go.mdx
/docs/website/docs/sdk-reference/ios.mdx
/docs/website/docs/sdk-reference/java.mdx
/docs/website/docs/sdk-reference/js-ssr.mdx
/docs/website/docs/sdk-reference/js.mdx
/docs/website/docs/sdk-reference/kotlin.mdx
/docs/website/docs/sdk-reference/node.mdx
/docs/website/docs/sdk-reference/openfeature/dotnet.mdx
/docs/website/docs/sdk-reference/openfeature/go.mdx
/docs/website/docs/sdk-reference/openfeature/java.mdx
/docs/website/docs/sdk-reference/openfeature/js.mdx
/docs/website/docs/sdk-reference/openfeature/node.mdx
/docs/website/docs/sdk-reference/openfeature/overview.mdx
/docs/website/docs/sdk-reference/openfeature/php.mdx
/docs/website/docs/sdk-reference/openfeature/python.mdx
/docs/website/docs/sdk-reference/openfeature/rust.mdx
/docs/website/docs/sdk-reference/overview.mdx
/docs/website/docs/sdk-reference/php.mdx
/docs/website/docs/sdk-reference/python.mdx
/docs/website/docs/sdk-reference/react.mdx
/docs/website/docs/sdk-reference/ruby.mdx
/docs/website/docs/sdk-reference/rust.mdx
/docs/website/docs/sdk-reference/unity.mdx
/docs/website/docs/sdk-reference/unreal.mdx
/docs/website/docs/service/status.mdx
/docs/website/docs/subscription-plan-limits.mdx
/docs/website/docs/targeting/feature-flag-evaluation.mdx
/docs/website/docs/targeting/percentage-options.mdx
/docs/website/docs/targeting/targeting-overview.mdx
/docs/website/docs/targeting/targeting-rule/flag-condition.mdx
/docs/website/docs/targeting/targeting-rule/segment-condition.mdx
/docs/website/docs/targeting/targeting-rule/targeting-rule-overview.mdx
/docs/website/docs/targeting/targeting-rule/user-condition.mdx
/docs/website/docs/targeting/user-object.mdx
188 changes: 188 additions & 0 deletions website/validate-document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
const fs = require('fs');
const path = require('path');
const ignore = require('ignore');

const imageTagsRegex = /(<img\b[^>]*>)|!\[.*?\]\((.*?)\)/g;
const imageNameRegex = /^(?:[a-z0-9_\-]+?)(?:_(\d{2,4})dpi)?\.(png|jpe?g|gif|mp4)$/i;
const srcAttributeRegex = attributeRegex('src');
const altAttributeRegex = attributeRegex('alt');
const widthAttributeRegex = attributeRegex('width');
const heightAttributeRegex = attributeRegex('height');
const decodingAttributeRegex = attributeRegex('decoding');
const loadingAttributeRegex = attributeRegex('loading');

const checkImageNameConvention = (imagePath, errors) => {
const imageName = path.basename(imagePath);
const imageNameMatch = imageName.match(imageNameRegex);
if (!imageNameMatch) {
errors.push(['warn', `Image (${imagePath}) does not follow the name convention: {name}_{density}dpi.{extension}. (Assuming 96 DPI.)`]);
return [96, path.extname(imageName)];
}

let [, dpi, ext] = imageNameMatch;
if (!dpi) {
errors.push(['warn', `Image (${imagePath}) does not include the image DPI value. (Assuming 96 DPI.)`]);
return [96, ext];
}

return [Number(dpi), ext];
}

const checkForImageAttributes = (imageTag, errors) => {
const srcAttributeMatch = imageTag.match(srcAttributeRegex);
const altAttributeMatch = imageTag.match(altAttributeRegex);
const widthAttributeMatch = imageTag.match(widthAttributeRegex);
const heightAttributeMatch = imageTag.match(heightAttributeRegex);
const decodingAttributeMatch = imageTag.match(decodingAttributeRegex);
const loadingAttributeMatch = imageTag.match(loadingAttributeRegex);
let imageSrc, cssWidth, cssHeight;

if (srcAttributeMatch) {
imageSrc = srcAttributeMatch[1] || srcAttributeMatch[2];
} else {
errors.push(`Attribute (src="...") not found in ${imageTag}`)
}

if (!altAttributeMatch) {
errors.push(`Attribute (alt="...") not found in ${imageTag}`)
}

if (widthAttributeMatch) {
cssWidth = Number(widthAttributeMatch[1] || widthAttributeMatch[2])
} else {
errors.push(['warn', `Attribute (width="...") not found in ${imageTag}`])
}

if (heightAttributeMatch) {
cssHeight = Number(heightAttributeMatch[1] || heightAttributeMatch[2])
} else {
errors.push(['warn', `Attribute (height="...") not found in ${imageTag}`])
}

if (!decodingAttributeMatch) {
errors.push(['warn', `Attribute (decoding="...") not found in ${imageTag}`])
}

if (!loadingAttributeMatch) {
errors.push(['warn', `Attribute (loading="...") not found in ${imageTag}`])
}

return [imageSrc, cssWidth, cssHeight];
};

const checkImages = async (content) => {
const errors = [];

let i = -1;
for (const tag of extractImageData(content)) {
i++;

const isMarkdown = tag.startsWith('![');
if (isMarkdown) {
// Markdown image detected
errors.push(['warn', `Markdown image syntax found (${tag}). Use <img src="..." alt="..." width="..." height="..." /> instead for images.`]);
continue;
}

let [imageSrc] = checkForImageAttributes(tag, errors);

const pathPrefix = "/docs/"
if (imageSrc.startsWith(pathPrefix)) {
imageSrc = imageSrc.substring(pathPrefix.length - 1);
} else if (/^https?:/i.test(imageSrc)) {
continue;
} else {
errors.push(`Invalid image src found in ${tag}`);
continue;
}

checkImageNameConvention(imageSrc, errors);

}

return errors;
};

const checkDocumentFile = async (fileFullPath, ignore) => {
const filePath = path.relative(__dirname, fileFullPath).replaceAll('\\', '/');
console.log(`Checking file: "${filePath}"`);

const errors = [], warnings = [];

try {
const content = fs.readFileSync(fileFullPath, 'utf-8');
console.log('Running image checks...');
const imageCheckErrors = await checkImages(content);
if (imageCheckErrors.length) {
const strict = !ignore.ignores(filePath);
for (const error of imageCheckErrors) {
const [isWarning, message] = !Array.isArray(error) ? [false, error] : [error[0] === 'warn', error[1]];
(!isWarning || strict ? errors : warnings).push(message);
}
} else {
console.log('Image checks passed.')
}

} catch (err) {
console.error(`Error reading file "${filePath}" : ${err.message}`);
errors.push(`File read error: ${err.message}`);
}

if (warnings.length > 0) {
console.warn(`Warnings in file "${filePath}":\n- ${warnings.join('\n- ')}`);
}

if (errors.length > 0) {
console.error(`Errors in file "${filePath}":\n- ${errors.join('\n- ')}`);
return errors;
} else {
console.log(`${filePath}: Passed all checks.`);
return null;
}


}

const files = process.argv.slice(2);
if (files.length === 0) {
console.log('No markdown files to check.');
process.exit(0);
}

let allErrors = [];
const checkFiles = async () => {
const ig = ignore().add(fs.readFileSync(path.join(__dirname, '.validationignore'), 'utf-8'));
for (const file of files) {
if (file.endsWith('.mdx')) {
const filePath = path.resolve(__dirname, file);
const errors = await checkDocumentFile(filePath, ig);
if (errors) {
allErrors = allErrors.concat(errors);
}
}
}

if (allErrors.length > 0) {
console.error('Markdown validation failed with the following errors:');
allErrors.forEach((error) => console.error(`- ${error}`));
process.exit(1);
} else {
console.log('All markdown files passed validation.');
}
}

// Helper functions

function attributeRegex(attribute) {
return new RegExp(`${attribute}=(?:"([^"]*)"|'([^']*)')`);
}

function* extractImageData(content) {
let imageTagsRegexMatch;
while ((imageTagsRegexMatch = imageTagsRegex.exec(content)) !== null) {
const [tag] = imageTagsRegexMatch;
yield tag;
}
}

checkFiles();