feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,75 @@
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
const roots = ['apps/app', 'apps/app-frontend', 'packages/app-lib']
const ignoredNames = new Set(['LICENSE', 'COPYING.md'])
const ignoredDirectories = new Set(['dist', 'build', 'node_modules', 'target', '.gradle', '.sqlx', 'bin'])
const forbiddenPatterns = [
['official product name', /Modrinth App/g],
['official API identity', /modrinth\/theseus/gi],
['official support identity', /support@modrinth\.com/gi],
['official deep link', /modrinth:\/\//gi],
[
'official telemetry',
/phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ|posthog\.modrinth\.com|ingest\.us\.sentry\.io/gi,
],
['advertising bridge', /plugin:ads|api::ads|Aditude/gi],
['official update feed', /launcher-files\.modrinth\.com\/updates\.json/gi],
['official signing service', /DIGICERT_ONE_SIGNER_CREDENTIALS/gi],
]
async function* files(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (ignoredDirectories.has(entry.name)) continue
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) yield* files(entryPath)
else if (!ignoredNames.has(entry.name)) yield entryPath
}
}
const failures = []
for (const root of roots) {
for await (const file of files(root)) {
let contents
try {
contents = await readFile(file, 'utf8')
} catch {
continue
}
for (const [label, pattern] of forbiddenPatterns) {
pattern.lastIndex = 0
if (pattern.test(contents)) failures.push(`${file}: ${label}`)
}
}
}
const tauriConfig = JSON.parse(await readFile('apps/app/tauri.conf.json', 'utf8'))
const frontendConfig = await readFile('apps/app-frontend/src/config.ts', 'utf8')
const requiredInvariants = [
['product name', tauriConfig.productName === 'Axolotl Launcher'],
['bundle identifier', tauriConfig.identifier === 'red.ghs.axolotl'],
[
'deep-link scheme',
tauriConfig.plugins?.['deep-link']?.desktop?.schemes?.includes('axolotl') === true,
],
[
'User-Agent format',
frontendConfig.includes('garbage-human-studio/axolotl/${version} (${os})'),
],
[
'private Modrinth services disabled',
frontendConfig.includes('privateModrinthServices: false'),
],
['GHS telemetry disabled', frontendConfig.includes('ghsTelemetry: false')],
]
for (const [label, valid] of requiredInvariants) {
if (!valid) failures.push(`configuration: missing ${label}`)
}
if (failures.length > 0) {
console.error(`Axolotl brand guard failed:\n${failures.join('\n')}`)
process.exit(1)
}
console.log('Axolotl brand guard passed.')

View File

@ -0,0 +1,276 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
const migrationsDirectory = 'packages/app-lib/migrations'
const repository = process.env.GITHUB_REPOSITORY ?? 'Mystic-Stars/Axolotl'
// v1.7.5 is the canonical snapshot immediately before the v1.7.6 incident.
const canonicalBootstrap = {
ref: '7ddbfb8e57db4b0044a04cf28f25fb29e08c3279',
tag: 'v1.7.5',
}
// Published divergences remain evidence of an incident and never replace the canonical bytes.
const knownPublishedDivergences = new Set([
[
'v1.7.6',
'packages/app-lib/migrations/20260714120000_translation.sql',
'c6fdf52790db7e67905003216ee7c099ec9ac29df1ee1b62602eb791881f321470f7e2b965c39fc8733b10ad114eace5',
].join('\0'),
])
function git(args, encoding = 'utf8') {
return execFileSync('git', args, {
encoding,
maxBuffer: 16 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
}
function checksum(contents) {
return createHash('sha384').update(contents).digest('hex')
}
function migrationChecksum(migration) {
migration.checksum ??= checksum(git(['cat-file', 'blob', migration.blob], null))
return migration.checksum
}
function migrationVersion(file) {
const name = file.slice(file.lastIndexOf('/') + 1)
const match = /^(\d+)_([a-z0-9][a-z0-9_-]*)\.sql$/.exec(name)
return match ? Number(match[1]) : null
}
function migrationMapAt(ref) {
const migrations = new Map()
const output = git(['ls-tree', '-r', '-z', ref, '--', migrationsDirectory])
for (const record of output.split('\0')) {
if (!record) continue
const tabIndex = record.indexOf('\t')
const metadata = record.slice(0, tabIndex).split(' ')
const file = record.slice(tabIndex + 1)
if (!file.endsWith('.sql')) continue
migrations.set(file, {
blob: metadata[2],
checksum: null,
version: migrationVersion(file),
})
}
return new Map([...migrations].sort(([left], [right]) => left.localeCompare(right)))
}
function validateMigrationSet(migrations, failures) {
const versions = new Map()
for (const [file, migration] of migrations) {
if (migration.version === null) {
failures.push(`INVALID NAME ${file}`)
continue
}
const existing = versions.get(migration.version)
if (existing) {
failures.push(`DUPLICATE VERSION ${migration.version}: ${existing}, ${file}`)
} else {
versions.set(migration.version, file)
}
}
}
function compareCurrentWithCanonical(canonical, currentRef, failures) {
const current = migrationMapAt(currentRef)
validateMigrationSet(current, failures)
for (const [file, expected] of canonical) {
const actual = current.get(file)
if (!actual) {
failures.push(`DELETED ${file}`)
continue
}
if (actual.blob !== expected.blob) {
failures.push(
`MODIFIED ${file}\n Expected SHA-384: ${migrationChecksum(expected)}\n Actual SHA-384: ${migrationChecksum(actual)}`,
)
}
}
const maximumCanonicalVersion = Math.max(
...Array.from(canonical.values(), (migration) => migration.version ?? 0),
)
for (const [file, migration] of current) {
if (
!canonical.has(file) &&
migration.version !== null &&
migration.version <= maximumCanonicalVersion
) {
failures.push(
`OUT-OF-ORDER ${file}: new migration version must be greater than ${maximumCanonicalVersion}`,
)
}
}
}
function parseVersion(tag) {
const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(tag)
if (!match) return null
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null,
}
}
function compareVersions(left, right) {
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) return left[key] - right[key]
}
if (left.prerelease === right.prerelease) return 0
if (left.prerelease === null) return 1
if (right.prerelease === null) return -1
return left.prerelease.localeCompare(right.prerelease, 'en', { numeric: true })
}
function publishedReleases() {
const args = [
'release',
'list',
'--limit',
'1000',
'--json',
'tagName,isDraft,publishedAt',
'--repo',
repository,
]
const releases = JSON.parse(execFileSync('gh', args, { encoding: 'utf8' }))
const bootstrapVersion = parseVersion(canonicalBootstrap.tag)
return releases
.filter((release) => !release.isDraft)
.map((release) => ({ ...release, version: parseVersion(release.tagName) }))
.filter((release) => release.version && compareVersions(release.version, bootstrapVersion) >= 0)
.sort((left, right) => Date.parse(left.publishedAt) - Date.parse(right.publishedAt))
}
function auditPublishedReleases(currentRef) {
const failures = []
const warnings = []
const canonical = migrationMapAt(canonicalBootstrap.ref)
validateMigrationSet(canonical, failures)
for (const release of publishedReleases()) {
if (release.tagName === canonicalBootstrap.tag) continue
const released = migrationMapAt(release.tagName)
for (const file of canonical.keys()) {
if (!released.has(file)) {
failures.push(`PUBLISHED DELETE ${release.tagName}: ${file}`)
}
}
for (const [file, migration] of released) {
const expected = canonical.get(file)
if (!expected) {
canonical.set(file, migration)
continue
}
if (migration.blob === expected.blob) continue
const releasedChecksum = migrationChecksum(migration)
const divergence = [release.tagName, file, releasedChecksum].join('\0')
if (knownPublishedDivergences.has(divergence)) {
warnings.push(
`${release.tagName} contains the known historical migration divergence in ${file}`,
)
continue
}
failures.push(
`UNRECOGNIZED PUBLISHED DIVERGENCE ${release.tagName}: ${file}\n` +
` Canonical SHA-384: ${migrationChecksum(expected)}\n` +
` Released SHA-384: ${releasedChecksum}`,
)
}
}
compareCurrentWithCanonical(canonical, currentRef, failures)
finish(failures, warnings, `published release history from ${canonicalBootstrap.tag}`)
}
function resolveBaseRef(baseRef) {
const isAvailable = () => {
try {
git(['rev-parse', '--verify', `${baseRef}^{commit}`])
return true
} catch {
return false
}
}
if (isAvailable()) return baseRef
for (const remote of ['origin', 'AXL']) {
try {
git(['fetch', remote, baseRef])
if (isAvailable()) return baseRef
} catch {
// Try the next remote.
}
}
try {
const upstream = git([
'rev-parse',
'--abbrev-ref',
'--symbolic-full-name',
'@{upstream}',
]).trim()
const upstreamCommit = git(['rev-parse', `${upstream}^{commit}`]).trim()
const headCommit = git(['rev-parse', 'HEAD^{commit}']).trim()
if (upstream && upstreamCommit !== headCommit) return upstream
} catch {
// Fall through to HEAD^.
}
console.warn(
`Migration guard: base ref ${baseRef} is not available locally; falling back to HEAD^`,
)
return 'HEAD^'
}
function compareWithBase(baseRef, currentRef) {
const failures = []
const canonical = migrationMapAt(baseRef)
validateMigrationSet(canonical, failures)
compareCurrentWithCanonical(canonical, currentRef, failures)
finish(failures, [], baseRef)
}
function finish(failures, warnings, baseline) {
for (const warning of warnings) console.warn(`Migration guard notice: ${warning}`)
if (failures.length > 0) {
console.error(
`Migration guard failed against ${baseline}:\n\n${failures.join('\n\n')}\n\n` +
'Historical migrations are immutable. Add a new forward migration instead.',
)
process.exit(1)
}
console.log(`Migration guard passed against ${baseline}.`)
}
const args = process.argv.slice(2)
const currentIndex = args.indexOf('--current')
const currentRef = currentIndex === -1 ? 'HEAD' : args[currentIndex + 1]
const baseIndex = args.indexOf('--base')
if (args.includes('--release')) {
auditPublishedReleases(currentRef)
} else if (baseIndex !== -1 && args[baseIndex + 1]) {
compareWithBase(resolveBaseRef(args[baseIndex + 1]), currentRef)
} else {
console.error(
'Usage: node check-migrations.mjs (--release | --base <git-ref>) [--current <git-ref>]',
)
process.exit(2)
}

View File

