duto 0.0.0 → 0.260808.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ import { IMPORT, Datte } from 't0n'
2
+ import { join } from 'pathe'
3
+ import { gray, green, yellow, red, cyan } from 't0n/color'
4
+ import type { UserConfig } from 'vite'
5
+
6
+ import { resolveExtension } from '@/file'
7
+ import config from '@/vite-plugin'
8
+ import { _root } from '@/utils'
9
+ import { match, match404 } from '@/routes'
10
+ import type { Metadata, PluginOptions } from '@/types'
11
+ import { getMetadata } from '@/meta'
12
+
13
+ export async function getConfig(name = 'config'): Promise<UserConfig & PluginOptions> {
14
+ let opts = {} as UserConfig
15
+ const file = resolveExtension(name)
16
+ if (file) opts = await IMPORT(join(_root, file), 'default')
17
+ return config(opts)
18
+ }
19
+
20
+ // export const formatTime = (ms: number) => {
21
+ // if (ms < 1000) return `${ms}ms`
22
+ // return `${(ms / 1000).toFixed(2)}s`
23
+ // }
24
+ export const formatTime = (ms: number) => {
25
+ if (ms < 0.001) return `${(ms * 1_000_000).toFixed(2)}ns`
26
+ if (ms < 1) return `${(ms * 1000).toFixed(2)}µs`
27
+ if (ms < 1000) return `${ms.toFixed(2)}ms`
28
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
29
+ return `${Math.floor(ms / 60000)}m ${((ms % 60000) / 1000).toFixed(1)}s`
30
+ }
31
+
32
+ const statusColorMap = new Map([
33
+ [5, red],
34
+ [4, yellow],
35
+ [3, cyan],
36
+ ])
37
+
38
+ const getStatusColor = (status: number) => (statusColorMap.get(Math.floor(status / 100)) ?? green)(String(status))
39
+
40
+ const clients = new Set<Bun.ServerWebSocket<void>>()
41
+ export function reload() {
42
+ for (const ws of clients) {
43
+ if (ws.readyState === WebSocket.OPEN)
44
+ ws.send('reload')
45
+ }
46
+ }
47
+
48
+ export function preview(
49
+ meta: Metadata,
50
+ host: string = '0.0.0.0',
51
+ port: number = 4173
52
+ ) {
53
+ return Bun.serve({
54
+ hostname: host,
55
+ port,
56
+
57
+ websocket: {
58
+ open(ws) {
59
+ clients.add(ws)
60
+ },
61
+ close(ws) {
62
+ clients.delete(ws)
63
+ },
64
+ message() {},
65
+ },
66
+ async fetch(req, server) {
67
+ const start = performance.now()
68
+
69
+ const url = new URL(req.url)
70
+ const path = decodeURIComponent(url.pathname)
71
+ const headers = {
72
+ 'Access-Control-Allow-Origin': '*',
73
+ 'Access-Control-Allow-Methods': 'GET',
74
+ 'Access-Control-Allow-Headers': '*',
75
+ }
76
+
77
+ if (req.method === 'OPTIONS')
78
+ return new Response(null, { status: 204, headers })
79
+
80
+ if (req.headers.get('upgrade') === 'websocket'
81
+ && req.headers.get('sec-websocket-protocol') === 'duto') {
82
+ server.upgrade(req)
83
+ return
84
+ }
85
+
86
+ let status = 500
87
+ let route: string | undefined
88
+ let matched: Bun.MatchedRoute | null | undefined
89
+
90
+ try {
91
+ let res: Response
92
+ const dist = 'dist' // TODO: get from configs
93
+
94
+ let file = Bun.file(join(_root, dist, path))
95
+
96
+ if (await file.exists()) {
97
+ // route = path
98
+ res = new Response(file)
99
+ } else {
100
+ matched = match(path)
101
+
102
+ // console.log(matched)
103
+ route = matched?.name || match404(meta, path)
104
+
105
+ res = new Response(null, { status: 404 })
106
+
107
+ // for (const asset of path !== '/' && matched && matched.kind === 'exact' ? [
108
+ // path,
109
+ // join(path, 'index'),
110
+ // ] : ['index']) {
111
+ for (const asset of [
112
+ path,
113
+ join(path, 'index'),
114
+ 'index',
115
+ ]) {
116
+ file = Bun.file(join(_root, dist, asset +'.html'))
117
+
118
+ if (await file.exists()) {
119
+ // if (route === '/' || !route) route = asset
120
+ res = new Response(file)
121
+ break
122
+ }
123
+ }
124
+ }
125
+
126
+ status = res.status
127
+ return res
128
+ } catch (err) {
129
+ status = 500
130
+ throw err
131
+ } finally {
132
+ if (route || matched) {
133
+ const elapsed = `${Math.max(1, Math.ceil(performance.now() - start))}ms`
134
+
135
+ console.log([
136
+ gray(Datte.dateTime()),
137
+ getStatusColor(status),
138
+ path,
139
+ route && route !== path ? gray(route) : '',
140
+ gray(elapsed),
141
+ ].filter(Boolean).join(' '))
142
+ }
143
+ }
144
+ },
145
+ })
146
+ }
@@ -0,0 +1,123 @@
1
+ // @ts-ignore
2
+ import ':duto/client/hmr'
3
+
4
+ import { render, hydrate, h } from 'preact'
5
+ import { Switch, Route, Router } from 'wouter-preact'
6
+ import { useLocation, useParams, useSearchParams, } from 'wouter-preact'
7
+
8
+ import {
9
+ fallbackLocale, locales, messages, routes, routeMap,
10
+ // @ts-ignore
11
+ } from ':duto/i18n'
12
+ // import i18n from '../i18n/client'
13
+ import i18n, { updatePickers } from '../i18n/client'
14
+
15
+ i18n(fallbackLocale, locales, messages, routes, routeMap)
16
+
17
+ import EI18n from '../i18n/element'
18
+ import Island from './island'
19
+ import Link from './link'
20
+
21
+ // import Router from './router'
22
+
23
+ import {
24
+ views, SRoutes,
25
+ // @ts-ignore
26
+ } from ':duto'
27
+ // @ts-ignore
28
+ import ':root/styles'
29
+
30
+ window.$duto = {
31
+ sroutes: SRoutes,
32
+ location: '/',
33
+ navigate: {},
34
+ params: {},
35
+ search: {},
36
+ setSearch: {},
37
+ }
38
+ import { renderRoute } from './router'
39
+ // import { navigate } from 'wouter-preact/use-hash-location'
40
+
41
+ for (let [tag, el, opts] of [
42
+ ['i-i', EI18n],
43
+ ['i-l', Link, { extends: 'a' }],
44
+ ['i-s', Island],
45
+ ] as [string, new () => HTMLElement, ElementDefinitionOptions?][]) {
46
+ if (!customElements.get(tag))
47
+ customElements.define(tag, el, opts)
48
+ }
49
+ // Router.load(SRoutes).mount(views)
50
+
51
+ // render(h(Router, null,
52
+ // h(() => {
53
+ // // const __ = useRoute(location.pathname)
54
+ // document.body.removeAttribute('hidden')
55
+ // const [, navigate] = useLocation()
56
+ // // console.log(navigate)
57
+ // // this.navigate = navigate
58
+
59
+ // // TODO: talvez registrar todos os valores pelo id do elemento em uma fila e atualizar nesse tick...
60
+ // // input, select etc registra um update val e aqui popula..
61
+ // updatePickers()
62
+ // return null
63
+ // }, null),
64
+ // h(Switch, null,
65
+ // h(Route, { path: SRoutes.has(location.pathname) ? location.pathname : '/' }),
66
+ // ...views.map(([path, Component, defs, getProps]) =>
67
+ // h(Route, {
68
+ // path,
69
+ // component: ({ params }: { params: Record<string, string> }) => {
70
+ // // Trigger async render (updates container imperatively to avoid
71
+ // // React/Preact hook rules issues with async components)
72
+
73
+ // renderRoute(document.body, Component, params, defs, getProps)
74
+ // // Return null — renderRoute will call render() directly on the container
75
+ // return null
76
+ // },
77
+ // })
78
+ // ),
79
+
80
+ // ) ), document.body)
81
+ render(<Router>
82
+ {h(() => {
83
+ document.body.removeAttribute('hidden')
84
+ const [location, navigate] = useLocation()
85
+ window.$duto.location = location
86
+ window.$duto.navigate = navigate
87
+ window.$duto.params = useParams()
88
+ const [search, setSearch] = useSearchParams()
89
+ window.$duto.search = search
90
+ window.$duto.setSearch = setSearch
91
+
92
+ // TODO: talvez registrar todos os valores pelo id do elemento em uma fila e atualizar nesse tick...
93
+ // input, select etc registra um update val e aqui popula..
94
+ updatePickers()
95
+
96
+ // React.useEffect(() => {
97
+ // document.body.removeAttribute('hidden');
98
+ // navigateRef.current = navigate;
99
+ // updatePickers();
100
+ // }, [navigate]);
101
+ // return null;
102
+ }, null)}
103
+ <Switch>
104
+ <Route path={SRoutes.has(location.pathname) ? location.pathname : '/'} component={({ params }) => {
105
+ delete params[0]
106
+ window.$duto.route = { pattern: location.pathname, params, path: location.pathname }
107
+ }} />
108
+ {/* @ts-ignore */}
109
+ {...views.map(([path, Component, defs, getProps, layouts]) => (
110
+ <Route path={path} component={({ params }: { params: Record<string, string> }) => {
111
+ // Trigger async render (updates container imperatively to avoid
112
+ // React/Preact hook rules issues with async components)
113
+
114
+ delete params[0]
115
+ window.$duto.route = { pattern: path, params, path: location.pathname }
116
+
117
+ renderRoute(document.body, Component, params, defs, getProps, layouts)
118
+ // Return null — renderRoute will call render() directly on the container
119
+ return null
120
+ }} />
121
+ ))}
122
+ </Switch>
123
+ </Router>, document.body)
@@ -0,0 +1,13 @@
1
+ let hmr: WebSocket | null = null
2
+ const url = `ws${location.protocol === 'https:' ? 'ws' : ''}://${location.host}`
3
+
4
+ const connect = () => {
5
+ hmr = new WebSocket(url, 'duto')
6
+ hmr.onmessage = ({ data }) => data === 'reload' && location.reload()
7
+ hmr.onerror = () => hmr?.close()
8
+ }
9
+ const reconect = () => (!hmr || hmr?.readyState === WebSocket.CLOSED) && location.reload()
10
+ connect()
11
+ document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' && reconect())
12
+ window.addEventListener('focus', reconect)
13
+ window.addEventListener('pageshow', reconect)
@@ -0,0 +1,136 @@
1
+ import hydrator from '../preact/client'
2
+
3
+ const propTypes: { [k: string]: (value: any) => any } = {
4
+ 0: (value) => reviveObject(value),
5
+ 1: (value) => reviveArray(value),
6
+ 2: (value) => new RegExp(value),
7
+ 3: (value) => new Date(value),
8
+ 4: (value) => new Map(reviveArray(value)),
9
+ 5: (value) => new Set(reviveArray(value)),
10
+ 6: (value) => BigInt(value),
11
+ 7: (value) => new URL(value),
12
+ 8: (value) => new Uint8Array(value),
13
+ 9: (value) => new Uint16Array(value),
14
+ 10: (value) => new Uint32Array(value),
15
+ 11: (value) => Number.POSITIVE_INFINITY * value,
16
+ }
17
+
18
+ // Not using JSON.parse reviver because it's bottom-up but we want top-down
19
+ const reviveTuple = (raw: any): any => {
20
+ const [type, value] = raw
21
+ return type in propTypes ? propTypes[type](value) : undefined
22
+ }
23
+
24
+ const reviveArray = (raw: any): any => (raw as Array<any>).map(reviveTuple)
25
+ const reviveObject = (raw: any): any => {
26
+ if (typeof raw !== 'object' || raw === null) return raw
27
+ return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveTuple(value)]))
28
+ }
29
+
30
+ // 🌊🏝🌴
31
+ export default class Island extends HTMLElement {
32
+ public Component: any
33
+ private _hydrated = false
34
+ static observedAttributes = ['props']
35
+
36
+ connectedCallback() {
37
+ if (!this.hasAttribute('await-children') || document.readyState === 'interactive' || document.readyState === 'complete') {
38
+ this.build()
39
+ return
40
+ }
41
+
42
+ setTimeout(() => {
43
+ this.build()
44
+ }, 0)
45
+ }
46
+
47
+ disconnectedCallback() {
48
+ // document.removeEventListener('d:after-swap', this.unmount);
49
+ // document.addEventListener('d:after-swap', this.unmount, { once: true });
50
+
51
+ this.unmount()
52
+ }
53
+
54
+ attributeChangedCallback(name: string, oldValue: string, newValue: string) {
55
+ if (this.Component) {
56
+ // console.log(`[AttributeChanged] O atributo "${name}" mudou de "${oldValue}" para "${newValue}".`, this)
57
+ this._hydrated = false
58
+ this.hydrate()
59
+ }
60
+ }
61
+
62
+ async build() {
63
+ const componentModule = await import(this.getAttribute('url')!)
64
+ const componentExport = this.getAttribute('export') || 'default'
65
+ if (!componentExport.includes('.')) {
66
+ this.Component = componentModule[componentExport]
67
+ } else {
68
+ this.Component = componentModule
69
+ for (const part of componentExport.split('.'))
70
+ this.Component = this.Component[part]
71
+ }
72
+
73
+ await this.hydrate()
74
+ }
75
+
76
+ // hydrate = async () => {
77
+ async hydrate() {
78
+ // Make sure the island is mounted on the DOM before hydrating. It could be unmounted
79
+ // when the parent island hydrates and re-creates this island.
80
+ if (!this.isConnected || this._hydrated) return
81
+
82
+ // Wait for parent island to hydrate first so we hydrate top-down. The `ssr` attribute
83
+ // represents that it has not completed hydration yet.
84
+ const parent = this.parentElement?.closest('i-s')
85
+ if (parent) {
86
+ parent.addEventListener('d:hydrate', this.hydrate, { once: true })
87
+ return
88
+ }
89
+
90
+ const slotted = this.querySelectorAll('d-b')
91
+ const slots: Record<string, string> = {};
92
+ // Always check to see if there are templates.
93
+ // This happens if slots were passed but the client component did not render them
94
+ const templates = this.querySelectorAll('template[d-template]')
95
+ for (const template of templates) {
96
+ const closest = template.closest(this.tagName)
97
+ if (!closest?.isSameNode(this)) continue
98
+ slots[template.getAttribute('d-template') || 'default'] = template.innerHTML
99
+ template.remove()
100
+ }
101
+ for (const slot of slotted) {
102
+ const closest = slot.closest(this.tagName)
103
+ if (!closest?.isSameNode(this)) continue
104
+ slots[slot.getAttribute('name') || 'default'] = slot.innerHTML
105
+ }
106
+
107
+ let props: Record<string, unknown>
108
+
109
+ try {
110
+ props = this.hasAttribute('props') ? reviveObject(JSON.parse(this.getAttribute('props')!)) : {}
111
+ } catch (e) {
112
+ let componentName: string = this.getAttribute('url') || '<unknown>'
113
+ const componentExport = this.getAttribute('export')
114
+
115
+ if (componentExport)
116
+ componentName += ` (export ${componentExport})`
117
+
118
+ console.error(
119
+ `[hydrate] Error parsing props for component ${componentName}`,
120
+ this.getAttribute('props'),
121
+ e,
122
+ )
123
+ throw e
124
+ }
125
+
126
+ // @ts-ignore
127
+ await hydrator(this, this.Component, props, slots)
128
+
129
+ this._hydrated = true
130
+ this.dispatchEvent(new CustomEvent('d:hydrate'))
131
+ }
132
+
133
+ unmount() {
134
+ if (!this.isConnected) this.dispatchEvent(new CustomEvent('d:unmount'))
135
+ }
136
+ }
@@ -0,0 +1,70 @@
1
+ export default class Link extends HTMLAnchorElement {
2
+ static observedAttributes = [
3
+ 'href',
4
+ 'target',
5
+ 'download',
6
+ 'rel',
7
+ 'hreflang',
8
+ 'referrerpolicy',
9
+ 'ping',
10
+ 'type',
11
+ ]
12
+
13
+ constructor() {
14
+ super()
15
+
16
+ this.addEventListener('click', this.#onClick)
17
+ }
18
+
19
+ disconnectedCallback() {
20
+ this.removeEventListener('click', this.#onClick)
21
+ }
22
+
23
+ #onClick = (e: MouseEvent) => {
24
+ // Respeita comportamento padrão
25
+ if (
26
+ e.defaultPrevented
27
+ || this.target === '_blank'
28
+ || e.metaKey
29
+ || e.ctrlKey
30
+ || e.shiftKey
31
+ || e.altKey
32
+ || e.button !== 0
33
+ ) {
34
+ return
35
+ }
36
+ // Allow ctrl/cmd+click to open in new tab normally
37
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
38
+ // e.preventDefault()
39
+ if ((e.target as HTMLAnchorElement).hasAttribute('href'))
40
+ window.dispatchEvent(new CustomEvent('d:navigate', { detail: e.target }))
41
+
42
+ // Exemplo: interceptar navegação SPA
43
+ // event.preventDefault()
44
+ // router.push(this.href)
45
+ }
46
+
47
+ // Proxy opcional para facilitar typings
48
+ get url() {
49
+ return new URL(this.href)
50
+ }
51
+ }
52
+
53
+
54
+ window.addEventListener('d:navigate', e => {
55
+ const el = (e as CustomEvent<HTMLAnchorElement>).detail
56
+
57
+ console.warn('d:navigate', el)
58
+ // if (href) _navigate?.(href)
59
+ // _navigate?.('/haha-2')
60
+ })
61
+
62
+ // const links = document.querySelectorAll('a[d]')
63
+ // for (const link of links) { // @ts-ignore
64
+ // link.addEventListener('click', (e: MouseEvent) => {
65
+ // // Allow ctrl/cmd+click to open in new tab normally
66
+ // if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
67
+ // e.preventDefault()
68
+ // window.dispatchEvent(new CustomEvent('d:navigate', { detail: e.target }))
69
+ // })
70
+ // }