diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c5b2d3d..ad0c8b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,16 @@ jobs: run: npm ci - name: Lint and format check - run: npm run check + run: npx biome check . + + - name: Validate pages registry + run: npm run validate-pages + + - name: Validate i18n keys + run: npm run validate:i18n + + - name: TypeScript type check + run: npm run typecheck build: runs-on: ubuntu-latest diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx index 853f49ce..3162f271 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx @@ -415,8 +415,8 @@ export default function Browse({ result, filters }: Props) {
{t(AuditLogsKeys.Browse.Showing, { - start: startItem, - end: endItem, + start: String(startItem), + end: String(endItem), total: result.totalCount.toLocaleString(), })} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx index cffd9698..f5ee2057 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx @@ -173,7 +173,7 @@ export default function Detail({ entry, correlated }: Props) { router.get('/audit-logs/browse')}> {t(AuditLogsKeys.Detail.BackToBrowse)} @@ -181,7 +181,7 @@ export default function Detail({ entry, correlated }: Props) { } breadcrumbs={[ { label: t(AuditLogsKeys.Detail.BreadcrumbAuditLogs), href: '/audit-logs/browse' }, - { label: t(AuditLogsKeys.Detail.BreadcrumbEntry, { id: entry.id }) }, + { label: t(AuditLogsKeys.Detail.BreadcrumbEntry, { id: String(entry.id) }) }, ]} > {/* Overview Card */} @@ -382,7 +382,7 @@ export default function Detail({ entry, correlated }: Props) { {t(AuditLogsKeys.Detail.CorrelatedTitle)} - {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: correlated.length })} + {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: String(correlated.length) })} diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx index 8cdeca05..d09287de 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx @@ -82,7 +82,7 @@ export default function Detail({ job }: Props) { {job.state} {job.retryCount > 0 && ( - {t(BackgroundJobsKeys.Detail.RetryCount, { count: job.retryCount })} + {t(BackgroundJobsKeys.Detail.RetryCount, { count: String(job.retryCount) })} )}
@@ -138,7 +138,7 @@ export default function Detail({ job }: Props) { - {t(BackgroundJobsKeys.Detail.LogsCard, { count: job.logs.length })} + {t(BackgroundJobsKeys.Detail.LogsCard, { count: String(job.logs.length) })} diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx index a03b9487..77e30005 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx @@ -41,7 +41,7 @@ export default function List({ jobs }: Props) { return ( {t(ProductsKeys.Manage.DeleteDialog.Title)} - {t(ProductsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name })} + {t(ProductsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name ?? '' })} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx index c8b5d5fa..bcb37494 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx @@ -73,12 +73,12 @@ const API_BASE = '/api/rate-limiting'; function PolicyTypeBadge({ type }: { type: string }) { const variant = - type === 'TokenBucket' ? 'secondary' : type === 'SlidingWindow' ? 'outline' : 'default'; + type === 'TokenBucket' ? 'info' : type === 'SlidingWindow' ? 'warning' : 'default'; return {type}; } function TargetBadge({ target }: { target: string }) { - return {target}; + return {target}; } export default function Admin({ rules, activePolicies }: AdminProps) { diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx b/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx index 42e155de..f34fe1b1 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx +++ b/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx @@ -167,7 +167,9 @@ export default function MenuManager({ menuItems: initial, availablePages }: Menu {t(SettingsKeys.MenuManager.CardTreeTitle)} {totalItems > 0 && ( - {t(SettingsKeys.MenuManager.ItemsCount, { count: totalItems })} + + {t(SettingsKeys.MenuManager.ItemsCount, { count: String(totalItems) })} + )}
diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx b/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx index 684d66d5..69baf0e9 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx @@ -31,7 +31,7 @@ export default function Browse({ tenants }: { tenants: BrowseTenant[] }) { tenant.hostCount !== 1 ? TenantsKeys.Browse.HostCount_other : TenantsKeys.Browse.HostCount_one, - { count: tenant.hostCount }, + { count: String(tenant.hostCount) }, )} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx b/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx index 0a50e93c..9568f418 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx @@ -35,7 +35,7 @@ export default function Manage({ tenants }: { tenants: Tenant[] }) { <> router.get('/tenants/create')}> {t(TenantsKeys.Manage.CreateButton)} @@ -108,7 +108,7 @@ export default function Manage({ tenants }: { tenants: Tenant[] }) { {t(TenantsKeys.Manage.DeleteDialog.Title)} - {t(TenantsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name })} + {t(TenantsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name ?? '' })} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx index 08fecac4..69f7440d 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx @@ -57,7 +57,7 @@ export default function DeletePersonalData({ requirePassword, errors }: Props) { /> )} - diff --git a/package.json b/package.json index 4166cb6a..eb0a4ed5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dev": "node tools/dev-orchestrator.mjs", "lint": "biome lint .", "format": "biome format --write .", - "check": "biome check . && npm run validate-pages && npm run validate:i18n", + "check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run typecheck", + "typecheck": "node tools/typecheck.mjs", "check:fix": "biome check --write . && npm run validate-pages", "validate-pages": "node template/SimpleModule.Host/ClientApp/validate-pages.mjs", "generate:types": "dotnet build template/SimpleModule.Host && node tools/extract-ts-types.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator modules", diff --git a/packages/SimpleModule.UI/components/layouts/app-layout.tsx b/packages/SimpleModule.UI/components/layouts/app-layout.tsx index 7176bd52..cbfb8a8a 100644 --- a/packages/SimpleModule.UI/components/layouts/app-layout.tsx +++ b/packages/SimpleModule.UI/components/layouts/app-layout.tsx @@ -175,7 +175,7 @@ function AdminGroup({ } export function AppLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { auth, menus, csrfToken } = props; const pathname = typeof window !== 'undefined' ? window.location.pathname : '/'; diff --git a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx index dc9a15e4..5def956a 100644 --- a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx +++ b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx @@ -6,7 +6,7 @@ import { PublicLayout } from './public-layout'; import type { SharedProps } from './types'; function AutoLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { auth } = props; if (auth?.isAuthenticated) { diff --git a/packages/SimpleModule.UI/components/layouts/public-layout.tsx b/packages/SimpleModule.UI/components/layouts/public-layout.tsx index 2ae524a6..b2bbd5a5 100644 --- a/packages/SimpleModule.UI/components/layouts/public-layout.tsx +++ b/packages/SimpleModule.UI/components/layouts/public-layout.tsx @@ -287,7 +287,7 @@ function MobileOverlay({ } export function PublicLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { publicMenu = [] } = props; const [mobileOpen, setMobileOpen] = React.useState(false); const closeMobile = React.useCallback(() => setMobileOpen(false), []); diff --git a/tools/typecheck.mjs b/tools/typecheck.mjs new file mode 100644 index 00000000..214766bc --- /dev/null +++ b/tools/typecheck.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/** + * typecheck.mjs + * + * Runs `tsc --noEmit` in every module and package that has a tsconfig.json. + * Each project is checked independently so @/* path aliases resolve correctly. + * All checks run in parallel for speed. + * + * Exit codes: + * 0 = All projects pass type checking + * 1 = Type errors found + */ + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; + +const projectRoot = path.resolve(import.meta.dirname, '..'); +const modulesDir = path.join(projectRoot, 'modules'); +const packagesDir = path.join(projectRoot, 'packages'); + +const require = createRequire(import.meta.url); +const tscBin = require.resolve('typescript/bin/tsc'); + +function findProjects(baseDir, layout) { + const dirs = []; + if (!fs.existsSync(baseDir)) return dirs; + + for (const entry of fs.readdirSync(baseDir)) { + const full = path.join(baseDir, entry); + if (!fs.statSync(full).isDirectory()) continue; + + if (layout === 'flat') { + // packages/Foo — check directly + if (fs.existsSync(path.join(full, 'tsconfig.json'))) { + dirs.push(full); + } + } else { + // modules/Foo/src/Bar — recurse into src/* + const srcDir = path.join(full, 'src'); + if (!fs.existsSync(srcDir)) continue; + for (const sub of fs.readdirSync(srcDir)) { + const subFull = path.join(srcDir, sub); + if ( + fs.statSync(subFull).isDirectory() && + fs.existsSync(path.join(subFull, 'tsconfig.json')) + ) { + dirs.push(subFull); + } + } + } + } + return dirs; +} + +function checkProject(dir) { + return new Promise((resolve) => { + const proc = spawn(process.execPath, [tscBin, '--noEmit'], { + cwd: dir, + stdio: 'pipe', + }); + let output = ''; + proc.stdout.on('data', (d) => (output += d)); + proc.stderr.on('data', (d) => (output += d)); + proc.on('close', (code) => resolve({ dir, code, output })); + }); +} + +const projects = [ + ...findProjects(modulesDir, 'nested'), + ...findProjects(packagesDir, 'flat'), +]; + +const results = await Promise.all(projects.map(checkProject)); + +const failures = []; +for (const { dir, code, output } of results) { + const label = path.relative(projectRoot, dir); + if (code === 0) { + console.log(` \u2713 ${label}`); + } else { + failures.push({ label, output }); + console.log(` \u2717 ${label}`); + } +} + +if (failures.length > 0) { + console.log('\n--- Type errors ---\n'); + for (const { label, output } of failures) { + console.log(`${label}:`); + console.log(output); + } +} + +console.log( + `\nTypecheck: ${projects.length - failures.length}/${projects.length} passed`, +); +process.exit(failures.length > 0 ? 1 : 0);