@ -0,0 +1,327 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const [command, tag, outputDirectory] = process.argv.slice(2)
const apiEndpoint = (process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool').replace(/\/$/, '')
const repo = process.env.CNB_REPO_SLUG || 'axlmc/Axolotl'
const token = process.env.CNB_TOKEN
const tokenUser = process.env.CNB_TOKEN_USER_NAME || 'cnb'
const repoUrl = process.env.CNB_REPO_URL_HTTPS || `https://cnb.cool/${repo}.git`
const githubReleaseBaseUrl = (
process.env.GITHUB_RELEASE_BASE_URL || 'https://github.com/Mystic-Stars/Axolotl/releases/download'
).replace(/\/$/, '')
const githubApiBaseUrl = (
process.env.GITHUB_API_BASE_URL || 'https://api.github.com/repos/Mystic-Stars/Axolotl'
).replace(/\/$/, '')
const configuredAssetMirrorConcurrency = Number(process.env.ASSET_MIRROR_CONCURRENCY || 4)
const assetMirrorConcurrency =
Number.isSafeInteger(configuredAssetMirrorConcurrency) && configuredAssetMirrorConcurrency > 0
? configuredAssetMirrorConcurrency
: 4
if (command !== 'finalize' || !tag || !outputDirectory || !token) {
throw new Error('Usage: node cnb-release.mjs finalize <tag> <output-dir>; CNB_TOKEN is required')
}
const apiHeaders = {
Accept: 'application/vnd.cnb.api+json',
Authorization: `Bearer ${token}`,
}
const githubApiHeaders = {
Accept: 'application/vnd.github+json',
'User-Agent': 'Axolotl-CNB-Release',
...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
}
async function apiRequest(url, options = {}, allowedStatuses = []) {
const response = await fetch(url, {
...options,
headers: {
...apiHeaders,
...options.headers,
},
})
if (!response.ok && !allowedStatuses.includes(response.status)) {
throw new Error(
`${options.method || 'GET'} ${url} failed (${response.status}): ${await response.text()}`,
)
}
return response
}
async function getRelease() {
const response = await apiRequest(
`${apiEndpoint}/${repo}/-/releases/tags/${encodeURIComponent(tag)}`,
{},
[404],
)
return response.status === 404 ? null : await response.json()
}
async function ensureRelease(githubRelease) {
const existing = await getRelease()
if (existing) {
return existing
}
const prerelease = tag.includes('-')
const response = await apiRequest(
`${apiEndpoint}/${repo}/-/releases`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: tag,
target_commitish: process.env.CNB_COMMIT || tag,
name:
process.env.CNB_TAG_RELEASE_TITLE ||
githubRelease.name ||
`Axolotl Launcher ${tag}`,
body: process.env.CNB_TAG_RELEASE_DESC || githubRelease.body || '',
draft: true,
prerelease,
make_latest: 'false',
}),
},
[409],
)
if (response.status !== 409) {
return await response.json()
}
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1000))
const release = await getRelease()
if (release) {
return release
}
}
throw new Error(`Release ${tag} was created concurrently but could not be loaded`)
}
async function createAssetUpload(release, assetName, size) {
const uploadResponse = await apiRequest(
`${apiEndpoint}/${repo}/-/releases/${release.id}/asset-upload-url`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_name: assetName, size, overwrite: true, ttl: 0 }),
},
)
return await uploadResponse.json()
}
async function verifyAssetUpload(upload) {
const verifyUrl = new URL(upload.verify_url, apiEndpoint).toString()
await apiRequest(`${verifyUrl}${verifyUrl.includes('?') ? '&' : '?'}ttl=0`, { method: 'POST' })
}
async function uploadAsset(release, filePath) {
const assetName = path.basename(filePath)
const size = fs.statSync(filePath).size
const upload = await createAssetUpload(release, assetName, size)
const fileResponse = await fetch(upload.upload_url, {
method: 'PUT',
body: fs.createReadStream(filePath),
duplex: 'half',
headers: {
'Content-Length': String(size),
'Content-Type': 'application/octet-stream',
},
})
if (!fileResponse.ok) {
throw new Error(
`Uploading ${assetName} failed (${fileResponse.status}): ${await fileResponse.text()}`,
)
}
await verifyAssetUpload(upload)
console.log(`Uploaded ${assetName}`)
}
async function loadGithubManifest() {
const manifestUrl = `${githubReleaseBaseUrl}/${encodeURIComponent(tag)}/latest.json`
const response = await fetch(manifestUrl)
if (!response.ok) {
throw new Error(
`Downloading GitHub manifest failed (${response.status}): ${await response.text()}`,
)
}
const manifest = await response.json()
if (manifest.version !== tag.replace(/^v/, '')) {
throw new Error(`GitHub manifest version ${manifest.version} does not match ${tag}`)
}
return manifest
}
async function loadGithubRelease(url) {
const response = await fetch(url, {
headers: githubApiHeaders,
})
if (!response.ok) {
throw new Error(`Loading GitHub release failed (${response.status}): ${await response.text()}`)
}
return await response.json()
}
async function getGithubRelease() {
return await loadGithubRelease(
`${githubApiBaseUrl}/releases/tags/${encodeURIComponent(tag)}`,
)
}
async function getLatestGithubRelease() {
return await loadGithubRelease(`${githubApiBaseUrl}/releases/latest`)
}
async function mirrorGithubAsset(release, asset) {
const upload = await createAssetUpload(release, asset.name, asset.size)
const response = await fetch(asset.browser_download_url)
if (!response.ok || !response.body) {
throw new Error(
`Downloading ${asset.name} failed (${response.status}): ${await response.text()}`,
)
}
const uploadResponse = await fetch(upload.upload_url, {
method: 'PUT',
body: response.body,
duplex: 'half',
headers: {
'Content-Length': String(asset.size),
'Content-Type': 'application/octet-stream',
},
})
if (!uploadResponse.ok) {
throw new Error(
`Uploading ${asset.name} failed (${uploadResponse.status}): ${await uploadResponse.text()}`,
)
}
await verifyAssetUpload(upload)
console.log(`Mirrored ${asset.name}`)
}
async function mirrorGithubAssets(release, githubRelease) {
const assets = (githubRelease.assets || []).filter((asset) => asset.name !== 'latest.json')
const mirroredNames = new Set(assets.map((asset) => asset.name))
if (mirroredNames.size !== assets.length) {
throw new Error('GitHub release contains duplicate asset names')
}
for (const asset of assets) {
if (
!asset.name ||
!asset.browser_download_url ||
!Number.isSafeInteger(asset.size) ||
asset.size < 0
) {
throw new Error(`Invalid or duplicate GitHub release asset: ${asset.name}`)
}
}
let nextAsset = 0
const workers = Array.from(
{ length: Math.min(assetMirrorConcurrency, assets.length) },
async () => {
while (nextAsset < assets.length) {
const asset = assets[nextAsset++]
await mirrorGithubAsset(release, asset)
}
},
)
await Promise.all(workers)
return mirroredNames
}
function createCnbManifest(githubManifest, mirroredNames) {
const requiredPlatforms = [
'darwin-aarch64',
'darwin-x86_64',
'linux-aarch64',
'linux-x86_64',
'windows-x86_64',
]
const platforms = {}
for (const platform of requiredPlatforms) {
const update = githubManifest.platforms?.[platform]
if (!update || typeof update.signature !== 'string' || update.signature.trim().length < 32) {
throw new Error(`Missing signed GitHub update for ${platform}`)
}
const filename = decodeURIComponent(path.posix.basename(new URL(update.url).pathname))
if (!mirroredNames.has(filename)) {
throw new Error(`GitHub release is missing updater artifact ${filename} for ${platform}`)
}
platforms[platform] = {
...update,
url: `https://cnb.cool/${repo}/-/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`,
}
}
return {
...githubManifest,
version: tag.replace(/^v/, ''),
platforms,
}
}
function publishUpdateBranch(manifestPath) {
if (tag.includes('-')) {
return
}
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'axolotl-cnb-update-'))
const auth = Buffer.from(`${tokenUser}:${token}`).toString('base64')
const git = (...args) => execFileSync('git', args, { cwd: directory, stdio: 'inherit' })
git('init')
git('config', 'user.name', 'Axolotl CNB Release')
git('config', 'user.email', 'build@cnb.cool')
git('checkout', '--orphan', 'update')
fs.copyFileSync(manifestPath, path.join(directory, 'latest.json'))
git('add', 'latest.json')
git('commit', '-m', `Publish ${tag}`)
git('remote', 'add', 'origin', repoUrl)
git(
'-c',
`http.extraHeader=Authorization: Basic ${auth}`,
'push',
'--force',
'origin',
'HEAD:update',
)
}
async function finalizeRelease() {
fs.mkdirSync(outputDirectory, { recursive: true })
const [githubManifest, githubRelease, latestGithubRelease] = await Promise.all([
loadGithubManifest(),
getGithubRelease(),
getLatestGithubRelease(),
])
const isLatestRelease = githubRelease.tag_name === latestGithubRelease.tag_name
const release = await ensureRelease(githubRelease)
const mirroredNames = await mirrorGithubAssets(release, githubRelease)
const manifest = createCnbManifest(githubManifest, mirroredNames)
const manifestPath = path.join(outputDirectory, 'latest.json')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
await uploadAsset(release, manifestPath)
const prerelease = tag.includes('-')
await apiRequest(`${apiEndpoint}/${repo}/-/releases/${release.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: githubRelease.name || `Axolotl Launcher ${tag}`,
body: githubRelease.body || '',
draft: false,
prerelease,
make_latest: isLatestRelease ? 'true' : 'false',
}),
})
if (isLatestRelease) {
publishUpdateBranch(manifestPath)
} else {
console.log(`Skipped update branch for non-latest GitHub release ${tag}`)
}
console.log(`Published CNB release ${tag}`)
}
await finalizeRelease()

View File

@ -0,0 +1,74 @@
import fs from 'node:fs/promises'
import { createRequire } from 'node:module'
const require = createRequire(new URL('../../apps/app-frontend/package.json', import.meta.url))
const ts = require('typescript')
const [tag, outputPath] = process.argv.slice(2)
const version = tag?.replace(/^v/, '')
if (!version || !outputPath) {
throw new Error(
'Usage: node scripts/axolotl/create-release-notes.mjs <version-tag> <output-path>',
)
}
const catalogSource = await fs.readFile('apps/app-frontend/src/announcements/catalog.ts', 'utf8')
const catalogModule = await import(
`data:text/javascript;base64,${Buffer.from(
ts.transpileModule(catalogSource, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
).toString('base64')}`
)
const announcement = catalogModule.getAnnouncementByVersion(version)
if (!announcement) {
throw new Error(`No bundled announcement found for release ${version}`)
}
const categoryLabels = {
added: { en: 'Added', zh: '新增' },
changed: { en: 'Changed', zh: '变更' },
deprecated: { en: 'Deprecated', zh: '弃用' },
removed: { en: 'Removed', zh: '移除' },
fixed: { en: 'Fixed', zh: 'Bug 修复' },
security: { en: 'Security', zh: '安全修复' },
}
function renderLanguage(language) {
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const lines = [`## ${language === 'zh' ? '中文' : 'English'}`, '']
for (const type of catalogModule.ANNOUNCEMENT_CHANGE_TYPES) {
const changes = announcement.changes[type]
if (!changes?.length) continue
lines.push(`### ${categoryLabels[type][language]}`, '')
for (const change of changes) {
lines.push(`- ${change[locale]}`)
}
lines.push('')
}
if (announcement.notes) {
lines.push(`### ${language === 'zh' ? '说明' : 'Notes'}`, '', announcement.notes[locale], '')
}
return lines
}
const lines = [
`# ${announcement.title['zh-CN']}`,
'',
`发布日期 / Published: ${announcement.publishedAt}`,
'',
...renderLanguage('zh'),
...renderLanguage('en'),
]
await fs.writeFile(outputPath, `${lines.join('\n').replace(/\n+$/, '')}\n`)
console.log(`Generated release notes for ${version} from the launcher announcement catalog.`)

View File

@ -0,0 +1,72 @@
import fs from 'node:fs'
import path from 'node:path'
const [releasePath, signaturesPath, tag, outputPath] = process.argv.slice(2)
if (!releasePath || !signaturesPath || !tag || !outputPath) {
throw new Error(
'Usage: node create-update-manifest.mjs <release.json> <signatures-dir> <version-tag> <output.json>',
)
}
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
const assets = release.assets
if (!Array.isArray(assets)) {
throw new Error('Release metadata does not contain an assets array')
}
const targets = [
{
platforms: ['darwin-aarch64', 'darwin-x86_64'],
assetSuffix: '_universal.app.tar.gz',
},
{
platforms: ['linux-aarch64'],
assetSuffix: '_aarch64.AppImage.tar.gz',
},
{
platforms: ['linux-x86_64'],
assetSuffix: '_amd64.AppImage.tar.gz',
},
{
platforms: ['windows-x86_64'],
assetSuffix: '_x64-setup.nsis.zip',
},
]
const platforms = {}
for (const target of targets) {
const matches = assets.filter((asset) => asset.name?.endsWith(target.assetSuffix))
if (matches.length !== 1) {
throw new Error(
`Expected one release asset ending in ${target.assetSuffix}, found ${matches.length}`,
)
}
const asset = matches[0]
const signaturePath = path.join(signaturesPath, `${asset.name}.sig`)
if (!fs.existsSync(signaturePath)) {
throw new Error(`Missing updater signature ${path.basename(signaturePath)}`)
}
const signature = fs.readFileSync(signaturePath, 'utf8')
const url = asset.browser_download_url ?? asset.url
if (!url) {
throw new Error(`Release asset ${asset.name} does not contain a download URL`)
}
for (const platform of target.platforms) {
platforms[platform] = { signature, url }
}
}
const manifest = {
version: tag.replace(/^v/, ''),
notes: release.body ?? '',
pub_date: new Date().toISOString(),
platforms,
}
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)

View File

@ -0,0 +1,113 @@
import fs from 'node:fs'
import path from 'node:path'
const [releasePath, tag, outputPath] = process.argv.slice(2)
if (!releasePath || !tag || !outputPath) {
throw new Error(
'Usage: node create-update-server-catalog.mjs <release.json> <version-tag> <output.json>',
)
}
const version = tag.replace(/^v/, '')
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
if (!Array.isArray(release.assets)) throw new Error('Release metadata does not contain an assets array')
function updaterTargets(filename) {
if (filename.endsWith('_universal.app.tar.gz')) return ['darwin-aarch64', 'darwin-x86_64']
if (filename.endsWith('_aarch64.AppImage.tar.gz')) return ['linux-aarch64']
if (filename.endsWith('_amd64.AppImage.tar.gz')) return ['linux-x86_64']
if (filename.endsWith('_x64-setup.nsis.zip')) return ['windows-x86_64']
return null
}
function describe(filename) {
const targets = updaterTargets(filename)
if (targets) {
return { kind: 'updater', platform: targets[0], targetPlatforms: targets, variant: 'tauri' }
}
if (filename.endsWith('.sig')) return { kind: 'signature', platform: null, targetPlatforms: [] }
if (filename.endsWith('_x64_modern-setup.exe')) {
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'modern' }
}
if (filename.endsWith('_x64_nsis-setup.exe')) {
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'native' }
}
if (filename.endsWith('_x64-setup.exe')) {
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'legacy' }
}
if (filename.endsWith('_x64_portable.zip')) {
return { kind: 'portable', platform: 'windows', targetPlatforms: [], variant: 'portable' }
}
if (filename.endsWith('.dmg')) {
return { kind: 'installer', platform: 'macos', targetPlatforms: [], variant: 'dmg' }
}
if (filename.endsWith('.AppImage')) {
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'appimage' }
}
if (filename.endsWith('.deb')) {
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'deb' }
}
if (filename.endsWith('.rpm')) {
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'rpm' }
}
throw new Error(`Unrecognized release artifact ${filename}`)
}
function architecture(filename) {
if (/universal/i.test(filename)) return 'universal'
if (/(aarch64|arm64)/i.test(filename)) return 'aarch64'
if (/(amd64|x86_64|x64)/i.test(filename)) return 'x86_64'
return null
}
function digest(asset) {
if (typeof asset.digest !== 'string' || !asset.digest.startsWith('sha256:')) {
throw new Error(`Release asset ${asset.name} has no SHA-256 digest`)
}
return asset.digest.slice('sha256:'.length)
}
const assets = release.assets
.filter((asset) => asset.name !== 'latest.json')
.map((asset) => ({
filename: asset.name,
size: asset.size,
sha256: digest(asset),
downloadUrl: asset.browser_download_url ?? asset.url,
architecture: architecture(asset.name),
...describe(asset.name),
}))
const primaryArtifacts = assets.filter((artifact) => artifact.kind !== 'signature')
const artifactKeys = new Set()
for (const artifact of assets.filter((candidate) => candidate.kind === 'signature')) {
const primary = primaryArtifacts.find(
(candidate) => candidate.filename === artifact.filename.slice(0, -'.sig'.length),
)
if (!primary) throw new Error(`Signature has no matching release artifact ${artifact.filename}`)
Object.assign(artifact, {
platform: primary.platform,
architecture: primary.architecture,
targetPlatforms: primary.targetPlatforms,
variant: primary.variant,
})
}
for (const artifact of primaryArtifacts) {
const signature = assets.find(
(candidate) => candidate.kind === 'signature' && candidate.filename === `${artifact.filename}.sig`,
)
artifact.signatureFilename = signature?.filename ?? null
if (artifact.kind === 'updater' && !signature) {
throw new Error(`Missing Tauri updater signature for ${artifact.filename}`)
}
const key = [artifact.kind, artifact.platform, artifact.architecture, artifact.variant].join(':')
if (artifactKeys.has(key)) throw new Error(`Duplicate release artifact classification ${key}`)
artifactKeys.add(key)
}
fs.writeFileSync(
outputPath,
`${JSON.stringify({ version, artifacts: primaryArtifacts, files: assets }, null, 2)}\n`,
)

View File

