From 8f5670bf4559f26cb9c88153089706d8163cef9b Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 1 Jun 2023 19:50:46 +0500 Subject: [PATCH] feat(changelog): add support for changelog blocks (#226) --- package-lock.json | 86 +++++++++-- package.json | 2 + src/transform/plugins/changelog/collect.ts | 60 ++++++++ src/transform/plugins/changelog/index.ts | 165 +++++++++++++++++++++ src/transform/plugins/changelog/types.ts | 11 ++ src/transform/typings.ts | 3 + test/changelog.test.ts | 80 ++++++++++ test/data/changelog.md | 40 +++++ 8 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 src/transform/plugins/changelog/collect.ts create mode 100644 src/transform/plugins/changelog/index.ts create mode 100644 src/transform/plugins/changelog/types.ts create mode 100644 test/changelog.test.ts create mode 100644 test/data/changelog.md diff --git a/package-lock.json b/package-lock.json index d805ca89..c47150f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3344,6 +3344,15 @@ "resolve-from": "^5.0.0" }, "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -3354,6 +3363,16 @@ "path-exists": "^4.0.0" } }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -3915,6 +3934,12 @@ "pretty-format": "^28.0.0" } }, + "@types/js-yaml": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", + "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "dev": true + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -6949,22 +6974,11 @@ "dev": true }, "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "dependencies": { - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "requires": { - "sprintf-js": "~1.0.2" - } - } + "argparse": "^2.0.1" } }, "jsbn": { @@ -7287,6 +7301,25 @@ "integrity": "sha1-11to8RVlnK9WjkrUPLRgpHEkjDk=", "requires": { "js-yaml": "^3.8.1" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } } }, "markdown-it-sup": { @@ -7303,6 +7336,27 @@ "chai": "^1.10.0", "js-yaml": "^3.13.1", "object-assign": "^4.1.1" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } } }, "markdownlint": { @@ -8902,7 +8956,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "sshpk": { "version": "1.16.1", diff --git a/package.json b/package.json index be87ca3b..50038942 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "chalk": "4.1.2", "get-root-node-polyfill": "1.0.0", "github-slugger": "1.4.0", + "js-yaml": "^4.1.0", "lodash": "4.17.21", "markdown-it": "13.0.1", "markdown-it-attrs": "4.1.4", @@ -56,6 +57,7 @@ "@types/github-slugger": "1.3.0", "@types/highlight.js": "10.1.0", "@types/jest": "28.1.7", + "@types/js-yaml": "^4.0.5", "@types/lodash": "4.14.183", "@types/markdown-it": "12.2.3", "@types/markdown-it-attrs": "4.1.0", diff --git a/src/transform/plugins/changelog/collect.ts b/src/transform/plugins/changelog/collect.ts new file mode 100644 index 00000000..0f073835 --- /dev/null +++ b/src/transform/plugins/changelog/collect.ts @@ -0,0 +1,60 @@ +import {bold} from 'chalk'; +import {ChangelogItem} from './types'; +import initMarkdownit from '../../md'; +import changelogPlugin from './index'; +import imsize from '../imsize'; +import {MarkdownItPluginOpts} from '../typings'; + +const BLOCK_START = '{% changelog %}'; +const BLOCK_END = '{% endchangelog %}\n'; + +function parseChangelogs(str: string, path?: string) { + const {parse, compile, env} = initMarkdownit({ + plugins: [changelogPlugin, imsize], + extractChangelogs: true, + path, + }); + + compile(parse(str)); + + return env.changelogs || []; +} + +type Options = Pick & { + changelogs?: ChangelogItem[]; + extractChangelogs?: boolean; +}; + +const collect = (input: string, {path: filepath, log, changelogs, extractChangelogs}: Options) => { + let result = input; + let lastPos = 0; + const rawChanges = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const pos = result.indexOf(BLOCK_START, lastPos); + lastPos = pos; + if (pos === -1) { + break; + } + const endPos = result.indexOf(BLOCK_END, pos + BLOCK_START.length); + if (endPos === -1) { + log.error(`Changelog block must be closed${filepath ? ` in ${bold(filepath)}` : ''}`); + break; + } + + const change = result.slice(pos, endPos + BLOCK_END.length); + + rawChanges.push(change); + + result = result.slice(0, pos) + result.slice(endPos + BLOCK_END.length); + } + + if (rawChanges.length && changelogs && extractChangelogs) { + changelogs.push(...parseChangelogs(rawChanges.join('\n\n'), filepath)); + } + + return result; +}; + +export = collect; diff --git a/src/transform/plugins/changelog/index.ts b/src/transform/plugins/changelog/index.ts new file mode 100644 index 00000000..e99774e8 --- /dev/null +++ b/src/transform/plugins/changelog/index.ts @@ -0,0 +1,165 @@ +import {MarkdownItPluginCb} from '../typings'; +import Core from 'markdown-it/lib/parser_core'; +import Token from 'markdown-it/lib/token'; +import {bold} from 'chalk'; +import yaml from 'js-yaml'; +import StateCore from 'markdown-it/lib/rules_core/state_core'; + +interface Metadata { + date: string; +} + +interface Options { + extractChangelogs?: boolean; +} + +const CHANGELOG_OPEN_RE = /^\{% changelog %}/; +const CHANGELOG_CLOSE_RE = /^\{% endchangelog %}/; + +function isOpenToken(tokens: Token[], i: number) { + return ( + tokens[i].type === 'paragraph_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 2].type === 'paragraph_close' && + CHANGELOG_OPEN_RE.test(tokens[i + 1].content) + ); +} + +function isCloseToken(tokens: Token[], i: number) { + return ( + tokens[i]?.type === 'paragraph_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 2].type === 'paragraph_close' && + CHANGELOG_CLOSE_RE.test(tokens[i + 1].content) + ); +} + +function isTitle(tokens: Token[], i = 0) { + return ( + tokens[i].type === 'heading_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 2].type === 'heading_close' + ); +} + +function isImageParagraph(tokens: Token[], i = 0) { + return ( + tokens[i].type === 'paragraph_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 2].type === 'paragraph_close' && + tokens[i + 1].children?.some((t) => t.type === 'image') + ); +} + +function parseBody(tokens: Token[], state: StateCore) { + const {md, env} = state; + + const metadataToken = tokens.shift(); + if (metadataToken?.type !== 'fence') { + throw new Error('Metadata tag not found'); + } + const rawMetadata = yaml.load(metadataToken.content) as Metadata; + const metadata = { + ...rawMetadata, + date: new Date(rawMetadata.date).toISOString(), + }; + + if (!isTitle(tokens)) { + throw new Error('Title tag not found'); + } + const title = tokens.splice(0, 3)[1].content; + + let image; + if (isImageParagraph(tokens)) { + const paragraphTokens = tokens.splice(0, 3); + const imageToken = paragraphTokens[1]?.children?.find((token) => token.type === 'image'); + if (imageToken) { + const width = Number(imageToken.attrGet('width')); + const height = Number(imageToken.attrGet('height')); + let ratio; + if (Number.isFinite(width) && Number.isFinite(height)) { + ratio = height / width; + } + let alt = imageToken.attrGet('title') || ''; + if (!alt && imageToken.children) { + alt = md.renderer.renderInlineAsText(imageToken.children, md.options, env); + } + image = { + src: imageToken.attrGet('src'), + alt, + ratio, + }; + } + } + + const description = md.renderer.render(tokens, md.options, env); + + return { + ...metadata, + title, + image, + description, + }; +} + +const changelog: MarkdownItPluginCb = function (md, {extractChangelogs, log, path}) { + const plugin: Core.RuleCore = (state) => { + const {tokens, env} = state; + + for (let i = 0, len = tokens.length; i < len; i++) { + const isOpen = isOpenToken(tokens, i); + if (!isOpen) continue; + + const openAt = i; + let isCloseFound = false; + while (i < len) { + i++; + if (isCloseToken(tokens, i)) { + isCloseFound = true; + break; + } + } + + if (!isCloseFound) { + log.error(`Changelog close tag in not found: ${bold(path)}`); + break; + } + + const closeAt = i + 2; + + if (env && extractChangelogs) { + const content = tokens.slice(openAt, closeAt + 1); + + // cut open + content.splice(0, 3); + // cut close + content.splice(-3); + + try { + const change = parseBody(content, state); + + if (!env.changelogs) { + env.changelogs = []; + } + + env.changelogs.push(change); + } catch (err) { + log.error(`Changelog error: ${(err as Error).message} in ${bold(path)}`); + continue; + } + } + + tokens.splice(openAt, closeAt + 1 - openAt); + len = tokens.length; + i = openAt - 1; + } + }; + + try { + md.core.ruler.before('curly_attributes', 'changelog', plugin); + } catch (e) { + md.core.ruler.push('changelog', plugin); + } +}; + +export = changelog; diff --git a/src/transform/plugins/changelog/types.ts b/src/transform/plugins/changelog/types.ts new file mode 100644 index 00000000..e85ead75 --- /dev/null +++ b/src/transform/plugins/changelog/types.ts @@ -0,0 +1,11 @@ +export interface ChangelogItem { + title: string; + image: { + src: string; + alt: string; + ratio?: string; + }; + description: string; + date: string; + [x: string]: unknown; +} diff --git a/src/transform/typings.ts b/src/transform/typings.ts index 431657d7..7857a564 100644 --- a/src/transform/typings.ts +++ b/src/transform/typings.ts @@ -4,6 +4,7 @@ import DefaultStateCore from 'markdown-it/lib/rules_core/state_core'; import {SanitizeOptions} from './sanitize'; import {MarkdownItPluginCb} from './plugins/typings'; import {LogLevels} from './log'; +import {ChangelogItem} from './plugins/changelog/types'; export interface MarkdownIt extends DefaultMarkdownIt { assets?: string[]; @@ -43,6 +44,7 @@ export interface OptionsType { plugins?: MarkdownItPluginCb[]; highlightLangs?: HighlightLangMap; root?: string; + extractChangelogs?: boolean; [x: string]: unknown; } @@ -58,4 +60,5 @@ export type EnvType = { headings: Heading[]; assets?: unknown[]; meta?: object; + changelogs?: ChangelogItem[]; } & Extras; diff --git a/test/changelog.test.ts b/test/changelog.test.ts new file mode 100644 index 00000000..1a1871d5 --- /dev/null +++ b/test/changelog.test.ts @@ -0,0 +1,80 @@ +import transform from '../src/transform'; +import path from 'path'; +import fs from 'fs'; +import changelogPlugin from '../src/transform/plugins/changelog'; +import changelogCollect from '../src/transform/plugins/changelog/collect'; +import imsize from '../src/transform/plugins/imsize'; +import {Logger} from '../src/transform/log'; +import {ChangelogItem} from '../src/transform/plugins/changelog/types'; + +describe('Changelog', () => { + test('Should cut changelog', async () => { + expect.assertions(2); + + const data = await fs.promises.readFile(path.join(__dirname, 'data/changelog.md'), 'utf8'); + + const { + result: {html, changelogs: logs}, + } = transform(data, { + plugins: [changelogPlugin, imsize], + }); + + expect(html).toBe(`

Some changelog

\n

After changelog

\n`); + + expect(logs).toBe(undefined); + }); + + test('Should cut changelog and write it in env', async () => { + expect.assertions(2); + + const data = await fs.promises.readFile(path.join(__dirname, 'data/changelog.md'), 'utf8'); + + const { + result: {html, changelogs: logs}, + } = transform(data, { + plugins: [changelogPlugin, imsize], + extractChangelogs: true, + }); + + expect(html).toBe(`

Some changelog

\n

After changelog

\n`); + + expect(logs).toEqual( + new Array(3).fill({ + date: '2023-05-10T00:00:00.000Z', + storyId: 123321, + title: 'Change log title', + image: { + alt: 'My image', + ratio: 0.5625, + src: '../src/asd.png', + }, + description: '

Change log payload

\n', + }), + ); + }); + + test('Should cut changelog and write it in variable', async () => { + expect.assertions(2); + + const data = await fs.promises.readFile(path.join(__dirname, 'data/changelog.md'), 'utf8'); + + const changelogs: ChangelogItem[] = []; + const html = changelogCollect(data, { + path: '', + changelogs, + log: console as unknown as Logger, + extractChangelogs: true, + }); + + expect(html).toBe(`# Some changelog + + + + + +After changelog +`); + + expect(changelogs.length).toBe(3); + }); +}); diff --git a/test/data/changelog.md b/test/data/changelog.md new file mode 100644 index 00000000..5aea906b --- /dev/null +++ b/test/data/changelog.md @@ -0,0 +1,40 @@ +# Some changelog + +{% changelog %} +``` +date: 2023-05-10 +storyId: 123321 +``` +# Change log title +![My image](../src/asd.png =16x9) + +Change log payload + +{% endchangelog %} + +{% changelog %} +``` +date: 2023-05-10 +storyId: 123321 +``` +# Change log title +![My image](../src/asd.png =16x9) + +Change log payload + +{% endchangelog %} + + +{% changelog %} +``` +date: 2023-05-10 +storyId: 123321 +``` +# Change log title +![My image](../src/asd.png =16x9) + +Change log payload + +{% endchangelog %} + +After changelog