fix types

This commit is contained in:
Linus Miller 2025-12-13 19:25:23 +01:00
parent 504b47335d
commit 7fdbef7573
59 changed files with 779 additions and 398 deletions

View File

@ -16,8 +16,8 @@ knex.destroy()
async function readdir(dir: string) { async function readdir(dir: string) {
const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => { const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => {
const [, aNum] = a.match(rFileName) const [, aNum] = a.match(rFileName) as string[]
const [, bNum] = b.match(rFileName) const [, bNum] = b.match(rFileName) as string[]
if (parseInt(aNum) > parseInt(bNum)) { if (parseInt(aNum) > parseInt(bNum)) {
return 1 return 1

View File

@ -1,16 +1,15 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
// import s from './accounts_page.module.scss'
const AccountsPage = () => { import type { Account } from '../../../shared/types.ts'
const [accounts, setAccounts] = useState([])
const AccountsPage: FunctionComponent = () => {
const [accounts, setAccounts] = useState<Account[]>([])
useEffect(() => { useEffect(() => {
rek('/api/accounts').then((accounts) => { rek('/api/accounts').then(setAccounts)
setAccounts(accounts)
})
}, []) }, [])
return ( return (

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect } from 'preact/hooks' import { useCallback, useEffect } from 'preact/hooks'
import { LocationProvider, Route, Router } from 'preact-iso' import { LocationProvider, Route, Router } from 'preact-iso'
import { get } from 'lowline' import { get } from 'lowline'
@ -11,6 +11,11 @@ import throttle from '../../shared/utils/throttle.ts'
import s from './app.module.scss' import s from './app.module.scss'
type Props = {
error?: Error
title?: string
}
const remember = throttle(function remember() { const remember = throttle(function remember() {
window.history.replaceState( window.history.replaceState(
{ {
@ -22,7 +27,7 @@ const remember = throttle(function remember() {
) )
}, 100) }, 100)
export default function App({ error, title }) { const App: FunctionComponent<Props> = ({ error, title }) => {
useEffect(() => { useEffect(() => {
addEventListener('scroll', remember) addEventListener('scroll', remember)
@ -52,7 +57,7 @@ export default function App({ error, title }) {
) : ( ) : (
<Router onRouteChange={onRouteChange}> <Router onRouteChange={onRouteChange}>
{routes.map((route) => ( {routes.map((route) => (
<Route key={route.path} route={route} path={route.path} component={route.component} /> <Route key={route.path} path={route.path} component={route.component} />
))} ))}
</Router> </Router>
)} )}
@ -63,3 +68,5 @@ export default function App({ error, title }) {
</LocationProvider> </LocationProvider>
) )
} }
export default App

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
import rek from 'rek' import rek from 'rek'
@ -6,13 +6,15 @@ import Head from './head.ts'
import { formatNumber } from '../utils/format_number.ts' import { formatNumber } from '../utils/format_number.ts'
import s from './balances_page.module.scss' import s from './balances_page.module.scss'
const BalancesPage = () => { import type { Balance, FinancialYear } from '../../../shared/types.ts'
const [balances, setBalances] = useState([])
const BalancesPage: FunctionComponent = () => {
const [balances, setBalances] = useState<Balance[]>([])
const [years, setYears] = useState<number[]>([]) const [years, setYears] = useState<number[]>([])
useEffect(() => { useEffect(() => {
rek(`/api/balances`).then(setBalances) rek(`/api/balances`).then(setBalances)
rek(`/api/financial-years`).then((years) => setYears(years.map((fy) => fy.year))) rek(`/api/financial-years`).then((financialYears: FinancialYear[]) => setYears(financialYears.map((fy) => fy.year)))
}, []) }, [])
return ( return (

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks' import { useCallback, useEffect, useState } from 'preact/hooks'
import { useLocation } from 'preact-iso' import { useLocation } from 'preact-iso'
import { isEmpty } from 'lowline' import { isEmpty } from 'lowline'
@ -8,12 +8,14 @@ import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
import serializeForm from '../../shared/utils/serialize_form.ts' import serializeForm from '../../shared/utils/serialize_form.ts'
import type { Entry, FinancialYear, Journal } from '../../../shared/types.ts'
const dateYear = new Date().getFullYear() const dateYear = new Date().getFullYear()
const EntriesPage = () => { const EntriesPage: FunctionComponent = () => {
const [journals, setJournals] = useState([]) const [journals, setJournals] = useState<Journal[]>([])
const [financialYears, setFinancialYears] = useState([]) const [financialYears, setFinancialYears] = useState<FinancialYear[]>([])
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState<Entry[]>([])
const location = useLocation() const location = useLocation()
@ -22,7 +24,7 @@ const EntriesPage = () => {
const onSubmit = useCallback((e: SubmitEvent) => { const onSubmit = useCallback((e: SubmitEvent) => {
e.preventDefault() e.preventDefault()
const values = serializeForm(e.target as HTMLFormElement) const values = serializeForm(e.target as HTMLFormElement) as Record<string, string>
const search = !isEmpty(values) ? '?' + qs.stringify(values) : '' const search = !isEmpty(values) ? '?' + qs.stringify(values) : ''
@ -30,16 +32,12 @@ const EntriesPage = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
rek('/api/journals').then((journals) => { rek('/api/journals').then(setJournals)
setJournals(journals) rek('/api/financial-years').then(setFinancialYears)
})
rek('/api/financial-years').then((financialYears) => {
setFinancialYears(financialYears)
})
}, []) }, [])
useEffect(() => { useEffect(() => {
rek(`/api/entries?journal=${selectedJournal}&year=${selectedYear}`).then((entries) => setEntries(entries)) rek(`/api/entries?journal=${selectedJournal}&year=${selectedYear}`).then(setEntries)
}, [selectedJournal, selectedYear]) }, [selectedJournal, selectedYear])
return financialYears.length && journals.length ? ( return financialYears.length && journals.length ? (

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import { useRoute } from 'preact-iso' import { useRoute } from 'preact-iso'
import { formatNumber } from '../utils/format_number.ts' import { formatNumber } from '../utils/format_number.ts'
@ -8,14 +8,12 @@ import { type Entry } from '../../../shared/types.ts'
import Head from './head.ts' import Head from './head.ts'
const EntriesPage = () => { const EntryPage: FunctionComponent = () => {
const [entry, setEntry] = useState<Entry>(null) const [entry, setEntry] = useState<Entry | null>(null)
const route = useRoute() const route = useRoute()
useEffect(() => { useEffect(() => {
rek(`/api/entries/${route.params.id}`).then((entry) => { rek(`/api/entries/${route.params.id}`).then(setEntry)
setEntry(entry)
})
}, []) }, [])
if (!entry) return if (!entry) return
@ -84,4 +82,4 @@ const EntriesPage = () => {
) )
} }
export default EntriesPage export default EntryPage

View File

@ -1,7 +1,7 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import Head from './head.ts' import Head from './head.ts'
const OtherPage = ({ error }) => ( const OtherPage: FunctionComponent<{ error: Error }> = ({ error }) => (
<section> <section>
<Head> <Head>
<title> : Error</title> <title> : Error</title>

View File

@ -1,5 +1,5 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
const Footer = () => <footer /> const Footer: FunctionComponent = () => <footer />
export default Footer export default Footer

View File

@ -1,9 +1,14 @@
import { toChildArray, Component } from 'preact' import { toChildArray, Component, type VNode } from 'preact'
import { mapKeys } from 'lowline' import { mapKeys } from 'lowline'
type Tag = {
type: string
attributes: Record<string, string>
}
const CLASSNAME = '__preact_generated__' const CLASSNAME = '__preact_generated__'
const DOMAttributeNames = { const DOMAttributeNames: Record<string, string> = {
acceptCharset: 'accept-charset', acceptCharset: 'accept-charset',
className: 'class', className: 'class',
htmlFor: 'for', htmlFor: 'for',
@ -12,11 +17,11 @@ const DOMAttributeNames = {
const isBrowser = typeof window !== 'undefined' const isBrowser = typeof window !== 'undefined'
let mounted = [] let mounted: Head[] = []
function reducer(components) { function reducer(components: Head[]) {
return components return components
.map((c) => toChildArray(c.props.children)) .map((c) => toChildArray(c.props.children) as VNode<any>[])
.reduce((result, c) => result.concat(c), []) .reduce((result, c) => result.concat(c), [])
.reverse() .reverse()
.filter(unique()) .filter(unique())
@ -27,18 +32,18 @@ function reducer(components) {
result.title += toChildArray(c.props.children).join('') result.title += toChildArray(c.props.children).join('')
} else { } else {
result.tags.push({ result.tags.push({
type: c.type, type: c.type as string,
attributes: mapKeys(c.props, (_value, key) => DOMAttributeNames[key] || key), attributes: mapKeys(c.props, (_value, key) => DOMAttributeNames[key] || key),
}) })
} }
return result return result
}, },
{ title: '', tags: [] }, { title: '', tags: [] } as { title: string; tags: Tag[] },
) )
} }
function updateClient({ title, tags }) { function updateClient({ title, tags }: { title: string; tags: Tag[] }) {
const head = document.head const head = document.head
const prevElements = Array.from(head.getElementsByClassName(CLASSNAME)) const prevElements = Array.from(head.getElementsByClassName(CLASSNAME))
@ -60,7 +65,7 @@ function updateClient({ title, tags }) {
document.title = title document.title = title
} }
function createDOMElement(tag) { function createDOMElement(tag: Tag) {
const el = document.createElement(tag.type) const el = document.createElement(tag.type)
const attributes = tag.attributes || {} const attributes = tag.attributes || {}
@ -80,11 +85,11 @@ const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
// returns a function for filtering head child elements // returns a function for filtering head child elements
// which shouldn't be duplicated, like <title/>. // which shouldn't be duplicated, like <title/>.
function unique() { function unique() {
const tags = [] const tags: string[] = []
const metaTypes = [] const metaTypes: string[] = []
const metaCategories = {} const metaCategories: Record<string, string[]> = {}
return (h) => { return (h: VNode<Record<string, string>>) => {
switch (h.type) { switch (h.type) {
case 'base': case 'base':
if (~tags.indexOf(h.type)) { if (~tags.indexOf(h.type)) {
@ -109,8 +114,8 @@ function unique() {
metaTypes.push(metatype) metaTypes.push(metatype)
} else { } else {
const category = h.props[metatype] const category = h.props[metatype] as string
const categories = metaCategories[metatype] || [] const categories: string[] = metaCategories[metatype] || []
if (~categories.indexOf(category)) { if (~categories.indexOf(category)) {
return false return false

View File

@ -1,7 +1,9 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import s from './header.module.scss' import s from './header.module.scss'
const Header = ({ routes }) => ( import type { Route } from '../../../shared/types.ts'
const Header: FunctionComponent<{ routes: Route[] }> = ({ routes }) => (
<header> <header>
<h1>BRF Tegeltrasten</h1> <h1>BRF Tegeltrasten</h1>
<nav className={s.nav}> <nav className={s.nav}>

View File

@ -2,12 +2,14 @@ import { h, type FunctionalComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import rek from 'rek' import rek from 'rek'
import type { Transaction } from '../../../shared/types.ts'
interface Props { interface Props {
year: number year: number
} }
const Invoices: FunctionalComponent<Props> = ({ year }) => { const Invoices: FunctionalComponent<Props> = ({ year }) => {
const [invoices, setInvoices] = useState([]) const [invoices, setInvoices] = useState<Transaction[]>([])
useEffect(() => { useEffect(() => {
rek(`/api/invoices/by-year/${year}`).then(setInvoices) rek(`/api/invoices/by-year/${year}`).then(setInvoices)

View File

@ -1,16 +1,18 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import { useRoute } from 'preact-iso' import { useRoute } from 'preact-iso'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
import { formatNumber } from '../utils/format_number.ts' import { formatNumber } from '../utils/format_number.ts'
const InvoicePage = () => { import type { Invoice } from '../../../shared/types.ts'
const [invoice, setInvoice] = useState(null)
const InvoicePage: FunctionComponent = () => {
const [invoice, setInvoice] = useState<Invoice | null>(null)
const route = useRoute() const route = useRoute()
useEffect(() => { useEffect(() => {
rek(`/api/invoices/${route.params.id}`).then((invoice) => setInvoice(invoice)) rek(`/api/invoices/${route.params.id}`).then(setInvoice)
}, []) }, [])
return ( return (

View File

@ -1,25 +1,25 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
// @ts-ignore
import Format from 'easy-tz/format' import Format from 'easy-tz/format'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import { useRoute } from 'preact-iso' import { useRoute } from 'preact-iso'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
import type { Invoice, Supplier } from '../../../shared/types.ts'
const format = Format.bind(null, null, 'YYYY.MM.DD') const format = Format.bind(null, null, 'YYYY.MM.DD')
const InvoicesPage = () => { const InvoicesPage: FunctionComponent = () => {
const [supplier, setSupplier] = useState(null) const [supplier, setSupplier] = useState<Supplier | null>(null)
const [invoices, setInvoices] = useState([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [totalAmount, setTotalAmount] = useState<number>(null) const [totalAmount, setTotalAmount] = useState<number | null>(null)
const route = useRoute() const route = useRoute()
useEffect(() => { useEffect(() => {
rek(`/api/suppliers/${route.params.supplier}`).then((supplier) => setSupplier(supplier)) rek(`/api/suppliers/${route.params.supplier}`).then(setSupplier)
rek(`/api/invoices?supplier=${route.params.supplier}`).then((invoices) => setInvoices(invoices)) rek(`/api/invoices?supplier=${route.params.supplier}`).then(setInvoices)
rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then((totalAmount) => rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then(setTotalAmount)
setTotalAmount(totalAmount.amount),
)
}, [route.params.supplier]) }, [route.params.supplier])
return ( return (

View File

@ -1,13 +1,15 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
const InvoicesPage = () => { import type { Supplier } from '../../../shared/types.ts'
const [suppliers, setSuppliers] = useState([])
const InvoicesPage: FunctionComponent = () => {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
useEffect(() => { useEffect(() => {
rek('/api/suppliers').then((suppliers) => setSuppliers(suppliers)) rek('/api/suppliers').then(setSuppliers)
}, []) }, [])
return ( return (

View File

@ -1,5 +1,7 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
export default function NotFoundPage() { const NotFoundPage: FunctionComponent = () => {
return <h1>Not Found</h1> return <h1>Not Found</h1>
} }
export default NotFoundPage

View File

@ -2,12 +2,14 @@ import { h, type FunctionalComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import rek from 'rek' import rek from 'rek'
import type { TransactionFull } from '../../../shared/types.ts'
interface Props { interface Props {
objectId: number objectId: number
} }
const Result: FunctionalComponent<Props> = ({ objectId }) => { const Result: FunctionalComponent<Props> = ({ objectId }) => {
const [transactions, setTransactions] = useState([]) const [transactions, setTransactions] = useState<TransactionFull[]>([])
useEffect(() => { useEffect(() => {
rek(`/api/objects/${objectId}`).then(setTransactions) rek(`/api/objects/${objectId}`).then(setTransactions)

View File

@ -1,16 +1,17 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import rek from 'rek' import rek from 'rek'
import Head from './head.ts' import Head from './head.ts'
import Object from './object.tsx' import Object from './object.tsx'
import s from './results_page.module.scss' import s from './results_page.module.scss'
import type { Object as ObjectType } from '../../../shared/types.ts'
const ObjectsPage = () => { const ObjectsPage: FunctionComponent = () => {
const [objects, setObjects] = useState([]) const [objects, setObjects] = useState<ObjectType[]>([])
const [currentObject, setCurrentObject] = useState(null) const [currentObject, setCurrentObject] = useState<ObjectType | null>(null)
useEffect(() => { useEffect(() => {
rek('/api/objects').then(setObjects) rek('/api/objects').then((objects: ObjectType[]) => setObjects(objects))
}, []) }, [])
return ( return (

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
import rek from 'rek' import rek from 'rek'
@ -6,13 +6,15 @@ import Head from './head.ts'
import { formatNumber } from '../utils/format_number.ts' import { formatNumber } from '../utils/format_number.ts'
import s from './results_page.module.scss' import s from './results_page.module.scss'
const ResultsPage = () => { import type { FinancialYear, Result } from '../../../shared/types.ts'
const [results, setResults] = useState([])
const ResultsPage: FunctionComponent = () => {
const [results, setResults] = useState<Result[]>([])
const [years, setYears] = useState<number[]>([]) const [years, setYears] = useState<number[]>([])
useEffect(() => { useEffect(() => {
rek(`/api/results`).then(setResults) rek(`/api/results`).then((results: Result[]) => setResults(results))
rek(`/api/financial-years`).then((years) => setYears(years.map((fy) => fy.year))) rek(`/api/financial-years`).then((financialYears: FinancialYear[]) => setYears(financialYears.map((fy) => fy.year)))
}, []) }, [])
return ( return (

View File

@ -1,6 +1,6 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
const StartPage = () => ( const StartPage: FunctionComponent = () => (
<section> <section>
<h1>Fart Page</h1> <h1>Fart Page</h1>

View File

@ -1,4 +1,4 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks' import { useCallback, useEffect, useState } from 'preact/hooks'
import { useLocation } from 'preact-iso' import { useLocation } from 'preact-iso'
import { isEmpty } from 'lowline' import { isEmpty } from 'lowline'
@ -8,16 +8,18 @@ import Head from './head.ts'
import serializeForm from '../../shared/utils/serialize_form.ts' import serializeForm from '../../shared/utils/serialize_form.ts'
import { formatNumber } from '../utils/format_number.ts' import { formatNumber } from '../utils/format_number.ts'
const TransactionsPage = () => { import type { TransactionFull, FinancialYear } from '../../../shared/types.ts'
const [financialYears, setFinancialYears] = useState([])
const [transactions, setTransactions] = useState([]) const TransactionsPage: FunctionComponent = () => {
const [financialYears, setFinancialYears] = useState<FinancialYear[]>([])
const [transactions, setTransactions] = useState<TransactionFull[]>([])
const location = useLocation() const location = useLocation()
const onSubmit = useCallback((e: SubmitEvent) => { const onSubmit = useCallback((e: SubmitEvent) => {
e.preventDefault() e.preventDefault()
const values = serializeForm(e.target as HTMLFormElement) const values = serializeForm(e.target as HTMLFormElement) as Record<string, string>
const search = !isEmpty(values) ? '?' + qs.stringify(values) : '' const search = !isEmpty(values) ? '?' + qs.stringify(values) : ''
@ -25,7 +27,7 @@ const TransactionsPage = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
rek('/api/financial-years').then((financialYears) => { rek('/api/financial-years').then((financialYears: FinancialYear[]) => {
setFinancialYears(financialYears) setFinancialYears(financialYears)
}) })
}, []) }, [])
@ -33,7 +35,7 @@ const TransactionsPage = () => {
useEffect(() => { useEffect(() => {
const search = location.url.split('?')[1] || '' const search = location.url.split('?')[1] || ''
rek(`/api/transactions${search ? '?' + search : ''}`).then((transactions) => { rek(`/api/transactions${search ? '?' + search : ''}`).then((transactions: TransactionFull[]) => {
setTransactions(transactions) setTransactions(transactions)
}) })
}, [location.url]) }, [location.url])

View File

@ -5,7 +5,7 @@ const priceFormatter = new Intl.NumberFormat('sv-SE', {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}) })
export const formatPrice = (price) => priceFormatter.format(price) export const formatPrice = (price: number) => priceFormatter.format(price)
const numberFormatter = new Intl.NumberFormat('sv-SE', { const numberFormatter = new Intl.NumberFormat('sv-SE', {
style: 'decimal', style: 'decimal',
@ -13,4 +13,4 @@ const numberFormatter = new Intl.NumberFormat('sv-SE', {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}) })
export const formatNumber = (nbr) => numberFormatter.format(nbr) export const formatNumber = (nbr: number) => numberFormatter.format(nbr)

View File

@ -1,24 +1,47 @@
import { h, type FunctionComponent, type PointerEventHandler } from 'preact' import { h, type FunctionComponent, type PointerEventHandler } from 'preact'
import cn from 'classnames' import cn from 'classnames'
export default function buttonFactory({ defaults, icons, styles }) { type Styles<C extends string, D extends string, S extends string> = {
const Button: FunctionComponent<{ base: string
autoHeight?: string
fullWidth?: string
icon?: string
invert?: string
} & Record<C, string> &
Record<D, string> &
Record<S, string>
type Props<C extends string, D extends string, I extends string, S extends string> = {
autoHeight?: boolean autoHeight?: boolean
className?: string className?: string
color?: string color?: C
design?: string design?: D
disabled?: boolean disabled?: boolean
fullWidth?: boolean fullWidth?: boolean
icon?: string icon?: I
iconSize?: string iconSize?: string
invert?: boolean invert?: boolean
onClick?: PointerEventHandler<HTMLButtonElement> onClick?: PointerEventHandler<HTMLButtonElement>
size?: string size?: S
tabIndex?: number tabIndex?: number
tag?: string tag?: string
title?: string title?: string
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'
}> = ({ }
type Options<C extends string, D extends string, I extends string, S extends string> = {
defaults: Partial<Props<C, D, I, S>>
icons: Record<I, string>
styles: Styles<C, D, S>
}
export default function buttonFactory<
C extends string = never,
D extends string = never,
I extends string = never,
S extends string = never,
>({ defaults, icons, styles }: Options<C, D, I, S>) {
const Button: FunctionComponent<Props<C, D, I, S>> = ({
autoHeight = defaults?.autoHeight, autoHeight = defaults?.autoHeight,
children, children,
className, className,

View File

@ -1,26 +1,30 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { type ChangeEvent } from 'preact/compat'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
/** type Styles<S extends string> = {
* @param {{ base?: string
* base?: string element?: string
* touched?: string label?: string
* element?: string touched?: string
* label?: string } & Record<S, string>
* }} styles
* @returns {import('preact').FunctionComponent<{ type Props<S extends string> = {
* autoFocus?: boolean autoFocus?: boolean
* className?: string className?: string
* defaultChecked?: boolean defaultChecked?: any
* disabled?: boolean disabled?: boolean
* name: string label?: string
* label?: string name?: string
* required?: boolean onChange?: (e: ChangeEvent) => void
* }>} required?: boolean
*/ style: S
export default function checkboxFactory(styles) { value?: string
const Checkbox = ({ }
export default function checkboxFactory<S extends string = never>(styles: Styles<S>) {
const Checkbox: FunctionComponent<Props<S>> = ({
autoFocus, autoFocus,
className, className,
defaultChecked, defaultChecked,
@ -29,16 +33,16 @@ export default function checkboxFactory(styles) {
label, label,
onChange, onChange,
required, required,
style = 'normal', style,
value = 'true', value = 'true',
}) => { }) => {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const checkboxRef = useRef<HTMLInputElement>() const checkboxRef = useRef<HTMLInputElement>(null)
const onBlur = useCallback(() => setTouched(true), []) const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => { useEffect(() => {
const form = checkboxRef.current.form const form = checkboxRef.current!.form!
const resetTouched = setTouched.bind(null, false) const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched) form.addEventListener('reset', resetTouched)
@ -47,7 +51,7 @@ export default function checkboxFactory(styles) {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (autoFocus) checkboxRef.current.focus() if (autoFocus) checkboxRef.current!.focus()
}, [autoFocus]) }, [autoFocus])
return ( return (

View File

@ -1,11 +1,36 @@
import { h } from 'preact' import { h, type FunctionComponent } from 'preact'
import { useCallback, useRef, useState } from 'preact/hooks' import { useCallback, useRef, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
// TODO when form resets after update the previous image flashes quickly after setImageSrc(null) and before new defaultValue propagates // TODO when form resets after update the previous image flashes quickly after setImageSrc(null) and before new defaultValue propagates
const fileUploadFactory = ({ styles }) => { type Styles = {
const FileUpload = ({ base?: string
element?: string
label?: string
noMargin?: string
touched?: string
image?: string
}
type Options = {
styles: Styles
}
type Props = {
autoFocus?: boolean
className?: string
defaultValue?: any
disabled?: boolean
label?: string
name?: string
noMargin?: boolean
placeholder?: string
required?: boolean
}
export default function fileUploadFactory({ styles }: Options) {
const FileUpload: FunctionComponent<Props> = ({
autoFocus, autoFocus,
className, className,
defaultValue, defaultValue,
@ -17,14 +42,14 @@ const fileUploadFactory = ({ styles }) => {
required, required,
}) => { }) => {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const inputRef = useRef(null) const inputRef = useRef<HTMLInputElement>(null)
const imgRef = useRef(null) const imgRef = useRef<HTMLImageElement>(null)
const [imageSrc, setImageSrc] = useState(null) const [imageSrc, setImageSrc] = useState<string | null>(null)
const onImageChange = useCallback((e) => { const onImageChange = useCallback((e: Event) => {
e.preventDefault() e.preventDefault()
const form = inputRef.current.form const form = inputRef.current!.form!
const onReset = () => { const onReset = () => {
setTouched(false) setTouched(false)
@ -33,8 +58,8 @@ const fileUploadFactory = ({ styles }) => {
form.addEventListener('reset', onReset) form.addEventListener('reset', onReset)
imgRef.current.addEventListener('load', () => URL.revokeObjectURL(imgRef.current.src), { once: true }) imgRef.current!.addEventListener('load', () => URL.revokeObjectURL(imgRef.current!.src), { once: true })
setImageSrc(URL.createObjectURL(e.currentTarget.files[0])) setImageSrc(URL.createObjectURL((e.currentTarget! as HTMLInputElement).files![0]))
return () => form.removeEventListerner('reset', onReset) return () => form.removeEventListerner('reset', onReset)
}, []) }, [])
@ -66,5 +91,3 @@ const fileUploadFactory = ({ styles }) => {
return FileUpload return FileUpload
} }
export default fileUploadFactory

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
import escapeRegex from '../utils/escape_regex.ts' import escapeRegex from '../utils/escape_regex.ts'
export default function inputFactory(styles: { type Styles<C extends string, D extends string, S extends string> = {
base?: string base?: string
touched?: string touched?: string
element?: string element?: string
@ -13,22 +13,38 @@ export default function inputFactory(styles: {
icon?: string icon?: string
labelIcon?: string labelIcon?: string
center?: string center?: string
}): FunctionComponent<{ } & Record<C, string> &
autoFocus?: boolean Record<D, string> &
Record<S, string>
type Props<C extends string, D extends string, S extends string> = {
autoComplete?: string autoComplete?: string
autoFocus?: boolean
center?: boolean
className?: string className?: string
classNames?: Partial<Styles<C, D, S>>
color: C
defaultValue?: string defaultValue?: string
design: D
disabled?: boolean disabled?: boolean
icon: string
label: string
name: string name: string
noMargin?: boolean noMargin?: boolean
label: string
pattern?: string pattern?: string
placeholder?: string placeholder?: string
required?: boolean required?: boolean
type?: string sameAs?: string
showValidity?: boolean showValidity?: boolean
}> { size?: S
const Input = ({ type?: string
value?: any
}
export default function inputFactory<C extends string = never, D extends string = never, S extends string = never>(
styles: Styles<C, D, S>,
) {
const Input: FunctionComponent<Props<C, D, S>> = ({
autoComplete, autoComplete,
autoFocus, autoFocus,
center, center,
@ -46,28 +62,27 @@ export default function inputFactory(styles: {
placeholder, placeholder,
required, required,
sameAs, sameAs,
size = 'medium', size,
type = 'text', type = 'text',
value, value,
showValidity = true, showValidity = true,
}) => { }) => {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const inputRef = useRef<HTMLInputElement>() const inputRef = useRef<HTMLInputElement>(null)
const onBlur = useCallback(() => setTouched(true), []) const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => { useEffect(() => {
function onInput(e) { function onInput(e: Event) {
inputRef.current!.pattern = escapeRegex(e.target.value) inputRef.current!.pattern = escapeRegex((e.target! as HTMLInputElement).value)
} }
const form = inputRef.current!.form const form = inputRef.current!.form!
const resetTouched = setTouched.bind(null, false) const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched) form.addEventListener('reset', resetTouched)
let sameAsInput let sameAsInput: HTMLInputElement
if (sameAs) { if (sameAs) {
sameAsInput = form[sameAs] sameAsInput = form[sameAs]
sameAsInput.addEventListener('input', onInput) sameAsInput.addEventListener('input', onInput)
@ -80,7 +95,7 @@ export default function inputFactory(styles: {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (autoFocus) inputRef.current.focus() if (autoFocus) inputRef.current!.focus()
}, [autoFocus]) }, [autoFocus])
const id = styles.element + '_' + name const id = styles.element + '_' + name

View File

@ -1,21 +1,48 @@
import { h, type FunctionComponent } from 'preact' import { h, type FunctionComponent } from 'preact'
import cn from 'classnames' import cn from 'classnames'
export default function linkButtonFactory({ defaults, icons, styles }) { type Styles<C extends string, D extends string, S extends string> = {
const LinkButton: FunctionComponent<{ base?: string
touched?: string
element?: string
label?: string
icon?: string
autoHeight?: string
fullWidth?: string
invert?: string
} & Record<C, string> &
Record<D, string> &
Record<S, string>
type Options<C extends string, D extends string, I extends string, S extends string> = {
defaults: Pick<Props<C, D, I, S>, 'autoHeight' | 'color' | 'design' | 'fullWidth' | 'icon' | 'invert' | 'size'>
icons: Record<I, string>
styles: Styles<C, D, S>
}
type Props<C extends string, D extends string, I extends string, S extends string> = {
autoHeight?: boolean autoHeight?: boolean
className?: string className?: string
color?: string color?: C
design?: string design?: D
href?: string href?: string
fullWidth?: boolean fullWidth?: boolean
icon?: string icon?: I
iconSize?: string iconSize?: string
invert?: boolean invert?: boolean
size?: string size?: S
tabIndex?: number tabIndex?: number
title?: string title?: string
}> = ({ }
export default function linkButtonFactory<
C extends string = never,
D extends string = never,
I extends string = never,
S extends string = never,
>({ defaults, icons, styles }: Options<C, D, I, S>) {
const LinkButton: FunctionComponent<Props<C, D, I, S>> = ({
autoHeight = defaults?.autoHeight, autoHeight = defaults?.autoHeight,
children, children,
className, className,

View File

@ -1,6 +1,9 @@
import { type ContainerNode, type FunctionComponent } from 'preact'
import { createPortal } from 'preact/compat' import { createPortal } from 'preact/compat'
const Portal = ({ children, container = typeof document !== 'undefined' && document.body }) => const Portal: FunctionComponent<{ container: ContainerNode }> = ({
createPortal(children, container) children,
container = typeof document !== 'undefined' && document.body,
}) => container && createPortal(children, container)
export default Portal export default Portal

View File

@ -2,36 +2,54 @@ import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
import mergeStyles from '../utils/merge_styles.ts' import mergeStyles from '../utils/merge_styles.ts'
import { Script } from '@fastify/type-provider-typebox'
// interface Styles { type Styles<C extends string, D extends string, S extends string> = {
// base?: string base?: string
// touched?: string touched?: string
// element?: string element?: string
// label?: string label?: string
// } icon?: string
} & Record<C, string> &
Record<D, string> &
Record<S, string>
export default function selectFactory({ styles }): FunctionComponent<{ type Options<C extends string, D extends string, S extends string> = {
styles: Styles<C, D, S>
}
type Props<C extends string, D extends string, S extends string> = {
autoFocus?: boolean autoFocus?: boolean
className?: string className?: string
color?: C
defaultValue?: string defaultValue?: string
design?: D
disabled?: boolean disabled?: boolean
icon: ANY
label: string
name: string name: string
noEmpty?: boolean noEmpty?: boolean
label: string onChange?: ANY
options: string[] | number[] | { label: string; value: string | number }[]
placeholder?: string placeholder?: string
required?: boolean required?: boolean
options: string[] | number[] | { label: string; value: string | number } size: S
}> { }
// designs / color / size
export default function selectFactory<C extends string = never, D extends string = never, S extends string = never>({
styles,
}: Options<C, D, S>) {
if (Array.isArray(styles)) { if (Array.isArray(styles)) {
styles = mergeStyles(styles) styles = mergeStyles(styles)
} }
const Select = ({ const Select: FunctionComponent<Props<C, D, S>> = ({
autoFocus, autoFocus,
className, className,
color, color,
defaultValue, defaultValue,
design = 'main', design,
disabled, disabled,
icon, icon,
name, name,
@ -40,11 +58,11 @@ export default function selectFactory({ styles }): FunctionComponent<{
onChange, onChange,
placeholder, placeholder,
required, required,
size = 'medium', size,
options, options,
}) => { }) => {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const selectRef = useRef<HTMLSelectElement>() const selectRef = useRef<HTMLSelectElement>(null)
const onBlur = useCallback(() => setTouched(true), []) const onBlur = useCallback(() => setTouched(true), [])
@ -90,10 +108,10 @@ export default function selectFactory({ styles }): FunctionComponent<{
{options && {options &&
options.map((option) => ( options.map((option) => (
<option <option
key={option.value != null ? option.value : option} key={typeof option === 'object' ? option.value : option}
value={option.value != null ? option.value : option} value={typeof option === 'object' ? option.value : option}
> >
{option.label || option} {typeof option === 'object' ? option.label : option}
</option> </option>
))} ))}
</select> </select>

View File

@ -3,48 +3,50 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames' import cn from 'classnames'
import mergeStyles from '../utils/merge_styles.ts' import mergeStyles from '../utils/merge_styles.ts'
type Props = {
autoFocus?: boolean
className?: string
cols?: number
defaultValue?: string
disabled?: boolean
label: string
name: string
placeholder?: string
required?: boolean
rows: number
showValidity?: boolean
}
export default function textareaFactory(styles: { export default function textareaFactory(styles: {
base?: string base?: string
touched?: string touched?: string
element?: string element?: string
label?: string label?: string
statusIcon?: string statusIcon?: string
}): FunctionComponent<{ }): FunctionComponent<Props> {
autoFocus?: boolean
className?: string
cols?: number
defaultValue?: string
disabled?: boolean
name: string
label: string
placeholder?: string
required?: boolean
showValidity?: boolean
}> {
if (Array.isArray(styles)) { if (Array.isArray(styles)) {
styles = mergeStyles(styles) styles = mergeStyles(styles)
} }
const Textarea = ({ const Textarea = ({
cols,
autoFocus, autoFocus,
className, className,
cols,
defaultValue, defaultValue,
disabled, disabled,
name,
label, label,
name,
placeholder, placeholder,
required, required,
rows, rows,
showValidity = true, showValidity = true,
}) => { }: Props) => {
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>() const textareaRef = useRef<HTMLTextAreaElement>(null)
const onBlur = useCallback(() => setTouched(true), []) const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => { useEffect(() => {
const form = textareaRef.current!.form const form = textareaRef.current!.form!
const resetTouched = setTouched.bind(null, false) const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched) form.addEventListener('reset', resetTouched)

View File

@ -1,6 +1,13 @@
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import isLoading from '../utils/is_loading.ts' import isLoading from '../utils/is_loading.ts'
interface State {
error: Error | null
pending: boolean
success: boolean
response: any
}
const useRequestState = () => { const useRequestState = () => {
const initialState = { const initialState = {
error: null, error: null,
@ -9,7 +16,7 @@ const useRequestState = () => {
response: null, response: null,
} }
const [state, setState] = useState(initialState) const [state, setState] = useState<State>(initialState)
const actions = { const actions = {
reset() { reset() {
@ -18,7 +25,7 @@ const useRequestState = () => {
setState(initialState) setState(initialState)
}, },
error(error) { error(error: Error) {
isLoading(false) isLoading(false)
setState({ setState({

View File

@ -1,6 +1,6 @@
const scrollingElement = typeof window !== 'undefined' && (document.scrollingElement || document.documentElement) const scrollingElement = typeof window !== 'undefined' && (document.scrollingElement || document.documentElement)
let scrollTop let scrollTop: number
export default { export default {
disable() { disable() {
@ -15,6 +15,6 @@ export default {
document.body.style.position = '' document.body.style.position = ''
document.body.style.top = '' document.body.style.top = ''
document.body.style.width = '' document.body.style.width = ''
scrollingElement.scrollTop = scrollTop ;(scrollingElement as Element).scrollTop = scrollTop
}, },
} }

View File

@ -1,3 +1,3 @@
export default function escapeRegex(string) { export default function escapeRegex(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
} }

View File

@ -1,3 +1,3 @@
export default (state) => { export default (state: boolean) => {
document.body.classList.toggle('loading', state) document.body.classList.toggle('loading', state)
} }

View File

@ -9,7 +9,7 @@ export default function mergeStyles(styles: Record<string, string>[]) {
} }
} }
const obj = {} const obj: Record<string, string> = {}
for (const prop of props) { for (const prop of props) {
obj[prop] = cn(styles.map((style) => style[prop])) obj[prop] = cn(styles.map((style) => style[prop]))

View File

@ -1,5 +1,5 @@
export default function (form: HTMLFormElement, hook?: (...args: ANY[]) => ANY) { export default function (form: HTMLFormElement, hook?: (...args: ANY[]) => ANY) {
const result = {} const result: Record<string, string | string[]> = {}
for (const element of form.elements as unknown as HTMLInputElement[]) { for (const element of form.elements as unknown as HTMLInputElement[]) {
const { checked, name, type, value } = element const { checked, name, type, value } = element

View File

@ -1,10 +1,10 @@
// copied and modified from https://github.com/sindresorhus/throttleit/blob/ed1d22c70a964ef0299d0400dbfd1fbedef56a59/index.js // copied and modified from https://github.com/sindresorhus/throttleit/blob/ed1d22c70a964ef0299d0400dbfd1fbedef56a59/index.js
export default function throttle(fnc, wait) { export default function throttle<F extends (...args: any[]) => any>(fnc: F, wait: number) {
let timeout let timeout: ReturnType<typeof setTimeout>
let lastCallTime = 0 let lastCallTime = 0
return function throttled(...args) { return function throttled(...args: Parameters<F>) {
clearTimeout(timeout) clearTimeout(timeout)
const now = Date.now() const now = Date.now()

View File

@ -1,5 +1,6 @@
// modified version of: https://github.com/modosc/global-jsdom/blob/d1dd3cdeeeddd4d0653496a728e0f81e18776654/packages/global-jsdom/esm/index.mjs // modified version of: https://github.com/modosc/global-jsdom/blob/d1dd3cdeeeddd4d0653496a728e0f81e18776654/packages/global-jsdom/esm/index.mjs
// @ts-ignore
import JSDOM from 'jsdom' import JSDOM from 'jsdom'
const html = '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>' const html = '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>'
@ -17,6 +18,7 @@ const { document } = window
const KEYS = Object.getOwnPropertyNames(window).filter((k) => !k.startsWith('_') && !(k in globalThis)) const KEYS = Object.getOwnPropertyNames(window).filter((k) => !k.startsWith('_') && !(k in globalThis))
// @ts-ignore
KEYS.forEach((key) => (globalThis[key] = window[key])) KEYS.forEach((key) => (globalThis[key] = window[key]))
globalThis.document = document globalThis.document = document

View File

@ -48,6 +48,7 @@
"@babel/core": "^7.26.10", "@babel/core": "^7.26.10",
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.1",
"@testing-library/preact": "^3.2.4", "@testing-library/preact": "^3.2.4",
"@types/d3-dsv": "^3.0.7",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@typescript/native-preview": "7.0.0-dev.20251126.1", "@typescript/native-preview": "7.0.0-dev.20251126.1",

8
pnpm-lock.yaml generated
View File

@ -78,6 +78,9 @@ importers:
'@testing-library/preact': '@testing-library/preact':
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(preact@10.27.2) version: 3.2.4(preact@10.27.2)
'@types/d3-dsv':
specifier: ^3.0.7
version: 3.0.7
'@types/lodash': '@types/lodash':
specifier: ^4.17.16 specifier: ^4.17.16
version: 4.17.20 version: 4.17.20
@ -924,6 +927,9 @@ packages:
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -2949,6 +2955,8 @@ snapshots:
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/d3-dsv@3.0.7': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/lodash@4.17.20': {} '@types/lodash@4.17.20': {}

View File

@ -1,33 +1,27 @@
const evals = ['false', 'true', 'null', 'undefined'] function read<
const numberRegex = /^\d+$/ const C extends readonly string[],
D extends Partial<Record<C[number], any>> & Record<Exclude<keyof D, C[number]>, never> = {},
>(columns: C, defaults: D): Record<C[number], any> {
const missing: string[] = []
export function read(columns, defaults = {}) { const result: Record<string, any> = {}
columns = [...new Set(columns.concat(Object.keys(defaults)))].sort()
const missing = [] for (const column of columns) {
let value: string | number | boolean | null | undefined = process.env[column]
const result = columns.reduce((result, variable) => {
/* eslint-disable-next-line n/no-process-env */
let value = process.env[variable] || defaults[variable]
if (value === undefined) { if (value === undefined) {
missing.push(variable) value = defaults[column as keyof D] ?? null
missing.push(column)
} else { } else {
if (typeof value === 'string') { if (typeof value === 'string') {
if (numberRegex.test(value)) { if (/^\d+$/.test(value)) value = Number(value)
value = Number.parseInt(value) else if (['false', 'true', 'null', 'undefined'].includes(value)) value = eval(value)
} else if (evals.includes(value)) {
// eslint-disable-next-line no-eval
value = eval(value)
} }
} }
result[variable] = value result[column] = value
} }
return result
}, {})
if (missing.length) { if (missing.length) {
throw new Error(`Missing required env variables: ${missing.join(', ')}`) throw new Error(`Missing required env variables: ${missing.join(', ')}`)
} }
@ -52,7 +46,7 @@ export default read(
'PGPORT', 'PGPORT',
'PGUSER', 'PGUSER',
'REDIS_HOST', 'REDIS_HOST',
], ] as const,
{ {
PGPASSWORD: null, PGPASSWORD: null,
PGPORT: null, PGPORT: null,

View File

@ -1,12 +1,13 @@
import { DatabaseError } from 'pg-protocol' import { DatabaseError } from 'pg-protocol'
import StatusError from '../lib/status_error.ts' import StatusError from '../lib/status_error.ts'
import { type ErrorHandler } from '../types.ts'
const databaseErrorCodes = { const databaseErrorCodes: Record<string, number> = {
23505: 409, 23505: 409,
23514: 409, 23514: 409,
} }
function defaultHandler(error, request, reply) { const defaultHandler: ErrorHandler = (error, _request, reply) => {
return reply return reply
.type('text/html') .type('text/html')
.send( .send(
@ -14,21 +15,24 @@ function defaultHandler(error, request, reply) {
) )
} }
export default function ErrorHandler(handler = defaultHandler) { export default function createErrorHandler(handler: ErrorHandler = defaultHandler): ErrorHandler {
return async function errorHandler(error, request, reply) { return async function errorHandler(error, request, reply) {
if (error instanceof DatabaseError && error.code in databaseErrorCodes) { if (error instanceof DatabaseError && error.code && error.code in databaseErrorCodes) {
error = new StatusError(databaseErrorCodes[error.code], error.detail || 'Database Error', { cause: error }) error = new StatusError(databaseErrorCodes[error.code], error.detail || 'Database Error', { cause: error })
} }
reply.log.error({ req: request, err: error }, error?.message) reply.log.error({ req: request, err: error }, error?.message)
// @ts-ignore
reply.status(error.status || error.statusCode || 500) reply.status(error.status || error.statusCode || 500)
if (request.headers.accept?.includes('application/json')) { if (request.headers.accept?.includes('application/json')) {
return reply.send( return reply.send(
// @ts-ignore
error.toJSON?.() || { error.toJSON?.() || {
name: error.name, name: error.name,
message: error.message, message: error.message,
// @ts-ignore
status: error.status || error.statusCode || 500, status: error.status || error.statusCode || 500,
}, },
) )

View File

@ -1,3 +1,4 @@
import type { RouteHandler } from 'fastify'
import env from '../env.ts' import env from '../env.ts'
const CONTENT = { const CONTENT = {
@ -11,8 +12,10 @@ Disallow: /admin
`, `,
} }
const contents = (CONTENT[env.NODE_ENV] || CONTENT.development).trim() const contents = (CONTENT[env.NODE_ENV as 'development' | 'production'] || CONTENT.development).trim()
export default function robots(_request, reply) { const robots: RouteHandler = (_request, reply) => {
return reply.type('text/plain').send(contents) return reply.type('text/plain').send(contents)
} }
export default robots

View File

@ -2,6 +2,7 @@ import env from '../env.ts'
if (env.NODE_ENV === 'development') { if (env.NODE_ENV === 'development') {
// output filename in console log and colour console.dir // output filename in console log and colour console.dir
// @ts-ignore
await import('@bmp/console').then((mod) => mod.default({ log: true, error: true, dir: true })) await import('@bmp/console').then((mod) => mod.default({ log: true, error: true, dir: true }))
const { default: chalk } = await import('chalk') const { default: chalk } = await import('chalk')

View File

@ -2,9 +2,9 @@ import { Readable } from 'node:stream'
type ExpressionPrimitives = string | number | Date | boolean | undefined | null | ExpressionPrimitives[] type ExpressionPrimitives = string | number | Date | boolean | undefined | null | ExpressionPrimitives[]
type Expression = ExpressionPrimitives | (() => ExpressionPrimitives) | Promise<ExpressionPrimitives> type Expression = ExpressionPrimitives | (() => ExpressionPrimitives) | Promise<Expression>
type ParseGenerator = Generator<ExpressionPrimitives, void> type ParseGenerator = Generator<ExpressionPrimitives | Promise<ExpressionPrimitives>, void>
export interface Template { export interface Template {
generator: (minify?: boolean) => ParseGenerator generator: (minify?: boolean) => ParseGenerator
@ -14,16 +14,17 @@ export interface Template {
const cache = new WeakMap() const cache = new WeakMap()
function apply(expression: Expression) { function apply(expression: Expression): ExpressionPrimitives | Promise<ExpressionPrimitives> {
if (!expression) { if (expression == null) {
return '' return ''
} else if ((expression as Promise<ExpressionPrimitives>).then) { } else if (expression instanceof Promise) {
return (expression as Promise<ExpressionPrimitives>).then(apply) return expression.then((resolved) => apply(resolved))
} else if (typeof expression === 'function') { } else if (typeof expression === 'function') {
return apply(expression()) return apply(expression())
} else { } else if (Array.isArray(expression)) {
return (expression as ExpressionPrimitives[]).join?.('') ?? expression return expression.join('')
} }
return expression
} }
function* parse(strings: TemplateStringsArray, expressions: Expression[], minify?: boolean): ParseGenerator { function* parse(strings: TemplateStringsArray, expressions: Expression[], minify?: boolean): ParseGenerator {
@ -67,8 +68,8 @@ function stream(generator: ParseGenerator) {
async function text(generator: ParseGenerator) { async function text(generator: ParseGenerator) {
let result = '' let result = ''
for await (const chunk of generator) { for (const chunk of generator) {
result += chunk result += chunk instanceof Promise ? await chunk : chunk
} }
return result return result

View File

@ -1,6 +1,6 @@
import _ from 'lodash' import _ from 'lodash'
// eslint-disable-next-line import/no-named-as-default
import knex from 'knex' import knex from 'knex'
// @ts-ignore
import pg from 'pg' import pg from 'pg'
import env from '../env.ts' import env from '../env.ts'
@ -8,8 +8,8 @@ const queryProto = pg.Query.prototype
const handleRowDescription = queryProto.handleRowDescription const handleRowDescription = queryProto.handleRowDescription
queryProto.handleRowDescription = function (msg) { queryProto.handleRowDescription = function (msg: { fields: { name: string }[] }) {
msg.fields.forEach((field) => { msg.fields.forEach((field: { name: string }) => {
field.name = _.camelCase(field.name) field.name = _.camelCase(field.name)
}) })

View File

@ -110,9 +110,18 @@ test('parseSRU', (t: TestContext) => {
}) })
test('parseTrans', (t: TestContext) => { test('parseTrans', (t: TestContext) => {
type Expected = {
accountNumber: number
objectList: [number, number][] | null
amount: number
transactionDate: string | null
description: string | null
quantity: number | null
signature: null
}
// Fortnox 3.57.11 // Fortnox 3.57.11
let line = '#TRANS 1790 {1 "1"} 7509 "" "Faktura 9631584500436 172-57 - Perspektiv Bredband AB" 0' let line = '#TRANS 1790 {1 "1"} 7509 "" "Faktura 9631584500436 172-57 - Perspektiv Bredband AB" 0'
let expected = { let expected: Expected = {
accountNumber: 1790, accountNumber: 1790,
objectList: [[1, 1]], objectList: [[1, 1]],
amount: 7509, amount: 7509,

View File

@ -3,7 +3,14 @@
const rDim = /#DIM\s+(-?\d+)\s+"([^"]*)"$/ const rDim = /#DIM\s+(-?\d+)\s+"([^"]*)"$/
export function parseDim(line: string) { export function parseDim(line: string) {
const [, number, name] = line.match(rDim) const result = line.match(rDim)
if (!result) {
console.error(line)
throw Error('parsing error')
}
const [, number, name] = result
return { return {
number: parseInt(number), number: parseInt(number),
@ -30,7 +37,7 @@ export function parseIB(line: string) {
accountNumber: parseInt(accountNumber), accountNumber: parseInt(accountNumber),
yearNumber: parseInt(yearNumber), yearNumber: parseInt(yearNumber),
balance: parseFloat(balance), balance: parseFloat(balance),
quantity: parseInt(quantity), quantity: quantity ? parseInt(quantity) : null,
} }
} }
@ -45,7 +52,7 @@ export function parseKonto(line: string) {
throw Error('parsing error') throw Error('parsing error')
} }
const [, number, description] = line.match(rKonto) const [, number, description] = result
return { return {
number: parseInt(number), number: parseInt(number),
@ -58,7 +65,14 @@ export function parseKonto(line: string) {
const rObjekt = /^#OBJEKT\s+(\d+)\s+"?(\d+)"?\s"([^"]*)"$/ const rObjekt = /^#OBJEKT\s+(\d+)\s+"?(\d+)"?\s"([^"]*)"$/
export function parseObjekt(line: string) { export function parseObjekt(line: string) {
const [, dimensionNumber, number, name] = line.match(rObjekt) const result = line.match(rObjekt)
if (!result) {
console.error(line)
throw Error('parsing error')
}
const [, dimensionNumber, number, name] = result
return { return {
dimensionNumber: parseInt(dimensionNumber), dimensionNumber: parseInt(dimensionNumber),
@ -73,7 +87,14 @@ export function parseObjekt(line: string) {
const rRAR = /^#RAR\s+(-?\d+)\s+(\d{8,8})\s+(\d{8,8})$/ const rRAR = /^#RAR\s+(-?\d+)\s+(\d{8,8})\s+(\d{8,8})$/
export function parseRAR(line: string) { export function parseRAR(line: string) {
const [, yearNumber, startDate, endDate] = line.match(rRAR) const result = line.match(rRAR)
if (!result) {
console.error(line)
throw Error('parsing error')
}
const [, yearNumber, startDate, endDate] = result
return { return {
yearNumber: parseInt(yearNumber), yearNumber: parseInt(yearNumber),
@ -87,7 +108,14 @@ export function parseRAR(line: string) {
const rSRU = /^#SRU\s+(\d{4,4})\s+(\d{4,4})$/ const rSRU = /^#SRU\s+(\d{4,4})\s+(\d{4,4})$/
export function parseSRU(line: string) { export function parseSRU(line: string) {
const [, number, sru] = line.match(rSRU) const result = line.match(rSRU)
if (!result) {
console.error(line)
throw Error('parsing error')
}
const [, number, sru] = result
return { return {
number: parseInt(number), number: parseInt(number),
@ -112,16 +140,16 @@ export function parseTrans(line: string) {
const [, accountNumber, objectListString, amount, transactionDate, description, quantity, signature] = result const [, accountNumber, objectListString, amount, transactionDate, description, quantity, signature] = result
let objectList = null let objectList: [number, number][] | null = null
if (objectListString) { if (objectListString) {
objectList = [] objectList = []
const result = objectListString.matchAll(rObjectList) const result = objectListString.matchAll(rObjectList)
for (const match of result) { for (const match of result) {
const [, dimension, object] = match const [, dimension, object] = match as string[]
objectList.push([dimension, object].map((val) => parseInt(val))) objectList.push([dimension, object].map((val) => parseInt(val)) as [number, number])
} }
} }
@ -164,7 +192,14 @@ export function parseUB(line: string) {
const rVer = /#VER\s+(\w+)\s+(\d+)\s+(\d{8,8})\s+"([^"]*)"\s+(\d{8,8})(?:\s+(.*))?/ const rVer = /#VER\s+(\w+)\s+(\d+)\s+(\d{8,8})\s+"([^"]*)"\s+(\d{8,8})(?:\s+(.*))?/
export function parseVer(line: string) { export function parseVer(line: string) {
const [, journal, number, transactionDate, description, entryDate, signature] = line.match(rVer) const result = line.match(rVer)
if (!result) {
console.error(line)
throw Error('parsing error')
}
const [, journal, number, transactionDate, description, entryDate, signature] = result
return { return {
journal, journal,

View File

@ -1,4 +1,4 @@
import { type ReadableStream } from 'node:stream/web' import type { ReadableStream } from 'stream/web'
import knex from './knex.ts' import knex from './knex.ts'
import split, { type Decoder } from './split.ts' import split, { type Decoder } from './split.ts'
@ -41,7 +41,7 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const journals = new Map() const journals = new Map()
let currentEntry: { id: number; description: string } let currentEntry: { id: number; description: string }
let currentInvoiceId: number let currentInvoiceId: number | null
let currentYear = null let currentYear = null
const details: Record<string, string> = {} const details: Record<string, string> = {}
@ -214,25 +214,27 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
} }
invoiceId = ( invoiceId = (
await trx('invoice').insert({ financialYearId: currentYear.id, fiskenNumber, supplierId }).returning('id') await trx('invoice')
.insert({ financialYearId: currentYear!.id, fiskenNumber, supplierId })
.returning('id')
)[0]?.id )[0]?.id
} }
if (transaction.accountNumber === 2441 && currentEntry.description?.includes('Fakturajournal')) { if (transaction.accountNumber === 2441 && currentEntry!.description?.includes('Fakturajournal')) {
await trx('invoice').update('amount', Math.abs(transaction.amount)).where('id', invoiceId) await trx('invoice').update('amount', Math.abs(transaction.amount)).where('id', invoiceId)
} }
} }
if (invoiceId && currentInvoiceId) { if (invoiceId! && currentInvoiceId!) {
throw new Error('invoiceId and currentInvoiceId') throw new Error('invoiceId and currentInvoiceId')
} }
const transactionId = ( const transactionId = (
await trx('transaction') await trx('transaction')
.insert({ .insert({
entryId: currentEntry.id, entryId: currentEntry!.id,
...transaction, ...transaction,
invoiceId: invoiceId || currentInvoiceId, invoiceId: invoiceId! || currentInvoiceId!,
}) })
.returning('id') .returning('id')
)[0].id )[0].id
@ -269,11 +271,11 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const existingAccountBalance = await trx('accountBalance') const existingAccountBalance = await trx('accountBalance')
.first('*') .first('*')
.where({ financialYearId: currentYear.id, accountNumber }) .where({ financialYearId: currentYear!.id, accountNumber })
if (!existingAccountBalance) { if (!existingAccountBalance) {
await trx('accountBalance').insert({ await trx('accountBalance').insert({
financialYearId: currentYear.id, financialYearId: currentYear!.id,
accountNumber, accountNumber,
out: balance, out: balance,
outQuantity: quantity, outQuantity: quantity,
@ -285,7 +287,7 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
outQuantity: quantity, outQuantity: quantity,
}) })
.where({ .where({
financialYearId: currentYear.id, financialYearId: currentYear!.id,
accountNumber, accountNumber,
}) })
} }

View File

@ -1,9 +1,10 @@
import util from 'node:util' import util from 'node:util'
import chalk from 'chalk' import chalk from 'chalk'
import _ from 'lodash' import _ from 'lodash'
// @ts-ignore
import highlightStack from '@bmp/highlight-stack' import highlightStack from '@bmp/highlight-stack'
const LEVELS = { const LEVELS: Record<string, string> = {
default: 'USERLVL', default: 'USERLVL',
60: 'FATAL', 60: 'FATAL',
50: 'ERROR', 50: 'ERROR',
@ -13,7 +14,7 @@ const LEVELS = {
10: 'TRACE', 10: 'TRACE',
} }
const COLORS = { const COLORS: Record<string, any> = {
60: chalk.bgRed, 60: chalk.bgRed,
50: chalk.red, 50: chalk.red,
40: chalk.yellow, 40: chalk.yellow,
@ -24,7 +25,7 @@ const COLORS = {
const requests = new Map() const requests = new Map()
function colorStatusCode(statusCode) { function colorStatusCode(statusCode: number) {
if (statusCode < 300) { if (statusCode < 300) {
return chalk.bold.green(statusCode) return chalk.bold.green(statusCode)
} else if (statusCode < 400) { } else if (statusCode < 400) {
@ -36,30 +37,56 @@ function colorStatusCode(statusCode) {
} }
} }
interface BaseLineObject {
level: number
time: number
pid: number
reqId: string
msg: string
}
interface ErrorLineObject extends BaseLineObject {
level: 50
err: ANY
}
interface IncomingRequestLineObject extends BaseLineObject {
msg: 'incoming request'
req: Record<string, any>
}
interface RequestCompletedLineObject extends BaseLineObject {
msg: 'request completed'
res: Record<string, any>
responseTime: number
}
type LineObject = ErrorLineObject | IncomingRequestLineObject | RequestCompletedLineObject | BaseLineObject
export default { export default {
write(line) { write(line: string) {
const obj = JSON.parse(line) const obj: LineObject = JSON.parse(line)
if (obj.msg === 'incoming request') { if (obj.msg === 'incoming request') {
requests.set(obj.reqId, obj.req) requests.set(obj.reqId, (obj as IncomingRequestLineObject).req)
} else if (obj.msg === 'request completed') { } else if (obj.msg === 'request completed') {
const req = requests.get(obj.reqId) const req = requests.get(obj.reqId)
requests.delete(obj.reqId) requests.delete(obj.reqId)
process.stdout.write( process.stdout.write(
`${chalk.bold(req.method)} ${req.url} ${colorStatusCode(obj.res.statusCode)} ${obj.responseTime.toFixed( `${chalk.bold(req.method)} ${req.url} ${colorStatusCode((obj as RequestCompletedLineObject).res.statusCode)} ${(
3, obj as RequestCompletedLineObject
)} ms\n`, ).responseTime.toFixed(3)} ms\n`,
) )
} else if (obj.level === 50) { } else if (obj.level === 50) {
// TODO figure out if there is a way to get the error instances here... console.log(Error) and util.inspect(Error) // TODO figure out if there is a way to get the error instances here... console.log(Error) and util.inspect(Error)
// looks better than the serialized errors // looks better than the serialized errors
if (obj.err.status < 500) return if ((obj as ErrorLineObject).err.status < 500) return
process.stdout.write( process.stdout.write(
`${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${highlightStack(obj.err.stack)}\n`, `${COLORS[obj.level](LEVELS[obj.level] || LEVELS.default)} ${highlightStack((obj as ErrorLineObject).err.stack)}\n`,
) )
const details = _.omit(obj.err, ['type', 'message', 'stack']) const details = _.omit((obj as ErrorLineObject).err, ['type', 'message', 'stack'])
if (!_.isEmpty(details)) { if (!_.isEmpty(details)) {
process.stdout.write(`${chalk.red('Details')} ${util.inspect(details, false, 4, true)}\n`) process.stdout.write(`${chalk.red('Details')} ${util.inspect(details, false, 4, true)}\n`)

View File

@ -1,17 +1,19 @@
import { TransformStream } from 'stream/web'
const defaultMatcher = /\r?\n/ const defaultMatcher = /\r?\n/
export type Decoder = { export type Decoder = {
decode: (chunk?: ArrayBufferView) => void decode: (chunk: Uint8Array) => string
} }
interface Options { interface Options {
decoder?: Decoder decoder?: Decoder
} }
export default function split(matcher: RegExp, { decoder }: Options = {}) { export default function split(matcher?: RegExp | null, { decoder }: Options = {}) {
matcher ??= defaultMatcher matcher ??= defaultMatcher
let rest: string | null = null let rest: string | null | undefined = null
return new TransformStream({ return new TransformStream({
start() {}, start() {},

View File

@ -1,35 +1,7 @@
import { type FastifyPluginCallback } from 'fastify' import { type FastifyPluginCallback } from 'fastify'
import _ from 'lodash' import _ from 'lodash'
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import { type Template } from '../lib/html.ts' import type { Config, Entry, EntryWithoutName } from './vite/types.ts'
export interface Entry {
name: string
path: string
template: (...args: any[]) => Template
routes?: ANY[]
ssr?: boolean
hostname?: string
ctx?: Record<string, any>
setupRebuildCache?: (buildCache: () => any) => any
preHandler?: ANY
createPreHandler?: (route: ANY, entry: Entry) => any
createErrorHandler?: (handler?: ANY) => ANY
}
export type EntryWithoutName = Omit<Entry, 'name'>
export interface Config {
mode: 'development' | 'production'
createPreHandler?: (route: ANY, entry: Entry) => any
createErrorHandler?: (handler?: ANY) => ANY
entries: Record<string, EntryWithoutName>
}
export interface ParsedConfig {
mode: 'development' | 'production'
entries: Entry[]
}
const vitePlugin: FastifyPluginCallback<Config> = async function (fastify, config) { const vitePlugin: FastifyPluginCallback<Config> = async function (fastify, config) {
const parsedConfig = { const parsedConfig = {

View File

@ -1,9 +1,10 @@
import { type FastifyInstance } from 'fastify' import type { FastifyInstance, RouteHandler } from 'fastify'
import fmiddie from '@fastify/middie' import fmiddie from '@fastify/middie'
import { createServer } from 'vite' import { createServer } from 'vite'
import StatusError from '../../lib/status_error.ts' import StatusError from '../../lib/status_error.ts'
import { type Entry, type ParsedConfig } from '../vite.ts' import type { Route } from '../../../shared/types.ts'
import type { Entry, ParsedConfig, Renderer, RenderFunction } from './types.ts'
export default async function viteDevelopment(fastify: FastifyInstance, config: ParsedConfig) { export default async function viteDevelopment(fastify: FastifyInstance, config: ParsedConfig) {
const devServer = await createServer({ const devServer = await createServer({
@ -40,7 +41,7 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry) {
if (entry.preHandler) { if (entry.preHandler) {
fastify.addHook('preHandler', entry.preHandler) fastify.addHook('preHandler', entry.preHandler)
} }
const routes = entry.routes || (await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)).routes const routes: Route[] = entry.routes || (await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)).routes
for (const route of routes.flatMap((route) => route.routes || route)) { for (const route of routes.flatMap((route) => route.routes || route)) {
// const preHandler = entry.preHandler || entry.createPreHandler?.(route, entry) // const preHandler = entry.preHandler || entry.createPreHandler?.(route, entry)
@ -79,9 +80,10 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry) {
throw new StatusError(404) throw new StatusError(404)
}) })
if (entry.createErrorHandler) {
fastify.setErrorHandler( fastify.setErrorHandler(
entry.createErrorHandler(async (error, request, reply) => { entry.createErrorHandler(async (error, request, reply) => {
reply.type('text/html').status(error.status || 500) reply.type('text/html').status((error as StatusError).status || 500)
return renderer( return renderer(
request.url, request.url,
@ -94,13 +96,16 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry) {
) )
}), }),
) )
}
} }
function createRenderer(fastify, entry) { function createRenderer(fastify: FastifyInstance, entry: Entry): Renderer {
return async (url, ctx) => { return async (url, ctx) => {
ctx = Object.assign({ url }, entry.ctx, ctx) ctx = Object.assign({ url }, entry.ctx, ctx)
const { render } = await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`) const { render } = (await fastify.devServer.ssrLoadModule(`/${entry.name}/server.ts`)) as {
render?: RenderFunction
}
const renderPromise = render && entry.ssr !== false ? render(ctx) : null const renderPromise = render && entry.ssr !== false ? render(ctx) : null
@ -121,7 +126,7 @@ function createRenderer(fastify, entry) {
} }
} }
function createHandler(renderer) { function createHandler(renderer: Renderer): RouteHandler {
return async function handler(request, reply) { return async function handler(request, reply) {
reply.type('text/html') reply.type('text/html')

View File

@ -1,12 +1,14 @@
import { type FastifyInstance } from 'fastify'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
import { type Readable } from 'node:stream'
import { resolveConfig, type ResolvedConfig as ViteResolvedConfig, type Manifest as ViteManifest } from 'vite'
import type { FastifyInstance, RouteHandler } from 'fastify'
import fstatic from '@fastify/static' import fstatic from '@fastify/static'
import { resolveConfig } from 'vite'
import StatusError from '../../lib/status_error.ts' import StatusError from '../../lib/status_error.ts'
import { type Entry, type ParsedConfig } from '../vite.ts' import type { Route } from '../../../shared/types.ts'
import type { Entry, ParsedConfig, Renderer, RenderCache, RenderFunction, ViteEntryFile } from './types.ts'
export default async function viteProduction(fastify: FastifyInstance, config: ParsedConfig) { export default async function viteProduction(fastify: FastifyInstance, config: ParsedConfig) {
const viteConfig = await resolveConfig({}, 'build', 'production') const viteConfig = await resolveConfig({}, 'build', 'production')
@ -25,18 +27,27 @@ export default async function viteProduction(fastify: FastifyInstance, config: P
} }
} }
async function setupEntry(fastify: FastifyInstance, entry: Entry, config: ParsedConfig, viteConfig) { async function setupEntry(
const viteFilePath = path.join(viteConfig.build.outDir, entry.name + '.js') fastify: FastifyInstance,
const viteFile = fs.existsSync(viteFilePath) entry: Entry,
? await import(viteFilePath) _config: ParsedConfig,
viteConfig: ViteResolvedConfig,
) {
const viteEntryFilePath = path.join(viteConfig.build.outDir, entry.name + '.js')
const viteEntryFile: ViteEntryFile = fs.existsSync(viteEntryFilePath)
? await import(viteEntryFilePath)
: await import(path.join(viteConfig.root, entry.name, 'server.ts')) : await import(path.join(viteConfig.root, entry.name, 'server.ts'))
const manifestPath = path.join(viteConfig.build.outDir, `manifest.${entry.name}.json`) const manifestPath = path.join(viteConfig.build.outDir, `manifest.${entry.name}.json`)
const manifest = fs.existsSync(manifestPath) ? (await import(manifestPath, { with: { type: 'json' } })).default : null const manifest: ViteManifest = fs.existsSync(manifestPath)
const routes = entry.routes || viteFile.routes ? (await import(manifestPath, { with: { type: 'json' } })).default
: null
const routes = entry.routes || viteEntryFile.routes
if (!routes) throw new Error('No routes found')
const cache = new Map() const cache = new Map()
const renderer = createRenderer(fastify, entry, viteFile.render, manifest) const renderer = createRenderer(fastify, entry, viteEntryFile.render, manifest)
const cachedHandler = createCachedHandler(cache) const cachedHandler = createCachedHandler(cache)
const handler = createHandler(renderer) const handler = createHandler(renderer)
@ -90,9 +101,10 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry, config: Parsed
throw new StatusError(404) throw new StatusError(404)
}) })
if (entry.createErrorHandler) {
fastify.setErrorHandler( fastify.setErrorHandler(
entry.createErrorHandler(async (error, request, reply) => { entry.createErrorHandler(async (error, request, reply) => {
reply.type('text/html').status(error.status || 500) reply.type('text/html').status((error as StatusError).status || 500)
return renderer( return renderer(
request.url, request.url,
@ -105,9 +117,15 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry, config: Parsed
) )
}), }),
) )
}
} }
function createRenderer(fastify, entry, render, manifest) { function createRenderer(
_fastify: FastifyInstance,
entry: Entry,
render: RenderFunction | null | undefined,
manifest: ViteManifest,
): Renderer {
const files = manifest[`${entry.name}/client.ts`] const files = manifest[`${entry.name}/client.ts`]
const bundle = path.join('/', files.file) const bundle = path.join('/', files.file)
const preload = files.imports?.map((name) => path.join('/', manifest[name].file)) const preload = files.imports?.map((name) => path.join('/', manifest[name].file))
@ -124,7 +142,7 @@ function createRenderer(fastify, entry, render, manifest) {
script: bundle, script: bundle,
preload, preload,
css, css,
content: renderPromise?.then((result) => result.content), content: renderPromise?.then((result) => result.html),
head: renderPromise?.then((result) => result.head), head: renderPromise?.then((result) => result.head),
state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx), state: renderPromise?.then((result) => result.state) || Promise.resolve(ctx),
}) })
@ -132,7 +150,7 @@ function createRenderer(fastify, entry, render, manifest) {
} }
} }
function createHandler(renderer) { function createHandler(renderer: Renderer): RouteHandler {
return async function handler(request, reply) { return async function handler(request, reply) {
reply.type('text/html') reply.type('text/html')
@ -140,7 +158,7 @@ function createHandler(renderer) {
} }
} }
function createCachedHandler(cache) { function createCachedHandler(cache: RenderCache): RouteHandler {
return function cachedHandler(request, reply) { return function cachedHandler(request, reply) {
reply.type('text/html') reply.type('text/html')
@ -148,13 +166,13 @@ function createCachedHandler(cache) {
} }
} }
async function buildCache(entry, routes, renderer, cache) { async function buildCache(_entry: Entry, routes: Route[], renderer: Renderer, cache: RenderCache) {
for (const route of routes) { for (const route of routes) {
if (route.cache) { if (route.cache) {
let html = '' let html = ''
// TODO run preHandlers // TODO run preHandlers
for await (const chunk of renderer(route.path)) { for await (const chunk of renderer(route.path) as Readable) {
html += chunk html += chunk
} }

View File

@ -0,0 +1,56 @@
import type { Readable } from 'node:stream'
import type { preHandlerHookHandler } from 'fastify'
import type { Template } from '../../lib/html.ts'
import type { ErrorHandler } from '../../types.ts'
import type { Route } from '../../../shared/types.ts'
export interface Config {
mode: 'development' | 'production'
createPreHandler?: (route: Route, entry: Entry) => preHandlerHookHandler
createErrorHandler?: (handler?: ErrorHandler) => ErrorHandler
entries: Record<string, EntryWithoutName>
}
export interface ParsedConfig {
mode: 'development' | 'production'
entries: Entry[]
}
export interface Entry {
name: string
path: string
template: (...args: any[]) => Template
routes?: Route[]
ssr?: boolean
hostname?: string
ctx?: Record<string, any>
setupRebuildCache?: (buildCache: () => any) => any
preHandler?: preHandlerHookHandler
createPreHandler?: (route: Route, entry: Entry) => preHandlerHookHandler
createErrorHandler?: (handler?: ErrorHandler) => ErrorHandler
}
export type EntryWithoutName = Omit<Entry, 'name'>
export type RenderCache = Map<string, string>
export interface RenderFunctionResult {
html?: string
head: {
title?: string
tags?: {
type: string
tags: Record<string, string>
}
}
state: Record<string, any>
}
export type RenderFunction = (ctx: ANY) => Promise<RenderFunctionResult>
export type Renderer = (url: string, ctx?: ANY) => string | Promise<string> | Readable
export interface ViteEntryFile {
routes?: Route[]
render?: RenderFunction
}

View File

@ -1,6 +1,21 @@
import html from '../lib/html.ts' import html from '../lib/html.ts'
export default ({ content, css, head, preload, script, state }) => html`<!DOCTYPE html> interface Options {
content: string
css: string[]
head: Promise<{
title?: string
tags?: {
type: string
attributes: Record<string, string>
}[]
}>
preload?: string[]
script: string
state: Promise<Record<string, any>>
}
export default ({ content, css, head, preload, script, state }: Options) => html`<!DOCTYPE html>
<html lang='sv-SE'> <html lang='sv-SE'>
<head> <head>
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />

10
server/types.ts Normal file
View File

@ -0,0 +1,10 @@
import { type FastifyRequest, type FastifyReply } from 'fastify'
import { type DatabaseError } from 'pg-protocol'
import type StatusError from './lib/status_error.ts'
export type ErrorHandler = (
error: Error | DatabaseError | StatusError,
request: FastifyRequest,
reply: FastifyReply,
) => any | Promise<any>

2
shared/global.d.ts vendored
View File

@ -20,7 +20,7 @@ declare module 'fastify' {
} }
interface FastifyReply { interface FastifyReply {
ctx: { [key: string]: any } ctx: Record<string, any> | null
} }
} }

View File

@ -1,15 +1,14 @@
// export const FinancialYear = Type.Object({ export type Account = {
// year: Type.Number(), id: number
// startDate: Type.String(), number: number
// endDate: Type.String(),
// })
// export type FinancialYearType = Static<typeof FinancialYear>
export interface Transaction {
description: string description: string
amount: number
} }
export type Balance = {
accountNumber: string
description: string
} & Record<number, number>
export interface Entry { export interface Entry {
id: number id: number
journal: string journal: string
@ -24,3 +23,71 @@ export interface Entry {
amount: number amount: number
}[] }[]
} }
export type FinancialYear = {
year: number
startDate: string
endDate: string
}
export type Invoice = {
id: number
fiskenNumber?: number
phmNumber?: number
invoiceDate: string
dueDate: string
invoiceNumber: number
amount: number
files?: { filename: string }[]
transactions?: {
account_number: number
amount: number
description: number
entry_id: number
}[]
}
export type Journal = {
id: number
identifier: string
}
export type Object = {
id: number
dimensionName: string
name: string
}
export type Result = {
accountNumber: number
description?: string
} & Record<number, number>
export type Supplier = {
id: number
name: string
}
export interface Transaction {
accountNumber: number
description: string
amount: number
entryId: number
}
export interface TransactionFull extends Transaction {
transactionDate: string
invoiceId: number
entryDescription: string
}
export interface Route {
path: string
name: string
title: string
component: (args: ANY) => ANY
cache?: boolean
nav?: boolean
routes?: Route[]
locales?: ANY[]
}

View File

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true,
"noEmit": true, "noEmit": true,
"target": "esnext", "target": "esnext",
"jsx": "react-jsx", "jsx": "react-jsx",