From 0ff8dc5c0374e0efd7a1cd1bbfb830a292220d94 Mon Sep 17 00:00:00 2001 From: OniriCorpe Date: Sat, 2 Mar 2024 02:09:33 +0100 Subject: [PATCH] add markdownlint-rules-grav-pages files --- .../frontmatter.schema.json | 119 ++++++++++++++++ markdownlint-rules-grav-pages/valid-images.js | 31 +++++ .../valid-internal-links.js | 128 ++++++++++++++++++ .../valid-metadata-block.js | 69 ++++++++++ 4 files changed, 347 insertions(+) create mode 100644 markdownlint-rules-grav-pages/frontmatter.schema.json create mode 100644 markdownlint-rules-grav-pages/valid-images.js create mode 100644 markdownlint-rules-grav-pages/valid-internal-links.js create mode 100644 markdownlint-rules-grav-pages/valid-metadata-block.js diff --git a/markdownlint-rules-grav-pages/frontmatter.schema.json b/markdownlint-rules-grav-pages/frontmatter.schema.json new file mode 100644 index 00000000..c56d06fa --- /dev/null +++ b/markdownlint-rules-grav-pages/frontmatter.schema.json @@ -0,0 +1,119 @@ +{ + "type": "object", + "properties": { + "title": { "type": "bool", "minLength": 2, "maxLength": 80 }, + "media_order": { "type": "string" }, + "body_classes": { "type": "string" }, + "published": { "type": "boolean" }, + "visible": { "type": "boolean" }, + "redirect": { "type": "string" }, + "cache_enable": { "type": "boolean" }, + "debugger": { "type": "boolean" }, + "never_cache_twig": { "type": "boolean" }, + "twig_first": { "type": "boolean" }, + "routable": { "type": "boolean" }, + "login_redirect_here": { "type": "boolean" }, + "last_modified": { "type": "boolean" }, + "http_response_code": { "type": "integer" }, + "lightbox": { "type": "boolean" }, + "etag": { "type": "boolean" }, + "template": { "type": "string" }, + "template_format": { "type": "string" }, + "date": { "type": "string" }, + "publish_date": { "type": "string" }, + "unpublish_date": { "type": "string" }, + "expires": { "type": "integer" }, + "menu": { "type": "string", "minLength": 2, "maxLength": 30 }, + "slug": { "type": "string" }, + "routes": { + "type": "object", + "properties": { + "default": { "type": "string" }, + "canonical": { "type": "string" }, + "aliases": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "taxonomy": { + "type": "object", + "properties": { + "category": { "type": "array", "items": { "type": "string" } }, + "tag": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "external_url": { "type": "string" }, + "external_links": { + "type": "object", + "properties": { + "target": { "type": "string" } + }, + "additionalProperties": false + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "twitter": { "type": "string" }, + "bio": { "type": "string" } + }, + "additionalProperties": false + }, + "summary": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "format": { "type": "string" }, + "size": { "type": "integer" } + }, + "additionalProperties": false + }, + "sitemap": { + "type": "object", + "properties": { + "changefreq": { "type": "string" }, + "priority": { "type": "number" } + }, + "additionalProperties": false + }, + "process": { + "type": "object", + "properties": { + "markdown": { "type": "boolean" }, + "twig": { "type": "boolean" } + }, + "additionalProperties": false + }, + "markdown": { + "type": "object", + "properties": { + "extra": { "type": "boolean" }, + "auto_line_breaks": { "type": "boolean" }, + "auto_url_links": { "type": "boolean" }, + "escape_markup": { "type": "boolean" }, + "special_chars": { "type": "object", "additionalProperties": { "type": "string" } } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "properties": { + "refresh": { "type": "integer" } + }, + "additionalProperties": { "type": "string" } + }, + "page-toc": { + "type": "object", + "properties": { + "active": { "type": "boolean" }, + "start": { "type": "integer" }, + "depth": { "type": "integer" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "title" + ] +} diff --git a/markdownlint-rules-grav-pages/valid-images.js b/markdownlint-rules-grav-pages/valid-images.js new file mode 100644 index 00000000..6f8738d0 --- /dev/null +++ b/markdownlint-rules-grav-pages/valid-images.js @@ -0,0 +1,31 @@ +const fs = require('fs'); +const flat = require('../lib/flat'); + +module.exports = { + names: ['valid-images'], + description: 'Rule that reports if a file has valid image references', + tags: ['test'], + function: function rule(params, onError) { + flat(params.tokens).filter((token) => token.type === 'image').forEach((image) => { + image.attrs.forEach((attr) => { + if (attr[0] === 'src') { + let imgSrc = attr[1]; + if (!imgSrc.match(/^(https?:)/)) { + if (imgSrc.includes('?')) { + imgSrc = imgSrc.slice(0, imgSrc.indexOf('?')); + } + const path = `${params.name.split('/').slice(0, -1).join('/')}/${imgSrc}`; + + if (!fs.existsSync(path) || !fs.lstatSync(path).isFile()) { + onError({ + lineNumber: image.lineNumber, + detail: `Image src '${imgSrc}' does not link to a valid file.`, + context: image.line, + }); + } + } + } + }); + }); + }, +}; diff --git a/markdownlint-rules-grav-pages/valid-internal-links.js b/markdownlint-rules-grav-pages/valid-internal-links.js new file mode 100644 index 00000000..cadb3b34 --- /dev/null +++ b/markdownlint-rules-grav-pages/valid-internal-links.js @@ -0,0 +1,128 @@ +const transliteration = require('transliteration'); +const fs = require('fs'); +const url = require('url'); +const md = require('markdown-it')({ html: true }); +const helpers = require('markdownlint/helpers/helpers'); +const flat = require('../lib/flat'); + +const validateAnchor = (link, href, tokens, onError) => { + let anchorFound = false; + tokens.filter((token) => token.type === 'heading_open').forEach((heading) => { + if (!heading.line) { + return; + } + + const headingAnchor = transliteration.slugify(heading.line.toLowerCase(), { + replace: { + ä: 'ae', + ö: 'oe', + ü: 'ue', + }, + }); + + if (`#${headingAnchor}` === href) { + anchorFound = true; + } + }); + if (!anchorFound) { + onError({ + lineNumber: link.lineNumber, + detail: `Did not find matching heading for anchor '${href}'`, + context: link.line, + }); + } +}; + +// Annotate tokens with line/lineNumber, duplication from markdownlint because function +// is not exposed +const annotateTokens = (tokens, lines) => { + let tbodyMap = null; + return tokens.map((token) => { + // Handle missing maps for table body + if (token.type === 'tbody_open') { + tbodyMap = token.map.slice(); + } else if ((token.type === 'tr_close') && tbodyMap) { + tbodyMap[0] += 1; + } else if (token.type === 'tbody_close') { + tbodyMap = null; + } + const mappedToken = token; + if (tbodyMap && !token.map) { + mappedToken.map = tbodyMap.slice(); + } + // Update token metadata + if (token.map) { + mappedToken.line = lines[token.map[0]]; + mappedToken.lineNumber = token.map[0] + 1; + // Trim bottom of token to exclude whitespace lines + while (token.map[1] && !(lines[token.map[1] - 1].trim())) { + mappedToken.map[1] -= 1; + } + // Annotate children with lineNumber + let childLineNumber = token.lineNumber; + (token.children || []).map((child) => { + const mappedChild = child; + mappedChild.lineNumber = childLineNumber; + mappedChild.line = lines[childLineNumber - 1]; + if ((child.type === 'softbreak') || (child.type === 'hardbreak')) { + childLineNumber += 1; + } + return mappedChild; + }); + } + + return mappedToken; + }); +}; + +const parseMarkdownContent = (fileContent) => { + // Remove UTF-8 byte order marker (if present) + let content = fileContent.replace(/^\ufeff/, ''); + // Ignore the content of HTML comments + content = helpers.clearHtmlCommentText(content); + // Parse content into tokens and lines + const tokens = md.parse(content, {}); + const lines = content.split(helpers.newLineRe); + return annotateTokens(tokens, lines); +}; + +module.exports = { + names: ['valid-internal-links'], + description: 'Rule that reports if a file has an internal link to an invalid file', + tags: ['test'], + function: function rule(params, onError) { + flat(params.tokens).filter((token) => token.type === 'link_open').forEach((link) => { + link.attrs.forEach((attr) => { + if (attr[0] === 'href') { + const href = attr[1]; + if (href.match(/^#/)) { + validateAnchor(link, href, params.tokens, onError); + } else if (href && !href.match(/^(mailto:|https?:)/)) { + const parsedHref = url.parse(href); + let path; + if (parsedHref.pathname.match(/^\//)) { + path = `pages${parsedHref.pathname}`; + } else { + path = `${params.name.split('/').slice(0, -1).join('/')}/${parsedHref.pathname}`; + } + if (!fs.existsSync(path) || !fs.lstatSync(path).isFile()) { + onError({ + lineNumber: link.lineNumber, + detail: `Relative link '${href}' does not link to a valid file.`, + context: link.line, + }); + } else if (parsedHref.hash) { + // console.log(md.parse(fs.readFileSync(path).toString())); + validateAnchor( + link, + parsedHref.hash, + parseMarkdownContent(fs.readFileSync(path).toString()), + onError, + ); + } + } + } + }); + }); + }, +}; diff --git a/markdownlint-rules-grav-pages/valid-metadata-block.js b/markdownlint-rules-grav-pages/valid-metadata-block.js new file mode 100644 index 00000000..70d46201 --- /dev/null +++ b/markdownlint-rules-grav-pages/valid-metadata-block.js @@ -0,0 +1,69 @@ +const YAML = require('yamljs'); +const { Validator } = require('jsonschema'); +// See https://learn.getgrav.org/content/headers +const metadataSchema = require('./frontmatter.schema.json'); + +module.exports = { + names: ['valid-metadata-block'], + description: 'Rule that reports if a file does not have a valid grav metadata block', + tags: ['test'], + function: function rule(params, onError) { + if (!params.frontMatterLines || params.frontMatterLines.length < 3) { + onError({ + lineNumber: 1, + detail: 'Missing grav metadata block', + }); + return; + } + const frontMatterLines = params.frontMatterLines.filter((line) => !!line); + if (frontMatterLines[0] !== '---') { + onError({ + lineNumber: 1, + detail: "Grav metadata block has to start with a '---'", + context: frontMatterLines[0], + }); + return; + } + if (frontMatterLines[frontMatterLines.length - 1] !== '---') { + onError({ + lineNumber: 1, + detail: "Grav metadata block has to end with a '---'", + context: frontMatterLines[frontMatterLines.length - 1], + }); + return; + } + const yamlString = frontMatterLines.slice(1, -1).join('\n'); + let yamlDocument; + try { + yamlDocument = YAML.parse(yamlString); + } catch (err) { + onError({ + lineNumber: 1, + detail: 'Error parsing grav metadata block', + context: err.toString(), + }); + return; + } + + if (!yamlDocument) { + onError({ + lineNumber: 1, + detail: 'Grav metadata is not a valid yaml document', + context: yamlString, + }); + return; + } + + const v = new Validator(); + + const validation = v.validate(yamlDocument, metadataSchema); + + validation.errors.forEach((validationError) => { + onError({ + lineNumber: 1, + detail: validationError.toString(), + context: yamlString, + }); + }); + }, +};