Skip to content

Commit 8f5670b

Browse files
authored
feat(changelog): add support for changelog blocks (#226)
1 parent fe83903 commit 8f5670b

File tree

8 files changed

+431
-16
lines changed

8 files changed

+431
-16
lines changed

package-lock.json

+70-16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"chalk": "4.1.2",
3535
"get-root-node-polyfill": "1.0.0",
3636
"github-slugger": "1.4.0",
37+
"js-yaml": "^4.1.0",
3738
"lodash": "4.17.21",
3839
"markdown-it": "13.0.1",
3940
"markdown-it-attrs": "4.1.4",
@@ -56,6 +57,7 @@
5657
"@types/github-slugger": "1.3.0",
5758
"@types/highlight.js": "10.1.0",
5859
"@types/jest": "28.1.7",
60+
"@types/js-yaml": "^4.0.5",
5961
"@types/lodash": "4.14.183",
6062
"@types/markdown-it": "12.2.3",
6163
"@types/markdown-it-attrs": "4.1.0",
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {bold} from 'chalk';
2+
import {ChangelogItem} from './types';
3+
import initMarkdownit from '../../md';
4+
import changelogPlugin from './index';
5+
import imsize from '../imsize';
6+
import {MarkdownItPluginOpts} from '../typings';
7+
8+
const BLOCK_START = '{% changelog %}';
9+
const BLOCK_END = '{% endchangelog %}\n';
10+
11+
function parseChangelogs(str: string, path?: string) {
12+
const {parse, compile, env} = initMarkdownit({
13+
plugins: [changelogPlugin, imsize],
14+
extractChangelogs: true,
15+
path,
16+
});
17+
18+
compile(parse(str));
19+
20+
return env.changelogs || [];
21+
}
22+
23+
type Options = Pick<MarkdownItPluginOpts, 'path' | 'log'> & {
24+
changelogs?: ChangelogItem[];
25+
extractChangelogs?: boolean;
26+
};
27+
28+
const collect = (input: string, {path: filepath, log, changelogs, extractChangelogs}: Options) => {
29+
let result = input;
30+
let lastPos = 0;
31+
const rawChanges = [];
32+
33+
// eslint-disable-next-line no-constant-condition
34+
while (true) {
35+
const pos = result.indexOf(BLOCK_START, lastPos);
36+
lastPos = pos;
37+
if (pos === -1) {
38+
break;
39+
}
40+
const endPos = result.indexOf(BLOCK_END, pos + BLOCK_START.length);
41+
if (endPos === -1) {
42+
log.error(`Changelog block must be closed${filepath ? ` in ${bold(filepath)}` : ''}`);
43+
break;
44+
}
45+
46+
const change = result.slice(pos, endPos + BLOCK_END.length);
47+
48+
rawChanges.push(change);
49+
50+
result = result.slice(0, pos) + result.slice(endPos + BLOCK_END.length);
51+
}
52+
53+
if (rawChanges.length && changelogs && extractChangelogs) {
54+
changelogs.push(...parseChangelogs(rawChanges.join('\n\n'), filepath));
55+
}
56+
57+
return result;
58+
};
59+
60+
export = collect;
+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {MarkdownItPluginCb} from '../typings';
2+
import Core from 'markdown-it/lib/parser_core';
3+
import Token from 'markdown-it/lib/token';
4+
import {bold} from 'chalk';
5+
import yaml from 'js-yaml';
6+
import StateCore from 'markdown-it/lib/rules_core/state_core';
7+
8+
interface Metadata {
9+
date: string;
10+
}
11+
12+
interface Options {
13+
extractChangelogs?: boolean;
14+
}
15+
16+
const CHANGELOG_OPEN_RE = /^\{% changelog %}/;
17+
const CHANGELOG_CLOSE_RE = /^\{% endchangelog %}/;
18+
19+
function isOpenToken(tokens: Token[], i: number) {
20+
return (
21+
tokens[i].type === 'paragraph_open' &&
22+
tokens[i + 1].type === 'inline' &&
23+
tokens[i + 2].type === 'paragraph_close' &&
24+
CHANGELOG_OPEN_RE.test(tokens[i + 1].content)
25+
);
26+
}
27+
28+
function isCloseToken(tokens: Token[], i: number) {
29+
return (
30+
tokens[i]?.type === 'paragraph_open' &&
31+
tokens[i + 1].type === 'inline' &&
32+
tokens[i + 2].type === 'paragraph_close' &&
33+
CHANGELOG_CLOSE_RE.test(tokens[i + 1].content)
34+
);
35+
}
36+
37+
function isTitle(tokens: Token[], i = 0) {
38+
return (
39+
tokens[i].type === 'heading_open' &&
40+
tokens[i + 1].type === 'inline' &&
41+
tokens[i + 2].type === 'heading_close'
42+
);
43+
}
44+
45+
function isImageParagraph(tokens: Token[], i = 0) {
46+
return (
47+
tokens[i].type === 'paragraph_open' &&
48+
tokens[i + 1].type === 'inline' &&
49+
tokens[i + 2].type === 'paragraph_close' &&
50+
tokens[i + 1].children?.some((t) => t.type === 'image')
51+
);
52+
}
53+
54+
function parseBody(tokens: Token[], state: StateCore) {
55+
const {md, env} = state;
56+
57+
const metadataToken = tokens.shift();
58+
if (metadataToken?.type !== 'fence') {
59+
throw new Error('Metadata tag not found');
60+
}
61+
const rawMetadata = yaml.load(metadataToken.content) as Metadata;
62+
const metadata = {
63+
...rawMetadata,
64+
date: new Date(rawMetadata.date).toISOString(),
65+
};
66+
67+
if (!isTitle(tokens)) {
68+
throw new Error('Title tag not found');
69+
}
70+
const title = tokens.splice(0, 3)[1].content;
71+
72+
let image;
73+
if (isImageParagraph(tokens)) {
74+
const paragraphTokens = tokens.splice(0, 3);
75+
const imageToken = paragraphTokens[1]?.children?.find((token) => token.type === 'image');
76+
if (imageToken) {
77+
const width = Number(imageToken.attrGet('width'));
78+
const height = Number(imageToken.attrGet('height'));
79+
let ratio;
80+
if (Number.isFinite(width) && Number.isFinite(height)) {
81+
ratio = height / width;
82+
}
83+
let alt = imageToken.attrGet('title') || '';
84+
if (!alt && imageToken.children) {
85+
alt = md.renderer.renderInlineAsText(imageToken.children, md.options, env);
86+
}
87+
image = {
88+
src: imageToken.attrGet('src'),
89+
alt,
90+
ratio,
91+
};
92+
}
93+
}
94+
95+
const description = md.renderer.render(tokens, md.options, env);
96+
97+
return {
98+
...metadata,
99+
title,
100+
image,
101+
description,
102+
};
103+
}
104+
105+
const changelog: MarkdownItPluginCb<Options> = function (md, {extractChangelogs, log, path}) {
106+
const plugin: Core.RuleCore = (state) => {
107+
const {tokens, env} = state;
108+
109+
for (let i = 0, len = tokens.length; i < len; i++) {
110+
const isOpen = isOpenToken(tokens, i);
111+
if (!isOpen) continue;
112+
113+
const openAt = i;
114+
let isCloseFound = false;
115+
while (i < len) {
116+
i++;
117+
if (isCloseToken(tokens, i)) {
118+
isCloseFound = true;
119+
break;
120+
}
121+
}
122+
123+
if (!isCloseFound) {
124+
log.error(`Changelog close tag in not found: ${bold(path)}`);
125+
break;
126+
}
127+
128+
const closeAt = i + 2;
129+
130+
if (env && extractChangelogs) {
131+
const content = tokens.slice(openAt, closeAt + 1);
132+
133+
// cut open
134+
content.splice(0, 3);
135+
// cut close
136+
content.splice(-3);
137+
138+
try {
139+
const change = parseBody(content, state);
140+
141+
if (!env.changelogs) {
142+
env.changelogs = [];
143+
}
144+
145+
env.changelogs.push(change);
146+
} catch (err) {
147+
log.error(`Changelog error: ${(err as Error).message} in ${bold(path)}`);
148+
continue;
149+
}
150+
}
151+
152+
tokens.splice(openAt, closeAt + 1 - openAt);
153+
len = tokens.length;
154+
i = openAt - 1;
155+
}
156+
};
157+
158+
try {
159+
md.core.ruler.before('curly_attributes', 'changelog', plugin);
160+
} catch (e) {
161+
md.core.ruler.push('changelog', plugin);
162+
}
163+
};
164+
165+
export = changelog;
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface ChangelogItem {
2+
title: string;
3+
image: {
4+
src: string;
5+
alt: string;
6+
ratio?: string;
7+
};
8+
description: string;
9+
date: string;
10+
[x: string]: unknown;
11+
}

0 commit comments

Comments
 (0)