@ -0,0 +1,83 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'axolotl-update-catalog-'))
const releasePath = path.join(directory, 'release.json')
const output = path.join(directory, 'catalog.json')
const files = [
'Axolotl.Launcher-1.9.5-beta.1-1.aarch64.rpm',
'Axolotl.Launcher-1.9.5-beta.1-1.aarch64.rpm.sig',
'Axolotl.Launcher-1.9.5-beta.1-1.x86_64.rpm',
'Axolotl.Launcher-1.9.5-beta.1-1.x86_64.rpm.sig',
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage',
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.sig',
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.tar.gz',
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.tar.gz.sig',
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage',
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.sig',
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.tar.gz',
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.tar.gz.sig',
'Axolotl.Launcher_1.9.5-beta.1_amd64.deb',
'Axolotl.Launcher_1.9.5-beta.1_amd64.deb.sig',
'Axolotl.Launcher_1.9.5-beta.1_arm64.deb',
'Axolotl.Launcher_1.9.5-beta.1_arm64.deb.sig',
'Axolotl.Launcher_1.9.5-beta.1_universal.dmg',
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.exe',
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.exe.sig',
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.nsis.zip',
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.nsis.zip.sig',
'Axolotl.Launcher_universal.app.tar.gz',
'Axolotl.Launcher_universal.app.tar.gz.sig',
'Axolotl_Launcher_1.9.5-beta.1_x64_modern-setup.exe',
'Axolotl_Launcher_1.9.5-beta.1_x64_modern-setup.exe.sig',
'Axolotl_Launcher_1.9.5-beta.1_x64_nsis-setup.exe',
'Axolotl_Launcher_1.9.5-beta.1_x64_nsis-setup.exe.sig',
'Axolotl_Launcher_1.9.5-beta.1_x64_portable.zip',
]
try {
fs.writeFileSync(
releasePath,
JSON.stringify({
assets: files.map((name, index) => ({
name,
size: index + 1,
digest: `sha256:${crypto.createHash('sha256').update(name).digest('hex')}`,
browser_download_url: `https://github.com/Mystic-Stars/Axolotl/releases/download/v1.9.5-beta.1/${name}`,
})),
}),
)
const result = spawnSync(
process.execPath,
['scripts/axolotl/create-update-server-catalog.mjs', releasePath, 'v1.9.5-beta.1', output],
{ cwd: path.resolve(import.meta.dirname, '..', '..'), encoding: 'utf8' },
)
assert.equal(result.status, 0, result.stderr)
const catalog = JSON.parse(fs.readFileSync(output, 'utf8'))
assert.equal(catalog.version, '1.9.5-beta.1')
assert.equal(catalog.files.length, files.length)
assert.deepEqual(
catalog.artifacts.find((artifact) => artifact.filename.endsWith('_amd64.AppImage.tar.gz'))
.targetPlatforms,
['linux-x86_64'],
)
assert.equal(
catalog.artifacts.find((artifact) => artifact.filename.endsWith('modern-setup.exe')).variant,
'modern',
)
assert.equal(
catalog.artifacts.find((artifact) => artifact.filename.endsWith('portable.zip')).kind,
'portable',
)
assert.deepEqual(
catalog.artifacts.find((artifact) => artifact.filename.endsWith('_universal.app.tar.gz'))
.targetPlatforms,
['darwin-aarch64', 'darwin-x86_64'],
)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}

View File

@ -0,0 +1,38 @@
import fs from 'node:fs/promises'
// 从 app 前端的公告 catalogapps/app-frontend/src/announcements/catalog.ts
// 发布时人工维护的唯一数据源)导出网站 changelog 数据。
// 该文件提交到仓库 main 分支后由 CNB 镜像同步,网站 changelog 页面
// 每次用户访问时从 CNB 拉取,国内用户无需访问 GitHub API。
// catalog.ts 只使用可擦除语法(无 enum/namespace直接由 Node 原生类型剥离导入,
// 无需 typescript 依赖verify-and-publish job 不安装依赖)。
const [outputPath] = process.argv.slice(2)
if (!outputPath) {
throw new Error('Usage: node create-website-release-catalog.mjs <output.json>')
}
const catalogModule = await import(
new URL('../../apps/app-frontend/src/announcements/catalog.ts', import.meta.url)
)
const announcements = catalogModule.launcherAnnouncements
if (!Array.isArray(announcements) || announcements.length === 0) {
throw new Error('Announcement catalog does not contain any announcements')
}
const catalog = {
updated_at: new Date().toISOString(),
announcements: announcements.map(({ id, version, publishedAt, title, changes, notes, externalUrl }) => ({
id,
version,
publishedAt,
title,
changes,
notes,
externalUrl,
})),
}
await fs.writeFile(outputPath, `${JSON.stringify(catalog, null, 2)}\n`)
console.log(`Wrote ${outputPath} with ${catalog.announcements.length} announcements`)

View File

@ -0,0 +1,28 @@
import fs from 'node:fs'
// 从 GitHub release 元数据生成网站下载元数据tag + asset 文件名列表)。
// 该文件提交到仓库 main 分支后由 CNB 镜像同步,网站从 CNB 拉取,
// 使国内用户无需访问 GitHub API 即可获取下载链接。
const [releasePath, outputPath] = process.argv.slice(2)
if (!releasePath || !outputPath) {
throw new Error('Usage: node create-website-release-metadata.mjs <release.json> <output.json>')
}
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) {
throw new Error('Release metadata does not contain tag_name and an assets array')
}
const metadata = {
tag_name: release.tag_name,
assets: release.assets
.map((asset) => asset.name)
.filter((name) => typeof name === 'string' && name.length > 0),
}
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`)
console.log(
`Wrote ${outputPath} for ${metadata.tag_name} (${metadata.assets.length} assets)`,
)

View File

@ -0,0 +1,40 @@
import fs from 'node:fs/promises'
const [file] = process.argv.slice(2)
const input = file
? await fs.readFile(file, 'utf8')
: await new Promise((resolve, reject) => {
let data = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
data += chunk
})
process.stdin.on('end', () => resolve(data))
process.stdin.on('error', reject)
})
const engines = { 0: 'legacy', 1: 'xmcl' }
const rules = {
1: 'R1NoProgress',
2: 'R2BelowExpectation',
3: 'R3SegmentWaste',
4: 'R4FrequentSwitches',
}
const sources = {
0: 'official',
1: 'bmclapi',
2: 'mcim',
3: 'alternate',
4: 'unknown',
5: 'tianpao',
}
for (const line of input.split('\n')) {
if (!line) continue
const [timestamp, engine, rule, source, ...detail] = line.split('|')
const date = new Date(Number(timestamp) * 1000).toISOString()
console.log(
`${date} engine=${engines[engine] ?? engine} rule=${rules[rule] ?? rule} source=${sources[source] ?? source} ${detail.join('|')}`,
)
}

View File

@ -0,0 +1,140 @@
import { readFile } from 'node:fs/promises'
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
const localePairs = [
[
'apps/app-frontend/src/locales/en-US/index.json',
'apps/app-frontend/src/locales/zh-CN/index.json',
],
['packages/ui/src/locales/en-US/index.json', 'packages/ui/src/locales/zh-CN/index.json'],
]
const failures = []
const allowedUntranslatedMessages = new Set([
'Chaos Cubed',
'MINECON Earth 2017',
'Modrinth',
'.minecraft',
'Striding Hero',
'Axolotl Launcher',
'Explore high-quality Minecraft content on Modrinth.',
'example.modrinth.gg',
'{title} - {count}',
'Hooks',
'/path/to/java',
'https://example.com/api/yggdrasil',
'Fabric',
'Forge',
'Paper',
'Cleanroom',
'LiteLoader',
'LogShare.CN',
'NeoForge',
'OptiFine',
'Quilt',
'Mr Pack',
'TNT',
'CurseForge',
'BBCode',
'mclo.gs',
'CMI',
'CSV',
'AI',
'HTML',
'Mojang G1GC',
'PCL G1GC',
'PCL',
'Shenandoah',
'ZGC',
'MineDown',
'MiniMessage',
'TabooLib',
'TrChat',
'TNT',
'Java {version}',
'Studio',
'https://api-free.deepl.com/v2/translate',
'Java',
'Minecraft EULA',
'server.properties',
'{type} · {version}',
'{value} MB',
'> {command}',
'Beta',
'Release',
])
function messageText(value) {
return typeof value === 'string' ? value : (value?.message ?? value?.defaultMessage ?? '')
}
function hasExplicitTranslation(value) {
return typeof value === 'string' || typeof value?.message === 'string'
}
function argumentNames(message) {
const names = new Set()
const argumentTypes = new Set([
TYPE.argument,
TYPE.number,
TYPE.date,
TYPE.time,
TYPE.select,
TYPE.plural,
])
function visit(elements) {
for (const element of elements) {
if (argumentTypes.has(element.type)) names.add(element.value)
if (element.options) {
for (const option of Object.values(element.options)) visit(option.value)
}
if (element.children) visit(element.children)
}
}
visit(parse(message))
return [...names].sort()
}
for (const [sourcePath, translationPath] of localePairs) {
const source = JSON.parse(await readFile(sourcePath, 'utf8'))
const translation = JSON.parse(await readFile(translationPath, 'utf8'))
for (const key of Object.keys(source)) {
if (!(key in translation)) {
failures.push(`${translationPath}: missing ${key}`)
continue
}
try {
const sourceMessage = messageText(source[key])
const translationMessage = messageText(translation[key])
const sourceArguments = argumentNames(sourceMessage)
const translationArguments = argumentNames(translationMessage)
if (sourceArguments.join('\0') !== translationArguments.join('\0')) {
failures.push(
`${translationPath}: ICU arguments for ${key} are [${translationArguments.join(', ')}], expected [${sourceArguments.join(', ')}]`,
)
}
if (
sourceMessage === translationMessage &&
hasExplicitTranslation(translation[key]) &&
/[A-Za-z]{2}/.test(sourceMessage) &&
!allowedUntranslatedMessages.has(sourceMessage)
) {
failures.push(`${translationPath}: untranslated ${key}`)
}
} catch (error) {
failures.push(`${translationPath}: invalid ICU message ${key}: ${error.message}`)
}
}
}
if (failures.length > 0) {
console.error(`Simplified Chinese coverage check failed:\n${failures.join('\n')}`)
process.exit(1)
}
console.log('Simplified Chinese key coverage and ICU argument checks passed.')

View File

@ -0,0 +1,56 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
const [catalogPath, releasePath, tag] = process.argv.slice(2)
const serverUrl = process.env.UPDATE_SERVER_URL?.replace(/\/$/, '')
const webhookSecret = process.env.UPDATE_SERVER_WEBHOOK_SECRET
if (!catalogPath || !releasePath || !tag || !serverUrl || !webhookSecret) {
throw new Error(
'Usage: UPDATE_SERVER_URL=... UPDATE_SERVER_WEBHOOK_SECRET=... node scripts/axolotl/publish-update-server.mjs <catalog.json> <release.json> <version-tag>',
)
}
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'))
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
const version = tag.replace(/^v/, '')
if (catalog.version !== version || release.tag_name !== tag) {
throw new Error('Release catalog metadata does not match the release tag')
}
if (!release.published_at) {
throw new Error(`GitHub release ${tag} must be published before notifying the Update Server`)
}
const payload = JSON.stringify({
event_id: `github-${tag}-${release.id ?? release.node_id ?? version}`,
tag,
version,
channel: version.includes('-') ? 'beta' : 'release',
release: {
id: release.id,
tag_name: release.tag_name,
draft: release.draft,
body: release.body ?? '',
published_at: release.published_at,
assets: release.assets,
},
catalog,
force_update: process.env.UPDATE_SERVER_FORCE_UPDATE === 'true',
})
const timestamp = String(Math.floor(Date.now() / 1000))
const signature = crypto
.createHmac('sha256', webhookSecret)
.update(`${timestamp}.${payload}`)
.digest('hex')
const response = await fetch(`${serverUrl}/api/webhook/release`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': timestamp,
'X-Webhook-Signature': `sha256=${signature}`,
},
body: payload,
})
if (!response.ok) {
throw new Error(`Update Server publish failed: ${response.status} ${await response.text()}`)
}

View File

@ -0,0 +1,32 @@
import fs from 'node:fs'
const tag = process.argv[2]
const version = tag?.replace(/^v/, '')
if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Expected a semantic version tag such as v1.2.3, received: ${tag ?? '<none>'}`)
}
const packagePath = 'apps/app-frontend/package.json'
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
packageJson.version = version
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, '\t')}\n`)
for (const cargoPath of ['apps/app/Cargo.toml', 'packages/app-lib/Cargo.toml']) {
const cargoToml = fs.readFileSync(cargoPath, 'utf8')
const packageVersionPattern = /^(\[package\][\s\S]*?^version\s*=\s*)"([^"]+)"/m
const match = cargoToml.match(packageVersionPattern)
if (!match) {
throw new Error(`Could not find package version in ${cargoPath}`)
}
if (match[2] === version) {
continue
}
const updated = cargoToml.replace(packageVersionPattern, `$1"${version}"`)
fs.writeFileSync(cargoPath, updated)
}
console.log(`Configured Axolotl Launcher ${version}`)

View File

@ -0,0 +1,71 @@
import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { gzipSync } from 'node:zlib'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '../..')
const blockbenchRoot = resolve(repositoryRoot, 'third-party/blockbench')
const destination = resolve(repositoryRoot, 'apps/app/resources/blockbench-skin')
const bundle = resolve(blockbenchRoot, 'dist/skin.bundle.js')
try {
await access(bundle, constants.R_OK)
} catch {
throw new Error(
`Missing ${bundle}. Run npm run build-skin in ${blockbenchRoot} before syncing.`,
)
}
await mkdir(destination, { recursive: true })
const expectedFiles = new Set()
await Promise.all([
syncTree(resolve(blockbenchRoot, 'assets')),
syncTree(resolve(blockbenchRoot, 'css')),
syncTree(resolve(blockbenchRoot, 'font')),
syncFile(resolve(blockbenchRoot, 'index.html')),
])
const bundleContents = await readFile(bundle)
await writeIfChanged(resolve(destination, 'dist/skin.bundle.js.gz'), gzipSync(bundleContents, { level: 9 }))
expectedFiles.add('dist/skin.bundle.js.gz')
await removeStaleFiles(destination)
async function syncTree(sourceDirectory) {
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
const sourcePath = resolve(sourceDirectory, entry.name)
if (entry.isDirectory()) await syncTree(sourcePath)
else if (entry.isFile()) await syncFile(sourcePath)
}
}
async function syncFile(sourcePath) {
const relativePath = relative(blockbenchRoot, sourcePath).replaceAll('\\', '/')
expectedFiles.add(relativePath)
await writeIfChanged(resolve(destination, relativePath), await readFile(sourcePath))
}
async function writeIfChanged(destinationPath, contents) {
try {
const existing = await readFile(destinationPath)
if (existing.equals(contents)) return
} catch {}
await mkdir(dirname(destinationPath), { recursive: true })
await writeFile(destinationPath, contents)
}
async function removeStaleFiles(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) {
await removeStaleFiles(path)
if ((await readdir(path)).length === 0) await rm(path, { recursive: true })
continue
}
if (entry.isFile() && !expectedFiles.has(relative(destination, path).replaceAll('\\', '/'))) {
await rm(path)
}
}
}

View File

@ -0,0 +1,114 @@
import fs from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const REPOSITORY = 'Mystic-Stars/Axolotl'
const PER_PAGE = 100
const MAX_PAGES = 10
const OUTPUT_PATH = fileURLToPath(
new URL('../../apps/app-frontend/src/data/about/contributors.json', import.meta.url),
)
function requestHeaders() {
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'Axolotl-Launcher-Contributors-Sync',
}
const token = process.env.AXOLOTL_GITHUB_TOKEN || process.env.GITHUB_TOKEN
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
async function fetchPage(page) {
const url = new URL(`https://api.github.com/repos/${REPOSITORY}/contributors`)
url.searchParams.set('per_page', String(PER_PAGE))
url.searchParams.set('page', String(page))
let failure
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetch(url, {
headers: requestHeaders(),
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (error) {
failure = error
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 1_000))
}
}
throw new Error(`Unable to fetch contributors from ${url}`, { cause: failure })
}
function normalizeContributor(contributor) {
if (!contributor || typeof contributor !== 'object' || Array.isArray(contributor))
return undefined
if (typeof contributor.login !== 'string' || !contributor.login) return undefined
if (typeof contributor.html_url !== 'string' || !contributor.html_url) return undefined
if (typeof contributor.avatar_url !== 'string' || !contributor.avatar_url) return undefined
if (!Number.isInteger(contributor.contributions) || contributor.contributions < 1)
return undefined
const avatarUrl = new URL(contributor.avatar_url)
avatarUrl.searchParams.set('s', '96')
return {
name: contributor.login,
avatarUrl: avatarUrl.toString(),
url: contributor.html_url,
contributions: contributor.contributions,
}
}
async function fetchContributors() {
const pages = []
for (let page = 1; page <= MAX_PAGES; page++) {
const contributors = await fetchPage(page)
if (!Array.isArray(contributors)) throw new Error(`Page ${page} did not contain an array`)
pages.push(contributors)
if (contributors.length < PER_PAGE) break
}
const contributors = pages
.flat()
.map(normalizeContributor)
.filter((contributor) => contributor !== undefined)
.sort(
(left, right) =>
right.contributions - left.contributions || left.name.localeCompare(right.name),
)
if (contributors.length === 0)
throw new Error('The contributors response did not contain any people')
return contributors
}
async function main() {
let contributors
try {
contributors = await fetchContributors()
} catch (error) {
if (existsSync(OUTPUT_PATH)) {
console.warn(
`Unable to refresh contributors, keeping the existing snapshot: ${error.message}`,
)
return
}
throw error
}
const nextText = `${JSON.stringify(contributors, null, '\t')}\n`
const currentText = existsSync(OUTPUT_PATH) ? await fs.readFile(OUTPUT_PATH, 'utf8') : ''
if (currentText === nextText) {
console.log(`Contributors are up to date (${contributors.length} people).`)
return
}
await fs.writeFile(OUTPUT_PATH, nextText)
console.log(`Synchronized ${contributors.length} contributors from ${REPOSITORY}.`)
}
await main()

