amateras 0.5.0 → 0.6.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.
- package/README.md +23 -26
- package/ext/html/node/$Anchor.ts +2 -2
- package/ext/html/node/$Canvas.ts +2 -2
- package/ext/html/node/$Dialog.ts +2 -2
- package/ext/html/node/$Form.ts +2 -2
- package/ext/html/node/$Image.ts +2 -2
- package/ext/html/node/$Input.ts +2 -2
- package/ext/html/node/$Label.ts +2 -2
- package/ext/html/node/$Media.ts +2 -2
- package/ext/html/node/$OptGroup.ts +2 -2
- package/ext/html/node/$Option.ts +2 -2
- package/ext/html/node/$Select.ts +2 -2
- package/ext/html/node/$TextArea.ts +2 -2
- package/ext/i18n/README.md +20 -0
- package/ext/i18n/src/index.ts +106 -12
- package/ext/i18n/src/structure/I18n.ts +12 -8
- package/ext/i18n/src/structure/I18nTranslation.ts +35 -0
- package/ext/idb/src/structure/builder/$IDBBuilder.ts +8 -8
- package/ext/markdown/README.md +53 -0
- package/ext/markdown/package.json +7 -0
- package/ext/markdown/src/index.ts +3 -0
- package/ext/markdown/src/lib/type.ts +26 -0
- package/ext/markdown/src/lib/util.ts +21 -0
- package/ext/markdown/src/structure/Markdown.ts +54 -0
- package/ext/markdown/src/structure/MarkdownLexer.ts +111 -0
- package/ext/markdown/src/structure/MarkdownParser.ts +33 -0
- package/ext/markdown/src/syntax/alert.ts +46 -0
- package/ext/markdown/src/syntax/blockquote.ts +35 -0
- package/ext/markdown/src/syntax/bold.ts +11 -0
- package/ext/markdown/src/syntax/code.ts +11 -0
- package/ext/markdown/src/syntax/codeblock.ts +44 -0
- package/ext/markdown/src/syntax/heading.ts +14 -0
- package/ext/markdown/src/syntax/horizontalRule.ts +11 -0
- package/ext/markdown/src/syntax/image.ts +23 -0
- package/ext/markdown/src/syntax/italic.ts +11 -0
- package/ext/markdown/src/syntax/link.ts +46 -0
- package/ext/markdown/src/syntax/list.ts +121 -0
- package/ext/markdown/src/syntax/table.ts +67 -0
- package/ext/markdown/src/syntax/text.ts +19 -0
- package/ext/router/README.md +111 -17
- package/ext/router/package.json +10 -0
- package/ext/router/src/index.ts +69 -0
- package/ext/router/src/node/Page.ts +34 -0
- package/ext/router/src/node/Router.ts +191 -0
- package/ext/router/{node → src/node}/RouterAnchor.ts +13 -2
- package/ext/router/src/structure/PageBuilder.ts +24 -0
- package/ext/router/src/structure/Route.ts +105 -0
- package/ext/signal/README.md +93 -0
- package/ext/signal/package.json +9 -0
- package/ext/signal/src/index.ts +128 -0
- package/{src → ext/signal/src}/structure/Signal.ts +6 -10
- package/ext/ssr/index.ts +4 -4
- package/ext/ui/lib/VirtualScroll.ts +25 -0
- package/ext/ui/node/Accordian.ts +97 -0
- package/ext/ui/node/Form.ts +53 -0
- package/ext/ui/node/Grid.ts +0 -0
- package/ext/ui/node/Table.ts +43 -0
- package/ext/ui/node/Tabs.ts +114 -0
- package/ext/ui/node/Toast.ts +16 -0
- package/ext/ui/node/Waterfall.ts +72 -0
- package/ext/ui/package.json +11 -0
- package/package.json +6 -3
- package/src/core.ts +30 -60
- package/src/global.ts +9 -2
- package/src/index.ts +1 -2
- package/src/lib/assignProperties.ts +57 -0
- package/src/lib/native.ts +25 -8
- package/src/lib/uppercase.ts +3 -0
- package/src/node/$Element.ts +7 -41
- package/src/node/$EventTarget.ts +45 -0
- package/src/node/$Node.ts +60 -65
- package/src/node/$Virtual.ts +65 -0
- package/src/node.ts +7 -6
- package/ext/i18n/src/node/I18nText.ts +0 -35
- package/ext/markdown/index.ts +0 -121
- package/ext/router/index.ts +0 -73
- package/ext/router/node/Page.ts +0 -27
- package/ext/router/node/Route.ts +0 -54
- package/ext/router/node/Router.ts +0 -149
- package/src/lib/assign.ts +0 -38
- package/src/lib/assignHelper.ts +0 -18
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { BLOCK, TABLE, TABLE_COLUMN, TABLE_ROW } from "#lib/type";
|
|
2
|
+
import { htmltag, setBlockTokenizer, setProcessor } from "#lib/util";
|
|
3
|
+
import type { BlockToken, MarkdownLexer } from "#structure/MarkdownLexer";
|
|
4
|
+
import type { MarkdownParser } from "#structure/MarkdownParser";
|
|
5
|
+
import { _Array_from } from "amateras/lib/native";
|
|
6
|
+
|
|
7
|
+
export const tableProcessor = (parser: MarkdownParser) => setProcessor(parser, TABLE, (token) => {
|
|
8
|
+
let thead = '';
|
|
9
|
+
let tbody = '';
|
|
10
|
+
let rowIndex = 0;
|
|
11
|
+
for (const row of token.content!) {
|
|
12
|
+
let rowHTML = '';
|
|
13
|
+
for (let i = 0; i < row.content!.length; i++) {
|
|
14
|
+
const col = row.content![i]!;
|
|
15
|
+
const align = token.data!.align[i];
|
|
16
|
+
const tagname = rowIndex === 0 ? 'th' : 'td';
|
|
17
|
+
rowHTML += `<${tagname} align="${align ?? 'left'}">${parser.parse(col.content!)}</${tagname}>`
|
|
18
|
+
}
|
|
19
|
+
if (rowIndex === 0) thead += htmltag('thead', htmltag('tr', rowHTML));
|
|
20
|
+
else tbody += htmltag('tr', rowHTML);
|
|
21
|
+
rowIndex++
|
|
22
|
+
}
|
|
23
|
+
tbody = htmltag('tbody', tbody);
|
|
24
|
+
return htmltag('table', thead + tbody)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const tableTokenizer = (lexer: MarkdownLexer) => setBlockTokenizer(lexer, TABLE, {
|
|
28
|
+
regex: /\|(?:.+\|)+/,
|
|
29
|
+
handle(_, position, lines) {
|
|
30
|
+
const tokens: BlockToken[] = [];
|
|
31
|
+
const align = []
|
|
32
|
+
while (position < lines.length) {
|
|
33
|
+
const row: BlockToken = {
|
|
34
|
+
type: TABLE_ROW,
|
|
35
|
+
layout: BLOCK,
|
|
36
|
+
content: []
|
|
37
|
+
}
|
|
38
|
+
const line = lines[position]!;
|
|
39
|
+
const matches = _Array_from(line.matchAll(/\| ([^|]+)/g));
|
|
40
|
+
if (!matches.length) break;
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
const text = match[1]!;
|
|
43
|
+
const separator = text.match(/(:)?---+(:)?/);
|
|
44
|
+
if (separator) {
|
|
45
|
+
const [_, LEFT, RIGHT] = separator;
|
|
46
|
+
align.push(RIGHT ? LEFT ? 'center' : 'right' : 'left');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
row.content.push({
|
|
50
|
+
type: TABLE_COLUMN,
|
|
51
|
+
content: lexer.inlineTokenize(text.trim()),
|
|
52
|
+
layout: BLOCK
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
if (row.content.length) tokens.push(row);
|
|
56
|
+
position++
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
content: tokens,
|
|
60
|
+
data: { align },
|
|
61
|
+
multiLine: {
|
|
62
|
+
skip: position,
|
|
63
|
+
tokens: []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { EMPTY_LINE, TEXT, TEXT_LINE } from "#lib/type";
|
|
2
|
+
import { htmltag, setProcessor } from "#lib/util";
|
|
3
|
+
import type { MarkdownParser } from "#structure/MarkdownParser";
|
|
4
|
+
|
|
5
|
+
export const textProcessor = (parser: MarkdownParser) => setProcessor(parser, TEXT, token => token.text!);
|
|
6
|
+
|
|
7
|
+
export const textLineProcessor = (parser: MarkdownParser) => setProcessor(parser, TEXT_LINE, (_, tokens) => {
|
|
8
|
+
let html = '';
|
|
9
|
+
let i = 0;
|
|
10
|
+
for (const token of tokens) {
|
|
11
|
+
if (token.type === EMPTY_LINE) break;
|
|
12
|
+
html += parser.parse(token.content!);
|
|
13
|
+
i++;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
html: htmltag('p', html),
|
|
17
|
+
skipTokens: i
|
|
18
|
+
};
|
|
19
|
+
})
|
package/ext/router/README.md
CHANGED
|
@@ -9,8 +9,8 @@ import 'amateras/router';
|
|
|
9
9
|
## Create Route Map
|
|
10
10
|
```ts
|
|
11
11
|
// create home page route
|
|
12
|
-
const HomePage =
|
|
13
|
-
.pageTitle('Home')
|
|
12
|
+
const HomePage = $.route(page => page
|
|
13
|
+
.pageTitle('Home | My Site') // set window title
|
|
14
14
|
.content([
|
|
15
15
|
$('h1').content('Home')
|
|
16
16
|
])
|
|
@@ -24,6 +24,9 @@ $(document.body).content([
|
|
|
24
24
|
])
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
> [!NOTE]
|
|
28
|
+
> Don't forget to `.listen()` the path change!
|
|
29
|
+
|
|
27
30
|
## Router Anchor
|
|
28
31
|
Use `RouterAnchor` to prevent load page when open link by default `HTMLAnchorElement` element.
|
|
29
32
|
```ts
|
|
@@ -36,46 +39,137 @@ $('ra').content('Contact').href('/contact');
|
|
|
36
39
|
- `$.forward()`: Forward page.
|
|
37
40
|
- `$.back()`: Back page.
|
|
38
41
|
|
|
39
|
-
##
|
|
42
|
+
## Async Route
|
|
43
|
+
```ts
|
|
44
|
+
// ./page/home_page.ts
|
|
45
|
+
export default $.route(page => {
|
|
46
|
+
return page
|
|
47
|
+
.content([
|
|
48
|
+
$('h1').content('Home Page')
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ./router.ts
|
|
53
|
+
$('router')
|
|
54
|
+
.route('/about', () => import('./page/home_page'))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Path Parameter
|
|
58
|
+
TypeScript will parse the parameter in the path, parameter always start with `:`, after the colon comes the name of the parameter. You can access theses parameter using `Page.params`.
|
|
59
|
+
|
|
40
60
|
```ts
|
|
41
61
|
$('router')
|
|
42
62
|
.route('/user/@:username', page => {
|
|
43
|
-
console.log(page.params)
|
|
63
|
+
console.log(page.params.username);
|
|
64
|
+
return page;
|
|
44
65
|
})
|
|
45
|
-
.route('/posts?search'), page => {
|
|
46
|
-
console.log(page.query)
|
|
47
|
-
}
|
|
48
66
|
.listen()
|
|
49
67
|
// simulate page open
|
|
50
|
-
.resolve('/user/@amateras') //
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
.resolve('/user/@amateras') // 'amateras'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If you want separate router and route builder to different file, use generic parameter to define parameter name on `$.route` method.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// greating_page.ts
|
|
75
|
+
export default $.route<['name']>(page => {
|
|
76
|
+
return page
|
|
77
|
+
.content(`Hello, ${name}`)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// router.ts
|
|
81
|
+
$('router')
|
|
82
|
+
.route('/greating', () => import('./greating_page'))
|
|
83
|
+
// ^ typescript wiil report an error, the route builder required 'name' parameter
|
|
84
|
+
|
|
85
|
+
.route('/greating/:name', () => import('./greating_page'))
|
|
86
|
+
// ^ pass
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Optional Parameter
|
|
90
|
+
Sometime we parameter can be optional, you can define the optional parameter by add `?` sign after the name of the parameter.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const userPage = $.route<'photoId', 'postId?'>(page => {
|
|
94
|
+
return page
|
|
95
|
+
.content([
|
|
96
|
+
`Photo ID: ${page.params.photoId}`, // photoId: string
|
|
97
|
+
`Post ID: ${page.params.postId}` // postId: string | undefined
|
|
98
|
+
])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
$('router')
|
|
102
|
+
.route('/photos/:photoId', userPage)
|
|
103
|
+
// ^ pass
|
|
104
|
+
.route('/posts/:postId/photos/:photoId', userPage)
|
|
105
|
+
// ^ pass
|
|
53
106
|
```
|
|
54
107
|
|
|
55
108
|
## Nesting Route
|
|
109
|
+
`Router` element is the container of `Page` element, we can archieve nesting route by create `Router` and append it inside `Page`.
|
|
56
110
|
```ts
|
|
57
|
-
const ContactPage =
|
|
111
|
+
const ContactPage = $.route(page => page
|
|
58
112
|
.pageTitle('Home')
|
|
59
113
|
.content([
|
|
60
114
|
$('h1').content('Contact'),
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
$('router', page)
|
|
116
|
+
// here is the magic happened,
|
|
117
|
+
// pass the Page into router arguments
|
|
63
118
|
])
|
|
64
119
|
)
|
|
120
|
+
```
|
|
65
121
|
|
|
66
|
-
|
|
122
|
+
Then, we need to declare the router map like this:
|
|
67
123
|
|
|
124
|
+
```ts
|
|
68
125
|
$('router')
|
|
69
126
|
.route('/', HomePage)
|
|
70
127
|
.route('/contact', ContactPage, route => route
|
|
128
|
+
// we can define more child routes inside this '/contact' route!
|
|
71
129
|
.route('/', () => 'My name is Amateras.')
|
|
72
130
|
.route('/phone', () => '0123456789')
|
|
73
|
-
.route('/email', ContactEmailPage)
|
|
74
131
|
)
|
|
75
132
|
```
|
|
76
133
|
|
|
77
|
-
|
|
134
|
+
### Alias Path
|
|
135
|
+
|
|
136
|
+
Sometime, the page doesn't have just one path. We can declare the alias paths to one route!
|
|
137
|
+
|
|
78
138
|
```ts
|
|
79
139
|
$('router')
|
|
80
|
-
.route('/
|
|
140
|
+
.route('/', HomePage, route => route
|
|
141
|
+
.alias('/home')
|
|
142
|
+
.alias('/the/another/way/to/home')
|
|
143
|
+
// ... more alias path
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
What if the main path included parameters? Here is how to deal with it:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
$('router')
|
|
151
|
+
.route('/users/:username', UserPage, route = route
|
|
152
|
+
.alias('/u/:username')
|
|
153
|
+
.alias('/profile')
|
|
154
|
+
// ^ typescript will report an error
|
|
155
|
+
|
|
156
|
+
.alias('/profile', { username: 'amateras' })
|
|
157
|
+
// ^ pass, the params required is fulfilled
|
|
158
|
+
|
|
159
|
+
.alias('/profile', () => { return { username: getUsername() } })
|
|
160
|
+
// ^ even you can pass an arrow function!
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Group Path
|
|
165
|
+
|
|
166
|
+
There have a lot of paths got same prefix? We provide the solution:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
$('router')
|
|
170
|
+
.route('/', HomePage)
|
|
171
|
+
.group('/search', route => route
|
|
172
|
+
.route('/', SearchPage)
|
|
173
|
+
.route('/users', SearchUserPage)
|
|
174
|
+
)
|
|
81
175
|
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Page } from "#node/Page";
|
|
2
|
+
import { Router } from "#node/Router";
|
|
3
|
+
import { PageBuilder } from "#structure/PageBuilder";
|
|
4
|
+
import { Route, type RouteBuilder, type RouteParams } from "#structure/Route";
|
|
5
|
+
import { _bind, _Object_assign, forEach } from "../../../src/lib/native";
|
|
6
|
+
import type { AnchorTarget } from "../../html/node/$Anchor";
|
|
7
|
+
|
|
8
|
+
declare module 'amateras/core' {
|
|
9
|
+
// export function $(nodeName: 'ra'): RouterAnchor;
|
|
10
|
+
export namespace $ {
|
|
11
|
+
export function route<Params extends RouteParams = []>(builder: (page: Page<Params>) => Page<Params>): PageBuilder<Params>;
|
|
12
|
+
export function open(url: string | URL | Nullish, target?: AnchorTarget): typeof Router;
|
|
13
|
+
export function replace(url: string | URL | Nullish): typeof Router;
|
|
14
|
+
export function back(): typeof Router;
|
|
15
|
+
export function forward(): typeof Router;
|
|
16
|
+
export interface $NodeMap {
|
|
17
|
+
'router': typeof Router;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare global {
|
|
23
|
+
interface GlobalEventHandlersEventMap {
|
|
24
|
+
'routeopen': Event;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let prototype = {
|
|
29
|
+
route(this: { routes: Map<string, Route> }, path: string, builder: RouteBuilder, handle?: (route: Route) => Route) {
|
|
30
|
+
const route = new Route<any>(path, builder);
|
|
31
|
+
handle?.(route);
|
|
32
|
+
this.routes.set(path, route);
|
|
33
|
+
return this;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
group(this: { routes: Map<string, Route> }, path: string, handle: (route: Route) => Route) {
|
|
37
|
+
this.routes.set(path, handle(new Route<any>(path)))
|
|
38
|
+
return this;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
notFound(this: { routes: Map<string, Route> }, builder: RouteBuilder) {
|
|
42
|
+
this.routes.set('notfound', new Route('notfound', builder));
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// assign methods
|
|
48
|
+
_Object_assign(Router.prototype, prototype)
|
|
49
|
+
_Object_assign(Route.prototype, prototype)
|
|
50
|
+
_Object_assign($, {
|
|
51
|
+
route: (builder: (page: Page) => Page) => new PageBuilder(builder),
|
|
52
|
+
open: _bind(Router.open, Router),
|
|
53
|
+
replace: _bind(Router.replace, Router),
|
|
54
|
+
back: _bind(Router.back, Router),
|
|
55
|
+
forward: _bind(Router.forward, Router)
|
|
56
|
+
})
|
|
57
|
+
// assign node
|
|
58
|
+
$.assign(['router', Router])
|
|
59
|
+
// use style
|
|
60
|
+
forEach([
|
|
61
|
+
`router{display:block}`,
|
|
62
|
+
`page{display:block}`
|
|
63
|
+
], $.style);
|
|
64
|
+
|
|
65
|
+
export * from '#node/Page';
|
|
66
|
+
export * from '#node/Router';
|
|
67
|
+
export * from '#node/RouterAnchor';
|
|
68
|
+
export * from '#structure/PageBuilder';
|
|
69
|
+
export * from '#structure/Route';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RouteParams } from "#structure/Route";
|
|
2
|
+
import { $HTMLElement } from "amateras/node/$HTMLElement";
|
|
3
|
+
import { _null } from "../../../../src/lib/native";
|
|
4
|
+
import type { Router } from "./Router";
|
|
5
|
+
import { chain } from "../../../../src/lib/chain";
|
|
6
|
+
|
|
7
|
+
export class Page<Params extends RouteParams = []> extends $HTMLElement {
|
|
8
|
+
params: PageParamsResolver<Params>;
|
|
9
|
+
router: null | Router = _null
|
|
10
|
+
#pageTitle: string | null = _null;
|
|
11
|
+
built = false;
|
|
12
|
+
constructor(params: PageParamsResolver<Params>) {
|
|
13
|
+
super('page');
|
|
14
|
+
this.params = params;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pageTitle(): string | null;
|
|
18
|
+
pageTitle(title: string | null): this;
|
|
19
|
+
pageTitle(title?: string | null) {
|
|
20
|
+
return chain(this, arguments, () => this.#pageTitle, title, title => this.#pageTitle = title)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PageParams = { [key: string]: string }
|
|
25
|
+
export type PageParamsResolver<Params extends string[]> =
|
|
26
|
+
Prettify<
|
|
27
|
+
Params extends [`${infer String}`, ...infer Rest]
|
|
28
|
+
? Rest extends string[]
|
|
29
|
+
? String extends `${infer Key}?`
|
|
30
|
+
? { [key in Key]?: string } & PageParamsResolver<Rest>
|
|
31
|
+
: { [key in String]: string } & PageParamsResolver<Rest>
|
|
32
|
+
: never
|
|
33
|
+
: {}
|
|
34
|
+
>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { $HTMLElement } from "amateras/node/$HTMLElement";
|
|
2
|
+
import { Route, type RouteBuilder, type RoutePath, type RouteParamsResolver, type RouteParams, type RouteParamsStrings, type AsyncPageBuilder } from "../structure/Route";
|
|
3
|
+
import { _document } from "amateras/lib/env";
|
|
4
|
+
import { _instanceof, startsWith, _JSON_parse, forEach, _Object_entries, _JSON_stringify, _Object_assign, isFunction, _null } from "../../../../src/lib/native";
|
|
5
|
+
import type { AnchorTarget } from "../../../html/node/$Anchor";
|
|
6
|
+
import { Page, type PageParams } from "./Page";
|
|
7
|
+
import type { PageBuilder, PageBuilderFunction } from "#structure/PageBuilder";
|
|
8
|
+
// history index
|
|
9
|
+
let index = 0;
|
|
10
|
+
const _addEventListener = addEventListener;
|
|
11
|
+
const _location = location;
|
|
12
|
+
const {origin} = _location;
|
|
13
|
+
const _history = history;
|
|
14
|
+
const _sessionStorage = sessionStorage;
|
|
15
|
+
const documentElement = _document.documentElement;
|
|
16
|
+
const [PUSH, REPLACE] = [1, 2] as const;
|
|
17
|
+
const [FORWARD, BACK] = ['forward', 'back'] as const;
|
|
18
|
+
const scrollStorageKey = '__scroll__';
|
|
19
|
+
/** convert path string to URL object */
|
|
20
|
+
const toURL = (path: string | URL) =>
|
|
21
|
+
_instanceof(path, URL) ? path : startsWith(path, 'http') ? new URL(path) : new URL(startsWith(path, origin) ? path : origin + path);
|
|
22
|
+
|
|
23
|
+
type ScrollData = {[key: number]: {x: number, y: number}};
|
|
24
|
+
const scrollRecord = (e?: Event) => {
|
|
25
|
+
const data = _JSON_parse(_sessionStorage.getItem(scrollStorageKey) ?? '{}') as ScrollData;
|
|
26
|
+
data[index] = { x: documentElement.scrollLeft, y: documentElement.scrollTop };
|
|
27
|
+
// e is Event when called from scroll or beforeload
|
|
28
|
+
if (!e) forEach(_Object_entries(data), ([i]) => +i > index && delete data[+i])
|
|
29
|
+
_sessionStorage.setItem(scrollStorageKey, _JSON_stringify(data));
|
|
30
|
+
}
|
|
31
|
+
/** handle history state with push and replace state. */
|
|
32
|
+
const historyHandler = async (path: string | URL | Nullish, mode: 1 | 2, target?: AnchorTarget) => {
|
|
33
|
+
if (!path) return;
|
|
34
|
+
const url = toURL(path);
|
|
35
|
+
if (url.href === _location.href) return;
|
|
36
|
+
if (target && target !== '_self') return open(url, target);
|
|
37
|
+
if (url.origin !== origin) return open(url, target);
|
|
38
|
+
scrollRecord();
|
|
39
|
+
if (mode === PUSH) index += 1;
|
|
40
|
+
Router.direction = FORWARD;
|
|
41
|
+
_history[mode === PUSH ? 'pushState' : 'replaceState']({index}, '' , url);
|
|
42
|
+
forEach(Router.routers, router => router.resolve(path))
|
|
43
|
+
}
|
|
44
|
+
// disable browser scroll restoration
|
|
45
|
+
_history.scrollRestoration = 'manual';
|
|
46
|
+
|
|
47
|
+
export class Router extends $HTMLElement {
|
|
48
|
+
static direction: 'back' | 'forward' = FORWARD;
|
|
49
|
+
static routers = new Set<Router>();
|
|
50
|
+
routes = new Map<RoutePath, Route>();
|
|
51
|
+
pages = new Map<string, Page>();
|
|
52
|
+
constructor(page?: Page) {
|
|
53
|
+
super('router');
|
|
54
|
+
if (page) page.router = this;
|
|
55
|
+
else Router.routers.add(this);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static open(path: string | URL | Nullish, target?: AnchorTarget) {
|
|
59
|
+
historyHandler(path, PUSH, target);
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static back() {
|
|
64
|
+
_history.back();
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static forward() {
|
|
69
|
+
_history.forward();
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static replace(path: string | URL | Nullish) {
|
|
74
|
+
historyHandler(path, REPLACE);
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static get scroll(): ScrollData[number] {
|
|
79
|
+
return _JSON_parse(_sessionStorage.getItem(scrollStorageKey) ?? '{}')[index] ?? {x: 0, y: 0}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
listen() {
|
|
83
|
+
const resolve = () => {
|
|
84
|
+
const stateIndex = _history.state?.index ?? 0;
|
|
85
|
+
if (index > stateIndex) Router.direction = BACK;
|
|
86
|
+
if (index < stateIndex) Router.direction = FORWARD;
|
|
87
|
+
index = stateIndex;
|
|
88
|
+
this.resolve(_location.href);
|
|
89
|
+
}
|
|
90
|
+
_addEventListener('popstate', resolve);
|
|
91
|
+
_addEventListener('beforeunload', scrollRecord);
|
|
92
|
+
_addEventListener('scroll', scrollRecord, false);
|
|
93
|
+
resolve();
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async resolve(path: string | URL): Promise<this> {
|
|
98
|
+
const {pathname, href} = toURL(path);
|
|
99
|
+
const split = (p: string) => p.replaceAll(/\/+/g, '/').replace(/^\//, '').split('/').map(path => `/${path}`);
|
|
100
|
+
type RouteData = { route: Route, params: PageParams, pathId: string }
|
|
101
|
+
const searchRoute = (routes: typeof this.routes, targetPath: string): RouteData[] => {
|
|
102
|
+
let targetPathSplit = split(targetPath);
|
|
103
|
+
if (!routes.size) return [];
|
|
104
|
+
// check each route
|
|
105
|
+
for (const [_, route] of routes) {
|
|
106
|
+
// check each path pass
|
|
107
|
+
routePathLoop: for (const [path, paramsHandle] of route.paths) {
|
|
108
|
+
let routePathSplit = split(path);
|
|
109
|
+
let targetPathNodePosition = 0;
|
|
110
|
+
let params: { [key: string]: string } = isFunction(paramsHandle) ? paramsHandle() : paramsHandle ?? {};
|
|
111
|
+
let pathId = '';
|
|
112
|
+
// check each path node
|
|
113
|
+
pathNodeLoop: for (let i = 0; i < routePathSplit.length; i++) {
|
|
114
|
+
// reset target path node position
|
|
115
|
+
targetPathNodePosition = i;
|
|
116
|
+
const routeNode = routePathSplit[i];
|
|
117
|
+
const targetNode = targetPathSplit[i];
|
|
118
|
+
// path node undefined, break path loop
|
|
119
|
+
if (!routeNode || !targetNode) continue routePathLoop;
|
|
120
|
+
// path node is params node
|
|
121
|
+
if (routeNode.includes(':')) {
|
|
122
|
+
// target not matched
|
|
123
|
+
if (targetNode === '/') continue routePathLoop;
|
|
124
|
+
const [prefix, paramName] = routeNode.split(':') as [string, string];
|
|
125
|
+
if (!startsWith(targetNode, prefix)) continue routePathLoop;
|
|
126
|
+
params[paramName] = targetNode.replace(`${prefix}`, '');
|
|
127
|
+
pathId += targetNode;
|
|
128
|
+
continue pathNodeLoop;
|
|
129
|
+
}
|
|
130
|
+
// path node not matched, next path
|
|
131
|
+
if (routeNode !== targetNode) continue routePathLoop;
|
|
132
|
+
pathId += targetNode;
|
|
133
|
+
}
|
|
134
|
+
// target path node longer than route, next route
|
|
135
|
+
if (targetPathSplit[targetPathNodePosition + 1] && !route.routes.size) continue routePathLoop;
|
|
136
|
+
// all path node passed, route found
|
|
137
|
+
return [{route, params, pathId}, ...searchRoute(route.routes, targetPathSplit.slice(targetPathNodePosition + 1).join('/'))]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// no route passed
|
|
141
|
+
const notfound = routes.get('notfound');
|
|
142
|
+
if (notfound) return [{route: notfound, params: {}, pathId: 'notfound'}]
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const routes = searchRoute(this.routes, pathname);
|
|
146
|
+
let prevRouter: Router | null = this;
|
|
147
|
+
await forEach(routes, async ({route, params, pathId}) => {
|
|
148
|
+
// skip route group
|
|
149
|
+
const builderResolver = route.builder;
|
|
150
|
+
if (!builderResolver) return;
|
|
151
|
+
// get page from cache or create new page
|
|
152
|
+
const page = route.pages.get(pathId) ?? new Page(params);
|
|
153
|
+
// resolve builder
|
|
154
|
+
if (!page.built) await builderResolver.build(page);
|
|
155
|
+
page.built = true;
|
|
156
|
+
// set title
|
|
157
|
+
_document && (_document.title = page.pageTitle() ?? _document.title);
|
|
158
|
+
// check location is still same, page parent is not router before insert page
|
|
159
|
+
if (href === _location.href && page.parentNode !== prevRouter?.node) prevRouter?.content(page);
|
|
160
|
+
// set cache
|
|
161
|
+
route.pages.set(pathId, page);
|
|
162
|
+
prevRouter = page.router;
|
|
163
|
+
})
|
|
164
|
+
// handle scroll restoration
|
|
165
|
+
let { x, y } = Router.scroll ?? {x: 0, y: 0};
|
|
166
|
+
scrollTo(x, y);
|
|
167
|
+
// event
|
|
168
|
+
this.dispatchEvent(new Event('routeopen', {bubbles: true}));
|
|
169
|
+
return this;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface Router {
|
|
174
|
+
route<
|
|
175
|
+
P extends RoutePath,
|
|
176
|
+
B extends PageBuilder
|
|
177
|
+
>(path: P, builder: B, handle?: (route: Route<P, B['params']>) => Route<P>): Router
|
|
178
|
+
route<
|
|
179
|
+
K extends RoutePath,
|
|
180
|
+
P extends RouteParamsStrings<K>,
|
|
181
|
+
F extends PageBuilderFunction<P>
|
|
182
|
+
>(path: K, builder: F, handle?: (route: Route<K, P>) => Route<K, P>): this
|
|
183
|
+
route<
|
|
184
|
+
K extends RoutePath,
|
|
185
|
+
P extends RouteParamsStrings<K>,
|
|
186
|
+
F extends AsyncPageBuilder<P>
|
|
187
|
+
>(path: K, builder: F, handle?: (route: Route<K, P>) => Route<K, P>): this
|
|
188
|
+
route<P extends RoutePath>(path: P, builder: RouteBuilder<RouteParamsResolver<P>>, handle?: (route: Route<P>) => Route<P>): Router
|
|
189
|
+
group<P extends RoutePath>(path: P, handle: <R extends Route<P>>(route: R) => R): this;
|
|
190
|
+
notFound(builder: RouteBuilder<RouteParamsResolver<RoutePath>>): this;
|
|
191
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
import { $Anchor } from "
|
|
1
|
+
import { $Anchor } from "../../../html/node/$Anchor";
|
|
2
2
|
|
|
3
3
|
export class RouterAnchor extends $Anchor {
|
|
4
4
|
constructor() {
|
|
5
5
|
super();
|
|
6
6
|
this.on('click', e => {
|
|
7
|
+
if (e.shiftKey || e.ctrlKey) return;
|
|
7
8
|
e.preventDefault();
|
|
8
9
|
this.target() === '_replace'
|
|
9
10
|
? $.replace(this.href())
|
|
10
11
|
: $.open(this.href(), this.target())
|
|
11
12
|
})
|
|
12
13
|
}
|
|
13
|
-
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module 'amateras/core' {
|
|
17
|
+
export namespace $ {
|
|
18
|
+
export interface $NodeMap {
|
|
19
|
+
'ra': typeof RouterAnchor;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$.assign(['ra', RouterAnchor]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Page, type PageParams } from "#node/Page";
|
|
2
|
+
import { _instanceof, _Promise, isFunction } from "../../../../src/lib/native";
|
|
3
|
+
import type { $NodeContentResolver } from "../../../../src/node/$Node";
|
|
4
|
+
import type { AsyncPageBuilder, RouteParams } from "./Route";
|
|
5
|
+
|
|
6
|
+
export class PageBuilder<Params extends RouteParams = any> {
|
|
7
|
+
params!: Params
|
|
8
|
+
#builder: PageBuilderFunction<Params> | AsyncPageBuilder<Params>;
|
|
9
|
+
constructor(builder: PageBuilderFunction<Params>) {
|
|
10
|
+
this.#builder = builder;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async build(page: Page<Params>): Promise<Page<Params>> {
|
|
14
|
+
const resolver = this.#builder(page)
|
|
15
|
+
const handle = async (result: any) => {
|
|
16
|
+
if (_instanceof(result, Page)) return result;
|
|
17
|
+
else if (result[Symbol.toStringTag] === 'Module') return await result.default.build(page);
|
|
18
|
+
else return page.content(result);
|
|
19
|
+
}
|
|
20
|
+
return handle(_instanceof(resolver, _Promise) ? await resolver : resolver);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PageBuilderFunction<Params extends RouteParams> = (page: Page<Params>) => OrPromise<Page<Params> | $NodeContentResolver<Page<Params>>>
|