diff --git a/src/js/index.ts b/src/js/index.ts index 0ca72dfd..e36ef41d 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -2,3 +2,4 @@ import './polyfill'; import './tabs'; import './code'; import './cut'; +import './term'; diff --git a/src/js/term/index.ts b/src/js/term/index.ts new file mode 100644 index 00000000..0687dda2 --- /dev/null +++ b/src/js/term/index.ts @@ -0,0 +1,79 @@ +import { + Selector, + openClass, + openDefinitionClass, + createDefinitionElement, + setDefinitionId, + setDefinitionPosition, + closeDefinition, +} from './utils'; +import {getEventTarget, isCustom} from '../utils'; + +if (typeof document !== 'undefined') { + document.addEventListener('click', (event) => { + const openDefinition = document.getElementsByClassName( + openDefinitionClass, + )[0] as HTMLElement; + const target = getEventTarget(event) as HTMLElement; + + const termId = target.getAttribute('id'); + const termKey = target.getAttribute('term-key'); + let definitionElement = document.getElementById(termKey + '_element'); + + if (termKey && !definitionElement) { + definitionElement = createDefinitionElement(target); + } + + const isSameTerm = openDefinition && termId === openDefinition.getAttribute('term-id'); + if (isSameTerm) { + closeDefinition(openDefinition); + return; + } + + const isTargetDefinitionContent = target.closest( + [Selector.CONTENT.replace(' ', ''), openClass].join('.'), + ); + + if (openDefinition && !isTargetDefinitionContent) { + closeDefinition(openDefinition); + } + + if (isCustom(event) || !target.matches(Selector.TITLE) || !definitionElement) { + return; + } + + setDefinitionId(definitionElement, target); + setDefinitionPosition(definitionElement, target); + + definitionElement.classList.toggle(openClass); + }); + + document.addEventListener('keydown', (event) => { + const openDefinition = document.getElementsByClassName( + openDefinitionClass, + )[0] as HTMLElement; + if (event.key === 'Escape' && openDefinition) { + closeDefinition(openDefinition); + } + }); + + window.addEventListener('resize', () => { + const openDefinition = document.getElementsByClassName( + openDefinitionClass, + )[0] as HTMLElement; + + if (!openDefinition) { + return; + } + + const termId = openDefinition.getAttribute('term-id') || ''; + const termElement = document.getElementById(termId); + + if (!termElement) { + openDefinition.classList.toggle(openClass); + return; + } + + setDefinitionPosition(openDefinition, termElement); + }); +} diff --git a/src/js/term/utils.ts b/src/js/term/utils.ts new file mode 100644 index 00000000..bf96b357 --- /dev/null +++ b/src/js/term/utils.ts @@ -0,0 +1,147 @@ +export const Selector = { + TITLE: '.yfm .yfm-term_title', + CONTENT: '.yfm .yfm-term_dfn', +}; +export const openClass = 'open'; +export const openDefinitionClass = Selector.CONTENT.replace(/\./g, '') + ' ' + openClass; +let isListenerNeeded = true; + +export function createDefinitionElement(termElement: HTMLElement) { + const termKey = termElement.getAttribute('term-key'); + const definitionTemplate = document.getElementById( + `${termKey}_template`, + ) as HTMLTemplateElement; + const definitionElement = definitionTemplate?.content.cloneNode(true).firstChild as HTMLElement; + + definitionTemplate?.parentElement?.appendChild(definitionElement); + definitionTemplate.remove(); + + return definitionElement; +} + +export function setDefinitionId(definitionElement: HTMLElement, termElement: HTMLElement): void { + const termId = termElement.getAttribute('id') || Math.random().toString(36).substr(2, 8); + definitionElement?.setAttribute('term-id', termId); +} + +export function setDefinitionPosition( + definitionElement: HTMLElement, + termElement: HTMLElement, +): void { + const { + x: termX, + y: termY, + right: termRight, + left: termLeft, + width: termWidth, + } = termElement.getBoundingClientRect(); + + const termParent = termParentElement(termElement); + + if (!termParent) { + return; + } + + const {right: termParentRight, left: termParentLeft} = termParent.getBoundingClientRect(); + + if ((termParentRight < termLeft || termParentLeft > termRight) && !isListenerNeeded) { + closeDefinition(definitionElement); + return; + } + + if (isListenerNeeded && termParent) { + termParent.addEventListener('scroll', termOnResize); + isListenerNeeded = false; + } + + const relativeX = Number(definitionElement.getAttribute('relativeX')); + const relativeY = Number(definitionElement.getAttribute('relativeY')); + + if (relativeX === termX && relativeY === termY) { + return; + } + + definitionElement.setAttribute('relativeX', String(termX)); + definitionElement.setAttribute('relativeY', String(termY)); + + const offsetTop = 25; + const definitionParent = definitionElement.parentElement; + + if (!definitionParent) { + return; + } + + const {width: definitionWidth} = definitionElement.getBoundingClientRect(); + const {left: definitionParentLeft} = definitionParent.getBoundingClientRect(); + + // If definition not fit document change base alignment + const definitionRightCoordinate = definitionWidth + Number(getCoords(termElement).left); + const fitDefinitionDocument = + document.body.clientWidth > definitionRightCoordinate ? 0 : definitionWidth - termWidth; + + definitionElement.style.top = Number(getCoords(termElement).top + offsetTop) + 'px'; + definitionElement.style.left = + Number( + getCoords(termElement).left - + definitionParentLeft + + definitionParent.offsetLeft - + fitDefinitionDocument, + ) + 'px'; +} + +function termOnResize() { + const openDefinition = document.getElementsByClassName(openDefinitionClass)[0] as HTMLElement; + + if (!openDefinition) { + return; + } + const termId = openDefinition.getAttribute('term-id') || ''; + const termElement = document.getElementById(termId); + + if (!termElement) { + return; + } + + setDefinitionPosition(openDefinition, termElement); +} + +function termParentElement(term: HTMLElement | null) { + if (!term) { + return null; + } + + const closestScrollableParent = term.closest('table') || term.closest('code'); + + return closestScrollableParent || term.parentElement; +} + +export function closeDefinition(definition: HTMLElement) { + definition.classList.remove(openClass); + const termId = definition.getAttribute('term-id') || ''; + const termParent = termParentElement(document.getElementById(termId)); + + if (!termParent) { + return; + } + + termParent.removeEventListener('scroll', termOnResize); + isListenerNeeded = true; +} + +function getCoords(elem: HTMLElement) { + const box = elem.getBoundingClientRect(); + + const body = document.body; + const docEl = document.documentElement; + + const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; + const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + + const top = box.top + scrollTop - clientTop; + const left = box.left + scrollLeft - clientLeft; + + return {top: Math.round(top), left: Math.round(left)}; +} diff --git a/src/scss/_term.scss b/src/scss/_term.scss new file mode 100644 index 00000000..c5845d47 --- /dev/null +++ b/src/scss/_term.scss @@ -0,0 +1,79 @@ +.yfm-term { + &_title { + color: #027bf3; + cursor: pointer; + + border-bottom: 1px dotted; + + font-size: inherit; + line-height: inherit; + font-style: normal; + + &:hover { + color: #004080; + } + } + + &_dfn { + position: absolute; + z-index: 1000; + + width: fit-content; + max-width: 450px; + + @media screen and (max-width: 600px) { + & { + max-width: 80%; + } + } + + visibility: hidden; + opacity: 0; + + padding: 10px; + + background-color: rgb(255, 255, 255); + + font-size: inherit; + line-height: inherit; + font-style: normal; + + border-radius: 4px; + + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); + outline: none; + + &::before { + content: ''; + position: absolute; + z-index: -1; + top: 0; + right: 0; + bottom: 0; + left: 0; + + border-radius: inherit; + box-shadow: 0 0 0 1px rgb(229, 229, 229); + } + + &.open { + visibility: visible; + + animation-name: popup; + animation-duration: 0.1s; + animation-timing-function: ease-out; + animation-fill-mode: forwards; + + @keyframes popup { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + } + } +} diff --git a/src/scss/yfm.scss b/src/scss/yfm.scss index 8edd60df..8dfc33b7 100644 --- a/src/scss/yfm.scss +++ b/src/scss/yfm.scss @@ -7,3 +7,4 @@ @import 'print'; @import 'cut'; @import 'file'; +@import 'term'; diff --git a/src/transform/index.ts b/src/transform/index.ts index ad79f0b5..422b66b7 100644 --- a/src/transform/index.ts +++ b/src/transform/index.ts @@ -1,5 +1,6 @@ import {bold} from 'chalk'; import attrs from 'markdown-it-attrs'; +import Token from 'markdown-it/lib/token'; import {log, LogLevels} from './log'; import makeHighlight from './highlight'; @@ -12,6 +13,7 @@ import anchors from './plugins/anchors'; import code from './plugins/code'; import cut from './plugins/cut'; import deflist from './plugins/deflist'; +import term from './plugins/term'; import file from './plugins/file'; import imsize from './plugins/imsize'; import meta from './plugins/meta'; @@ -84,6 +86,7 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType { yfmTable, file, imsize, + term, ], highlightLangs = {}, ...customOptions @@ -107,13 +110,14 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType { const md = initMd({html: allowHTML, linkify, highlight, breaks}); // Need for ids of headers md.use(attrs, {leftDelimiter, rightDelimiter}); + plugins.forEach((plugin) => md.use(plugin, pluginOptions)); try { let title; let tokens; let titleTokens; - const env = {}; + const env = {} as {[key: string]: Token[] | unknown}; tokens = md.parse(input, env); @@ -130,8 +134,10 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType { } const headings = getHeadings(tokens, needFlatListHeadings); - const html = md.renderer.render(tokens, md.options, env); + // add all term template tokens to the end of the html + const termTokens = (env.termTokens as Token[]) || []; + const html = md.renderer.render([...tokens, ...termTokens], md.options, env); const assets = md.assets; const meta = md.meta; diff --git a/src/transform/plugins/code.ts b/src/transform/plugins/code.ts index 1dd1bf67..4f62ba38 100644 --- a/src/transform/plugins/code.ts +++ b/src/transform/plugins/code.ts @@ -1,6 +1,7 @@ /* eslint-disable max-len */ import {MarkdownItPluginCb} from './typings'; +import {generateID} from './utils'; const wrapInClipboard = (element: string | undefined, id: number) => { return ` @@ -42,12 +43,36 @@ const wrapInClipboard = (element: string | undefined, id: number) => { `; }; +interface EnvTerm { + terms: { + [keys: string]: string; + }; +} + +function termReplace(str: string, env: EnvTerm): string { + const regTerms = Object.keys(env.terms) + .map((el) => el.substr(1)) + .join('|'); + const regText = '\\[([^\\[]+)\\](\\(\\*(' + regTerms + ')\\))'; + const reg = new RegExp(regText, 'g'); + + const termCode = str.replace( + reg, + (_match: string, p1: string, _p2: string, p3: string) => + `${p1}`, + ); + + return termCode || str; +} + const code: MarkdownItPluginCb = (md) => { const superCodeRenderer = md.renderer.rules.fence; md.renderer.rules.fence = function (tokens, idx, options, env, self) { const superCode = superCodeRenderer?.(tokens, idx, options, env, self); + const superCodeWithTerms = + superCode && env?.terms ? termReplace(superCode, env) : superCode; - return wrapInClipboard(superCode, idx); + return wrapInClipboard(superCodeWithTerms, idx); }; }; diff --git a/src/transform/plugins/tabs.ts b/src/transform/plugins/tabs.ts index f50f417a..e24e8e12 100644 --- a/src/transform/plugins/tabs.ts +++ b/src/transform/plugins/tabs.ts @@ -1,13 +1,10 @@ import StateCore from 'markdown-it/lib/rules_core/state_core'; import Token from 'markdown-it/lib/token'; import {MarkdownItPluginCb} from './typings'; +import {generateID} from './utils'; const TAB_RE = /`?{% list (tabs) %}`?/; -function generateID() { - return Math.random().toString(36).substr(2, 8); -} - type Tab = { name: string; tokens: Token[]; diff --git a/src/transform/plugins/term/constants.ts b/src/transform/plugins/term/constants.ts new file mode 100644 index 00000000..c4480d05 --- /dev/null +++ b/src/transform/plugins/term/constants.ts @@ -0,0 +1 @@ +export const BASIC_TERM_REGEXP = '\\[([^\\[]+)\\](\\(\\*(\\w+)\\))'; diff --git a/src/transform/plugins/term/index.ts b/src/transform/plugins/term/index.ts new file mode 100644 index 00000000..d4ab68e4 --- /dev/null +++ b/src/transform/plugins/term/index.ts @@ -0,0 +1,139 @@ +import StateCore from 'markdown-it/lib/rules_core/state_core'; +import Token from 'markdown-it/lib/token'; + +import {MarkdownItPluginCb} from '../typings'; +import {generateID} from '../utils'; +import {termDefinitions} from './termDefinitions'; +import {BASIC_TERM_REGEXP} from './constants'; + +const term: MarkdownItPluginCb = (md, options) => { + const escapeRE = md.utils.escapeRE; + const arrayReplaceAt = md.utils.arrayReplaceAt; + + const {isLintRun} = options; + // Don't parse urls that starts with * + const defaultLinkValidation = md.validateLink; + md.validateLink = function (url) { + if (url.startsWith('*')) { + return false; + } + + return defaultLinkValidation(url); + }; + + function termReplace(state: StateCore) { + let i, j, l, tokens, token, text, nodes, pos, term, currentToken; + + const blockTokens = state.tokens; + + if (!state.env.terms) { + return; + } + + const regTerms = Object.keys(state.env.terms) + .map((el) => el.substr(1)) + .map(escapeRE) + .join('|'); + const regText = '\\[([^\\[]+)\\](\\(\\*(' + regTerms + ')\\))'; + const reg = new RegExp(regText, 'g'); + + for (j = 0, l = blockTokens.length; j < l; j++) { + if (blockTokens[j].type === 'heading_open') { + while (blockTokens[j].type !== 'heading_close') { + j++; + } + continue; + } + + if (blockTokens[j].type !== 'inline') { + continue; + } + + tokens = blockTokens[j].children as Token[]; + + for (i = tokens.length - 1; i >= 0; i--) { + currentToken = tokens[i]; + if (currentToken.type === 'link_close') { + while (tokens[i].type !== 'link_open') { + i--; + } + continue; + } + + if (!(currentToken.type === 'text')) { + continue; + } + + pos = 0; + text = currentToken.content; + reg.lastIndex = 0; + nodes = []; + + // Find terms without definitions + const regexAllTerms = new RegExp(BASIC_TERM_REGEXP, 'gm'); + const uniqueTerms = [ + ...new Set([...text.matchAll(regexAllTerms)].map((el) => `:${el[3]}`)), + ]; + const notDefinedTerms = uniqueTerms.filter( + (el) => !Object.keys(state.env.terms).includes(el), + ); + + if (notDefinedTerms.length && isLintRun) { + token = new state.Token('__yfm_lint', '', 0); + token.hidden = true; + token.map = blockTokens[j].map; + token.attrSet('YFM007', 'true'); + nodes.push(token); + } + + while ((term = reg.exec(text))) { + const termTitle = term[1]; + const termKey = term[3]; + + if (term.index > 0 || term[1].length > 0) { + token = new state.Token('text', '', 0); + token.content = text.slice(pos, term.index); + nodes.push(token); + } + + token = new state.Token('term_open', 'i', 1); + token.attrSet('class', 'yfm yfm-term_title'); + token.attrSet('term-key', ':' + termKey); + token.attrSet('aria-describedby', ':' + termKey + '_element'); + token.attrSet('id', generateID()); + nodes.push(token); + + token = new state.Token('text', '', 0); + token.content = termTitle; + nodes.push(token); + + token = new state.Token('term_close', 'i', -1); + nodes.push(token); + + pos = reg.lastIndex; + } + + if (!nodes.length) { + continue; + } + + if (pos < text.length) { + token = new state.Token('text', '', 0); + token.content = text.slice(pos); + nodes.push(token); + } + + // replace current node + blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes); + } + } + } + + md.block.ruler.before('reference', 'termDefinitions', termDefinitions(md, options), { + alt: ['paragraph', 'reference'], + }); + + md.core.ruler.after('linkify', 'termReplace', termReplace); +}; + +export = term; diff --git a/src/transform/plugins/term/termDefinitions.ts b/src/transform/plugins/term/termDefinitions.ts new file mode 100644 index 00000000..a8ab772b --- /dev/null +++ b/src/transform/plugins/term/termDefinitions.ts @@ -0,0 +1,152 @@ +import StateBlock from 'markdown-it/lib/rules_block/state_block'; +import {MarkdownIt} from '../../typings'; +import {MarkdownItPluginOpts} from '../typings'; +import {BASIC_TERM_REGEXP} from './constants'; + +export function termDefinitions(md: MarkdownIt, options: MarkdownItPluginOpts) { + return (state: StateBlock, startLine: number, endLine: number, silent: boolean) => { + let ch; + let labelEnd; + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + + if (pos + 2 >= max) { + return false; + } + + if (state.src.charCodeAt(pos++) !== 0x5b /* [ */) { + return false; + } + if (state.src.charCodeAt(pos++) !== 0x2a /* * */) { + return false; + } + + const labelStart = pos; + + for (; pos < max; pos++) { + ch = state.src.charCodeAt(pos); + if (ch === 0x5b /* [ */) { + return false; + } else if (ch === 0x5d /* ] */) { + labelEnd = pos; + break; + } else if (ch === 0x5c /* \ */) { + pos++; + } + } + + const newLineReg = new RegExp(/^(\r\n|\r|\n)/); + const termReg = new RegExp(/^\[\*(\w+)\]:/); + + let currentLine = startLine; + + // Allow multiline term definition + for (; currentLine < endLine; currentLine++) { + const nextLineStart = state.bMarks[currentLine + 1]; + const nextLineEnd = state.eMarks[currentLine + 1]; + + const nextLine = + nextLineStart === nextLineEnd + ? state.src[nextLineStart] + : state.src.slice(nextLineStart, nextLineEnd); + + if (newLineReg.test(nextLine) || termReg.test(nextLine)) { + break; + } + + state.line = currentLine + 1; + } + + max = state.eMarks[currentLine]; + + if (!labelEnd || labelEnd < 0 || state.src.charCodeAt(labelEnd + 1) !== 0x3a /* : */) { + return false; + } + + if (silent) { + return true; + } + + const label = state.src.slice(labelStart, labelEnd).replace(/\\(.)/g, '$1'); + const title = state.src.slice(labelEnd + 2, max).trim(); + + if (label.length === 0 || title.length === 0) { + return false; + } + + return processTermDefinition(md, options, state, currentLine, endLine, label, title); + }; +} + +function processTermDefinition( + md: MarkdownIt, + options: MarkdownItPluginOpts, + state: StateBlock, + currentLine: number, + endLine: number, + label: string, + title: string, +) { + let token; + + if (!state.env.terms) { + state.env.terms = {}; + } + + const basicTermDefinitionRegexp = new RegExp(BASIC_TERM_REGEXP, 'gm'); + // If term inside definition + + const {isLintRun} = options; + + if (basicTermDefinitionRegexp.test(title) && isLintRun) { + token = new state.Token('__yfm_lint', '', 0); + token.hidden = true; + token.map = [currentLine, endLine]; + token.attrSet('YFM008', 'true'); + state.tokens.push(token); + } + + // If term definition duplicated + if (state.env.terms[':' + label] && isLintRun) { + token = new state.Token('__yfm_lint', '', 0); + token.hidden = true; + token.map = [currentLine, endLine]; + token.attrSet('YFM006', 'true'); + state.tokens.push(token); + state.line = currentLine + 1; + return true; + } + + if (typeof state.env.terms[':' + label] === 'undefined') { + state.env.terms[':' + label] = title; + } + + const termNodes = []; + + token = new state.Token('template_open', 'template', 1); + token.attrSet('id', ':' + label + '_template'); + termNodes.push(token); + + token = new state.Token('term_open', 'dfn', 1); + token.attrSet('class', 'yfm yfm-term_dfn'); + token.attrSet('id', ':' + label + '_element'); + token.attrSet('role', 'tooltip'); + termNodes.push(token); + + termNodes.push(...md.parse(title, {})); + + token = new state.Token('term_close', 'dfn', -1); + termNodes.push(token); + + token = new state.Token('template_close', 'template', -1); + termNodes.push(token); + + if (!state.env.termTokens) { + state.env.termTokens = []; + } + + state.env.termTokens.push(...termNodes); + + state.line = currentLine + 1; + return true; +} diff --git a/src/transform/plugins/typings.ts b/src/transform/plugins/typings.ts index 7deeabb2..7b3675d5 100644 --- a/src/transform/plugins/typings.ts +++ b/src/transform/plugins/typings.ts @@ -6,6 +6,7 @@ export interface MarkdownItPluginOpts { log: Logger; lang: 'ru' | 'en'; root: string; + isLintRun: boolean; } export type MarkdownItPluginCb = (md: MarkdownIt, opts: T & MarkdownItPluginOpts) => void; diff --git a/src/transform/plugins/utils.ts b/src/transform/plugins/utils.ts index 671b268f..15fa3b45 100644 --- a/src/transform/plugins/utils.ts +++ b/src/transform/plugins/utils.ts @@ -32,3 +32,7 @@ export const nestedCloseTokenIdxFactory = }; export const сarriage = platform === 'win32' ? '\r\n' : '\n'; + +export function generateID() { + return Math.random().toString(36).substr(2, 8); +} diff --git a/src/transform/yfmlint/index.ts b/src/transform/yfmlint/index.ts index 356814be..e75df24b 100644 --- a/src/transform/yfmlint/index.ts +++ b/src/transform/yfmlint/index.ts @@ -4,20 +4,32 @@ import union from 'lodash/union'; import attrs from 'markdown-it-attrs'; import baseDefaultLintConfig from './yfmlint'; -import {yfm001, yfm002, yfm003, yfm004, yfm005} from './markdownlint-custom-rule'; +import { + yfm001, + yfm002, + yfm003, + yfm004, + yfm005, + yfm006, + yfm007, + yfm008, +} from './markdownlint-custom-rule'; import {errorToString, getLogLevel} from './utils'; import {Options} from './typings'; import {Dictionary} from 'lodash'; import {Logger, LogLevels} from '../log'; -const defaultLintRules = [yfm001, yfm002, yfm003, yfm004, yfm005]; +const defaultLintRules = [yfm001, yfm002, yfm003, yfm004, yfm005, yfm006, yfm007, yfm008]; const lintCache = new Set(); function yfmlint(opts: Options) { const {input, plugins: customPlugins, pluginOptions, customLintRules, sourceMap} = opts; const {path = 'input', log} = pluginOptions; + + pluginOptions.isLintRun = true; + const { LogLevels: {ERROR, WARN, DISABLED}, } = log; diff --git a/src/transform/yfmlint/markdownlint-custom-rule/index.ts b/src/transform/yfmlint/markdownlint-custom-rule/index.ts index 53d26a0e..52194af1 100644 --- a/src/transform/yfmlint/markdownlint-custom-rule/index.ts +++ b/src/transform/yfmlint/markdownlint-custom-rule/index.ts @@ -3,3 +3,6 @@ export {yfm002} from './yfm002'; export {yfm003} from './yfm003'; export {yfm004} from './yfm004'; export {yfm005} from './yfm005'; +export {yfm006} from './yfm006'; +export {yfm007} from './yfm007'; +export {yfm008} from './yfm008'; diff --git a/src/transform/yfmlint/markdownlint-custom-rule/yfm006.ts b/src/transform/yfmlint/markdownlint-custom-rule/yfm006.ts new file mode 100644 index 00000000..a639191f --- /dev/null +++ b/src/transform/yfmlint/markdownlint-custom-rule/yfm006.ts @@ -0,0 +1,26 @@ +import {Rule} from 'markdownlint'; + +export const yfm006: Rule = { + names: ['YFM006', 'term-definition-duplicated'], + description: 'Term definition duplicated', + tags: ['term'], + function: function YFM006(params, onError) { + const {config} = params; + if (!config) { + return; + } + params.tokens + .filter((token) => { + return token.type === '__yfm_lint'; + }) + .forEach((term) => { + // @ts-expect-error bad markdownlint typings + if (term.attrGet('YFM006')) { + onError({ + lineNumber: term.lineNumber, + context: term.line, + }); + } + }); + }, +}; diff --git a/src/transform/yfmlint/markdownlint-custom-rule/yfm007.ts b/src/transform/yfmlint/markdownlint-custom-rule/yfm007.ts new file mode 100644 index 00000000..79218903 --- /dev/null +++ b/src/transform/yfmlint/markdownlint-custom-rule/yfm007.ts @@ -0,0 +1,28 @@ +import {Rule} from 'markdownlint'; + +export const yfm007: Rule = { + names: ['YFM007', 'term-used-without-definition'], + description: 'Term used without definition', + tags: ['term'], + function: function YFM007(params, onError) { + const {config} = params; + if (!config) { + return; + } + params.tokens.forEach((el) => + el.children + ?.filter((token) => { + return token.type === '__yfm_lint'; + }) + .forEach((term) => { + // @ts-expect-error bad markdownlint typings + if (term.attrGet('YFM007')) { + onError({ + lineNumber: term.lineNumber, + context: term.line, + }); + } + }), + ); + }, +}; diff --git a/src/transform/yfmlint/markdownlint-custom-rule/yfm008.ts b/src/transform/yfmlint/markdownlint-custom-rule/yfm008.ts new file mode 100644 index 00000000..baeb9036 --- /dev/null +++ b/src/transform/yfmlint/markdownlint-custom-rule/yfm008.ts @@ -0,0 +1,26 @@ +import {Rule} from 'markdownlint'; + +export const yfm008: Rule = { + names: ['YFM008', 'term-inside-definition-not-allowed'], + description: 'Term inside definition not allowed', + tags: ['term'], + function: function YFM008(params, onError) { + const {config} = params; + if (!config) { + return; + } + params.tokens + .filter((token) => { + return token.type === '__yfm_lint'; + }) + .forEach((term) => { + // @ts-expect-error bad markdownlint typings + if (term.attrGet('YFM008')) { + onError({ + lineNumber: term.lineNumber, + context: term.line, + }); + } + }); + }, +}; diff --git a/src/transform/yfmlint/yfmlint.ts b/src/transform/yfmlint/yfmlint.ts index b1d31413..94fb29c4 100644 --- a/src/transform/yfmlint/yfmlint.ts +++ b/src/transform/yfmlint/yfmlint.ts @@ -58,6 +58,9 @@ const index: LintConfig = { YFM003: LogLevels.ERROR, // Link is unreachable YFM004: LogLevels.ERROR, // Table not closed YFM005: LogLevels.ERROR, // Tab list not closed + YFM006: LogLevels.WARN, // Term definition duplicated + YFM007: LogLevels.WARN, // Term used without definition + YFM008: LogLevels.WARN, // Term inside definition not allowed }, // Inline code length diff --git a/test/mocks/term/_includes/html.md b/test/mocks/term/_includes/html.md new file mode 100644 index 00000000..cb714bf0 --- /dev/null +++ b/test/mocks/term/_includes/html.md @@ -0,0 +1 @@ +The HyperText Markup Language or **HTML** is the standard markup language for documents designed to be displayed in a web browser. diff --git a/test/mocks/term/code.md b/test/mocks/term/code.md new file mode 100644 index 00000000..5c6e5fd4 --- /dev/null +++ b/test/mocks/term/code.md @@ -0,0 +1,7 @@ +[*html]: The HyperText Markup Language or **HTML** is the standard markup language for documents designed to be displayed in a web browser. + +# Web + +``` +[HTML](*html): Lorem +``` \ No newline at end of file diff --git a/test/mocks/term/includeContent.md b/test/mocks/term/includeContent.md new file mode 100644 index 00000000..6c991990 --- /dev/null +++ b/test/mocks/term/includeContent.md @@ -0,0 +1,5 @@ +[*html]: {% include [html](_includes/html.md) %} + +# Web + +The [HTML](*html) specification diff --git a/test/mocks/term/table.md b/test/mocks/term/table.md new file mode 100644 index 00000000..7fa92950 --- /dev/null +++ b/test/mocks/term/table.md @@ -0,0 +1,7 @@ +[*html]: The HyperText Markup Language or **HTML** is the standard markup language for documents designed to be displayed in a web browser. + +# Web + +| Language | Initial release | +|---------------|:---------------:| +| [HTML](*html) | 1993 | \ No newline at end of file diff --git a/test/mocks/term/term.md b/test/mocks/term/term.md new file mode 100644 index 00000000..09fa1efe --- /dev/null +++ b/test/mocks/term/term.md @@ -0,0 +1,5 @@ +[*html]: The HyperText Markup Language or **HTML** is the standard markup language for documents designed to be displayed in a web browser. + +# Web + +The [HTML](*html) specification diff --git a/test/term.test.ts b/test/term.test.ts new file mode 100644 index 00000000..2699278f --- /dev/null +++ b/test/term.test.ts @@ -0,0 +1,131 @@ +import {dirname, resolve} from 'path'; +import {readFileSync} from 'fs'; +import transform from '../src/transform'; +import links from '../src/transform/plugins/links'; +import term from '../src/transform/plugins/term'; +import includes from '../src/transform/plugins/includes'; +import code from '../src/transform/plugins/code'; + +const mocksPath = require.resolve('./utils.ts'); + +const transformYfm = (text: string, path?: string) => { + const { + result: {html}, + } = transform(text, { + plugins: [includes, links, code, term], + path: path || mocksPath, + root: dirname(path || mocksPath), + }); + return html; +}; + +const clearRandomId = (str: string) => { + const clearRandomId = new RegExp(/<([i\s]+).*?id="([^"]*?)".*?>(.+?)/, 'g'); + const randomId = clearRandomId.exec(str); + + return randomId ? str.replace(randomId[2], '') : str; +}; + +describe('Terms', () => { + test('Should create term in text with definition template', () => { + const inputPath = resolve(__dirname, './mocks/term/term.md'); + const input = readFileSync(inputPath, 'utf8'); + const result = transformYfm(input, inputPath); + + expect(clearRandomId(result)).toEqual( + '

Web

\n' + + '

The HTML specification

\n' + + '', + ); + }); + + test('Should create term in table with definition template', () => { + const inputPath = resolve(__dirname, './mocks/term/table.md'); + const input = readFileSync(inputPath, 'utf8'); + const result = transformYfm(input, inputPath); + + expect(clearRandomId(result)).toEqual( + '

Web

\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
LanguageInitial release
HTML1993
\n' + + '', + ); + }); + + test('Should create term in code with definition template', () => { + const inputPath = resolve(__dirname, './mocks/term/code.md'); + const input = readFileSync(inputPath, 'utf8'); + const result = transformYfm(input, inputPath); + + expect(clearRandomId(result)).toEqual( + '

Web

\n' + + '\n' + + '
\n' + + '
HTML: Lorem\n' +
+                '
\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
\n' + + '', + ); + }); + + test('Term should use content from include', () => { + const inputPath = resolve(__dirname, './mocks/term/includeContent.md'); + const input = readFileSync(inputPath, 'utf8'); + const result = transformYfm(input, inputPath); + + expect(clearRandomId(result)).toEqual( + '

Web

\n' + + '

The HTML specification

\n' + + '', + ); + }); +});