View File

@ -0,0 +1,310 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs/promises'
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { stripTypeScriptTypes } from 'node:module'
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import vm from 'node:vm'
const REPOSITORY_ROOT = fileURLToPath(new URL('../../', import.meta.url))
const CATALOG_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/lobehub_text_models.json')
const RUST_API_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/ai.rs')
const SOURCE_PATTERN = /^LobeHub ([0-9a-f]{40}) model-bank chat models$/
const RUST_SOURCE_PATTERN = /const CATALOG_SOURCE: &str = "LobeHub ([0-9a-f]{40})";/g
// Sponsored providers are paid partner listings that must survive every
// synchronization, even if a future rewrite of ai.rs drops upstream providers.
// Keep this list in sync with SPONSORED_PROVIDERS in packages/app-lib/src/api/ai.rs.
const SPONSORED_PROVIDER_IDS = ['codeflow']
function assertSponsoredProvidersPreserved(rustSource) {
for (const providerId of SPONSORED_PROVIDER_IDS) {
const pattern = new RegExp(`provider!\\s*\\(\\s*"${providerId}"`)
if (!pattern.test(rustSource)) {
throw new Error(
`Sponsored provider ${providerId} is missing from ai.rs; refusing to rewrite it`,
)
}
}
}
function usage() {
console.log(`Usage: node --experimental-vm-modules scripts/axolotl/sync-lobehub-models.mjs \\
--upstream <lobehub checkout> [--commit <40-character SHA>] [--check]`)
}
function parseArguments(arguments_) {
const options = { check: false, commit: undefined, upstream: undefined }
for (let index = 0; index < arguments_.length; index++) {
const argument = arguments_[index]
switch (argument) {
case '--check':
options.check = true
break
case '--commit':
case '--upstream': {
const value = arguments_[++index]
if (!value) throw new Error(`${argument} requires a value`)
options[argument.slice(2)] = value
break
}
case '--help':
case '-h':
usage()
process.exit(0)
default:
throw new Error(`Unknown argument: ${argument}`)
}
}
if (!options.upstream) throw new Error('--upstream is required')
return options
}
function requireObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`)
}
return value
}
function countModels(providers) {
return Object.values(providers).reduce((total, models) => total + models.length, 0)
}
function resolveCommit(upstreamRoot, requestedCommit) {
const commit =
requestedCommit ??
execFileSync('git', ['-C', upstreamRoot, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim()
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new Error(`Invalid LobeHub commit SHA: ${commit}`)
}
return commit
}
function createModelBankLoader(upstreamRoot) {
if (typeof vm.SourceTextModule !== 'function') {
throw new Error('Node.js must be run with --experimental-vm-modules')
}
const sourceRoot = realpathSync(resolve(upstreamRoot, 'packages/model-bank/src'))
const context = vm.createContext(Object.create(null), {
codeGeneration: { strings: false, wasm: false },
name: 'lobehub-model-bank',
})
const moduleCache = new Map()
const schema = vm.runInContext(
`(() => {
let schema
schema = new Proxy(function () { return schema }, { get() { return schema } })
return schema
})()`,
context,
)
const zodModule = new vm.SyntheticModule(
['z'],
function () {
this.setExport('z', schema)
},
{ context, identifier: 'sandbox:zod' },
)
const typeFestModule = new vm.SyntheticModule([], function () {}, {
context,
identifier: 'sandbox:type-fest',
})
function resolveLocalImport(specifier, parentPath) {
const basePath = resolve(dirname(parentPath), specifier)
const candidates = extname(basePath)
? [basePath]
: [`${basePath}.ts`, join(basePath, 'index.ts')]
for (const candidate of candidates) {
if (!existsSync(candidate) || !statSync(candidate).isFile()) continue
const canonicalPath = realpathSync(candidate)
const relativePath = relative(sourceRoot, canonicalPath)
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
throw new Error(`Import escapes model-bank/src: ${specifier}`)
}
return canonicalPath
}
throw new Error(`Unable to resolve ${specifier} from ${parentPath}`)
}
function getModule(filePath) {
const cached = moduleCache.get(filePath)
if (cached) return cached
const code = stripTypeScriptTypes(readFileSync(filePath, 'utf8'), {
mode: 'transform',
sourceUrl: pathToFileURL(filePath).href,
})
const module = new vm.SourceTextModule(code, {
context,
identifier: pathToFileURL(filePath).href,
importModuleDynamically() {
throw new Error('Dynamic imports are not allowed in the LobeHub model bank')
},
})
moduleCache.set(filePath, module)
return module
}
function linker(specifier, referencingModule) {
if (specifier === 'zod') return zodModule
if (specifier === 'type-fest') return typeFestModule
if (!specifier.startsWith('.')) {
throw new Error(`External import is not allowed in the model bank: ${specifier}`)
}
return getModule(resolveLocalImport(specifier, fileURLToPath(referencingModule.identifier)))
}
return async function loadModels() {
const entryPath = realpathSync(join(sourceRoot, 'aiModels/index.ts'))
const entry = getModule(entryPath)
await entry.link(linker)
await entry.evaluate({ timeout: 20_000 })
return entry.namespace.LOBE_DEFAULT_MODEL_LIST
}
}
function validateCurrentCatalog(catalog, rustSource) {
requireObject(catalog, 'Current model catalog')
const providers = requireObject(catalog.providers, 'Current model catalog providers')
const sourceMatch = SOURCE_PATTERN.exec(catalog.source)
if (!sourceMatch) throw new Error('Current model catalog has an invalid source')
const rustMatches = [...rustSource.matchAll(RUST_SOURCE_PATTERN)]
if (rustMatches.length !== 1) {
throw new Error('Expected exactly one CATALOG_SOURCE constant in ai.rs')
}
if (rustMatches[0][1] !== sourceMatch[1]) {
throw new Error('The JSON and Rust catalog source commits do not match')
}
for (const [providerId, models] of Object.entries(providers)) {
if (!/^[a-z0-9]+$/.test(providerId) || !Array.isArray(models)) {
throw new Error(`Invalid current catalog provider: ${providerId}`)
}
}
return providers
}
function buildCatalog(models, currentProviders) {
if (!Array.isArray(models)) {
throw new Error('LOBE_DEFAULT_MODEL_LIST must be an array')
}
const providers = Object.fromEntries(
Object.keys(currentProviders).map((providerId) => [providerId, []]),
)
const seen = new Set()
let upstreamChatCount = 0
for (const model of models) {
requireObject(model, 'LobeHub model')
if (model.type !== 'chat') continue
upstreamChatCount++
if (typeof model.providerId !== 'string' || !model.providerId) {
throw new Error('LobeHub chat model has no providerId')
}
if (typeof model.id !== 'string' || !model.id.trim()) {
throw new Error(`LobeHub ${model.providerId} chat model has no id`)
}
if (typeof model.enabled !== 'boolean') {
throw new Error(`LobeHub model ${model.providerId}/${model.id} has invalid enabled state`)
}
if (!Object.hasOwn(providers, model.providerId)) continue
const key = `${model.providerId}\0${model.id}`
if (seen.has(key)) throw new Error(`Duplicate LobeHub model: ${model.providerId}/${model.id}`)
seen.add(key)
const displayName = model.displayName || model.id
if (typeof displayName !== 'string' || !displayName.trim()) {
throw new Error(`LobeHub model ${model.providerId}/${model.id} has no display name`)
}
providers[model.providerId].push({
id: model.id,
name: displayName,
enabled: model.enabled,
})
}
for (const providerId of SPONSORED_PROVIDER_IDS) {
const sponsorModels = currentProviders[providerId]
if (!sponsorModels?.length) continue
providers[providerId] = [...sponsorModels]
console.log(
`Preserved ${sponsorModels.length} sponsored model(s) for ${providerId}`,
)
}
const previousCount = countModels(currentProviders)
const synchronizedCount = countModels(providers)
const minimumCount = Math.max(100, Math.floor(previousCount * 0.7))
const maximumCount = Math.max(500, Math.ceil(previousCount * 2))
if (synchronizedCount < minimumCount || synchronizedCount > maximumCount) {
throw new Error(
`Refusing suspicious model count change: ${previousCount} -> ${synchronizedCount}`,
)
}
for (const [providerId, previousModels] of Object.entries(currentProviders)) {
if (previousModels.length > 0 && providers[providerId].length === 0) {
throw new Error(`Refusing to empty existing provider ${providerId}`)
}
}
return { providers, synchronizedCount, upstreamChatCount }
}
const options = parseArguments(process.argv.slice(2))
const upstreamRoot = realpathSync(resolve(options.upstream))
const commit = resolveCommit(upstreamRoot, options.commit)
const [catalogText, rustSource] = await Promise.all([
fs.readFile(CATALOG_PATH, 'utf8'),
fs.readFile(RUST_API_PATH, 'utf8'),
])
assertSponsoredProvidersPreserved(rustSource)
const currentCatalog = JSON.parse(catalogText)
const currentProviders = validateCurrentCatalog(currentCatalog, rustSource)
const loadModels = createModelBankLoader(upstreamRoot)
const { providers, synchronizedCount, upstreamChatCount } = buildCatalog(
await loadModels(),
currentProviders,
)
const providersChanged = JSON.stringify(providers) !== JSON.stringify(currentProviders)
if (!providersChanged) {
console.log(
`LobeHub ${commit} has no model changes for ${Object.keys(providers).length} supported providers (${synchronizedCount}/${upstreamChatCount} chat models).`,
)
process.exit(0)
}
const nextCatalogText = `${JSON.stringify(
{
source: `LobeHub ${commit} model-bank chat models`,
providers,
},
null,
'\t',
)}\n`
const nextRustSource = rustSource.replace(
RUST_SOURCE_PATTERN,
`const CATALOG_SOURCE: &str = "LobeHub ${commit}";`,
)
assertSponsoredProvidersPreserved(nextRustSource)
if (options.check) {
console.error(
`Bundled model catalog is stale: LobeHub ${commit} has ${synchronizedCount}/${upstreamChatCount} applicable chat models.`,
)
process.exit(1)
}
await Promise.all([
fs.writeFile(CATALOG_PATH, nextCatalogText),
fs.writeFile(RUST_API_PATH, nextRustSource),
])
console.log(
`Synchronized ${synchronizedCount}/${upstreamChatCount} LobeHub chat models from ${commit} across ${Object.keys(providers).length} supported providers.`,
)

View File

@ -0,0 +1,67 @@
import fs from 'node:fs/promises'
const MINECRAFT_ASSETS_VERSION = '26.2'
const ITEMS_ROOT = new URL(
'../../apps/app-frontend/src/lab/recipe-generator/assets/items/',
import.meta.url,
)
const OUTPUT_FILE = new URL(
'../../apps/app-frontend/src/lab/recipe-generator/assets/vanilla/item-name-index.json',
import.meta.url,
)
function languageUrl(locale) {
return `https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@${MINECRAFT_ASSETS_VERSION}/assets/minecraft/lang/${locale}.json`
}
async function downloadJson(url) {
const response = await fetch(url)
if (!response.ok) throw new Error(`Unable to download ${url}: HTTP ${response.status}`)
return await response.json()
}
function itemTranslationKey(id, enUs) {
if (!id.startsWith('minecraft:') || /:\d+$/.test(id)) return undefined
const path = id.slice('minecraft:'.length)
const blockKey = `block.minecraft.${path}`
const itemKey = `item.minecraft.${path}`
if (typeof enUs[blockKey] === 'string') return blockKey
if (typeof enUs[itemKey] === 'string') return itemKey
return undefined
}
const manifestFiles = (await fs.readdir(ITEMS_ROOT)).filter((file) => file.endsWith('.json'))
const readableById = new Map()
for (const file of manifestFiles) {
const manifest = JSON.parse(await fs.readFile(new URL(file, ITEMS_ROOT), 'utf8'))
for (const item of manifest.items ?? []) {
readableById.set(item.id, item.readable)
}
}
const [enUs, zhCn] = await Promise.all([
downloadJson(languageUrl('en_us')),
downloadJson(languageUrl('zh_cn')),
])
const itemNameIndex = { en_us: {}, zh_cn: {} }
for (const [id, readable] of readableById) {
const key = itemTranslationKey(id, enUs)
if (!key) continue
itemNameIndex.en_us[key] = enUs[key] ?? readable
itemNameIndex.zh_cn[key] = zhCn[key] ?? itemNameIndex.en_us[key]
}
for (const locale of ['en_us', 'zh_cn']) {
itemNameIndex[locale] = Object.fromEntries(
Object.entries(itemNameIndex[locale]).sort(([left], [right]) => left.localeCompare(right)),
)
}
await fs.mkdir(new URL('.', OUTPUT_FILE), { recursive: true })
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(itemNameIndex, null, '\t')}\n`)
console.log(
`Updated recipe item names from Minecraft assets ${MINECRAFT_ASSETS_VERSION}: ${Object.keys(itemNameIndex.en_us).length} en_us entries and ${Object.keys(itemNameIndex.zh_cn).length} zh_cn entries.`,
)

