174 lines
4.7 KiB
JavaScript
174 lines
4.7 KiB
JavaScript
const { readFileSync, writeFileSync } = require('node:fs')
|
|
const { join, resolve } = require('node:path')
|
|
|
|
const root = resolve(__dirname, '..')
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(root, 'package.json'), 'utf8')
|
|
)
|
|
const releaseNotesFile = JSON.parse(
|
|
readFileSync(join(root, 'resources', 'release-notes.json'), 'utf8')
|
|
)
|
|
|
|
function fail(message) {
|
|
throw new Error(`Release notes validation failed: ${message}`)
|
|
}
|
|
|
|
function hasExactKeys(value, keys) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
Object.keys(value).length === keys.length &&
|
|
keys.every((key) => Object.hasOwn(value, key))
|
|
)
|
|
}
|
|
|
|
function validateItems(value, label) {
|
|
if (!Array.isArray(value) || value.length > 20) {
|
|
fail(`${label} must contain no more than 20 items`)
|
|
}
|
|
return value.map((item) => {
|
|
if (typeof item !== 'string') {
|
|
fail(`${label} contains a non-string item`)
|
|
}
|
|
const normalized = item.trim()
|
|
if (!normalized || normalized.length > 240) {
|
|
fail(`${label} contains an empty or oversized item`)
|
|
}
|
|
return normalized
|
|
})
|
|
}
|
|
|
|
function validateRelease(value, index) {
|
|
const label = `releases[${index}]`
|
|
if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) {
|
|
fail(`${label} has invalid fields`)
|
|
}
|
|
if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
|
|
value.version
|
|
)) {
|
|
fail(`${label}.version must be a stable semantic version`)
|
|
}
|
|
const date = new Date(`${value.releasedAt}T00:00:00.000Z`)
|
|
if (
|
|
!/^\d{4}-\d{2}-\d{2}$/u.test(value.releasedAt) ||
|
|
Number.isNaN(date.getTime()) ||
|
|
date.toISOString().slice(0, 10) !== value.releasedAt
|
|
) {
|
|
fail(`${label}.releasedAt must be a real YYYY-MM-DD date`)
|
|
}
|
|
if (!hasExactKeys(value.notes, ['zh-CN', 'en-US'])) {
|
|
fail(`${label}.notes must contain zh-CN and en-US`)
|
|
}
|
|
const notes = Object.fromEntries(
|
|
['zh-CN', 'en-US'].map((locale) => {
|
|
const localized = value.notes[locale]
|
|
if (!hasExactKeys(localized, ['features', 'fixes'])) {
|
|
fail(`${label}.notes.${locale} has invalid fields`)
|
|
}
|
|
const features = validateItems(
|
|
localized.features,
|
|
`${label}.notes.${locale}.features`
|
|
)
|
|
const fixes = validateItems(
|
|
localized.fixes,
|
|
`${label}.notes.${locale}.fixes`
|
|
)
|
|
if (features.length + fixes.length === 0) {
|
|
fail(`${label}.notes.${locale} must not be empty`)
|
|
}
|
|
return [locale, { features, fixes }]
|
|
})
|
|
)
|
|
if (
|
|
notes['zh-CN'].features.length !== notes['en-US'].features.length ||
|
|
notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length
|
|
) {
|
|
fail(`${label} localized section counts do not match`)
|
|
}
|
|
return {
|
|
version: value.version,
|
|
releasedAt: value.releasedAt,
|
|
notes
|
|
}
|
|
}
|
|
|
|
if (
|
|
!hasExactKeys(releaseNotesFile, ['formatVersion', 'releases']) ||
|
|
releaseNotesFile.formatVersion !== 1 ||
|
|
!Array.isArray(releaseNotesFile.releases) ||
|
|
releaseNotesFile.releases.length < 1 ||
|
|
releaseNotesFile.releases.length > 100
|
|
) {
|
|
fail('unsupported file format')
|
|
}
|
|
|
|
const allReleases = releaseNotesFile.releases.map(validateRelease)
|
|
const uniqueVersionCount = new Set(
|
|
allReleases.map((release) => release.version)
|
|
).size
|
|
if (uniqueVersionCount !== allReleases.length) {
|
|
fail('release versions must be unique')
|
|
}
|
|
|
|
const releases = allReleases.filter(
|
|
(release) => release?.version === packageJson.version
|
|
)
|
|
if (releases.length !== 1) {
|
|
fail(
|
|
`expected exactly one entry for package version ${packageJson.version}`
|
|
)
|
|
}
|
|
|
|
const release = releases[0]
|
|
|
|
const localizedDefinitions = [
|
|
{
|
|
locale: 'zh-CN',
|
|
title: `GoodBuddy ${release.version} 更新内容`,
|
|
features: '功能更新',
|
|
fixes: '问题修复'
|
|
},
|
|
{
|
|
locale: 'en-US',
|
|
title: `What's New in GoodBuddy ${release.version}`,
|
|
features: 'Features',
|
|
fixes: 'Bug Fixes'
|
|
}
|
|
]
|
|
|
|
function markdownSection(title, items) {
|
|
if (items.length === 0) {
|
|
return []
|
|
}
|
|
return [`## ${title}`, '', ...items.map((item) => `- ${item}`), '']
|
|
}
|
|
|
|
const markdown = localizedDefinitions
|
|
.flatMap((definition, index) => {
|
|
const notes = release.notes[definition.locale]
|
|
return [
|
|
...(index === 0 ? [] : ['---', '']),
|
|
`# ${definition.title}`,
|
|
'',
|
|
...markdownSection(definition.features, notes.features),
|
|
...markdownSection(definition.fixes, notes.fixes)
|
|
]
|
|
})
|
|
.join('\n')
|
|
.trimEnd()
|
|
.concat('\n')
|
|
|
|
const outputIndex = process.argv.indexOf('--output')
|
|
if (outputIndex >= 0) {
|
|
const outputPath = process.argv[outputIndex + 1]
|
|
if (!outputPath) {
|
|
fail('--output requires a path')
|
|
}
|
|
writeFileSync(resolve(root, outputPath), markdown, 'utf8')
|
|
} else {
|
|
process.stdout.write(
|
|
`Validated bilingual release notes for ${packageJson.version}\n`
|
|
)
|
|
}
|