Files
OrcaSlicer_WIKI/.github/workflows/unreferenced_images.yml
dependabot[bot] 7e5e2ca296 Bump actions/github-script from 8 to 9 (#222)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 14:29:03 -03:00

228 lines
9.8 KiB
YAML

name: Find Unreferenced Images
on:
pull_request:
paths:
- '**/*.md'
- '**/*.markdown'
- '**/*.mdown'
- '**/*.mkd'
- '**/*.mkdn'
- '**/*.mdx'
workflow_dispatch: {}
jobs:
unreferenced-images:
runs-on: ubuntu-latest
permissions:
contents: read
env:
ERROR_BLOCK: ''
RANKING_BLOCK: ''
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Find unreferenced images in /images
id: find_images
uses: actions/github-script@v9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
script: |
const fs = require('fs');
const path = require('path');
const workspace = process.cwd();
const workspaceRoot = path.resolve(workspace);
const currentRepo = context.repo.repo;
const currentOwner = context.repo.owner;
const allowedImageExt = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
const allowedMarkdownExt = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']);
function collectFilesUnder(relativeDir, extSet) {
const files = [];
const absoluteDir = relativeDir ? path.join(workspaceRoot, relativeDir) : workspaceRoot;
let entries;
try {
entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
} catch (_) {
return files;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
if (relativeDir === 'images' && entry.isDirectory() && entry.name === 'misc') continue;
const rel = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...collectFilesUnder(rel, extSet));
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (extSet.has(ext)) files.push(rel.replace(/\\/g, '/'));
}
}
return files;
}
function lineFromIndex(text, index) {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (text.charCodeAt(i) === 10) line += 1;
}
return line;
}
// Gather images under images/ folder
const candidateImages = collectFilesUnder('images', allowedImageExt).map(p => p.replace(/\\/g, '/'));
if (!candidateImages.length) {
core.info('No images found under images/; skipping unreferenced image check.');
return;
}
// Build a map of image -> count
const counts = new Map();
for (const img of candidateImages) counts.set(img, 0);
// Gather markdown files to scan
const markdownFiles = collectFilesUnder('', allowedMarkdownExt);
if (!markdownFiles.length) {
core.info('No Markdown files found; skipping references scan.');
}
const codeBlockPattern = /^```+([\s\S]*?)^```+$/gm;
const markdownImagePattern = /!\[(?:[^\]]*)\]\(\s*([^\)\s]+)(?:\s+"[^"]*")?\s*\)/g;
const htmlImagePattern = /<img\b[^>]*>/gi;
function parseGithubRawLink(rawUrl) {
let parsed;
try { parsed = new URL(rawUrl); } catch (_) { return null; }
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'raw.githubusercontent.com') {
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length < 3) return null;
const owner = parts[0];
const repo = parts[1];
const ref = decodeURIComponent(parts[2]);
const rel = parts.slice(3).map(decodeURIComponent).join('/');
return { owner, repo, ref, path: rel };
}
if (hostname === 'github.com') {
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length < 5) return null;
const owner = parts[0];
const repo = parts[1];
const blobOrRaw = parts[2];
if (!['raw', 'blob'].includes(blobOrRaw)) return null;
const ref = decodeURIComponent(parts[3]);
const rel = parts.slice(4).map(decodeURIComponent).join('/');
return { owner, repo, ref, path: rel };
}
return null;
}
function normalizeLocalPath(sourceFile, raw) {
if (!raw) return null;
let t = raw.trim();
if (t.startsWith('<') && t.endsWith('>')) t = t.slice(1, -1).trim();
// drop query string
const q = t.indexOf('?'); if (q !== -1) t = t.slice(0, q);
// absolute repo path
if (t.startsWith('/')) {
const rel = t.slice(1).replace(/\\/g, '/');
return rel;
}
// relative paths from source file
const candidate = path.normalize(path.join(path.dirname(sourceFile), t));
const relToRoot = path.relative(workspaceRoot, path.resolve(workspaceRoot, candidate)).replace(/\\/g, '/');
return relToRoot;
}
// Iterate markdown files and accumulate counts
for (const file of markdownFiles) {
const absolute = path.join(workspaceRoot, file);
let text = fs.readFileSync(absolute, 'utf8');
const textWithoutCodeBlocks = text.replace(codeBlockPattern, '');
// Markdown-style images: ![alt](url)
markdownImagePattern.lastIndex = 0;
let m;
while ((m = markdownImagePattern.exec(textWithoutCodeBlocks)) !== null) {
const url = m[1];
if (!url) continue;
// If url is a raw github link to this repo, parse it
const repoPath = parseGithubRawLink(url);
if (repoPath && repoPath.owner === currentOwner && repoPath.repo === currentRepo) {
const normalized = repoPath.path.replace(/\\/g, '/');
if (counts.has(normalized)) counts.set(normalized, counts.get(normalized) + 1);
continue;
}
// Local path
const local = normalizeLocalPath(file, url);
if (!local) continue;
// Try the path directly
if (counts.has(local)) counts.set(local, counts.get(local) + 1);
else {
// Fallback: match by basename only when unique to avoid ambiguous counting
const base = path.basename(local).toLowerCase();
const matches = Array.from(counts.keys()).filter(img => path.basename(img).toLowerCase() === base);
if (matches.length === 1) counts.set(matches[0], counts.get(matches[0]) + 1);
}
}
// <img src="..."> parsing
htmlImagePattern.lastIndex = 0;
while ((m = htmlImagePattern.exec(textWithoutCodeBlocks)) !== null) {
const tag = m[0];
// extract src
const attrPattern = /src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
const match = attrPattern.exec(tag);
const url = match && (match[1] || match[2] || match[3]) ? (match[1] || match[2] || match[3]) : null;
if (!url) continue;
const repoPath = parseGithubRawLink(url);
if (repoPath && repoPath.owner === currentOwner && repoPath.repo === currentRepo) {
const normalized = repoPath.path.replace(/\\/g, '/');
if (counts.has(normalized)) counts.set(normalized, counts.get(normalized) + 1);
continue;
}
const local = normalizeLocalPath(file, url);
if (!local) continue;
if (counts.has(local)) counts.set(local, counts.get(local) + 1);
else {
const base = path.basename(local).toLowerCase();
const matches = Array.from(counts.keys()).filter(img => path.basename(img).toLowerCase() === base);
if (matches.length === 1) counts.set(matches[0], counts.get(matches[0]) + 1);
}
}
}
// Generate ranking of images by count
const ranking = [];
for (const [img, cnt] of counts) ranking.push({ img, cnt });
ranking.sort((a, b) => b.cnt - a.cnt || a.img.localeCompare(b.img));
const rankingLines = ranking.map(r => `${r.img}, ${r.cnt}`);
core.exportVariable('RANKING_BLOCK', rankingLines.join('\n'));
// Find images with 0 references
const unreferenced = ranking.filter(r => r.cnt === 0).map(r => r.img);
if (unreferenced.length) {
core.exportVariable('ERROR_BLOCK', unreferenced.join('\n'));
core.info(`Found ${unreferenced.length} unreferenced image(s) in images/`);
return;
}
core.exportVariable('ERROR_BLOCK', '');
core.info('No unreferenced images found in images/.');
- name: Show image ranking
run: |
echo 'Image ranking (image path, number of references):'
printf '```\n%s\n```\n' "$RANKING_BLOCK"
- name: Show unreferenced images
if: env.ERROR_BLOCK != ''
run: |
echo 'Unreferenced images under images/ (image path):'
printf '```\n%s\n```\n' "$ERROR_BLOCK"
exit 1