View File

@ -0,0 +1,136 @@
import fs from 'node:fs/promises'
const SUMMARY_REF = 'b8170fbc07725bf4930d189ad5dc16f70e09b9cd'
const ATLAS_REF = 'a73f0316d9cea52a53381664328bda00e5fe79e4'
const EXPECTED_VERSION = '26.3-snapshot-6'
const OUTPUT_ROOT = new URL(
'../../apps/app-frontend/src/lab/schematic-preview/assets/vanilla/',
import.meta.url,
)
function summaryUrl(path) {
return `https://raw.githubusercontent.com/misode/mcmeta/${SUMMARY_REF}/${path}`
}
function atlasUrl(path) {
return `https://raw.githubusercontent.com/misode/mcmeta/${ATLAS_REF}/${path}`
}
async function download(url) {
let failure
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return new Uint8Array(await response.arrayBuffer())
} catch (error) {
failure = error
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 500))
}
}
throw new Error(`Unable to download ${url}`, { cause: failure })
}
function parseJson(bytes, source) {
try {
return JSON.parse(new TextDecoder().decode(bytes))
} catch (error) {
throw new Error(`Invalid JSON from ${source}`, { cause: error })
}
}
function requireObject(value, source) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${source} must contain a JSON object`)
}
return value
}
function pngDimensions(bytes) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value)) {
throw new Error('The downloaded texture atlas is not a PNG image')
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return [view.getUint32(16), view.getUint32(20)]
}
const sources = {
definitions: summaryUrl('assets/block_definition/data.min.json'),
models: summaryUrl('assets/model/data.min.json'),
blocks: summaryUrl('blocks/data.min.json'),
summaryVersion: summaryUrl('version.json'),
atlasLayout: atlasUrl('all/data.min.json'),
atlasImage: atlasUrl('all/atlas.png'),
atlasVersion: atlasUrl('version.json'),
}
const entries = await Promise.all(
Object.entries(sources).map(async ([name, url]) => [name, await download(url)]),
)
const downloaded = Object.fromEntries(entries)
const summaryVersion = requireObject(
parseJson(downloaded.summaryVersion, sources.summaryVersion),
'summary version',
)
const atlasVersion = requireObject(
parseJson(downloaded.atlasVersion, sources.atlasVersion),
'atlas version',
)
if (summaryVersion.id !== EXPECTED_VERSION || atlasVersion.id !== EXPECTED_VERSION) {
throw new Error(
`Expected ${EXPECTED_VERSION}, received summary ${summaryVersion.id} and atlas ${atlasVersion.id}`,
)
}
const definitions = requireObject(
parseJson(downloaded.definitions, sources.definitions),
'block definitions',
)
const models = requireObject(parseJson(downloaded.models, sources.models), 'block models')
const blocks = requireObject(parseJson(downloaded.blocks, sources.blocks), 'block summary')
const atlasLayout = requireObject(
parseJson(downloaded.atlasLayout, sources.atlasLayout),
'atlas layout',
)
const defaultProperties = Object.fromEntries(
Object.entries(blocks).map(([blockId, summary]) => {
if (
!Array.isArray(summary) ||
!summary[1] ||
typeof summary[1] !== 'object' ||
Array.isArray(summary[1])
) {
throw new Error(`Block summary ${blockId} has no default property object`)
}
return [blockId, summary[1]]
}),
)
const [atlasWidth, atlasHeight] = pngDimensions(downloaded.atlasImage)
for (const [textureId, region] of Object.entries(atlasLayout)) {
if (
!Array.isArray(region) ||
region.length !== 4 ||
region.some((value) => !Number.isInteger(value) || value < 0) ||
region[0] + region[2] > atlasWidth ||
region[1] + region[3] > atlasHeight
) {
throw new Error(`Atlas region ${textureId} is outside the ${atlasWidth}x${atlasHeight} image`)
}
}
await Promise.all([
fs.writeFile(new URL('block-state-index.json', OUTPUT_ROOT), JSON.stringify(definitions)),
fs.writeFile(new URL('block-model-index.json', OUTPUT_ROOT), JSON.stringify(models)),
fs.writeFile(
new URL('block-property-defaults.json', OUTPUT_ROOT),
JSON.stringify(defaultProperties),
),
fs.writeFile(new URL('texture-layout.json', OUTPUT_ROOT), JSON.stringify(atlasLayout)),
fs.writeFile(new URL('texture-atlas.png', OUTPUT_ROOT), downloaded.atlasImage),
])
console.log(
`Updated schematic resources to ${EXPECTED_VERSION}: ${Object.keys(definitions).length} block definitions, ${Object.keys(models).length} models, and ${Object.keys(atlasLayout).length} atlas regions (${atlasWidth}x${atlasHeight}).`,
)

View File

@ -0,0 +1,45 @@
import fs from 'node:fs'
const [manifestPath, tag, source = 'github'] = process.argv.slice(2)
const expectedVersion = tag?.replace(/^v/, '')
if (!manifestPath || !expectedVersion || !['github', 'cnb'].includes(source)) {
throw new Error('Usage: node verify-update-manifest.mjs <latest.json> <version-tag> [github|cnb]')
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
if (manifest.version !== expectedVersion) {
throw new Error(`Manifest version ${manifest.version} does not match ${expectedVersion}`)
}
const requiredPlatforms = [
'darwin-aarch64',
'darwin-x86_64',
'linux-aarch64',
'linux-x86_64',
'windows-x86_64',
]
for (const platform of requiredPlatforms) {
const update = manifest.platforms?.[platform]
if (!update || typeof update.signature !== 'string' || update.signature.trim().length < 32) {
throw new Error(`Missing signed update for ${platform}`)
}
const url = new URL(update.url)
const pathname = decodeURIComponent(url.pathname).toLowerCase()
const isExpectedUrl =
url.protocol === 'https:' &&
(source === 'github'
? url.hostname === 'github.com' &&
pathname.includes('/mystic-stars/axolotl/releases/download/')
: url.hostname === 'cnb.cool' &&
pathname.includes(`/axlmc/axolotl/-/releases/download/${tag.toLowerCase()}/`))
if (!isExpectedUrl) {
throw new Error(`Unexpected ${source} update URL for ${platform}: ${update.url}`)
}
}
console.log(`Verified signed ${source} updater manifest for ${expectedVersion}`)

View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Optically center the RGBA images stored in a Windows ICO file."""
from __future__ import annotations
import argparse
import math
import struct
import zlib
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def png_chunks(data: bytes):
position = 8
while position < len(data):
length = struct.unpack_from(">I", data, position)[0]
chunk_type = data[position + 4 : position + 8]
chunk_data = data[position + 8 : position + 8 + length]
position += length + 12
yield chunk_type, chunk_data
if chunk_type == b"IEND":
break
def decode_png(data: bytes) -> tuple[int, int, bytearray]:
if not data.startswith(PNG_SIGNATURE):
raise ValueError("ICO entry is not a PNG image")
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(
">IIBBBBB", data[16:29]
)
if (bit_depth, color_type, compression, filter_method, interlace) != (8, 6, 0, 0, 0):
raise ValueError(
"only non-interlaced 8-bit RGBA PNG entries are supported "
f"(got bit_depth={bit_depth}, color_type={color_type})"
)
idat = b"".join(chunk_data for chunk_type, chunk_data in png_chunks(data) if chunk_type == b"IDAT")
decoded = zlib.decompress(idat)
stride = width * 4
rows = bytearray(width * height * 4)
previous = bytearray(stride)
position = 0
for y in range(height):
filter_type = decoded[position]
position += 1
row = bytearray(decoded[position : position + stride])
position += stride
for index in range(stride):
left = row[index - 4] if index >= 4 else 0
up = previous[index]
up_left = previous[index - 4] if index >= 4 else 0
if filter_type == 1:
row[index] = (row[index] + left) & 0xFF
elif filter_type == 2:
row[index] = (row[index] + up) & 0xFF
elif filter_type == 3:
row[index] = (row[index] + ((left + up) // 2)) & 0xFF
elif filter_type == 4:
prediction = left + up - up_left
left_error = abs(prediction - left)
up_error = abs(prediction - up)
up_left_error = abs(prediction - up_left)
if left_error <= up_error and left_error <= up_left_error:
predictor = left
elif up_error <= up_left_error:
predictor = up
else:
predictor = up_left
row[index] = (row[index] + predictor) & 0xFF
elif filter_type != 0:
raise ValueError(f"unsupported PNG filter type {filter_type}")
rows[y * stride : (y + 1) * stride] = row
previous = row
return width, height, rows
def encode_png(width: int, height: int, rgba: bytes) -> bytes:
def chunk(chunk_type: bytes, chunk_data: bytes) -> bytes:
return struct.pack(">I", len(chunk_data)) + chunk_type + chunk_data + struct.pack(
">I", zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF
)
raw = b"".join(b"\x00" + rgba[y * width * 4 : (y + 1) * width * 4] for y in range(height))
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return PNG_SIGNATURE + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
def visual_center(width: int, height: int, rgba: bytes) -> tuple[float, float]:
alpha_sum = 0
weighted_x = 0
weighted_y = 0
for y in range(height):
for x in range(width):
alpha = rgba[(y * width + x) * 4 + 3]
alpha_sum += alpha
weighted_x += x * alpha
weighted_y += y * alpha
if not alpha_sum:
raise ValueError("PNG entry has no visible pixels")
return weighted_x / alpha_sum, weighted_y / alpha_sum
def shift_rgba(width: int, height: int, rgba: bytes, dx: int, dy: int) -> bytes:
shifted = bytearray(len(rgba))
for y in range(height):
for x in range(width):
target_x = x + dx
target_y = y + dy
if 0 <= target_x < width and 0 <= target_y < height:
source = (y * width + x) * 4
target = (target_y * width + target_x) * 4
shifted[target : target + 4] = rgba[source : source + 4]
return bytes(shifted)
def center_entry(data: bytes) -> tuple[bytes, int, int, tuple[float, float], tuple[float, float]]:
width, height, rgba = decode_png(data)
old_center = visual_center(width, height, rgba)
dx = math.floor((width - 1) / 2 - old_center[0] + 0.5)
dy = math.floor((height - 1) / 2 - old_center[1] + 0.5)
shifted = shift_rgba(width, height, rgba, dx, dy)
new_center = visual_center(width, height, shifted)
return encode_png(width, height, shifted), dx, dy, old_center, new_center
def rewrite_ico(input_path: Path, output_path: Path) -> None:
data = input_path.read_bytes()
reserved, image_type, count = struct.unpack_from("<HHH", data, 0)
if (reserved, image_type) != (0, 1):
raise ValueError("input is not an icon ICO file")
entries = []
for index in range(count):
offset = 6 + index * 16
entry = struct.unpack_from("<BBBBHHII", data, offset)
width, height, colors, reserved_byte, planes, bit_count, size, image_offset = entry
image = data[image_offset : image_offset + size]
if image.startswith(PNG_SIGNATURE):
new_image, dx, dy, old_center, new_center = center_entry(image)
label = width or 256
print(
f"{label}x{height or 256}: shift ({dx:+d}, {dy:+d}), "
f"center ({old_center[0]:.2f}, {old_center[1]:.2f}) -> "
f"({new_center[0]:.2f}, {new_center[1]:.2f})"
)
else:
new_image = image
print(f"{width or 256}x{height or 256}: unchanged non-PNG entry")
entries.append((width, height, colors, reserved_byte, planes, bit_count, new_image))
first_image_offset = 6 + 16 * count
output = bytearray(struct.pack("<HHH", reserved, image_type, count))
image_offset = first_image_offset
images = []
for width, height, colors, reserved_byte, planes, bit_count, image in entries:
output.extend(
struct.pack(
"<BBBBHHII",
width,
height,
colors,
reserved_byte,
planes,
bit_count,
len(image),
image_offset,
)
)
images.append(image)
image_offset += len(image)
for image in images:
output.extend(image)
output_path.write_bytes(output)
def main() -> None:
root = Path(__file__).resolve().parents[1]
default_input = root / "apps" / "app" / "icons" / "icon.ico"
default_output = default_input.with_name("icon-centered.ico")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", nargs="?", type=Path, default=default_input)
parser.add_argument("output", nargs="?", type=Path, default=default_output)
args = parser.parse_args()
rewrite_ico(args.input, args.output)
print(f"wrote {args.output}")
if __name__ == "__main__":
main()

609
scripts/coverage-i18n.ts Normal file
View File

@ -0,0 +1,609 @@
import type { TSESTree } from '@typescript-eslint/typescript-estree'
import { AST_NODE_TYPES, parse as parseTs } from '@typescript-eslint/typescript-estree'
import {
NodeTypes,
parse as parseTemplate,
type AttributeNode,
type ElementNode,
type RootNode,
type TemplateChildNode,
type TextNode,
} from '@vue/compiler-dom'
import { parse as parseVue } from '@vue/compiler-sfc'
import chalk from 'chalk'
import * as fs from 'node:fs'
import * as path from 'node:path'
interface FileResult {
path: string
hasI18n: boolean
plainStrings: string[]
i18nUsages: number
}
interface CoverageReport {
totalFiles: number
filesWithI18n: number
filesWithPlainStrings: number
fullyConverted: number
coverage: number
byDirectory: Record<
string,
{
total: number
withI18n: number
fullyConverted: number
coverage: number
}
>
filesNeedingWork: FileResult[]
}
const theme = {
primary: chalk.cyan,
success: chalk.green,
warning: chalk.yellow,
error: chalk.red,
muted: chalk.gray,
highlight: chalk.white.bold,
title: chalk.bold.cyan,
subtitle: chalk.dim,
}
const icons = {
check: chalk.green('✓'),
cross: chalk.red('✗'),
arrow: chalk.cyan('→'),
dot: '●',
warning: chalk.yellow('⚠'),
file: '◦',
folder: '▸',
globe: '◎',
sparkle: chalk.yellow('★'),
}
const TRANSLATABLE_ATTRS = new Set([
'label',
'placeholder',
'title',
'alt',
'aria-label',
'description',
'header',
'text',
'message',
'hint',
'tooltip',
])
// i18n symbols that indicate i18n usage
const I18N_SYMBOLS = [
'useVIntl',
'defineMessage',
'defineMessages',
'IntlFormatted',
'useI18n',
] as const
const I18N_CALL_PATTERNS = ['formatMessage', '$t'] as const
function findVueFiles(dir: string): string[] {
const files: string[] = []
function walk(currentDir: string) {
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (
!entry.name.startsWith('.') &&
entry.name !== 'node_modules' &&
entry.name !== 'legal'
) {
walk(fullPath)
}
} else if (entry.isFile() && entry.name.endsWith('.vue')) {
files.push(fullPath)
}
}
}
walk(dir)
return files
}
function isPlainTextString(text: string): boolean {
const trimmed = text.trim()
if (!trimmed) return false
if (trimmed.length < 2) return false
// Only punctuation/symbols/numbers
if (/^[\s\d\-_./\\:;,!?@#$%^&*()[\]{}|<>+=~`'"]+$/.test(trimmed)) return false
// Single identifier-like word (no spaces)
if (/^[a-z0-9_-]+$/i.test(trimmed) && !trimmed.includes(' ')) return false
// Just a Vue interpolation
if (/^\{\{.*\}\}$/.test(trimmed)) return false
// No letters at all
if (!/[a-zA-Z]/.test(trimmed)) return false
// URLs
if (/^https?:\/\//.test(trimmed)) return false
// File/route paths (but not "/ month" style text)
if (/^\/[a-zA-Z_][\w\-/[\]]*$/.test(trimmed)) return false
// Email addresses
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return false
return true
}
/**
* Walk TypeScript AST and call visitor for each node
*/
function walkTsAst(node: TSESTree.Node, visitor: (node: TSESTree.Node) => void) {
visitor(node)
for (const key of Object.keys(node)) {
const child = (node as unknown as Record<string, unknown>)[key]
if (child && typeof child === 'object') {
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
walkTsAst(item as TSESTree.Node, visitor)
}
}
} else if ('type' in child) {
walkTsAst(child as TSESTree.Node, visitor)
}
}
}
}
/**
* Walk Vue template AST and call visitor for each node
*/
function walkTemplateAst(
node: RootNode | TemplateChildNode,
visitor: (node: RootNode | TemplateChildNode) => void,
) {
visitor(node)
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children as TemplateChildNode[]) {
walkTemplateAst(child, visitor)
}
}
// Handle v-if/v-for branches
if (node.type === NodeTypes.IF) {
for (const branch of node.branches) {
walkTemplateAst(branch, visitor)
}
}
if (node.type === NodeTypes.FOR) {
for (const child of node.children) {
walkTemplateAst(child, visitor)
}
}
}
/**
* Parse TypeScript/JavaScript content into AST
*/
function parseTsContent(content: string, isJsx: boolean = false): TSESTree.Program | null {
try {
return parseTs(content, {
jsx: isJsx,
loc: true,
range: true,
})
} catch {
return null
}
}
/**
* Count i18n calls in a JavaScript expression using AST
*/
function countI18nCallsInExpression(expression: string): number {
// Wrap expression to make it parseable
const wrappedCode = `(${expression})`
const ast = parseTsContent(wrappedCode, false)
if (!ast) return 0
let count = 0
walkTsAst(ast, (node) => {
if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee
if (callee.type === AST_NODE_TYPES.Identifier) {
if (I18N_CALL_PATTERNS.includes(callee.name as (typeof I18N_CALL_PATTERNS)[number])) {
count++
}
}
// Also handle this.formatMessage() or intl.formatMessage()
if (
callee.type === AST_NODE_TYPES.MemberExpression &&
callee.property.type === AST_NODE_TYPES.Identifier
) {
if (
I18N_CALL_PATTERNS.includes(callee.property.name as (typeof I18N_CALL_PATTERNS)[number])
) {
count++
}
}
}
})
return count
}
/**
* Check if script has i18n imports or usage using AST
*/
function checkScriptForI18n(scriptContent: string): { hasI18n: boolean; i18nUsages: number } {
const ast = parseTsContent(scriptContent, true)
if (!ast) {
return { hasI18n: false, i18nUsages: 0 }
}
let hasI18n = false
let i18nUsages = 0
// Check imports
for (const node of ast.body) {
if (node.type === AST_NODE_TYPES.ImportDeclaration) {
const source = node.source.value as string
// Check for @modrinth/ui import
if (source === '@modrinth/ui') {
for (const specifier of node.specifiers) {
if (specifier.type === AST_NODE_TYPES.ImportSpecifier) {
const importedName =
specifier.imported.type === AST_NODE_TYPES.Identifier
? specifier.imported.name
: String(specifier.imported.value)
if (I18N_SYMBOLS.includes(importedName as (typeof I18N_SYMBOLS)[number])) {
hasI18n = true
}
}
}
}
}
}
// Walk AST for call expressions
walkTsAst(ast, (node) => {
if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee
if (callee.type === AST_NODE_TYPES.Identifier) {
const name = callee.name
// Check for i18n function calls
if (I18N_SYMBOLS.includes(name as (typeof I18N_SYMBOLS)[number])) {
hasI18n = true
}
if (I18N_CALL_PATTERNS.includes(name as (typeof I18N_CALL_PATTERNS)[number])) {
hasI18n = true
i18nUsages++
}
}
}
// Check for JSX elements: <IntlFormatted>
if (node.type === AST_NODE_TYPES.JSXOpeningElement) {
const name = node.name
if (name.type === AST_NODE_TYPES.JSXIdentifier && name.name === 'IntlFormatted') {
hasI18n = true
i18nUsages++
}
}
})
return { hasI18n, i18nUsages }
}
/**
* Extract plain text strings from template AST
*/
function extractTemplateStrings(templateContent: string): {
plainStrings: string[]
hasI18nPatterns: boolean
i18nUsages: number
} {
const plainStrings: string[] = []
let hasI18nPatterns = false
let i18nUsages = 0
let ast: RootNode
try {
ast = parseTemplate(templateContent)
} catch {
// If parsing fails, return empty results
return { plainStrings: [], hasI18nPatterns: false, i18nUsages: 0 }
}
walkTemplateAst(ast, (node) => {
// Check for text nodes with plain text content
if (node.type === NodeTypes.TEXT) {
const textNode = node as TextNode
if (isPlainTextString(textNode.content)) {
plainStrings.push(textNode.content.trim())
}
}
// Check element nodes
if (node.type === NodeTypes.ELEMENT) {
const elementNode = node as ElementNode
const tagName = elementNode.tag
// Check for IntlFormatted component
if (tagName === 'IntlFormatted') {
hasI18nPatterns = true
i18nUsages++
}
// Check attributes for translatable content
for (const prop of elementNode.props) {
// Static attributes
if (prop.type === NodeTypes.ATTRIBUTE) {
const attrNode = prop as AttributeNode
if (TRANSLATABLE_ATTRS.has(attrNode.name) && attrNode.value) {
if (isPlainTextString(attrNode.value.content)) {
plainStrings.push(`[${attrNode.name}]: ${attrNode.value.content}`)
}
}
}
// Directive attributes (v-bind, :attr, etc.)
if (prop.type === NodeTypes.DIRECTIVE) {
// Check for formatMessage or $t calls in directive expressions using AST
if (prop.exp && prop.exp.type === NodeTypes.SIMPLE_EXPRESSION) {
const callCount = countI18nCallsInExpression(prop.exp.content)
if (callCount > 0) {
hasI18nPatterns = true
i18nUsages += callCount
}
}
}
}
}
// Check interpolation expressions for i18n calls using AST
if (node.type === NodeTypes.INTERPOLATION) {
if (node.content && node.content.type === NodeTypes.SIMPLE_EXPRESSION) {
const callCount = countI18nCallsInExpression(node.content.content)
if (callCount > 0) {
hasI18nPatterns = true
i18nUsages += callCount
}
}
}
})
return { plainStrings, hasI18nPatterns, i18nUsages }
}
function analyzeVueFile(filePath: string): FileResult {
const content = fs.readFileSync(filePath, 'utf-8')
const { descriptor } = parseVue(content)
const result: FileResult = {
path: filePath,
hasI18n: false,
plainStrings: [],
i18nUsages: 0,
}
// Analyze script content using AST
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || ''
if (scriptContent) {
const scriptAnalysis = checkScriptForI18n(scriptContent)
result.hasI18n = scriptAnalysis.hasI18n
result.i18nUsages = scriptAnalysis.i18nUsages
}
// Analyze template content using AST
if (descriptor.template?.content) {
const templateAnalysis = extractTemplateStrings(descriptor.template.content)
result.plainStrings = templateAnalysis.plainStrings
if (templateAnalysis.hasI18nPatterns) {
result.hasI18n = true
}
result.i18nUsages += templateAnalysis.i18nUsages
}
return result
}
function generateReport(results: FileResult[], rootDir: string): CoverageReport {
const report: CoverageReport = {
totalFiles: results.length,
filesWithI18n: 0,
filesWithPlainStrings: 0,
fullyConverted: 0,
coverage: 0,
byDirectory: {},
filesNeedingWork: [],
}
for (const result of results) {
const relativePath = path.relative(rootDir, result.path)
const dirParts = relativePath.split(path.sep)
const dirKey = dirParts.slice(0, 3).join('/')
if (!report.byDirectory[dirKey]) {
report.byDirectory[dirKey] = { total: 0, withI18n: 0, fullyConverted: 0, coverage: 0 }
}
report.byDirectory[dirKey].total++
if (result.hasI18n) {
report.filesWithI18n++
report.byDirectory[dirKey].withI18n++
}
if (result.plainStrings.length > 0) {
report.filesWithPlainStrings++
report.filesNeedingWork.push(result)
} else if (result.hasI18n || result.i18nUsages > 0) {
report.fullyConverted++
report.byDirectory[dirKey].fullyConverted++
}
}
report.coverage =
report.totalFiles > 0 ? Math.round((report.fullyConverted / report.totalFiles) * 100) : 0
for (const dir of Object.keys(report.byDirectory)) {
const dirStats = report.byDirectory[dir]
dirStats.coverage =
dirStats.total > 0 ? Math.round((dirStats.fullyConverted / dirStats.total) * 100) : 0
}
return report
}
function progressBar(percent: number, width: number = 20): string {
const filled = Math.round((percent / 100) * width)
const empty = width - filled
let color: (s: string) => string
if (percent >= 80) color = chalk.green
else if (percent >= 50) color = chalk.yellow
else if (percent >= 25) color = chalk.hex('#FFA500')
else color = chalk.red
return color('━'.repeat(filled)) + chalk.gray('━'.repeat(empty))
}
function colorPercent(percent: number): string {
if (percent >= 80) return chalk.green.bold(`${percent}%`)
if (percent >= 50) return chalk.yellow.bold(`${percent}%`)
if (percent >= 25) return chalk.hex('#FFA500').bold(`${percent}%`)
return chalk.red.bold(`${percent}%`)
}
function printReport(report: CoverageReport, rootDir: string, verbose: boolean) {
console.log()
console.log(theme.title(` ${icons.globe} i18n Coverage Report`))
console.log(theme.muted(` ${'─'.repeat(45)}`))
console.log()
console.log(chalk.bold(' Summary'))
console.log()
console.log(` ${theme.muted('Total files')} ${theme.highlight(report.totalFiles)}`)
console.log(` ${theme.muted('Using i18n')} ${theme.highlight(report.filesWithI18n)}`)
console.log(
` ${theme.muted('Converted')} ${report.fullyConverted > 0 ? chalk.green.bold(report.fullyConverted) : theme.highlight(report.fullyConverted)}`,
)
console.log(
` ${theme.muted('Need work')} ${report.filesWithPlainStrings > 0 ? chalk.yellow.bold(report.filesWithPlainStrings) : theme.highlight(report.filesWithPlainStrings)}`,
)
console.log()
console.log(` ${theme.muted('Coverage')} ${colorPercent(report.coverage)}`)
console.log(` ${progressBar(report.coverage, 32)}`)
console.log()
console.log(theme.muted(` ${'─'.repeat(45)}`))
console.log(chalk.bold(' By Directory'))
console.log()
const sortedDirs = Object.entries(report.byDirectory).sort(([, a], [, b]) => b.total - a.total)
for (const [dir, stats] of sortedDirs) {
const shortDir = dir.replace('apps/', '').replace('/src', '')
const paddedDir = shortDir.padEnd(20)
console.log(
` ${theme.primary(paddedDir)} ${colorPercent(stats.coverage).padStart(12)} ${progressBar(stats.coverage, 12)} ${theme.muted(`${stats.fullyConverted}/${stats.total}`)}`,
)
}
console.log()
if (verbose && report.filesNeedingWork.length > 0) {
console.log(theme.muted(` ${'─'.repeat(45)}`))
console.log(chalk.bold(' Files Needing Work'))
console.log()
const sorted = [...report.filesNeedingWork].sort(
(a, b) => b.plainStrings.length - a.plainStrings.length,
)
for (const file of sorted.slice(0, 20)) {
const relativePath = path.relative(rootDir, file.path)
const shortPath = relativePath.replace('apps/', '').replace('/src/', '/')
const count = file.plainStrings.length
let countStr: string
if (count >= 50) countStr = chalk.red.bold(`${count}`)
else if (count >= 20) countStr = chalk.yellow.bold(`${count}`)
else countStr = chalk.white(`${count}`)
console.log(` ${icons.arrow} ${chalk.white(shortPath)}`)
console.log(` ${countStr} ${theme.muted('plain strings')}`)
for (const str of file.plainStrings.slice(0, 2)) {
const cleaned = str.replace(/\n/g, ' ').replace(/\t/g, ' ').trim()
const truncated = cleaned.length > 45 ? cleaned.slice(0, 42) + '...' : cleaned
console.log(` ${theme.muted(`"${truncated}"`)}`)
}
if (file.plainStrings.length > 2) {
console.log(` ${theme.subtitle(`+${file.plainStrings.length - 2} more`)}`)
}
console.log()
}
if (sorted.length > 20) {
console.log(` ${theme.subtitle(`... and ${sorted.length - 20} more files`)}`)
console.log()
}
}
console.log(theme.muted(` ${'─'.repeat(45)}`))
if (!verbose) {
console.log(theme.subtitle(` Run with ${chalk.cyan('--verbose')} to see files needing work`))
}
console.log()
}
function main() {
const args = process.argv.slice(2)
const verbose = args.includes('--verbose') || args.includes('-v')
const jsonOutput = args.includes('--json')
const rootDir = path.resolve(__dirname, '..')
// Directories to scan for Vue files
const scanDirs = ['apps/website/src', 'apps/app-frontend/src', 'packages/ui/src']
if (!jsonOutput) {
console.log()
process.stdout.write(theme.muted(' Scanning Vue files... '))
}
const allFiles: string[] = []
for (const dir of scanDirs) {
const fullPath = path.join(rootDir, dir)
if (fs.existsSync(fullPath)) {
allFiles.push(...findVueFiles(fullPath))
}
}
if (!jsonOutput) {
console.log(`${icons.check} ${theme.highlight(allFiles.length)} files`)
}
const results: FileResult[] = []
for (const file of allFiles) {
try {
results.push(analyzeVueFile(file))
} catch {
// Silent fail
}
}
const report = generateReport(results, rootDir)
if (jsonOutput) {
console.log(JSON.stringify(report, null, 2))
} else {
printReport(report, rootDir, verbose)
}
}
main()

