Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

- package-ecosystem: npm
directory: /
schedule:
interval: weekly
29 changes: 29 additions & 0 deletions .github/linters/.markdown-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
###########################
## Markdown Linter rules ##
###########################

# Linter rules doc:
# - https://github.com/DavidAnson/markdownlint

###############
# Rules by id #
###############
MD004: false # Unordered list style
MD007:
indent: 2 # Unordered list indentation
MD013:
line_length: 3000 # Line length
MD025:
front_matter_title: "" # Allow a body H1 alongside the front matter title
MD026:
punctuation: ".,;:!。,;:" # List of not allowed
MD029: false # Ordered list item prefix
MD033: false # Allow inline HTML
MD036: false # Emphasis used instead of a heading
MD041: false # First line in file should be a top level heading, PULL_REQUEST_TEMPLATE.md is an exception
MD060: false # Table column style — content mixes compact and aligned tables

#################
# Rules by tags #
#################
blank_lines: false # Error on blank lines
88 changes: 88 additions & 0 deletions .github/scripts/validate-markdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { spawnSync } from 'node:child_process';
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lint, readConfig } from 'markdownlint-cli2/markdownlint/promise';
import yamlParse from 'markdownlint-cli2/parsers/yaml';

/** Read repository file metadata without invoking a shell. */
function readGitOutput(directory, args) {
const result = spawnSync('git', ['-C', directory, ...args], { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`Git file discovery failed in ${JSON.stringify(directory)}: ${JSON.stringify(result.stderr.trim())}`);
}
return result.stdout;
}

/** Select existing tracked and nonignored Markdown, retaining Git symlink metadata. */
function findMarkdownFiles(directory) {
const deleted = new Set(readGitOutput(directory, ['ls-files', '--deleted', '-z']).split('\0'));
const files = readGitOutput(directory, [
'ls-files', '--cached', '--others', '--exclude-standard', '--deduplicate', '-z',
]).split('\0')
.filter((name) => !deleted.has(name))
.filter((name) => /\.(?:md|markdown)$/i.test(name) && !name.split('/').includes('node_modules'))
.map((name) => join(directory, ...name.split('/')))
.sort();
// Git symlinks can be ordinary path-text files in Windows checkouts.
const symlinks = new Set(readGitOutput(directory, ['ls-files', '--stage', '-z'])
.split('\0')
.filter((record) => record.startsWith('120000 '))
.map((record) => join(directory, ...record.slice(record.indexOf('\t') + 1).split('/'))));
return { files, symlinks };
}

// Optional first argument selects an isolated root; the shared rules stay fixed.
const directory = resolve(process.argv[2] ?? process.cwd());
if (!lstatSync(directory).isDirectory()) {
throw new Error(`Validation root is not a directory: ${JSON.stringify(directory)}`);
}
const repositoryRoot = readGitOutput(directory, ['rev-parse', '--show-toplevel']).replace(/\r?\n$/, '');
const resolvedRepositoryRoot = realpathSync(directory);
if (resolvedRepositoryRoot !== realpathSync(repositoryRoot)) {
throw new Error(`Validation must run from the repository root: ${JSON.stringify(repositoryRoot)}`);
}
const configurationPath = fileURLToPath(new URL('../linters/.markdown-lint.yml', import.meta.url));
const configuration = await readConfig(configurationPath, [yamlParse]);
const { files, symlinks } = findMarkdownFiles(directory);
if (files.length === 0) {
throw new Error(`No Markdown files were checked in ${JSON.stringify(directory)}. Check the validation root.`);
}
const contributionSource = join(directory, '.github', 'CONTRIBUTING.md');
if (!files.includes(contributionSource)) {
throw new Error(`Required community file is missing from the validation input: ${JSON.stringify(contributionSource)}. Restore the file or explicitly change the contract.`);
}
Comment thread
MariusStorhaug marked this conversation as resolved.
Resolved
for (const file of files) {
const metadata = lstatSync(file);
if (metadata.isSymbolicLink() || symlinks.has(file)) {
throw new Error(`Markdown symlinks are not allowed: ${JSON.stringify(file)}. Use a regular file instead.`);
}
if (!metadata.isFile()) {
throw new Error(`Markdown input must be a regular file: ${JSON.stringify(file)}`);
}
const resolvedLocation = relative(resolvedRepositoryRoot, realpathSync(file));
if (resolvedLocation === '..' || resolvedLocation.startsWith(`..${sep}`) || isAbsolute(resolvedLocation)) {
throw new Error(`Markdown input resolves outside the repository root: ${JSON.stringify(file)}. Use a repository-local source.`);
}
if (readFileSync(file, 'utf8').trim().length === 0) {
throw new Error(`Empty Markdown file: ${JSON.stringify(file)}. Restore its content before validation.`);
}
}

const results = await lint({ files, config: configuration });
const checkedFiles = Object.keys(results);
if (checkedFiles.length !== files.length) {
throw new Error(`Markdown lint checked ${checkedFiles.length} of ${files.length} selected files.`);
}
for (const file of checkedFiles) {
console.log(JSON.stringify(relative(directory, file)));
}
const hasErrors = Object.values(results).some((errors) => errors.length > 0);
if (hasErrors) {
console.error(JSON.stringify(results, null, 2));
}
console.log(`Checked ${checkedFiles.length} Markdown files; result: ${hasErrors ? 'failed' : 'passed'}.`);
process.exitCode = hasErrors ? 1 : 0;
Loading
Loading