Files
glitch_bloodsugar/scripts/glitch-components.mjs
T

289 lines
8.3 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const cwd = process.cwd()
function fail(message, exitCode = 1) {
console.error(message)
process.exit(exitCode)
}
function discoverComponents() {
return fs
.readdirSync(cwd, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('glitch_'))
.map((entry) => entry.name)
.filter((name) => fs.existsSync(path.join(cwd, name, 'package.json')))
.sort()
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function parseArgs(argv) {
const [command = 'list', ...rest] = argv
const options = {
command,
components: [],
hostDir: '',
stagingDir: path.join(cwd, '.glitch-release')
}
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index]
if (arg === '--host') {
options.hostDir = path.resolve(cwd, rest[index + 1] ?? '')
index += 1
continue
}
if (arg === '--staging') {
options.stagingDir = path.resolve(cwd, rest[index + 1] ?? '')
index += 1
continue
}
options.components.push(arg)
}
return options
}
function resolveComponents(requested) {
const discovered = discoverComponents()
if (requested.length === 0) return discovered
const missing = requested.filter((name) => !discovered.includes(name))
if (missing.length > 0) {
fail(`Unknown components: ${missing.join(', ')}`)
}
return requested
}
function loadComponent(componentDirName) {
const componentDir = path.join(cwd, componentDirName)
const packagePath = path.join(componentDir, 'package.json')
const manifestPath = path.join(componentDir, 'glitch.manifest.json')
const packageJson = readJson(packagePath)
const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : null
return {
componentDirName,
componentDir,
packagePath,
manifestPath,
packageJson,
manifest
}
}
function validateComponent(component) {
const requiredFiles = ['package.json', 'src/index.tsx', 'src/Component.tsx', 'src/types.ts', 'src/dev.tsx']
const errors = []
const warnings = []
const hasManifest = Boolean(component.manifest)
const hasStandardSignals = hasManifest
for (const relativePath of requiredFiles) {
if (!fs.existsSync(path.join(component.componentDir, relativePath))) {
if (hasStandardSignals) {
errors.push(`Missing required file: ${relativePath}`)
} else {
warnings.push(`Legacy structure is missing standard file: ${relativePath}`)
}
}
}
const hasViteConfig =
fs.existsSync(path.join(component.componentDir, 'vite.config.ts')) ||
fs.existsSync(path.join(component.componentDir, 'vite.config.mjs')) ||
fs.existsSync(path.join(component.componentDir, 'vite.config.js'))
if (!hasViteConfig) {
if (hasStandardSignals) {
errors.push('Missing required file: vite.config.ts')
} else {
warnings.push('Legacy structure is missing standard file: vite.config.ts')
}
}
if (!component.packageJson.scripts?.dev && hasStandardSignals) {
errors.push('Missing package script: dev')
}
if (!component.packageJson.scripts?.build && hasStandardSignals) {
errors.push('Missing package script: build')
}
const indexPath = path.join(component.componentDir, 'src', 'index.tsx')
if (fs.existsSync(indexPath)) {
const source = fs.readFileSync(indexPath, 'utf8')
if (!source.includes('export default') && hasStandardSignals) {
errors.push('src/index.tsx must export default')
}
if (!source.includes('export const metadata') && hasStandardSignals) {
errors.push('src/index.tsx must export metadata')
}
if (source.includes("import './dev.css'") || source.includes("import './dev-theme.css'")) {
warnings.push('Production entry imports a dev-only stylesheet')
}
}
const styleFiles = ['src/styles.css', 'src/styles.module.css'].filter((relativePath) =>
fs.existsSync(path.join(component.componentDir, relativePath))
)
for (const relativePath of styleFiles) {
const source = fs.readFileSync(path.join(component.componentDir, relativePath), 'utf8')
if (source.includes('@import url(')) {
warnings.push(`${relativePath} imports a remote stylesheet`)
}
}
if (!component.manifest) {
warnings.push('Missing glitch.manifest.json')
} else {
for (const key of ['componentId', 'displayName', 'version', 'folderName', 'packageName', 'entry']) {
if (!component.manifest[key]) {
errors.push(`glitch.manifest.json is missing "${key}"`)
}
}
}
if (!fs.existsSync(path.join(component.componentDir, 'README.md'))) {
warnings.push('Missing README.md')
}
return { errors, warnings }
}
function runBuild(component) {
const result = spawnSync('npm', ['run', 'build'], {
cwd: component.componentDir,
stdio: 'inherit'
})
if (result.status !== 0) {
fail(`Build failed for ${component.componentDirName}`, result.status ?? 1)
}
}
function ensureDirectory(directory) {
fs.mkdirSync(directory, { recursive: true })
}
function copyDirectoryContents(sourceDir, destinationDir) {
ensureDirectory(destinationDir)
for (const entry of fs.readdirSync(sourceDir)) {
fs.cpSync(path.join(sourceDir, entry), path.join(destinationDir, entry), {
force: true,
recursive: true
})
}
}
function releaseComponent(component, hostDir, stagingDir) {
const distDir = path.join(component.componentDir, 'dist')
if (!fs.existsSync(distDir)) {
fail(`Missing dist directory for ${component.componentDirName}. Run build first.`)
}
const manifest =
component.manifest ??
{
componentId: component.componentDirName.replace(/^glitch_/, '').replaceAll('_', '-'),
displayName: component.packageJson.description ?? component.packageJson.name,
version: component.packageJson.version,
folderName: component.componentDirName,
packageName: component.packageJson.name,
entry: `dist/${component.packageJson.main?.replace(/^\.\/dist\//, '') ?? 'index.js'}`,
source: 'src/index.tsx',
tags: ['legacy']
}
const releaseDir = path.join(hostDir, manifest.folderName)
copyDirectoryContents(distDir, releaseDir)
fs.writeFileSync(path.join(releaseDir, 'glitch.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
const stagingComponentDir = path.join(stagingDir, manifest.folderName)
copyDirectoryContents(distDir, stagingComponentDir)
fs.writeFileSync(
path.join(stagingComponentDir, 'glitch.manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
)
}
const options = parseArgs(process.argv.slice(2))
const componentNames = resolveComponents(options.components)
const components = componentNames.map(loadComponent)
switch (options.command) {
case 'list': {
for (const componentName of componentNames) {
console.log(componentName)
}
break
}
case 'validate': {
let errorCount = 0
let warningCount = 0
for (const component of components) {
const { errors, warnings } = validateComponent(component)
if (errors.length === 0 && warnings.length === 0) {
console.log(`OK ${component.componentDirName}`)
continue
}
console.log(component.componentDirName)
for (const error of errors) {
errorCount += 1
console.log(` ERROR ${error}`)
}
for (const warning of warnings) {
warningCount += 1
console.log(` WARN ${warning}`)
}
}
if (errorCount > 0) {
fail(`Validation failed with ${errorCount} error(s) and ${warningCount} warning(s).`)
}
console.log(`Validation passed with ${warningCount} warning(s).`)
break
}
case 'build': {
for (const component of components) {
console.log(`=== Building ${component.componentDirName} ===`)
runBuild(component)
}
break
}
case 'release': {
const hostDir =
options.hostDir ||
path.resolve(cwd, '..', 'gnommoweb', 'src', 'components', 'glitch')
ensureDirectory(hostDir)
ensureDirectory(options.stagingDir)
for (const component of components) {
console.log(`=== Releasing ${component.componentDirName} ===`)
runBuild(component)
releaseComponent(component, hostDir, options.stagingDir)
}
break
}
default:
fail(`Unknown command: ${options.command}`)
}