View File

@ -0,0 +1,148 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
contractFromMessage,
contractsEqual,
sourceContractChanged,
translationCompatibleWithSource,
} from './i18n-icu-contract'
test('same plain text contract is equal', () => {
assert.equal(
contractsEqual(contractFromMessage('Hello', 'a'), contractFromMessage('Goodbye', 'b')),
true,
)
})
test('variable rename changes contract', () => {
assert.equal(
contractsEqual(
contractFromMessage('Hello {name}', 'a'),
contractFromMessage('Hello {username}', 'b'),
),
false,
)
})
test('variable removal changes contract', () => {
assert.equal(
contractsEqual(contractFromMessage('Created by {user}', 'a'), contractFromMessage('Created', 'b')),
false,
)
})
test('rich text tag rename changes contract', () => {
assert.equal(
contractsEqual(
contractFromMessage('Read <link>docs</link>', 'a'),
contractFromMessage('Read <docs-link>docs</docs-link>', 'b'),
),
false,
)
})
test('literal html-like tags are treated as plain text', () => {
assert.equal(
contractsEqual(
contractFromMessage('Line one<br><br>Line two', 'a'),
contractFromMessage('Zeile eins<br><br>Zeile zwei', 'b'),
),
true,
)
})
test('select branch changes contract', () => {
assert.equal(
contractsEqual(
contractFromMessage('{type, select, mod {mod} other {project}}', 'a'),
contractFromMessage('{type, select, plugin {plugin} other {project}}', 'b'),
),
false,
)
})
test('translation can move phrase inside ICU branches', () => {
const source = contractFromMessage(
'In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} other {days}}',
'source',
)
const translation = contractFromMessage(
"{unit, select, hours {{amount, plural, one {Nell'ultima ora} other {Nelle ultime # ore}}} days {{amount, plural, one {Nell'ultimo giorno} other {Negli ultimi # giorni}}} other {Negli ultimi {amount} giorni}}",
'translation',
)
assert.equal(translationCompatibleWithSource(source, translation), true)
})
test('translation can use locale-specific plural categories', () => {
const source = contractFromMessage('{count, plural, one {# file} other {# files}}', 'source')
const translation = contractFromMessage(
'{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}',
'translation',
)
assert.equal(translationCompatibleWithSource(source, translation), true)
})
test('translation can simplify plural when wording does not vary', () => {
const source = contractFromMessage('{count, plural, one {# server} other {# servers}}', 'source')
const translation = contractFromMessage('{count} Server', 'translation')
assert.equal(translationCompatibleWithSource(source, translation), true)
})
test('translation cannot invent app select values', () => {
const source = contractFromMessage(
'{unit, select, hours {hours} days {days} other {days}}',
'source',
)
const translation = contractFromMessage(
'{unit, select, years {years} other {days}}',
'translation',
)
assert.equal(translationCompatibleWithSource(source, translation), false)
})
test('translation cannot invent variables', () => {
const source = contractFromMessage('Created by {user}', 'source')
const translation = contractFromMessage('Created by {username}', 'translation')
assert.equal(translationCompatibleWithSource(source, translation), false)
})
test('source shape rewrite with same runtime interface is not a contract change', () => {
assert.equal(
sourceContractChanged(
'In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} other {days}}',
'{unit, select, hours {{amount, plural, one {In the last hour} other {In the last # hours}}} days {{amount, plural, one {In the last day} other {In the last # days}}} other {In the last {amount} days}}',
'previous',
'current',
),
false,
)
})
test('invalid previous source message is treated as changed', () => {
assert.equal(
sourceContractChanged(
'Get support at {support-link}',
'Get support at <support-link></support-link>',
'previous',
'current',
),
true,
)
})
test('invalid current source message is rejected', () => {
assert.throws(() =>
sourceContractChanged(
'Get support at <support-link></support-link>',
'Get support at {support-link}',
'previous',
'current',
),
)
})

