mirror of
https://github.com/OrcaSlicer/OrcaSlicer_WIKI.git
synced 2026-05-17 00:25:45 +03:00
329 lines
13 KiB
YAML
329 lines
13 KiB
YAML
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@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Validate OrcaSlicer image references
|
|
id: validate_images
|
|
uses: actions/github-script@v9
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
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 candidateFiles = collectMarkdownFiles('');
|
|
if (!candidateFiles.length) {
|
|
core.info('No Markdown files found; skipping image validation.');
|
|
return;
|
|
}
|
|
|
|
// Regex helpers for Markdown images and inline HTML <img> tags.
|
|
const markdownImagePattern = /!\[(?<alt>[^\]]*)\]\(\s*(?<url>[^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
|
|
const htmlImagePattern = /<img\b[^>]*>/gi;
|
|
const codeBlockPattern = /^```+[\s\S]*?^```+$/gm;
|
|
|
|
// Helper function to remove code blocks from text
|
|
function removeCodeBlocks(text) {
|
|
return text.replace(codeBlockPattern, '');
|
|
}
|
|
|
|
const references = [];
|
|
|
|
for (const relativePath of candidateFiles) {
|
|
const absolutePath = path.join(workspace, relativePath);
|
|
const text = fs.readFileSync(absolutePath, 'utf8');
|
|
const textWithoutCodeBlocks = removeCodeBlocks(text);
|
|
|
|
// 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(textWithoutCodeBlocks)) !== 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(textWithoutCodeBlocks)) !== 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') {
|
|
const actualDisplay = failure.actualAlt || '(empty)';
|
|
return `${failure.filePath} line ${failure.line}: alt text must be "${failure.expectedAlt}" but was "${actualDisplay}"`;
|
|
}
|
|
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}`);
|
|
}
|
|
|
|
function collectMarkdownFiles(relativeDir) {
|
|
const files = [];
|
|
const absoluteDir = relativeDir ? path.join(workspace, relativeDir) : workspace;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
} catch (_) {
|
|
return files;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.name === '.git') {
|
|
continue;
|
|
}
|
|
const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
files.push(...collectMarkdownFiles(relPath));
|
|
} else if (entry.isFile()) {
|
|
const ext = path.extname(entry.name).toLowerCase();
|
|
if (allowedExt.has(ext)) {
|
|
files.push(relPath.replace(/\\/g, '/'));
|
|
}
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
- 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
|