diff --git a/.github/workflows/validate_images.yml b/.github/workflows/validate_images.yml
new file mode 100644
index 0000000..c619412
--- /dev/null
+++ b/.github/workflows/validate_images.yml
@@ -0,0 +1,323 @@
+name: Validate OrcaSlicer Images
+
+on:
+ pull_request:
+ paths:
+ - '**/*.md'
+ - '**/*.markdown'
+ - '**/*.mdown'
+ - '**/*.mkd'
+ - '**/*.mkdn'
+ - '**/*.mdx'
+
+jobs:
+ image-link-validation:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ env:
+ ERROR_BLOCK: ''
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Validate OrcaSlicer image references
+ id: validate_images
+ uses: actions/github-script@v7
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ script: |
+ const { execSync } = require('child_process');
+ const fs = require('fs');
+ const path = require('path');
+
+ const OWNER = 'OrcaSlicer';
+ const ownerLower = OWNER.toLowerCase();
+ const currentRepo = context.repo.repo;
+ const workspace = process.cwd();
+ const allowedExt = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']);
+
+ const baseSha = process.env.BASE_SHA;
+ const headSha = process.env.HEAD_SHA;
+ if (!baseSha || !headSha) {
+ core.setFailed('Missing base/head commit SHAs.');
+ return;
+ }
+
+ // Identify changed files in this PR so we scan only touched docs.
+ let diffOutput = '';
+ try {
+ diffOutput = execSync(`git diff --name-only ${baseSha}..${headSha}`, { encoding: 'utf8' }).trim();
+ } catch (error) {
+ core.setFailed(`git diff failed: ${error.message}`);
+ return;
+ }
+
+ if (!diffOutput) {
+ core.info('No files changed; skipping image validation.');
+ return;
+ }
+
+ // Filter only existing Markdown files because HTML snippets appear inside them.
+ const candidateFiles = diffOutput.split(/\r?\n/)
+ .map((file) => file.trim())
+ .filter(Boolean)
+ .filter((file) => allowedExt.has(path.extname(file).toLowerCase()))
+ .filter((file) => fs.existsSync(path.join(workspace, file)));
+
+ if (!candidateFiles.length) {
+ core.info('No Markdown or HTML files changed; skipping image validation.');
+ return;
+ }
+
+ // Regex helpers for Markdown images and inline HTML
tags.
+ const markdownImagePattern = /!\[(?[^\]]*)\]\(\s*(?[^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
+ const htmlImagePattern = /
]*>/gi;
+
+ const references = [];
+
+ for (const relativePath of candidateFiles) {
+ const absolutePath = path.join(workspace, relativePath);
+ const text = fs.readFileSync(absolutePath, 'utf8');
+
+ // Collect every image reference with enough metadata for validation.
+ const addReference = (url, index, altText = '', options = {}) => {
+ const line = lineFromIndex(text, index);
+ const repoPath = parseOrcaLink(url);
+ if (repoPath) {
+ references.push({
+ filePath: relativePath,
+ line,
+ url,
+ repoPath,
+ altText: altText.trim(),
+ isHtml: Boolean(options.isHtml),
+ altBeforeSrc: options.altBeforeSrc !== false,
+ });
+ }
+ };
+
+ markdownImagePattern.lastIndex = 0;
+ let match;
+ while ((match = markdownImagePattern.exec(text)) !== null) {
+ const url = match.groups ? match.groups.url : match[2];
+ if (url) {
+ const alt = match.groups ? match.groups.alt : match[1];
+ addReference(url.trim(), match.index, (alt || '').trim());
+ }
+ }
+
+ htmlImagePattern.lastIndex = 0;
+ while ((match = htmlImagePattern.exec(text)) !== null) {
+ const tag = match[0];
+ const attrs = {};
+ const attrPattern = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
+ const attrOrder = [];
+ let attrMatch;
+ while ((attrMatch = attrPattern.exec(tag)) !== null) {
+ const name = attrMatch[1].toLowerCase();
+ const value = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4] || '';
+ attrs[name] = value;
+ attrOrder.push({ name, index: attrMatch.index });
+ }
+ const url = attrs.src;
+ if (url) {
+ const altEntry = attrOrder.find((entry) => entry.name === 'alt');
+ const srcEntry = attrOrder.find((entry) => entry.name === 'src');
+ const altBeforeSrc = srcEntry && altEntry ? altEntry.index < srcEntry.index : true;
+ addReference(
+ url.trim(),
+ match.index,
+ (attrs.alt || '').trim(),
+ { isHtml: true, altBeforeSrc }
+ );
+ }
+ }
+ }
+
+ if (!references.length) {
+ core.info('No OrcaSlicer image links found in updated files.');
+ return;
+ }
+
+ const cache = new Map();
+
+ const failures = [];
+ for (const reference of references) {
+ if (reference.repoPath.needsRawQuery && !reference.repoPath.hasRawQuery) {
+ failures.push({ ...reference, reason: 'missingRawQuery' });
+ continue;
+ }
+ if (reference.isHtml && reference.altText && !reference.altBeforeSrc) {
+ failures.push({ ...reference, reason: 'altOrder' });
+ continue;
+ }
+ const expectedAlt = expectedAltFromRepoPath(reference.repoPath);
+ const actualAlt = reference.altText || '';
+ if (expectedAlt && actualAlt !== expectedAlt) {
+ failures.push({ ...reference, reason: 'altMismatch', expectedAlt, actualAlt });
+ continue;
+ }
+ // eslint-disable-next-line no-await-in-loop
+ const exists = await repoPathExists(reference.repoPath, currentRepo, workspace, cache);
+ if (!exists) {
+ failures.push({ ...reference, reason: 'missingFile' });
+ }
+ }
+
+ if (failures.length) {
+ const lines = failures.map((failure) => {
+ const rp = failure.repoPath;
+ if (failure.reason === 'missingRawQuery') {
+ return `${failure.filePath} line ${failure.line}: add ?raw=true to ${failure.url}`;
+ }
+ if (failure.reason === 'altMismatch') {
+ return `${failure.filePath} line ${failure.line}: alt text must be "${failure.expectedAlt}" but was "${failure.actualAlt}"`;
+ }
+ if (failure.reason === 'altOrder') {
+ return `${failure.filePath} line ${failure.line}: alt attribute must appear before src for ${failure.url}`;
+ }
+ return `${failure.filePath} line ${failure.line}: missing ${OWNER}/${rp.repo}:${rp.ref}/${rp.path}`;
+ });
+ const block = lines.join('\n');
+ core.exportVariable('ERROR_BLOCK', block);
+ return;
+ }
+
+ core.exportVariable('ERROR_BLOCK', '');
+ core.info(`Validated ${references.length} OrcaSlicer image link(s). All exist.`);
+
+ function lineFromIndex(text, index) {
+ let line = 1;
+ for (let i = 0; i < index; i += 1) {
+ if (text.charCodeAt(i) === 10) {
+ line += 1;
+ }
+ }
+ return line;
+ }
+
+ // Parse GitHub URLs and normalize owner/repo/ref/path info.
+ function parseOrcaLink(rawUrl) {
+ let parsed;
+ try {
+ parsed = new URL(rawUrl);
+ } catch (_) {
+ return null;
+ }
+
+ const scheme = parsed.protocol.replace(':', '').toLowerCase();
+ if (!['http', 'https'].includes(scheme)) {
+ return null;
+ }
+
+ const hostname = parsed.hostname.toLowerCase();
+ if (!['github.com', 'raw.githubusercontent.com'].includes(hostname)) {
+ return null;
+ }
+
+ const parts = parsed.pathname.split('/').filter(Boolean);
+ if (!parts.length || parts[0].toLowerCase() !== ownerLower) {
+ return null;
+ }
+
+ if (hostname === 'github.com') {
+ if (parts.length < 5) {
+ return null;
+ }
+ const repo = parts[1];
+ const blobOrRaw = parts[2];
+ const ref = decodeURIComponent(parts[3]);
+ if (!['blob', 'raw'].includes(blobOrRaw)) {
+ return null;
+ }
+ const relPath = decodeURIComponent(parts.slice(4).join('/'));
+ const rawParam = parsed.searchParams.get('raw');
+ const hasRawQuery = typeof rawParam === 'string' && rawParam.toLowerCase() === 'true';
+ return { repo, ref, path: relPath, needsRawQuery: true, hasRawQuery };
+ }
+
+ if (hostname === 'raw.githubusercontent.com') {
+ if (parts.length < 4) {
+ return null;
+ }
+ const repo = parts[1];
+ const ref = decodeURIComponent(parts[2]);
+ const relPath = decodeURIComponent(parts.slice(3).join('/'));
+ return { repo, ref, path: relPath, needsRawQuery: false, hasRawQuery: true };
+ }
+
+ return null;
+ }
+
+ // Expected alt text is the asset filename without extension.
+ function expectedAltFromRepoPath(repoPath) {
+ const baseName = path.basename(repoPath.path || '');
+ if (!baseName) {
+ return '';
+ }
+ const dotIndex = baseName.lastIndexOf('.');
+ if (dotIndex <= 0) {
+ return baseName;
+ }
+ return baseName.slice(0, dotIndex);
+ }
+
+ async function repoPathExists(repoPath, currentRepoName, root, cacheMap) {
+ const key = `${repoPath.repo}|${repoPath.ref}|${repoPath.path}`;
+ if (cacheMap.has(key)) {
+ return cacheMap.get(key);
+ }
+
+ let exists;
+ if (repoPath.repo === currentRepoName) {
+ const normalized = path.normalize(repoPath.path);
+ const candidate = path.join(root, normalized);
+ exists = candidate.startsWith(root) && fs.existsSync(candidate);
+ } else {
+ exists = await remoteExists(repoPath);
+ }
+
+ cacheMap.set(key, exists);
+ return exists;
+ }
+
+ async function remoteExists(repoPath) {
+ const encodedPath = repoPath.path
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => encodeURIComponent(segment))
+ .join('/');
+ const ref = encodeURIComponent(repoPath.ref);
+ const url = `https://api.github.com/repos/${OWNER}/${repoPath.repo}/contents/${encodedPath}?ref=${ref}`;
+ const headers = {
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'orca-image-validator',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ };
+ if (process.env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
+ }
+
+ const response = await fetch(url, { headers });
+ if (response.status === 200) {
+ return true;
+ }
+ if (response.status === 404) {
+ return false;
+ }
+ const body = await response.text();
+ throw new Error(`GitHub API ${response.status} for ${url}: ${body}`);
+ }
+
+ - name: Show invalid image references
+ if: env.ERROR_BLOCK != ''
+ run: |
+ echo 'Invalid OrcaSlicer image references:'
+ printf "\`\`\`\n%s\n\`\`\`\n" "${{ env.ERROR_BLOCK }}"
+ exit 1