View File

@ -0,0 +1,539 @@
import { Client as CrowdinClient, type Credentials } from '@crowdin/crowdin-api-client'
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { readFile, readdir, writeFile } from 'node:fs/promises'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parse as parseYaml } from 'yaml'
type MessageEntry = string | { message?: string; defaultMessage?: string }
type MessageFile = Record<string, MessageEntry>
type CrowdinFileEntry = { source: string; dest?: string; translation: string }
type ArgUse = 'argument' | 'number' | 'date' | 'time' | 'plural' | 'select'
type Contract = { args: Record<string, ArgUse[]>; tags: string[]; selectBranches: Record<string, string[]> }
type Issue = { file: string; key: string; reason: string }
type CrowdinListResponse<T> = {
data: Array<{ data: T }>
pagination: { offset: number; limit: number }
}
type CrowdinSourceString = { id: number; identifier: string; fileId: number; branchId: number }
const SOURCE_EXTENSION_PATTERN = /\.(vue|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DEFAULT_LOCALE = 'en-US'
function stripLeadingSlash(path: string) {
return path.replace(/^[/\\]+/, '')
}
function normalizeCrowdinPath(path: string) {
const normalized = path.replaceAll('\\', '/').replace(/^\/?/, '/')
return normalized.replaceAll('//', '/')
}
function textOf(entry: MessageEntry | undefined): string | undefined {
if (typeof entry === 'string') return entry
return entry?.message ?? entry?.defaultMessage
}
function stable<T extends string>(items: Set<T>) {
return [...items].sort()
}
function stableRecord<T extends string>(items: Map<string, Set<T>>) {
return Object.fromEntries(
[...items.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, values]) => [key, stable(values)]),
)
}
export function contractFromMessage(message: string, label: string): Contract {
const args = new Map<string, Set<ArgUse>>()
const tags = new Set<string>()
const selectBranches = new Map<string, Set<string>>()
function addArg(name: string, use: ArgUse) {
const uses = args.get(name) ?? new Set<ArgUse>()
uses.add(use)
args.set(name, uses)
}
function addSelectBranch(name: string, selector: string) {
const branches = selectBranches.get(name) ?? new Set<string>()
branches.add(selector)
selectBranches.set(name, branches)
}
function visit(elements: ReturnType<typeof parse>) {
for (const element of elements) {
switch (element.type) {
case TYPE.argument:
addArg(element.value, 'argument')
break
case TYPE.number:
addArg(element.value, 'number')
break
case TYPE.date:
addArg(element.value, 'date')
break
case TYPE.time:
addArg(element.value, 'time')
break
case TYPE.select: {
addArg(element.value, 'select')
for (const [selector, option] of Object.entries(element.options)) {
addSelectBranch(element.value, selector)
visit(option.value)
}
break
}
case TYPE.plural: {
addArg(element.value, 'plural')
for (const [selector, option] of Object.entries(element.options)) {
visit(option.value)
}
break
}
case TYPE.tag:
tags.add(element.value)
visit(element.children)
break
}
}
}
try {
visit(parse(message, { ignoreTag: false }))
} catch (error) {
try {
visit(parse(message, { ignoreTag: true }))
} catch {
throw new Error(`${label}: invalid ICU: ${(error as Error).message}`)
}
}
return { args: stableRecord(args), tags: stable(tags), selectBranches: stableRecord(selectBranches) }
}
export function contractsEqual(a: Contract, b: Contract) {
return JSON.stringify(a) === JSON.stringify(b)
}
export function translationCompatibleWithSource(source: Contract, translation: Contract) {
const sourceArgs = new Map(
Object.entries(source.args).map(([key, value]) => [key, new Set<ArgUse>(value)]),
)
const sourceTags = new Set(source.tags)
const sourceSelectBranches = new Map(
Object.entries(source.selectBranches).map(([key, value]) => [key, new Set(value)]),
)
for (const tag of translation.tags) {
if (!sourceTags.has(tag)) return false
}
for (const tag of source.tags) {
if (!translation.tags.includes(tag)) return false
}
for (const [arg, uses] of Object.entries(translation.args)) {
const allowedUses = sourceArgs.get(arg)
if (!allowedUses) return false
for (const use of uses) {
if (use === 'argument') continue
if (use === 'number' || use === 'plural') {
if (!allowedUses.has('number') && !allowedUses.has('plural')) return false
continue
}
if (!allowedUses.has(use)) return false
}
}
for (const [arg, branches] of Object.entries(translation.selectBranches)) {
const allowedBranches = sourceSelectBranches.get(arg)
if (!allowedBranches) return false
for (const branch of branches) {
if (branch !== 'other' && !allowedBranches.has(branch)) return false
}
}
return true
}
export function sourceContractChanged(
previousText: string,
currentText: string,
previousLabel: string,
currentLabel: string,
) {
const after = contractFromMessage(currentText, currentLabel)
try {
const before = contractFromMessage(previousText, previousLabel)
return !translationCompatibleWithSource(after, before)
} catch {
return true
}
}
async function readJson(file: string): Promise<MessageFile> {
return JSON.parse(await readFile(file, 'utf8')) as MessageFile
}
async function writeJson(file: string, value: MessageFile) {
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`)
}
async function loadCrowdinEntries(scope?: string) {
const raw = await readFile(resolve(ROOT, 'crowdin.yml'), 'utf8')
const config = parseYaml(raw) as { files: CrowdinFileEntry[] }
return config.files.filter((entry) => {
if (!scope) return true
return stripLeadingSlash(entry.source).startsWith(`${scope.replace(/\/$/, '')}/`)
})
}
async function sourceFilesFor(entry: CrowdinFileEntry) {
const source = stripLeadingSlash(entry.source)
if (!source.endsWith('*.json')) return [resolve(ROOT, source)]
const sourceDir = resolve(ROOT, source.slice(0, -'*.json'.length))
const files = await readdir(sourceDir)
return files.filter((file) => file.endsWith('.json')).map((file) => join(sourceDir, file))
}
async function translationFilesFor(entry: CrowdinFileEntry, sourceFile: string) {
const template = stripLeadingSlash(entry.translation)
const localeIndex = template.indexOf('%locale%')
if (localeIndex === -1) throw new Error(`Translation path lacks %locale%: ${entry.translation}`)
const beforeLocale = template.slice(0, localeIndex)
const afterLocale = template
.slice(localeIndex + '%locale%'.length)
.replace(/^[/\\]+/, '')
.replaceAll('%original_file_name%', basename(sourceFile))
const localeRoot = resolve(ROOT, beforeLocale)
const dirs = await readdir(localeRoot, { withFileTypes: true })
return dirs
.filter((dir) => dir.isDirectory() && dir.name !== DEFAULT_LOCALE)
.map((dir) => join(localeRoot, dir.name, afterLocale))
}
function sourceContracts(sourceFile: string, sourceMessages: MessageFile) {
const contracts = new Map<string, Contract>()
for (const [key, value] of Object.entries(sourceMessages)) {
const text = textOf(value)
if (text === undefined) throw new Error(`${sourceFile}:${key}: missing source message`)
contracts.set(key, contractFromMessage(text, `${sourceFile}:${key}`))
}
return contracts
}
export async function pruneLocalTranslations(options: { check: boolean; scope?: string }) {
const issues: Issue[] = []
const entries = await loadCrowdinEntries(options.scope)
const sourceRoot = resolve(ROOT, options.scope ?? '', 'src')
const sourceReferenceCache = new Map<string, Promise<boolean>>()
async function sourceReferencesKey(key: string) {
let referenced = sourceReferenceCache.get(key)
if (!referenced) {
referenced = (async () => {
const pattern = new RegExp(
`["'\`]${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'\`]`,
)
const files: string[] = []
async function walk(dir: string) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue
await walk(join(dir, entry.name))
} else if (SOURCE_EXTENSION_PATTERN.test(entry.name)) {
files.push(join(dir, entry.name))
}
}
}
if (existsSync(sourceRoot)) await walk(sourceRoot)
for (const file of files) {
if (pattern.test(await readFile(file, 'utf8'))) {
return true
}
}
return false
})()
sourceReferenceCache.set(key, referenced)
}
return referenced
}
for (const entry of entries) {
for (const sourceFile of await sourceFilesFor(entry)) {
const source = await readJson(sourceFile)
const contracts = sourceContracts(sourceFile, source)
for (const translationFile of await translationFilesFor(entry, sourceFile)) {
if (!existsSync(translationFile)) continue
const translations = await readJson(translationFile)
let changed = false
for (const [key, value] of Object.entries(translations)) {
const sourceContract = contracts.get(key)
const translationText = textOf(value)
if (!sourceContract) {
if (await sourceReferencesKey(key)) {
console.log(
`${relative(ROOT, translationFile)}: ${key} - still referenced in source but not extractable; keeping translation`,
)
continue
}
delete translations[key]
changed = true
issues.push({ file: translationFile, key, reason: 'source key no longer exists' })
continue
}
if (translationText === undefined) {
delete translations[key]
changed = true
issues.push({ file: translationFile, key, reason: 'translation has no message text' })
continue
}
try {
const translationContract = contractFromMessage(translationText, `${translationFile}:${key}`)
if (!translationCompatibleWithSource(sourceContract, translationContract)) {
delete translations[key]
changed = true
issues.push({
file: translationFile,
key,
reason: 'translation uses unsupported ICU variables, tags, or select branches',
})
}
} catch {
delete translations[key]
changed = true
issues.push({ file: translationFile, key, reason: 'translation ICU is invalid' })
}
}
if (changed && !options.check) await writeJson(translationFile, translations)
}
}
}
for (const issue of issues) {
console.log(`${relative(ROOT, issue.file)}: ${issue.key} - ${issue.reason}`)
}
if (options.check && issues.length > 0) {
throw new Error(`${issues.length} stale i18n translation(s) need pruning`)
}
}
function gitFile(ref: string, file: string) {
const rel = relative(ROOT, file).replaceAll('\\', '/')
try {
return execFileSync('git', ['show', `${ref}:${rel}`], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
} catch {
return null
}
}
function crowdinDestPath(entry: CrowdinFileEntry, sourceFile: string) {
const dest = entry.dest ?? entry.source
return normalizeCrowdinPath(dest.replaceAll('%original_file_name%', basename(sourceFile)))
}
async function changedSourceIds(baseRef: string, scope?: string) {
const changed = new Map<string, Set<string>>()
for (const entry of await loadCrowdinEntries(scope)) {
for (const sourceFile of await sourceFilesFor(entry)) {
const previousRaw = gitFile(baseRef, sourceFile)
if (!previousRaw) continue
const current = await readJson(sourceFile)
const previous = JSON.parse(previousRaw) as MessageFile
const destPath = crowdinDestPath(entry, sourceFile)
for (const [key, currentEntry] of Object.entries(current)) {
const previousText = textOf(previous[key])
const currentText = textOf(currentEntry)
if (previousText === undefined || currentText === undefined) continue
if (
sourceContractChanged(
previousText,
currentText,
`${baseRef}:${sourceFile}:${key}`,
`${sourceFile}:${key}`,
)
) {
const ids = changed.get(destPath) ?? new Set<string>()
ids.add(key)
changed.set(destPath, ids)
}
}
}
}
return changed
}
async function listAll<T>(
load: (limit: number, offset: number) => Promise<CrowdinListResponse<T>>,
) {
const all: T[] = []
let offset = 0
const limit = 500
for (;;) {
const response = await load(limit, offset)
const page = response.data.map((item) => item.data)
all.push(...page)
const pageLimit = response.pagination.limit || limit
if (page.length < pageLimit) return all
offset += pageLimit
}
}
export async function clearCrowdinChangedTranslations(options: {
baseRef: string
crowdinBranch: string
scope?: string
}) {
const projectId = Number(process.env.CROWDIN_PROJECT_ID)
const token = process.env.CROWDIN_PERSONAL_TOKEN
if (!projectId || !token) throw new Error('CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN are required')
const changed = await changedSourceIds(options.baseRef, options.scope)
if (changed.size === 0) {
console.log('No ICU contract changes found.')
return
}
const credentials: Credentials = { token }
const client = new CrowdinClient(credentials)
const branches = await listAll((limit, offset) =>
client.sourceFilesApi.listProjectBranches(projectId, {
name: options.crowdinBranch,
limit,
offset,
}) as Promise<CrowdinListResponse<{ id: number; name: string }>>,
)
const branch = branches.find((item) => item.name === options.crowdinBranch)
if (!branch) throw new Error(`Crowdin branch not found: ${options.crowdinBranch}`)
const files = await listAll((limit, offset) =>
client.sourceFilesApi.listProjectFiles(projectId, {
branchId: branch.id,
recursion: 1,
limit,
offset,
}) as Promise<CrowdinListResponse<{ id: number; path: string }>>,
)
const branchPathPrefix = normalizeCrowdinPath(options.crowdinBranch)
const fileByPath = new Map<string, { id: number; path: string }>()
for (const file of files) {
const filePath = normalizeCrowdinPath(file.path)
fileByPath.set(filePath, file)
if (filePath.startsWith(`${branchPathPrefix}/`)) {
fileByPath.set(normalizeCrowdinPath(filePath.slice(branchPathPrefix.length)), file)
}
}
let sourceStrings: CrowdinSourceString[] | undefined
for (const [destPath, keys] of changed) {
const file = fileByPath.get(destPath)
if (!file) throw new Error(`Crowdin file not found: ${destPath}`)
sourceStrings ??= await listAll((limit, offset) =>
client.sourceStringsApi.listProjectStrings(projectId, {
limit,
offset,
}) as Promise<CrowdinListResponse<CrowdinSourceString>>,
)
const strings = sourceStrings.filter(
(sourceString) => sourceString.branchId === branch.id && sourceString.fileId === file.id,
)
const stringByIdentifier = new Map(strings.map((sourceString) => [sourceString.identifier, sourceString]))
for (const key of keys) {
const sourceString = stringByIdentifier.get(key)
if (!sourceString) throw new Error(`Crowdin string not found: ${destPath}:${key}`)
await client.stringTranslationsApi.deleteAllTranslations(projectId, sourceString.id)
console.log(`Cleared translations for ${destPath}:${key}`)
}
}
}
function readOptions(args: string[]) {
const options: Record<string, string | boolean> = {}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg.startsWith('--')) continue
const key = arg.slice(2)
const next = args[i + 1]
if (!next || next.startsWith('--')) {
options[key] = true
} else {
options[key] = next
i++
}
}
return options
}
async function main() {
const [command, ...rest] = process.argv.slice(2)
const options = readOptions(rest)
if (command === 'prune-local') {
await pruneLocalTranslations({
check: options.check === true,
scope: typeof options.scope === 'string' ? options.scope : undefined,
})
return
}
if (command === 'clear-crowdin-changed') {
await clearCrowdinChangedTranslations({
baseRef: typeof options['base-ref'] === 'string' ? options['base-ref'] : 'HEAD^',
crowdinBranch:
typeof options['crowdin-branch'] === 'string'
? options['crowdin-branch']
: (() => {
throw new Error('--crowdin-branch is required')
})(),
scope: typeof options.scope === 'string' ? options.scope : undefined,
})
return
}
throw new Error('Usage: pnpm scripts i18n-icu-contract prune-local|clear-crowdin-changed')
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}

