feat:移除了弹窗,服务器添加sls
This commit is contained in:
75
scripts/axolotl/brand-guard.mjs
Normal file
75
scripts/axolotl/brand-guard.mjs
Normal 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.')
|
||||
276
scripts/axolotl/check-migrations.mjs
Normal file
276
scripts/axolotl/check-migrations.mjs
Normal 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)
|
||||
}
|
||||
327
scripts/axolotl/cnb-release.mjs
Normal file
327
scripts/axolotl/cnb-release.mjs
Normal 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()
|
||||
74
scripts/axolotl/create-release-notes.mjs
Normal file
74
scripts/axolotl/create-release-notes.mjs
Normal 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.`)
|
||||
72
scripts/axolotl/create-update-manifest.mjs
Normal file
72
scripts/axolotl/create-update-manifest.mjs
Normal 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`)
|
||||
113
scripts/axolotl/create-update-server-catalog.mjs
Normal file
113
scripts/axolotl/create-update-server-catalog.mjs
Normal 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`,
|
||||
)
|
||||
83
scripts/axolotl/create-update-server-catalog.test.mjs
Normal file
83
scripts/axolotl/create-update-server-catalog.test.mjs
Normal 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 })
|
||||
}
|
||||
38
scripts/axolotl/create-website-release-catalog.mjs
Normal file
38
scripts/axolotl/create-website-release-catalog.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
// 从 app 前端的公告 catalog(apps/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`)
|
||||
28
scripts/axolotl/create-website-release-metadata.mjs
Normal file
28
scripts/axolotl/create-website-release-metadata.mjs
Normal 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)`,
|
||||
)
|
||||
40
scripts/axolotl/decode-download-log.mjs
Normal file
40
scripts/axolotl/decode-download-log.mjs
Normal 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('|')}`,
|
||||
)
|
||||
}
|
||||
140
scripts/axolotl/i18n-check.mjs
Normal file
140
scripts/axolotl/i18n-check.mjs
Normal 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.')
|
||||
56
scripts/axolotl/publish-update-server.mjs
Normal file
56
scripts/axolotl/publish-update-server.mjs
Normal 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()}`)
|
||||
}
|
||||
32
scripts/axolotl/set-version.mjs
Normal file
32
scripts/axolotl/set-version.mjs
Normal 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}`)
|
||||
71
scripts/axolotl/sync-blockbench-skin-editor.mjs
Normal file
71
scripts/axolotl/sync-blockbench-skin-editor.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
scripts/axolotl/sync-contributors.mjs
Normal file
114
scripts/axolotl/sync-contributors.mjs
Normal 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()
|
||||
310
scripts/axolotl/sync-lobehub-models.mjs
Normal file
310
scripts/axolotl/sync-lobehub-models.mjs
Normal 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.`,
|
||||
)
|
||||
67
scripts/axolotl/sync-recipe-item-names.mjs
Normal file
67
scripts/axolotl/sync-recipe-item-names.mjs
Normal 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.`,
|
||||
)
|
||||
136
scripts/axolotl/sync-schematic-resources.mjs
Normal file
136
scripts/axolotl/sync-schematic-resources.mjs
Normal 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}).`,
|
||||
)
|
||||
45
scripts/axolotl/verify-update-manifest.mjs
Normal file
45
scripts/axolotl/verify-update-manifest.mjs
Normal 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}`)
|
||||
195
scripts/center-windows-icon.py
Normal file
195
scripts/center-windows-icon.py
Normal 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
609
scripts/coverage-i18n.ts
Normal 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()
|
||||
148
scripts/i18n-icu-contract.test.ts
Normal file
148
scripts/i18n-icu-contract.test.ts
Normal 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',
|
||||
),
|
||||
)
|
||||
})
|
||||
539
scripts/i18n-icu-contract.ts
Normal file
539
scripts/i18n-icu-contract.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
379
scripts/i18n-import-check.ts
Normal file
379
scripts/i18n-import-check.ts
Normal 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
24
scripts/run.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user