-
Notifications
You must be signed in to change notification settings - Fork 43
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
Changes from all commits
8291a17
89eefa4
deb1d20
73b27d5
ae5401c
9f0ad0a
75c4915
1c3ac59
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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; |
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); | ||
} catch (err) { | ||
log.error(`Changelog error: ${(err as Error).message} in ${bold(path)}`); | ||
continue; | ||
} | ||
} | ||
|
||
tokens.splice(openAt, closeAt + 1 - openAt); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Я не уверен, что в RFC имелось в виду такое поведение. Ты же вырезаешь конструкции вместе с контентом. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. нет, наоборот, эти секции не должны быть видны в доке. Это как бы сниппеты, которые показываются на сайте, а полная дока тут There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Погоди, мне казалось юзкейс такой: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Нет, блоками обозначаем сниппеты которые будут на сайте, а в доке полный чейндж лог, да, но он отличный от сниппетов. А вместе оно для консистентности и удобства их вести. А еще страницу можно будет дополнять, добавляя еще новые сниппеты There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Тогда я не понимаю зачем внутри ченжлога используется md разметка. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
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; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Не уверен что так заработает.
Как пример возьмем документацию Облака.
Она состоит из кучи проектов. И каждый захочет иметь собственный ченжлог.
Нужно либо добавлять обязательное поле в мета информацию, либо сплитить ченжлоги по имени файла.
There was a problem hiding this comment.
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 и там все аккуратно оно харнится