View File

@ -0,0 +1,379 @@
import { parse as parseVue } from '@vue/compiler-sfc'
import { parse as parseTs, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'
import type { TSESTree } from '@typescript-eslint/typescript-estree'
import chalk from 'chalk'
import * as fs from 'fs'
import * as path from 'path'
// i18n symbols that should be imported from @modrinth/ui
const I18N_SYMBOLS = ['useVIntl', 'defineMessage', 'defineMessages', 'IntlFormatted'] as const
type I18nSymbol = (typeof I18N_SYMBOLS)[number]
// formatMessage is special - it's destructured from useVIntl(), not directly imported
const FORMAT_MESSAGE = 'formatMessage'
// Valid import sources for i18n symbols
const VALID_IMPORT_SOURCES = ['@modrinth/ui']
// Directories to exclude from scanning
const EXCLUDED_DIRS = new Set(['node_modules', '.output', '.nuxt', 'dist', '.git', '.turbo'])
interface FileIssue {
file: string
symbol: string
line: number
}
interface ImportInfo {
symbol: string
source: string
}
interface Usage {
symbol: string
line: number
}
const theme = {
warning: chalk.yellow,
error: chalk.red,
success: chalk.green,
muted: chalk.gray,
highlight: chalk.white.bold,
file: chalk.cyan,
}
/**
* Recursively find all .vue and .ts files in directories
*/
function findFiles(dirs: string[]): string[] {
const files: string[] = []
function walk(dir: string) {
if (!fs.existsSync(dir)) return
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
// Skip excluded directories and hidden directories
if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
walk(fullPath)
}
} else if (entry.isFile()) {
if (entry.name.endsWith('.vue') || entry.name.endsWith('.ts')) {
// Skip .d.ts files
if (!entry.name.endsWith('.d.ts')) {
files.push(fullPath)
}
}
}
}
}
for (const dir of dirs) {
walk(dir)
}
return files
}
/**
* Parse TypeScript/JavaScript content into AST
*/
function parseTsContent(content: string, isJsx: boolean = false): TSESTree.Program | null {
try {
return parseTs(content, {
jsx: isJsx,
loc: true,
range: true,
})
} catch {
return null
}
}
/**
* Extract script content from Vue SFC
*/
function extractVueScript(content: string): { script: string; isTs: boolean } | null {
try {
const { descriptor } = parseVue(content)
const scriptContent = descriptor.scriptSetup?.content || descriptor.script?.content
if (!scriptContent) return null
const lang = descriptor.scriptSetup?.lang || descriptor.script?.lang
const isTs = lang === 'ts' || lang === 'tsx'
return { script: scriptContent, isTs }
} catch {
return null
}
}
/**
* Walk AST and call visitor for each node
*/
function walkAst(node: TSESTree.Node, visitor: (node: TSESTree.Node) => void) {
visitor(node)
for (const key of Object.keys(node)) {
const child = (node as Record<string, unknown>)[key]
if (child && typeof child === 'object') {
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
walkAst(item as TSESTree.Node, visitor)
}
}
} else if ('type' in child) {
walkAst(child as TSESTree.Node, visitor)
}
}
}
}
/**
* Extract import information from AST
*/
function extractImports(ast: TSESTree.Program): ImportInfo[] {
const imports: ImportInfo[] = []
for (const node of ast.body) {
if (node.type === AST_NODE_TYPES.ImportDeclaration) {
const source = node.source.value as string
for (const specifier of node.specifiers) {
if (specifier.type === AST_NODE_TYPES.ImportSpecifier) {
imports.push({
symbol: specifier.imported.type === AST_NODE_TYPES.Identifier
? specifier.imported.name
: String(specifier.imported.value),
source,
})
} else if (specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier) {
imports.push({
symbol: specifier.local.name,
source,
})
}
}
}
}
return imports
}
/**
* Find usages of i18n symbols in AST
*/
function findUsages(ast: TSESTree.Program): Usage[] {
const usages: Usage[] = []
const localVariables = new Set<string>()
// First pass: collect locally declared variables to avoid false positives
walkAst(ast, (node) => {
if (node.type === AST_NODE_TYPES.VariableDeclarator && node.id.type === AST_NODE_TYPES.Identifier) {
localVariables.add(node.id.name)
}
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id) {
localVariables.add(node.id.name)
}
})
// Second pass: find usages
walkAst(ast, (node) => {
// Check for call expressions: useVIntl(), defineMessage(), defineMessages(), formatMessage()
if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee
if (callee.type === AST_NODE_TYPES.Identifier) {
const name = callee.name
if (I18N_SYMBOLS.includes(name as I18nSymbol) || name === FORMAT_MESSAGE) {
usages.push({
symbol: name,
line: callee.loc.start.line,
})
}
}
}
// Check for JSX elements: <IntlFormatted>
if (node.type === AST_NODE_TYPES.JSXOpeningElement) {
const name = node.name
if (name.type === AST_NODE_TYPES.JSXIdentifier && name.name === 'IntlFormatted') {
usages.push({
symbol: 'IntlFormatted',
line: name.loc.start.line,
})
}
}
// Check for component references in Vue (e.g., components: { IntlFormatted })
if (node.type === AST_NODE_TYPES.Property) {
if (node.key.type === AST_NODE_TYPES.Identifier && node.key.name === 'IntlFormatted') {
// This is defining IntlFormatted as a component, check if it's imported
usages.push({
symbol: 'IntlFormatted',
line: node.key.loc.start.line,
})
}
}
})
return usages
}
/**
* Check if import source is valid for i18n symbols
*/
function isValidImportSource(source: string, filePath: string): boolean {
// Direct import from @modrinth/ui
if (VALID_IMPORT_SOURCES.includes(source)) {
return true
}
// Relative imports within packages/ui are valid
if (filePath.includes('packages/ui/') || filePath.includes('packages\\ui\\')) {
if (source.startsWith('./') || source.startsWith('../')) {
return true
}
}
return false
}
/**
* Analyze a single file for missing i18n imports
*/
function analyzeFile(filePath: string): FileIssue[] {
const issues: FileIssue[] = []
try {
const content = fs.readFileSync(filePath, 'utf-8')
let ast: TSESTree.Program | null = null
if (filePath.endsWith('.vue')) {
const scriptInfo = extractVueScript(content)
if (!scriptInfo) return []
ast = parseTsContent(scriptInfo.script, true)
} else {
ast = parseTsContent(content, filePath.endsWith('.tsx'))
}
if (!ast) return []
const imports = extractImports(ast)
const usages = findUsages(ast)
// Build a map of imported symbols from valid sources
const validImports = new Map<string, string>()
for (const imp of imports) {
if (isValidImportSource(imp.source, filePath)) {
validImports.set(imp.symbol, imp.source)
}
}
// Check if useVIntl is imported (for formatMessage validation)
const hasUseVIntl = validImports.has('useVIntl')
// Check each usage
for (const usage of usages) {
const symbol = usage.symbol
if (symbol === FORMAT_MESSAGE) {
// formatMessage is valid if useVIntl is imported
if (!hasUseVIntl) {
issues.push({
file: filePath,
symbol: `${symbol} (useVIntl not imported)`,
line: usage.line,
})
}
} else if (!validImports.has(symbol)) {
issues.push({
file: filePath,
symbol,
line: usage.line,
})
}
}
} catch {
// Silent fail for unparsable files
}
return issues
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2)
const verbose = args.includes('--verbose') || args.includes('-v')
const rootDir = path.resolve(__dirname, '..')
const dirsToScan = [
path.join(rootDir, 'apps/website/src'),
path.join(rootDir, 'apps/app-frontend/src'),
path.join(rootDir, 'packages'),
]
console.log()
process.stdout.write(theme.muted(' Scanning for i18n import issues... '))
const files = findFiles(dirsToScan)
console.log(theme.success(`found ${files.length} files`))
const allIssues: FileIssue[] = []
for (const file of files) {
const issues = analyzeFile(file)
allIssues.push(...issues)
}
console.log()
if (allIssues.length === 0) {
console.log(theme.success(' No missing i18n imports found!'))
console.log()
process.exit(0)
}
// Group issues by file
const issuesByFile = new Map<string, FileIssue[]>()
for (const issue of allIssues) {
const existing = issuesByFile.get(issue.file) || []
existing.push(issue)
issuesByFile.set(issue.file, existing)
}
// Print issues
for (const [file, issues] of issuesByFile) {
const relativePath = path.relative(rootDir, file)
console.log(theme.warning(` ${relativePath}`))
for (const issue of issues) {
console.log(theme.muted(` Line ${issue.line}: `) + theme.highlight(issue.symbol) + theme.muted(' is used but not imported'))
}
console.log()
}
// Summary
console.log(theme.muted(' ─'.repeat(30)))
console.log(
theme.warning(` Summary: ${issuesByFile.size} file(s) with ${allIssues.length} missing i18n import(s)`)
)
console.log()
if (verbose) {
console.log(theme.muted(' Tip: Import these symbols from @modrinth/ui'))
console.log(theme.muted(' Example: import { useVIntl, defineMessages } from \'@modrinth/ui\''))
console.log()
}
// Exit with 0 (warn only, don't block CI)
process.exit(0)
}
main()

24
scripts/run.mjs Normal file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env node
import { spawn } from 'child_process'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const [scriptName, ...args] = process.argv.slice(2)
if (!scriptName) {
console.error('Usage: pnpm scripts <script-name> [args...]')
console.error('Example: pnpm scripts coverage-i18n --verbose')
process.exit(1)
}
const scriptPath = join(__dirname, `${scriptName}.ts`)
const child = spawn('pnpx', ['tsx', scriptPath, ...args], {
stdio: 'inherit',
shell: true,
})
child.on('exit', (code) => {
process.exit(code ?? 0)
})