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) {
const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => {
const [, aNum] = a.match(rFileName)
const [, bNum] = b.match(rFileName)
const [, aNum] = a.match(rFileName) as string[]
const [, bNum] = b.match(rFileName) as string[]
if (parseInt(aNum) > parseInt(bNum)) {
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 rek from 'rek'
import Head from './head.ts'
// import s from './accounts_page.module.scss'
const AccountsPage = () => {
const [accounts, setAccounts] = useState([])
import type { Account } from '../../../shared/types.ts'
const AccountsPage: FunctionComponent = () => {
const [accounts, setAccounts] = useState<Account[]>([])
useEffect(() => {
rek('/api/accounts').then((accounts) => {
setAccounts(accounts)
})
rek('/api/accounts').then(setAccounts)
}, [])
return (

View File

@ -1,4 +1,4 @@
import { h } from 'preact'
import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect } from 'preact/hooks'
import { LocationProvider, Route, Router } from 'preact-iso'
import { get } from 'lowline'
@ -11,6 +11,11 @@ import throttle from '../../shared/utils/throttle.ts'
import s from './app.module.scss'
type Props = {
error?: Error
title?: string
}
const remember = throttle(function remember() {
window.history.replaceState(
{
@ -22,7 +27,7 @@ const remember = throttle(function remember() {
)
}, 100)
export default function App({ error, title }) {
const App: FunctionComponent<Props> = ({ error, title }) => {
useEffect(() => {
addEventListener('scroll', remember)
@ -52,7 +57,7 @@ export default function App({ error, title }) {
) : (
<Router onRouteChange={onRouteChange}>
{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>
)}
@ -63,3 +68,5 @@ export default function App({ error, title }) {
</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 cn from 'classnames'
import rek from 'rek'
@ -6,13 +6,15 @@ import Head from './head.ts'
import { formatNumber } from '../utils/format_number.ts'
import s from './balances_page.module.scss'
const BalancesPage = () => {
const [balances, setBalances] = useState([])
import type { Balance, FinancialYear } from '../../../shared/types.ts'
const BalancesPage: FunctionComponent = () => {
const [balances, setBalances] = useState<Balance[]>([])
const [years, setYears] = useState<number[]>([])
useEffect(() => {
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 (

View File

@ -1,4 +1,4 @@
import { h } from 'preact'
import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'
import { useLocation } from 'preact-iso'
import { isEmpty } from 'lowline'
@ -8,12 +8,14 @@ import rek from 'rek'
import Head from './head.ts'
import serializeForm from '../../shared/utils/serialize_form.ts'
import type { Entry, FinancialYear, Journal } from '../../../shared/types.ts'
const dateYear = new Date().getFullYear()
const EntriesPage = () => {
const [journals, setJournals] = useState([])
const [financialYears, setFinancialYears] = useState([])
const [entries, setEntries] = useState([])
const EntriesPage: FunctionComponent = () => {
const [journals, setJournals] = useState<Journal[]>([])
const [financialYears, setFinancialYears] = useState<FinancialYear[]>([])
const [entries, setEntries] = useState<Entry[]>([])
const location = useLocation()
@ -22,7 +24,7 @@ const EntriesPage = () => {
const onSubmit = useCallback((e: SubmitEvent) => {
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) : ''
@ -30,16 +32,12 @@ const EntriesPage = () => {
}, [])
useEffect(() => {
rek('/api/journals').then((journals) => {
setJournals(journals)
})
rek('/api/financial-years').then((financialYears) => {
setFinancialYears(financialYears)
})
rek('/api/journals').then(setJournals)
rek('/api/financial-years').then(setFinancialYears)
}, [])
useEffect(() => {
rek(`/api/entries?journal=${selectedJournal}&year=${selectedYear}`).then((entries) => setEntries(entries))
rek(`/api/entries?journal=${selectedJournal}&year=${selectedYear}`).then(setEntries)
}, [selectedJournal, selectedYear])
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 { useRoute } from 'preact-iso'
import { formatNumber } from '../utils/format_number.ts'
@ -8,14 +8,12 @@ import { type Entry } from '../../../shared/types.ts'
import Head from './head.ts'
const EntriesPage = () => {
const [entry, setEntry] = useState<Entry>(null)
const EntryPage: FunctionComponent = () => {
const [entry, setEntry] = useState<Entry | null>(null)
const route = useRoute()
useEffect(() => {
rek(`/api/entries/${route.params.id}`).then((entry) => {
setEntry(entry)
})
rek(`/api/entries/${route.params.id}`).then(setEntry)
}, [])
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'
const OtherPage = ({ error }) => (
const OtherPage: FunctionComponent<{ error: Error }> = ({ error }) => (
<section>
<Head>
<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

View File

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

View File

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

View File

@ -2,12 +2,14 @@ import { h, type FunctionalComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import rek from 'rek'
import type { Transaction } from '../../../shared/types.ts'
interface Props {
year: number
}
const Invoices: FunctionalComponent<Props> = ({ year }) => {
const [invoices, setInvoices] = useState([])
const [invoices, setInvoices] = useState<Transaction[]>([])
useEffect(() => {
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 { useRoute } from 'preact-iso'
import rek from 'rek'
import Head from './head.ts'
import { formatNumber } from '../utils/format_number.ts'
const InvoicePage = () => {
const [invoice, setInvoice] = useState(null)
import type { Invoice } from '../../../shared/types.ts'
const InvoicePage: FunctionComponent = () => {
const [invoice, setInvoice] = useState<Invoice | null>(null)
const route = useRoute()
useEffect(() => {
rek(`/api/invoices/${route.params.id}`).then((invoice) => setInvoice(invoice))
rek(`/api/invoices/${route.params.id}`).then(setInvoice)
}, [])
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 { useEffect, useState } from 'preact/hooks'
import { useRoute } from 'preact-iso'
import rek from 'rek'
import Head from './head.ts'
import type { Invoice, Supplier } from '../../../shared/types.ts'
const format = Format.bind(null, null, 'YYYY.MM.DD')
const InvoicesPage = () => {
const [supplier, setSupplier] = useState(null)
const [invoices, setInvoices] = useState([])
const [totalAmount, setTotalAmount] = useState<number>(null)
const InvoicesPage: FunctionComponent = () => {
const [supplier, setSupplier] = useState<Supplier | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [totalAmount, setTotalAmount] = useState<number | null>(null)
const route = useRoute()
useEffect(() => {
rek(`/api/suppliers/${route.params.supplier}`).then((supplier) => setSupplier(supplier))
rek(`/api/invoices?supplier=${route.params.supplier}`).then((invoices) => setInvoices(invoices))
rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then((totalAmount) =>
setTotalAmount(totalAmount.amount),
)
rek(`/api/suppliers/${route.params.supplier}`).then(setSupplier)
rek(`/api/invoices?supplier=${route.params.supplier}`).then(setInvoices)
rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then(setTotalAmount)
}, [route.params.supplier])
return (

View File

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

View File

@ -2,12 +2,14 @@ import { h, type FunctionalComponent } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import rek from 'rek'
import type { TransactionFull } from '../../../shared/types.ts'
interface Props {
objectId: number
}
const Result: FunctionalComponent<Props> = ({ objectId }) => {
const [transactions, setTransactions] = useState([])
const [transactions, setTransactions] = useState<TransactionFull[]>([])
useEffect(() => {
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 rek from 'rek'
import Head from './head.ts'
import Object from './object.tsx'
import s from './results_page.module.scss'
import type { Object as ObjectType } from '../../../shared/types.ts'
const ObjectsPage = () => {
const [objects, setObjects] = useState([])
const [currentObject, setCurrentObject] = useState(null)
const ObjectsPage: FunctionComponent = () => {
const [objects, setObjects] = useState<ObjectType[]>([])
const [currentObject, setCurrentObject] = useState<ObjectType | null>(null)
useEffect(() => {
rek('/api/objects').then(setObjects)
rek('/api/objects').then((objects: ObjectType[]) => setObjects(objects))
}, [])
return (

View File

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

View File

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

View File

@ -5,7 +5,7 @@ const priceFormatter = new Intl.NumberFormat('sv-SE', {
maximumFractionDigits: 0,
})
export const formatPrice = (price) => priceFormatter.format(price)
export const formatPrice = (price: number) => priceFormatter.format(price)
const numberFormatter = new Intl.NumberFormat('sv-SE', {
style: 'decimal',
@ -13,4 +13,4 @@ const numberFormatter = new Intl.NumberFormat('sv-SE', {
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 cn from 'classnames'
export default function buttonFactory({ defaults, icons, styles }) {
const Button: FunctionComponent<{
type Styles<C extends string, D extends string, S extends string> = {
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
className?: string
color?: string
design?: string
color?: C
design?: D
disabled?: boolean
fullWidth?: boolean
icon?: string
icon?: I
iconSize?: string
invert?: boolean
onClick?: PointerEventHandler<HTMLButtonElement>
size?: string
size?: S
tabIndex?: number
tag?: string
title?: string
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,
children,
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 cn from 'classnames'
/**
* @param {{
* base?: string
* touched?: string
* element?: string
* label?: string
* }} styles
* @returns {import('preact').FunctionComponent<{
* autoFocus?: boolean
* className?: string
* defaultChecked?: boolean
* disabled?: boolean
* name: string
* label?: string
* required?: boolean
* }>}
*/
export default function checkboxFactory(styles) {
const Checkbox = ({
type Styles<S extends string> = {
base?: string
element?: string
label?: string
touched?: string
} & Record<S, string>
type Props<S extends string> = {
autoFocus?: boolean
className?: string
defaultChecked?: any
disabled?: boolean
label?: string
name?: string
onChange?: (e: ChangeEvent) => void
required?: boolean
style: S
value?: string
}
export default function checkboxFactory<S extends string = never>(styles: Styles<S>) {
const Checkbox: FunctionComponent<Props<S>> = ({
autoFocus,
className,
defaultChecked,
@ -29,16 +33,16 @@ export default function checkboxFactory(styles) {
label,
onChange,
required,
style = 'normal',
style,
value = 'true',
}) => {
const [touched, setTouched] = useState(false)
const checkboxRef = useRef<HTMLInputElement>()
const checkboxRef = useRef<HTMLInputElement>(null)
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
const form = checkboxRef.current.form
const form = checkboxRef.current!.form!
const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched)
@ -47,7 +51,7 @@ export default function checkboxFactory(styles) {
}, [])
useEffect(() => {
if (autoFocus) checkboxRef.current.focus()
if (autoFocus) checkboxRef.current!.focus()
}, [autoFocus])
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 cn from 'classnames'
// TODO when form resets after update the previous image flashes quickly after setImageSrc(null) and before new defaultValue propagates
const fileUploadFactory = ({ styles }) => {
const FileUpload = ({
type Styles = {
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,
className,
defaultValue,
@ -17,14 +42,14 @@ const fileUploadFactory = ({ styles }) => {
required,
}) => {
const [touched, setTouched] = useState(false)
const inputRef = useRef(null)
const imgRef = useRef(null)
const [imageSrc, setImageSrc] = useState(null)
const inputRef = useRef<HTMLInputElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const onImageChange = useCallback((e) => {
const onImageChange = useCallback((e: Event) => {
e.preventDefault()
const form = inputRef.current.form
const form = inputRef.current!.form!
const onReset = () => {
setTouched(false)
@ -33,8 +58,8 @@ const fileUploadFactory = ({ styles }) => {
form.addEventListener('reset', onReset)
imgRef.current.addEventListener('load', () => URL.revokeObjectURL(imgRef.current.src), { once: true })
setImageSrc(URL.createObjectURL(e.currentTarget.files[0]))
imgRef.current!.addEventListener('load', () => URL.revokeObjectURL(imgRef.current!.src), { once: true })
setImageSrc(URL.createObjectURL((e.currentTarget! as HTMLInputElement).files![0]))
return () => form.removeEventListerner('reset', onReset)
}, [])
@ -66,5 +91,3 @@ const fileUploadFactory = ({ styles }) => {
return FileUpload
}
export default fileUploadFactory

View File

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

View File

@ -1,21 +1,48 @@
import { h, type FunctionComponent } from 'preact'
import cn from 'classnames'
export default function linkButtonFactory({ defaults, icons, styles }) {
const LinkButton: FunctionComponent<{
type Styles<C extends string, D extends string, S extends string> = {
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
className?: string
color?: string
design?: string
color?: C
design?: D
href?: string
fullWidth?: boolean
icon?: string
icon?: I
iconSize?: string
invert?: boolean
size?: string
size?: S
tabIndex?: number
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,
children,
className,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
const scrollingElement = typeof window !== 'undefined' && (document.scrollingElement || document.documentElement)
let scrollTop
let scrollTop: number
export default {
disable() {
@ -15,6 +15,6 @@ export default {
document.body.style.position = ''
document.body.style.top = ''
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, '\\$&')
}

View File

@ -1,3 +1,3 @@
export default (state) => {
export default (state: boolean) => {
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) {
obj[prop] = cn(styles.map((style) => style[prop]))

View File

@ -1,5 +1,5 @@
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[]) {
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
export default function throttle(fnc, wait) {
let timeout
export default function throttle<F extends (...args: any[]) => any>(fnc: F, wait: number) {
let timeout: ReturnType<typeof setTimeout>
let lastCallTime = 0
return function throttled(...args) {
return function throttled(...args: Parameters<F>) {
clearTimeout(timeout)
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
// @ts-ignore
import JSDOM from 'jsdom'
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))
// @ts-ignore
KEYS.forEach((key) => (globalThis[key] = window[key]))
globalThis.document = document

View File

@ -48,6 +48,7 @@
"@babel/core": "^7.26.10",
"@preact/preset-vite": "^2.10.1",
"@testing-library/preact": "^3.2.4",
"@types/d3-dsv": "^3.0.7",
"@types/lodash": "^4.17.16",
"@types/node": "^24.10.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':
specifier: ^3.2.4
version: 3.2.4(preact@10.27.2)
'@types/d3-dsv':
specifier: ^3.0.7
version: 3.0.7
'@types/lodash':
specifier: ^4.17.16
version: 4.17.20
@ -924,6 +927,9 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -2949,6 +2955,8 @@ snapshots:
'@types/aria-query@5.0.4': {}
'@types/d3-dsv@3.0.7': {}
'@types/estree@1.0.8': {}
'@types/lodash@4.17.20': {}

View File

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

View File

@ -1,12 +1,13 @@
import { DatabaseError } from 'pg-protocol'
import StatusError from '../lib/status_error.ts'
import { type ErrorHandler } from '../types.ts'
const databaseErrorCodes = {
const databaseErrorCodes: Record<string, number> = {
23505: 409,
23514: 409,
}
function defaultHandler(error, request, reply) {
const defaultHandler: ErrorHandler = (error, _request, reply) => {
return reply
.type('text/html')
.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) {
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 })
}
reply.log.error({ req: request, err: error }, error?.message)
// @ts-ignore
reply.status(error.status || error.statusCode || 500)
if (request.headers.accept?.includes('application/json')) {
return reply.send(
// @ts-ignore
error.toJSON?.() || {
name: error.name,
message: error.message,
// @ts-ignore
status: error.status || error.statusCode || 500,
},
)

View File

@ -1,3 +1,4 @@
import type { RouteHandler } from 'fastify'
import env from '../env.ts'
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)
}
export default robots

View File

@ -2,6 +2,7 @@ import env from '../env.ts'
if (env.NODE_ENV === 'development') {
// 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 }))
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 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 {
generator: (minify?: boolean) => ParseGenerator
@ -14,16 +14,17 @@ export interface Template {
const cache = new WeakMap()
function apply(expression: Expression) {
if (!expression) {
function apply(expression: Expression): ExpressionPrimitives | Promise<ExpressionPrimitives> {
if (expression == null) {
return ''
} else if ((expression as Promise<ExpressionPrimitives>).then) {
return (expression as Promise<ExpressionPrimitives>).then(apply)
} else if (expression instanceof Promise) {
return expression.then((resolved) => apply(resolved))
} else if (typeof expression === 'function') {
return apply(expression())
} else {
return (expression as ExpressionPrimitives[]).join?.('') ?? expression
} else if (Array.isArray(expression)) {
return expression.join('')
}
return expression
}
function* parse(strings: TemplateStringsArray, expressions: Expression[], minify?: boolean): ParseGenerator {
@ -67,8 +68,8 @@ function stream(generator: ParseGenerator) {
async function text(generator: ParseGenerator) {
let result = ''
for await (const chunk of generator) {
result += chunk
for (const chunk of generator) {
result += chunk instanceof Promise ? await chunk : chunk
}
return result

View File

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

View File

@ -110,9 +110,18 @@ test('parseSRU', (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
let line = '#TRANS 1790 {1 "1"} 7509 "" "Faktura 9631584500436 172-57 - Perspektiv Bredband AB" 0'
let expected = {
let expected: Expected = {
accountNumber: 1790,
objectList: [[1, 1]],
amount: 7509,

View File

@ -3,7 +3,14 @@
const rDim = /#DIM\s+(-?\d+)\s+"([^"]*)"$/
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 {
number: parseInt(number),
@ -30,7 +37,7 @@ export function parseIB(line: string) {
accountNumber: parseInt(accountNumber),
yearNumber: parseInt(yearNumber),
balance: parseFloat(balance),
quantity: parseInt(quantity),
quantity: quantity ? parseInt(quantity) : null,
}
}
@ -45,7 +52,7 @@ export function parseKonto(line: string) {
throw Error('parsing error')
}
const [, number, description] = line.match(rKonto)
const [, number, description] = result
return {
number: parseInt(number),
@ -58,7 +65,14 @@ export function parseKonto(line: string) {
const rObjekt = /^#OBJEKT\s+(\d+)\s+"?(\d+)"?\s"([^"]*)"$/
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 {
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})$/
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 {
yearNumber: parseInt(yearNumber),
@ -87,7 +108,14 @@ export function parseRAR(line: string) {
const rSRU = /^#SRU\s+(\d{4,4})\s+(\d{4,4})$/
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 {
number: parseInt(number),
@ -112,16 +140,16 @@ export function parseTrans(line: string) {
const [, accountNumber, objectListString, amount, transactionDate, description, quantity, signature] = result
let objectList = null
let objectList: [number, number][] | null = null
if (objectListString) {
objectList = []
const result = objectListString.matchAll(rObjectList)
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+(.*))?/
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 {
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 split, { type Decoder } from './split.ts'
@ -41,7 +41,7 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const journals = new Map()
let currentEntry: { id: number; description: string }
let currentInvoiceId: number
let currentInvoiceId: number | null
let currentYear = null
const details: Record<string, string> = {}
@ -214,25 +214,27 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
}
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
}
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)
}
}
if (invoiceId && currentInvoiceId) {
if (invoiceId! && currentInvoiceId!) {
throw new Error('invoiceId and currentInvoiceId')
}
const transactionId = (
await trx('transaction')
.insert({
entryId: currentEntry.id,
entryId: currentEntry!.id,
...transaction,
invoiceId: invoiceId || currentInvoiceId,
invoiceId: invoiceId! || currentInvoiceId!,
})
.returning('id')
)[0].id
@ -269,11 +271,11 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const existingAccountBalance = await trx('accountBalance')
.first('*')
.where({ financialYearId: currentYear.id, accountNumber })
.where({ financialYearId: currentYear!.id, accountNumber })
if (!existingAccountBalance) {
await trx('accountBalance').insert({
financialYearId: currentYear.id,
financialYearId: currentYear!.id,
accountNumber,
out: balance,
outQuantity: quantity,
@ -285,7 +287,7 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
outQuantity: quantity,
})
.where({
financialYearId: currentYear.id,
financialYearId: currentYear!.id,
accountNumber,
})
}

View File

@ -1,9 +1,10 @@
import util from 'node:util'
import chalk from 'chalk'
import _ from 'lodash'
// @ts-ignore
import highlightStack from '@bmp/highlight-stack'
const LEVELS = {
const LEVELS: Record<string, string> = {
default: 'USERLVL',
60: 'FATAL',
50: 'ERROR',
@ -13,7 +14,7 @@ const LEVELS = {
10: 'TRACE',
}
const COLORS = {
const COLORS: Record<string, any> = {
60: chalk.bgRed,
50: chalk.red,
40: chalk.yellow,
@ -24,7 +25,7 @@ const COLORS = {
const requests = new Map()
function colorStatusCode(statusCode) {
function colorStatusCode(statusCode: number) {
if (statusCode < 300) {
return chalk.bold.green(statusCode)
} 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 {
write(line) {
const obj = JSON.parse(line)
write(line: string) {
const obj: LineObject = JSON.parse(line)
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') {
const req = requests.get(obj.reqId)
requests.delete(obj.reqId)
process.stdout.write(
`${chalk.bold(req.method)} ${req.url} ${colorStatusCode(obj.res.statusCode)} ${obj.responseTime.toFixed(
3,
)} ms\n`,
`${chalk.bold(req.method)} ${req.url} ${colorStatusCode((obj as RequestCompletedLineObject).res.statusCode)} ${(
obj as RequestCompletedLineObject
).responseTime.toFixed(3)} ms\n`,
)
} 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)
// looks better than the serialized errors
if (obj.err.status < 500) return
if ((obj as ErrorLineObject).err.status < 500) return
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)) {
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/
export type Decoder = {
decode: (chunk?: ArrayBufferView) => void
decode: (chunk: Uint8Array) => string
}
interface Options {
decoder?: Decoder
}
export default function split(matcher: RegExp, { decoder }: Options = {}) {
export default function split(matcher?: RegExp | null, { decoder }: Options = {}) {
matcher ??= defaultMatcher
let rest: string | null = null
let rest: string | null | undefined = null
return new TransformStream({
start() {},

View File

@ -1,35 +1,7 @@
import { type FastifyPluginCallback } from 'fastify'
import _ from 'lodash'
import fp from 'fastify-plugin'
import { type Template } from '../lib/html.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[]
}
import type { Config, Entry, EntryWithoutName } from './vite/types.ts'
const vitePlugin: FastifyPluginCallback<Config> = async function (fastify, config) {
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 { createServer } from 'vite'
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) {
const devServer = await createServer({
@ -40,7 +41,7 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry) {
if (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)) {
// const preHandler = entry.preHandler || entry.createPreHandler?.(route, entry)
@ -79,9 +80,10 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry) {
throw new StatusError(404)
})
if (entry.createErrorHandler) {
fastify.setErrorHandler(
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(
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) => {
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
@ -121,7 +126,7 @@ function createRenderer(fastify, entry) {
}
}
function createHandler(renderer) {
function createHandler(renderer: Renderer): RouteHandler {
return async function handler(request, reply) {
reply.type('text/html')

View File

@ -1,12 +1,14 @@
import { type FastifyInstance } from 'fastify'
import path from 'node:path'
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 { resolveConfig } from 'vite'
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) {
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) {
const viteFilePath = path.join(viteConfig.build.outDir, entry.name + '.js')
const viteFile = fs.existsSync(viteFilePath)
? await import(viteFilePath)
async function setupEntry(
fastify: FastifyInstance,
entry: Entry,
_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'))
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 routes = entry.routes || viteFile.routes
const manifest: ViteManifest = fs.existsSync(manifestPath)
? (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 renderer = createRenderer(fastify, entry, viteFile.render, manifest)
const renderer = createRenderer(fastify, entry, viteEntryFile.render, manifest)
const cachedHandler = createCachedHandler(cache)
const handler = createHandler(renderer)
@ -90,9 +101,10 @@ async function setupEntry(fastify: FastifyInstance, entry: Entry, config: Parsed
throw new StatusError(404)
})
if (entry.createErrorHandler) {
fastify.setErrorHandler(
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(
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 bundle = path.join('/', files.file)
const preload = files.imports?.map((name) => path.join('/', manifest[name].file))
@ -124,7 +142,7 @@ function createRenderer(fastify, entry, render, manifest) {
script: bundle,
preload,
css,
content: renderPromise?.then((result) => result.content),
content: renderPromise?.then((result) => result.html),
head: renderPromise?.then((result) => result.head),
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) {
reply.type('text/html')
@ -140,7 +158,7 @@ function createHandler(renderer) {
}
}
function createCachedHandler(cache) {
function createCachedHandler(cache: RenderCache): RouteHandler {
return function cachedHandler(request, reply) {
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) {
if (route.cache) {
let html = ''
// TODO run preHandlers
for await (const chunk of renderer(route.path)) {
for await (const chunk of renderer(route.path) as Readable) {
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'
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'>
<head>
<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 {
ctx: { [key: string]: any }
ctx: Record<string, any> | null
}
}

View File

@ -1,15 +1,14 @@
// export const FinancialYear = Type.Object({
// year: Type.Number(),
// startDate: Type.String(),
// endDate: Type.String(),
// })
// export type FinancialYearType = Static<typeof FinancialYear>
export interface Transaction {
export type Account = {
id: number
number: number
description: string
amount: number
}
export type Balance = {
accountNumber: string
description: string
} & Record<number, number>
export interface Entry {
id: number
journal: string
@ -24,3 +23,71 @@ export interface Entry {
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": {
"strict": true,
"noEmit": true,
"target": "esnext",
"jsx": "react-jsx",