From 7f9504f3740ba8b414bcae05a7259b3748a2a0dc Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 8 Apr 2026 15:26:54 -0300 Subject: [PATCH] Case checkers (#206) --- .github/workflows/validate_internal_link.yml | 39 ++++++++++++++ .github/workflows/validate_tab_links.yml | 53 ++++++++++++++++---- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/.github/workflows/validate_internal_link.yml b/.github/workflows/validate_internal_link.yml index d7b71f2..3bf2b92 100644 --- a/.github/workflows/validate_internal_link.yml +++ b/.github/workflows/validate_internal_link.yml @@ -104,6 +104,11 @@ jobs: failures.push(formatFailure(reference, 'sameDocEmptyAnchor', reference.target)); continue; } + const decodedAnchor = decodeLinkComponent(classification.anchorRaw); + if (!isKebabCase(decodedAnchor)) { + failures.push(formatFailure(reference, 'anchorNotKebabCase', `#${classification.anchorRaw}`)); + continue; + } const anchors = getAnchors(reference.filePath); if (!anchors.has(classification.anchorSlug)) { failures.push(formatFailure(reference, 'missingSameDocAnchor', `#${classification.anchorRaw}`)); @@ -132,6 +137,12 @@ jobs: continue; } + const decodedAnchor = decodeLinkComponent(classification.anchorRaw); + if (!isKebabCase(decodedAnchor)) { + failures.push(formatFailure(reference, 'anchorNotKebabCase', `${docResult.linkPath}#${classification.anchorRaw}`)); + continue; + } + const anchors = getAnchors(docResult.relativePath); if (!anchors.has(classification.anchorSlug)) { failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${docResult.linkPath}#${classification.anchorRaw}`)); @@ -238,6 +249,12 @@ jobs: return result; } + if (!isSnakeCase(sanitized)) { + result.error = 'docNotSnakeCase'; + result.detail = rawPath; + return result; + } + ensureMarkdownIndex(); const matches = findMarkdownDocuments(sanitized); if (!matches.length) { @@ -311,6 +328,24 @@ jobs: 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 failure. + } + return decoded; + } + function getAnchors(relativePath) { if (headingCache.has(relativePath)) { return headingCache.get(relativePath); @@ -417,6 +452,8 @@ jobs: return `${reference.filePath} line ${reference.line}: document links must not include directories; use just the filename (got "${details}").`; case 'extensionNotAllowed': return `${reference.filePath} line ${reference.line}: link target "${details}" must omit the .md suffix.`; + case 'docNotSnakeCase': + return `${reference.filePath} line ${reference.line}: document name "${details}" must be snake_case.`; case 'missingDocName': return `${reference.filePath} line ${reference.line}: document link "${details}" must include a file name (without .md).`; case 'missingDocument': @@ -425,6 +462,8 @@ jobs: return `${reference.filePath} line ${reference.line}: document link matches multiple files (${details}).`; case 'crossDocEmptyAnchor': return `${reference.filePath} line ${reference.line}: link to ${details} must include a heading name after '#'.`; + case 'anchorNotKebabCase': + return `${reference.filePath} line ${reference.line}: heading reference ${details} must be kebab-case.`; case 'missingCrossDocAnchor': return `${reference.filePath} line ${reference.line}: heading ${details} was not found.`; case 'invalidHashCount': diff --git a/.github/workflows/validate_tab_links.yml b/.github/workflows/validate_tab_links.yml index 1d02444..ca91a99 100644 --- a/.github/workflows/validate_tab_links.yml +++ b/.github/workflows/validate_tab_links.yml @@ -76,13 +76,19 @@ jobs: continue; } - const matches = findMarkdownDocuments(docName); + 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', `${docName}.md`)); + failures.push(formatFailure(reference, 'missingDocument', `${decodedDocName}.md`)); continue; } if (matches.length > 1) { - failures.push(formatFailure(reference, 'ambiguousDocument', `${docName} -> ${matches.slice(0, 5).join(', ')}`)); + failures.push(formatFailure(reference, 'ambiguousDocument', `${decodedDocName} -> ${matches.slice(0, 5).join(', ')}`)); continue; } @@ -102,6 +108,12 @@ jobs: 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) { @@ -109,7 +121,7 @@ jobs: continue; } if (!anchors.has(anchorSlug)) { - failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${docName}#${anchorRaw}`)); + failures.push(formatFailure(reference, 'missingCrossDocAnchor', `${decodedDocName}#${anchorRaw}`)); } } @@ -281,6 +293,24 @@ jobs: 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); @@ -348,13 +378,8 @@ jobs: if (!raw) { return ''; } - let decoded = raw.trim(); - try { - decoded = decodeURIComponent(decoded); - } catch (_) { - // ignore decode failures - } - return slugify(decoded, { preserveCase: true }); + const decoded = decodeLinkComponent(raw); + return slugify(decoded); } function buildLineOffsets(text) { @@ -401,12 +426,18 @@ jobs: 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;