Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(changelog): add support for changelog blocks #226

Merged
merged 8 commits into from
Jun 1, 2023
Merged
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
86 changes: 70 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions src/transform/plugins/changelog/collect.ts
Original file line number Diff line number Diff line change
@@ -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<MarkdownItPluginOpts, 'path' | 'log'> & {
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;
165 changes: 165 additions & 0 deletions src/transform/plugins/changelog/index.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не уверен что так заработает.
Как пример возьмем документацию Облака.
Она состоит из кучи проектов. И каждый захочет иметь собственный ченжлог.
Нужно либо добавлять обязательное поле в мета информацию, либо сплитить ченжлоги по имени файла.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ненене, мы складываем json рядом, вот тут diplodoc-platform/cli#348 и там все аккуратно оно харнится

} catch (err) {
log.error(`Changelog error: ${(err as Error).message} in ${bold(path)}`);
continue;
}
}

tokens.splice(openAt, closeAt + 1 - openAt);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я не уверен, что в RFC имелось в виду такое поведение.
Думаю ожидаемый результат, что ченжлог порендерится в виде обычного md документа и вырежутся конструкции {% changelog %} и {% endchangelog %}

Ты же вырезаешь конструкции вместе с контентом.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

нет, наоборот, эти секции не должны быть видны в доке. Это как бы сниппеты, которые показываются на сайте, а полная дока тут

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Погоди, мне казалось юзкейс такой:
На сайте есть попап в котором отображаются три последних изменения.
И ссылка "Все изменения можно посмотреть тут". По клику переходим на страницу документации.
И на этой странице полный ченжлог.
При этом что на сайте, что на полной странице, описание любого изменения это короткий текс + картинка.

Copy link
Contributor Author

@Feverqwe Feverqwe May 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Нет, блоками обозначаем сниппеты которые будут на сайте, а в доке полный чейндж лог, да, но он отличный от сниппетов. А вместе оно для консистентности и удобства их вести. А еще страницу можно будет дополнять, добавляя еще новые сниппеты

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тогда я не понимаю зачем внутри ченжлога используется md разметка.
Можно все поместить в метаинформацию.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ну можно вставлять ссылки, форматировать текст итп

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;
11 changes: 11 additions & 0 deletions src/transform/plugins/changelog/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface ChangelogItem {
title: string;
image: {
src: string;
alt: string;
ratio?: string;
};
description: string;
date: string;
[x: string]: unknown;
}
Loading