mirror of
https://github.com/OrcaSlicer/OrcaSlicer_WIKI.git
synced 2026-05-17 00:25:45 +03:00
456 lines
17 KiB
YAML
456 lines
17 KiB
YAML
name: Validate Tab Links
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
schedule:
|
|
- cron: '0 3 * * 1'
|
|
|
|
jobs:
|
|
tab-link-validation:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
env:
|
|
ERROR_BLOCK: ''
|
|
steps:
|
|
- name: Checkout wiki
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Validate OrcaSlicer Tab links
|
|
id: validate_tab_links
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const TAB_URL = 'https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer/main/src/slic3r/GUI/Tab.cpp';
|
|
const workspaceRoot = process.cwd();
|
|
const markdownNameIndex = new Map();
|
|
const fileContents = new Map();
|
|
const headingCache = new Map();
|
|
let markdownIndexReady = false;
|
|
|
|
const response = await fetch(TAB_URL);
|
|
if (!response.ok) {
|
|
core.setFailed(`Failed to download Tab.cpp: ${response.status} ${response.statusText}`);
|
|
return;
|
|
}
|
|
const source = await response.text();
|
|
const lineOffsets = buildLineOffsets(source);
|
|
|
|
const references = collectReferences(source);
|
|
if (!references.length) {
|
|
core.info('No Tab link references found.');
|
|
return;
|
|
}
|
|
|
|
ensureMarkdownIndex();
|
|
|
|
const failures = [];
|
|
for (const reference of references) {
|
|
const target = reference.target;
|
|
const hashCount = (target.match(/#/g) || []).length;
|
|
if (hashCount > 1) {
|
|
failures.push(formatFailure(reference, 'hashCount', target));
|
|
continue;
|
|
}
|
|
|
|
const hasAnchor = hashCount === 1;
|
|
const hashIndex = hasAnchor ? target.indexOf('#') : -1;
|
|
const docName = hasAnchor ? target.slice(0, hashIndex).trim() : target.trim();
|
|
const anchorRaw = hasAnchor ? target.slice(hashIndex + 1).trim() : '';
|
|
|
|
if (!docName) {
|
|
failures.push(formatFailure(reference, 'missingDocName', target));
|
|
continue;
|
|
}
|
|
if (/[\\/]/.test(docName)) {
|
|
failures.push(formatFailure(reference, 'pathNotAllowed', target));
|
|
continue;
|
|
}
|
|
if (docName.toLowerCase().endsWith('.md')) {
|
|
failures.push(formatFailure(reference, 'extensionNotAllowed', target));
|
|
continue;
|
|
}
|
|
|
|
const decodedDocName = decodeLinkComponent(docName);
|
|
if (!isSnakeCase(decodedDocName)) {
|
|
failures.push(formatFailure(reference, 'docNotSnakeCase', docName));
|
|
continue;
|
|
}
|
|
|
|
const matches = findMarkdownDocuments(decodedDocName);
|
|
if (!matches.length) {
|
|
failures.push(formatFailure(reference, 'missingDocument', `${decodedDocName}.md`));
|
|
continue;
|
|
}
|
|
if (matches.length > 1) {
|
|
failures.push(formatFailure(reference, 'ambiguousDocument', `${decodedDocName} -> ${matches.slice(0, 5).join(', ')}`));
|
|
continue;
|
|
}
|
|
|
|
if (!hasAnchor) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = matches[0];
|
|
const absolutePath = path.join(workspaceRoot, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
failures.push(formatFailure(reference, 'missingDocument', relativePath));
|
|
continue;
|
|
}
|
|
|
|
if (!anchorRaw) {
|
|
failures.push(formatFailure(reference, 'missingAnchor', target));
|
|
continue;
|
|
}
|
|
|
|
const decodedAnchor = decodeLinkComponent(anchorRaw);
|
|
if (!isKebabCase(decodedAnchor)) {
|
|
failures.push(formatFailure(reference, 'anchorNotKebabCase', `${decodedDocName}#${anchorRaw}`));
|
|
continue;
|
|
}
|
|
|
|
const anchors = getAnchors(relativePath);
|
|
const anchorSlug = normalizeAnchor(anchorRaw);
|
|
if (!anchorSlug) {
|
|
failures.push(formatFailure(reference, 'missingAnchor', target));
|
|
continue;
|
|
}
|
|
if (!anchors.has(anchorSlug)) {
|
|
failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${decodedDocName}#${anchorRaw}`));
|
|
}
|
|
}
|
|
|
|
if (failures.length) {
|
|
failures.sort((a, b) => a.line - b.line);
|
|
const block = failures.map(failure => failure.message).join('\n');
|
|
core.exportVariable('ERROR_BLOCK', block);
|
|
return;
|
|
}
|
|
|
|
core.exportVariable('ERROR_BLOCK', '');
|
|
core.info(`Validated ${references.length} Tab link(s). All good.`);
|
|
|
|
function collectReferences(text) {
|
|
const refs = [];
|
|
const appendSinglePattern = /append_single_option_line\s*\(\s*"([^"]+)"\s*(?:,\s*"([^"]+)")?/g;
|
|
let match;
|
|
while ((match = appendSinglePattern.exec(text)) !== null) {
|
|
if (!match[2]) {
|
|
continue;
|
|
}
|
|
refs.push({
|
|
option: match[1],
|
|
target: match[2].trim(),
|
|
line: lineFromIndex(match.index),
|
|
});
|
|
}
|
|
|
|
const labelPathPattern = /\b[A-Za-z_]\w*\s*\.\s*label_path\s*=\s*"([^"]+)"/g;
|
|
while ((match = labelPathPattern.exec(text)) !== null) {
|
|
refs.push({
|
|
option: 'label_path',
|
|
target: match[1].trim(),
|
|
line: lineFromIndex(match.index),
|
|
});
|
|
}
|
|
|
|
for (const reference of collectAppendOptionLineReferences(text)) {
|
|
refs.push(reference);
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
function collectAppendOptionLineReferences(text) {
|
|
const refs = [];
|
|
const pattern = /append_option_line\s*\(/g;
|
|
let match;
|
|
while ((match = pattern.exec(text)) !== null) {
|
|
const callStart = match.index;
|
|
const argsResult = parseCallArguments(text, pattern.lastIndex);
|
|
if (!argsResult.args) {
|
|
pattern.lastIndex = argsResult.endIndex;
|
|
continue;
|
|
}
|
|
pattern.lastIndex = argsResult.endIndex;
|
|
if (argsResult.args.length < 3) {
|
|
continue;
|
|
}
|
|
const targetLiteral = extractStringLiteral(argsResult.args[2]);
|
|
if (!targetLiteral) {
|
|
continue;
|
|
}
|
|
refs.push({
|
|
option: 'append_option_line',
|
|
target: targetLiteral.trim(),
|
|
line: lineFromIndex(callStart),
|
|
});
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function parseCallArguments(text, startIndex) {
|
|
const args = [];
|
|
let current = '';
|
|
let depth = 1;
|
|
let inString = false;
|
|
let stringChar = '';
|
|
let escaped = false;
|
|
let i = startIndex;
|
|
for (; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
if (inString) {
|
|
current += ch;
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (ch === '\\') {
|
|
escaped = true;
|
|
} else if (ch === stringChar) {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === '\'') {
|
|
inString = true;
|
|
stringChar = ch;
|
|
current += ch;
|
|
continue;
|
|
}
|
|
if (ch === '(') {
|
|
depth += 1;
|
|
current += ch;
|
|
continue;
|
|
}
|
|
if (ch === ')') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
args.push(current.trim());
|
|
return { args, endIndex: i + 1 };
|
|
}
|
|
current += ch;
|
|
continue;
|
|
}
|
|
if (ch === ',' && depth === 1) {
|
|
args.push(current.trim());
|
|
current = '';
|
|
continue;
|
|
}
|
|
current += ch;
|
|
}
|
|
return { args: null, endIndex: text.length };
|
|
}
|
|
|
|
function extractStringLiteral(argumentText) {
|
|
const trimmed = argumentText.trim();
|
|
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
|
return '';
|
|
}
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
|
|
function ensureMarkdownIndex() {
|
|
if (markdownIndexReady) {
|
|
return;
|
|
}
|
|
indexMarkdownFiles('');
|
|
markdownIndexReady = true;
|
|
}
|
|
|
|
function indexMarkdownFiles(relativeDir) {
|
|
const absoluteDir = relativeDir ? path.join(workspaceRoot, relativeDir) : workspaceRoot;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.name === '.git') {
|
|
continue;
|
|
}
|
|
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
indexMarkdownFiles(relativePath);
|
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
const key = entry.name.slice(0, -3);
|
|
const normalized = relativePath.replace(/\\/g, '/');
|
|
if (markdownNameIndex.has(key)) {
|
|
markdownNameIndex.get(key).push(normalized);
|
|
} else {
|
|
markdownNameIndex.set(key, [normalized]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function findMarkdownDocuments(baseName) {
|
|
return markdownNameIndex.get(baseName) || [];
|
|
}
|
|
|
|
function isSnakeCase(value) {
|
|
return /^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(value);
|
|
}
|
|
|
|
function isKebabCase(value) {
|
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
}
|
|
|
|
function decodeLinkComponent(value) {
|
|
let decoded = value.trim();
|
|
try {
|
|
decoded = decodeURIComponent(decoded);
|
|
} catch (_) {
|
|
// ignore decode failures
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
function getAnchors(relativePath) {
|
|
if (headingCache.has(relativePath)) {
|
|
return headingCache.get(relativePath);
|
|
}
|
|
if (!fileContents.has(relativePath)) {
|
|
const absolutePath = path.join(workspaceRoot, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
headingCache.set(relativePath, new Set());
|
|
return headingCache.get(relativePath);
|
|
}
|
|
const text = fs.readFileSync(absolutePath, 'utf8');
|
|
fileContents.set(relativePath, text);
|
|
}
|
|
const text = fileContents.get(relativePath);
|
|
const anchors = collectHeadingAnchors(text);
|
|
headingCache.set(relativePath, anchors);
|
|
return anchors;
|
|
}
|
|
|
|
function collectHeadingAnchors(text) {
|
|
const anchors = new Set();
|
|
const slugCounts = new Map();
|
|
const lines = text.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
let headingText = match[2].trim();
|
|
headingText = headingText.replace(/\s+#+\s*$/, '').trim();
|
|
if (!headingText) {
|
|
continue;
|
|
}
|
|
let slug = slugify(headingText);
|
|
if (!slug) {
|
|
continue;
|
|
}
|
|
const count = slugCounts.get(slug) || 0;
|
|
if (count === 0) {
|
|
slugCounts.set(slug, 1);
|
|
anchors.add(slug);
|
|
} else {
|
|
slugCounts.set(slug, count + 1);
|
|
anchors.add(`${slug}-${count}`);
|
|
}
|
|
}
|
|
return anchors;
|
|
}
|
|
|
|
function slugify(value, options = {}) {
|
|
const preserveCase = options.preserveCase === true;
|
|
const normalized = value
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.trim();
|
|
const cased = preserveCase ? normalized : normalized.toLowerCase();
|
|
const cleaned = cased
|
|
.replace(preserveCase ? /[^A-Za-z0-9\s-]/g : /[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-');
|
|
return cleaned;
|
|
}
|
|
|
|
function normalizeAnchor(raw) {
|
|
if (!raw) {
|
|
return '';
|
|
}
|
|
const decoded = decodeLinkComponent(raw);
|
|
return slugify(decoded);
|
|
}
|
|
|
|
function buildLineOffsets(text) {
|
|
const offsets = [0];
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
if (text.charCodeAt(i) === 10) {
|
|
offsets.push(i + 1);
|
|
}
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
function lineFromIndex(position) {
|
|
let low = 0;
|
|
let high = lineOffsets.length - 1;
|
|
while (low <= high) {
|
|
const mid = (low + high) >> 1;
|
|
if (lineOffsets[mid] <= position) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return high + 1;
|
|
}
|
|
|
|
function formatFailure(reference, reason, details) {
|
|
const link = `https://github.com/OrcaSlicer/OrcaSlicer/blob/main/src/slic3r/GUI/Tab.cpp#L${reference.line}`;
|
|
const lineInfo = `[Tab.cpp line ${reference.line}](${link})`;
|
|
const failure = { line: reference.line, message: '' };
|
|
switch (reason) {
|
|
case 'hashCount':
|
|
failure.message = `${lineInfo}: link "${details}" cannot contain more than one '#'.`;
|
|
break;
|
|
case 'missingDocName':
|
|
failure.message = `${lineInfo}: link "${details}" must include a document name.`;
|
|
break;
|
|
case 'missingAnchor':
|
|
failure.message = `${lineInfo}: link "${details}" must include a heading name after '#'.`;
|
|
break;
|
|
case 'pathNotAllowed':
|
|
failure.message = `${lineInfo}: link "${details}" must omit any directory segments.`;
|
|
break;
|
|
case 'extensionNotAllowed':
|
|
failure.message = `${lineInfo}: link "${details}" must omit the .md suffix.`;
|
|
break;
|
|
case 'docNotSnakeCase':
|
|
failure.message = `${lineInfo}: document name "${details}" must be snake_case.`;
|
|
break;
|
|
case 'missingDocument':
|
|
failure.message = `${lineInfo}: document ${details} does not exist in the wiki.`;
|
|
break;
|
|
case 'ambiguousDocument':
|
|
failure.message = `${lineInfo}: document reference is ambiguous (${details}).`;
|
|
break;
|
|
case 'anchorNotKebabCase':
|
|
failure.message = `${lineInfo}: heading reference ${details} must be kebab-case.`;
|
|
break;
|
|
case 'missingCrossDocAnchor':
|
|
failure.message = `${lineInfo}: heading ${details} was not found.`;
|
|
break;
|
|
default:
|
|
failure.message = `${lineInfo}: invalid link ${details}.`;
|
|
}
|
|
return failure;
|
|
}
|
|
|
|
- name: Show invalid Tab links
|
|
if: env.ERROR_BLOCK != ''
|
|
run: |
|
|
echo 'Invalid Tab links:'
|
|
printf '```\n%s\n```\n' "${{ env.ERROR_BLOCK }}"
|
|
exit 1
|