-
+
Welcome to use Aleph.js !
Website
- ·
+
Get Started
- ·
+
Docs
- ·
+
Github
diff --git a/examples/default-src-directory/src/style/index.less b/examples/default-src-directory/src/style/index.less
index d609e89fc..b062b3416 100644
--- a/examples/default-src-directory/src/style/index.less
+++ b/examples/default-src-directory/src/style/index.less
@@ -53,6 +53,9 @@ main {
}
span {
color: #999;
+ &:after {
+ content: '·';
+ }
}
a {
margin: 0 9px;
diff --git a/examples/hello-world/app.tsx b/examples/hello-world/app.tsx
index 6dd797fc9..19a5dc6b1 100644
--- a/examples/hello-world/app.tsx
+++ b/examples/hello-world/app.tsx
@@ -1,12 +1,12 @@
-import React, { ComponentType } from 'https://esm.sh/react'
-import { Head } from 'https://deno.land/x/aleph/mod.ts'
+import type { ComponentType } from 'react'
+import React from 'react'
export default function App({ Page, pageProps }: { Page: ComponentType, pageProps: any }) {
return (
<>
-
+
Hello World - Aleph.js
-
+
>
)
diff --git a/examples/hello-world/components/logo.tsx b/examples/hello-world/components/logo.tsx
index b072b6ea1..03fd25480 100644
--- a/examples/hello-world/components/logo.tsx
+++ b/examples/hello-world/components/logo.tsx
@@ -1,7 +1,7 @@
-import React from 'https://esm.sh/react'
+import React from 'react'
-export default function Logo({ width = 75 }: { width?: number }) {
+export default function Logo({ size = 75 }: { size?: number }) {
return (
-
+
)
}
diff --git a/examples/hello-world/import_map.json b/examples/hello-world/import_map.json
deleted file mode 100644
index 54786c6d8..000000000
--- a/examples/hello-world/import_map.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "imports": {
- "https://deno.land/x/aleph/": "https://denopkg.com/alephjs/aleph.js/"
- }
-}
\ No newline at end of file
diff --git a/examples/hello-world/pages/index.tsx b/examples/hello-world/pages/index.tsx
index 442986b4c..8eb5504ad 100644
--- a/examples/hello-world/pages/index.tsx
+++ b/examples/hello-world/pages/index.tsx
@@ -1,6 +1,6 @@
-import { Import, useDeno } from 'https://deno.land/x/aleph/mod.ts'
-import React, { useState } from 'https://esm.sh/react'
-import Logo from '../components/logo.tsx'
+import { useDeno } from 'aleph';
+import React, { useState } from 'react';
+import Logo from '../components/logo.tsx';
export default function Home() {
const [count, setCount] = useState(0)
@@ -10,16 +10,16 @@ export default function Home() {
return (
-
+
Welcome to use Aleph.js !
Website
- ·
+
Get Started
- ·
+
Docs
- ·
+
Github
diff --git a/examples/hello-world/style/index.less b/examples/hello-world/style/index.less
index d609e89fc..b2ad08019 100644
--- a/examples/hello-world/style/index.less
+++ b/examples/hello-world/style/index.less
@@ -53,6 +53,9 @@ main {
}
span {
color: #999;
+ &:after {
+ content: '·';
+ }
}
a {
margin: 0 9px;
@@ -76,7 +79,7 @@ main {
margin: 30px auto 0;
border: 1px solid #eee;
border-radius: 6px;
- transition: all 0.15s ease-in;
+ transition: border-color 0.15s ease-in;
&:hover {
border-color: #ccc;
@@ -102,12 +105,12 @@ main {
font-family: Courier, monospace;
font-weight: 600;
color: #666;
- transition: all 0.1s ease-in-out;
+ transition: color 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
cursor: pointer;
&:hover {
color: #111;
- box-shadow: 0 0 2px rgba(0, 0, 0, 0.75);
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
}
button + button {
diff --git a/examples/hi/app.tsx b/examples/hi/app.tsx
index 6dd797fc9..19a5dc6b1 100644
--- a/examples/hi/app.tsx
+++ b/examples/hi/app.tsx
@@ -1,12 +1,12 @@
-import React, { ComponentType } from 'https://esm.sh/react'
-import { Head } from 'https://deno.land/x/aleph/mod.ts'
+import type { ComponentType } from 'react'
+import React from 'react'
export default function App({ Page, pageProps }: { Page: ComponentType, pageProps: any }) {
return (
<>
-
+
Hello World - Aleph.js
-
+
>
)
diff --git a/examples/hi/components/logo.tsx b/examples/hi/components/logo.tsx
index b072b6ea1..03fd25480 100644
--- a/examples/hi/components/logo.tsx
+++ b/examples/hi/components/logo.tsx
@@ -1,7 +1,7 @@
-import React from 'https://esm.sh/react'
+import React from 'react'
-export default function Logo({ width = 75 }: { width?: number }) {
+export default function Logo({ size = 75 }: { size?: number }) {
return (
-
+
)
}
diff --git a/examples/hi/pages/hi/[name].tsx b/examples/hi/pages/hi/[name].tsx
index aeca8b7d3..e453d0fdf 100644
--- a/examples/hi/pages/hi/[name].tsx
+++ b/examples/hi/pages/hi/[name].tsx
@@ -1,5 +1,5 @@
-import { Import, useDeno, Link, useRouter } from 'https://deno.land/x/aleph/mod.ts'
-import React from 'https://esm.sh/react'
+import { useDeno, useRouter } from 'aleph'
+import React from 'react'
import Logo from '../../components/logo.tsx'
export default function Home() {
@@ -10,19 +10,22 @@ export default function Home() {
return (
-
+
+
Hi, {params.name}!
+
+
Hi, {params.name} !
- Back
+ Back
Website
- ·
+
Get Started
- ·
+
Docs
- ·
+
Github
Built by Aleph.js in Deno v{version.deno}
diff --git a/examples/hi/pages/index.tsx b/examples/hi/pages/index.tsx
index 521778ed0..27384ad4a 100644
--- a/examples/hi/pages/index.tsx
+++ b/examples/hi/pages/index.tsx
@@ -1,5 +1,5 @@
-import { Import, Link, useDeno } from 'https://deno.land/x/aleph/mod.ts'
-import React, { useState } from 'https://esm.sh/react'
+import { useDeno } from 'aleph'
+import React, { useState } from 'react'
import Logo from '../components/logo.tsx'
export default function Home() {
@@ -10,7 +10,7 @@ export default function Home() {
return (
-
+
Welcome to use Aleph.js !
@@ -22,15 +22,15 @@ export default function Home() {
/>
- Go
+ Go
Website
- ·
+
Get Started
- ·
+
Docs
- ·
+
Github
Built by Aleph.js in Deno v{version.deno}
diff --git a/examples/hi/style/index.less b/examples/hi/style/index.less
index ccd9e2d94..ab4c308f8 100644
--- a/examples/hi/style/index.less
+++ b/examples/hi/style/index.less
@@ -63,8 +63,8 @@ main {
button {
display: inline-block;
- padding: 3px 12px;
- background-color: #d63369;
+ padding: 3px 15px;
+ background-color: #008181;
border-radius: 6px;
color: #fff;
cursor: pointer;
@@ -84,6 +84,9 @@ main {
}
span {
color: #999;
+ &:after {
+ content: '·';
+ }
}
a {
margin: 0 9px;
diff --git a/examples/markdown-blog/pages/about.tsx b/examples/markdown-blog/pages/about.tsx
index 9b4a97a25..81edf92ad 100644
--- a/examples/markdown-blog/pages/about.tsx
+++ b/examples/markdown-blog/pages/about.tsx
@@ -1,15 +1,14 @@
-import { Head, Link } from 'https://deno.land/x/aleph/mod.ts'
-import React from 'https://esm.sh/react'
+import React from 'react'
export default function About() {
return (
<>
-
+
About Me.
-
+
About Me.
Me , a full-stack web developor.
-
Home
+
Home
>
)
}
diff --git a/examples/markdown-blog/pages/blog.tsx b/examples/markdown-blog/pages/blog.tsx
index b6b5ea1ac..739b9ab39 100644
--- a/examples/markdown-blog/pages/blog.tsx
+++ b/examples/markdown-blog/pages/blog.tsx
@@ -1,18 +1,17 @@
-import { Head, Link } from 'https://deno.land/x/aleph/mod.ts'
-import React from 'https://esm.sh/react'
+import React from 'react'
export default function Blog() {
return (
<>
-
+
My Blog.
-
+
My Blog.
-
Home
+
Home
>
)
}
diff --git a/examples/markdown-blog/pages/index.tsx b/examples/markdown-blog/pages/index.tsx
index 81998cfcf..daab85657 100644
--- a/examples/markdown-blog/pages/index.tsx
+++ b/examples/markdown-blog/pages/index.tsx
@@ -1,16 +1,15 @@
-import { Head, Link } from 'https://deno.land/x/aleph/mod.ts'
-import React from 'https://esm.sh/react'
+import React from 'react'
export default function Home() {
return (
<>
-
+
Me.
-
+
Me.
>
)
diff --git a/examples/markdown-blog/pages/post.tsx b/examples/markdown-blog/pages/post.tsx
index 680f949e1..5b99ae29c 100644
--- a/examples/markdown-blog/pages/post.tsx
+++ b/examples/markdown-blog/pages/post.tsx
@@ -1,5 +1,4 @@
-import { Head, Link } from 'https://deno.land/x/aleph/mod.ts'
-import React, { ComponentType } from 'https://esm.sh/react'
+import React, { ComponentType } from 'react'
interface Metadata {
title: string
@@ -10,12 +9,12 @@ interface Metadata {
export default function Blog({ Page }: { Page: ComponentType & { meta: Metadata } }) {
return (
<>
-
+
{Page.meta.title}
-
+
created by {Page.meta.author} at {Page.meta.date}
-
My Blog
+
My Blog
>
)
}
diff --git a/examples/spa-app/aleph.config.ts b/examples/spa-app/aleph.config.ts
index 726bec417..c82737d27 100644
--- a/examples/spa-app/aleph.config.ts
+++ b/examples/spa-app/aleph.config.ts
@@ -1,4 +1,4 @@
-import { Config } from 'https://deno.land/x/aleph/types.ts'
+import type { Config } from 'aleph/types.ts'
const config: Config = {
ssr: false
diff --git a/examples/spa-app/app.tsx b/examples/spa-app/app.tsx
index 10af3a5a1..19a5dc6b1 100644
--- a/examples/spa-app/app.tsx
+++ b/examples/spa-app/app.tsx
@@ -1,12 +1,12 @@
-import React, { ComponentType } from 'https://esm.sh/react'
-import { Head } from 'https://deno.land/x/aleph/mod.ts'
+import type { ComponentType } from 'react'
+import React from 'react'
export default function App({ Page, pageProps }: { Page: ComponentType
, pageProps: any }) {
- return (
+ return (
<>
-
+
Hello World - Aleph.js
-
+
>
)
diff --git a/examples/spa-app/components/logo.tsx b/examples/spa-app/components/logo.tsx
index b072b6ea1..03fd25480 100644
--- a/examples/spa-app/components/logo.tsx
+++ b/examples/spa-app/components/logo.tsx
@@ -1,7 +1,7 @@
-import React from 'https://esm.sh/react'
+import React from 'react'
-export default function Logo({ width = 75 }: { width?: number }) {
+export default function Logo({ size = 75 }: { size?: number }) {
return (
-
+
)
}
diff --git a/examples/spa-app/pages/index.tsx b/examples/spa-app/pages/index.tsx
index 6af1e62a6..c547d19de 100644
--- a/examples/spa-app/pages/index.tsx
+++ b/examples/spa-app/pages/index.tsx
@@ -1,5 +1,4 @@
-import { Import } from 'https://deno.land/x/aleph/mod.ts'
-import React, { useState } from 'https://esm.sh/react'
+import React, { useState } from 'react'
import Logo from '../components/logo.tsx'
export default function Home() {
@@ -10,16 +9,16 @@ export default function Home() {
return (
-
+
Welcome to use Aleph.js !
Website
- ·
+
Get Started
- ·
+
Docs
- ·
+
Github
diff --git a/examples/spa-app/style/index.less b/examples/spa-app/style/index.less
index d609e89fc..b062b3416 100644
--- a/examples/spa-app/style/index.less
+++ b/examples/spa-app/style/index.less
@@ -53,6 +53,9 @@ main {
}
span {
color: #999;
+ &:after {
+ content: '·';
+ }
}
a {
margin: 0 9px;
diff --git a/examples/with-plugins/aleph.config.ts b/examples/with-plugins/aleph.config.ts
index b7bf29945..00edac568 100644
--- a/examples/with-plugins/aleph.config.ts
+++ b/examples/with-plugins/aleph.config.ts
@@ -1,5 +1,5 @@
-import sass from 'https://deno.land/x/aleph/plugins/sass.ts'
-import wasm from 'https://deno.land/x/aleph/plugins/wasm.ts'
+import sass from 'aleph/plugins/sass.ts'
+import wasm from 'aleph/plugins/wasm.ts'
export default {
plugins: [sass, wasm]
diff --git a/examples/with-plugins/pages/index.tsx b/examples/with-plugins/pages/index.tsx
index 54a258516..443a0a6fe 100644
--- a/examples/with-plugins/pages/index.tsx
+++ b/examples/with-plugins/pages/index.tsx
@@ -1,9 +1,12 @@
-import React from 'https://esm.sh/react'
+import React from 'react'
+// @ts-expect-error
import wasm from './42.wasm'
-import './style.sass'
export default function Home() {
return (
-
{wasm.main()}
+ <>
+
+
{wasm.main()}
+ >
)
}
diff --git a/examples/with-plugins/pages/style.sass b/examples/with-plugins/pages/style.sass
index a12d10d13..44be8ac6d 100644
--- a/examples/with-plugins/pages/style.sass
+++ b/examples/with-plugins/pages/style.sass
@@ -1,2 +1,10 @@
$head: 300px;
+body {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+}
h1 { font-size: $head; }
diff --git a/events.ts b/framework/core/events.ts
similarity index 100%
rename from events.ts
rename to framework/core/events.ts
diff --git a/hmr.ts b/framework/core/hmr.ts
similarity index 64%
rename from hmr.ts
rename to framework/core/hmr.ts
index 2d16bd1c9..6f459344c 100755
--- a/hmr.ts
+++ b/framework/core/hmr.ts
@@ -1,33 +1,22 @@
-import runtime from 'https://esm.sh/react-refresh@0.8.3/runtime'
+import { hashShort } from '../../shared/constants.ts'
import events from './events.ts'
-import util, { hashShort } from './util.ts'
interface Callback {
(...args: any[]): void
}
-// react-refresh
-// @link https://github.com/facebook/react/issues/16604#issuecomment-528663101
-runtime.injectIntoGlobalHook(window)
-Object.assign(window, {
- $RefreshReg$: () => { },
- $RefreshSig$: () => (type: any) => type
-})
-export const performReactRefresh = util.debounce(runtime.performReactRefresh, 30)
-export const RefreshRuntime = runtime
-
class Module {
- private _id: string
+ private _url: string
private _isLocked: boolean = false
private _isAccepted: boolean = false
private _acceptCallbacks: Callback[] = []
- get id() {
- return this._id
+ get url() {
+ return this._url
}
- constructor(id: string) {
- this._id = id
+ constructor(url: string) {
+ this._url = url
}
lock(): void {
@@ -39,7 +28,7 @@ class Module {
return
}
if (!this._isAccepted) {
- sendMessage({ id: this.id, type: 'hotAccept' })
+ sendMessage({ url: this._url, type: 'hotAccept' })
this._isAccepted = true
}
if (callback) {
@@ -76,25 +65,25 @@ socket.addEventListener('close', () => {
socket.addEventListener('message', ({ data: rawData }: { data?: string }) => {
if (rawData) {
try {
- const { type, moduleId, hash, updateUrl } = JSON.parse(rawData)
+ const { type, url, hash, updateUrl } = JSON.parse(rawData)
switch (type) {
case 'add':
- events.emit('add-module', { id: moduleId, hash })
+ events.emit('add-module', { url, hash })
break
case 'update':
- const mod = modules.get(moduleId)
+ const mod = modules.get(url)
if (mod) {
mod.applyUpdate(updateUrl)
}
break
case 'remove':
- if (modules.has(moduleId)) {
- modules.delete(moduleId)
- events.emit('remove-module', moduleId)
+ if (modules.has(url)) {
+ modules.delete(url)
+ events.emit('remove-module', url)
}
break
}
- console.log(`[HMR]${hash ? ' [' + hash.slice(0, hashShort) + ']' : ''} ${type} module '${moduleId}'`)
+ console.log(`[HMR]${hash ? ' [' + hash.slice(0, hashShort) + ']' : ''} ${type} module '${url}'`)
} catch (err) {
console.warn(err)
}
@@ -109,14 +98,14 @@ function sendMessage(msg: any) {
}
}
-export function createHotContext(id: string) {
- if (modules.has(id)) {
- const mod = modules.get(id)!
+export function createHotContext(url: string) {
+ if (modules.has(url)) {
+ const mod = modules.get(url)!
mod.lock()
return mod
}
- const mod = new Module(id)
- modules.set(id, mod)
+ const mod = new Module(url)
+ modules.set(url, mod)
return mod
}
diff --git a/nomodule.ts b/framework/core/nomodule.ts
similarity index 64%
rename from nomodule.ts
rename to framework/core/nomodule.ts
index a43953b5d..ba431e105 100644
--- a/nomodule.ts
+++ b/framework/core/nomodule.ts
@@ -31,23 +31,10 @@ for (var key in style) {
for (var key in hStyle) {
(hEl.style as any)[key] = hStyle[key]
}
-var isDev = false
-var scripts = document.getElementsByTagName('script')
-for (var i = 0; i < scripts.length; i++) {
- var s = scripts[i]
- if (/nomodule\.js\?dev$/.test(s.src)) {
- isDev = true
- break
- }
-}
// todo: i18n
// todo: add browser info
hEl.innerText = 'Your browser is out of date.'
-if (isDev) {
- pEl.innerHTML = 'Aleph.js requires
ES module support during development.'
-} else {
- pEl.innerHTML = 'Update your browser for more security, speed and the best experience on this site.'
-}
+pEl.innerHTML = 'Aleph.js requires
ES module support during development.'
el.appendChild(hEl)
el.appendChild(pEl)
document.body.appendChild(el)
diff --git a/routing.ts b/framework/core/routing.ts
similarity index 76%
rename from routing.ts
rename to framework/core/routing.ts
index 2449ce2d6..6d14728ee 100644
--- a/routing.ts
+++ b/framework/core/routing.ts
@@ -1,7 +1,8 @@
-import type { ComponentType } from 'https://esm.sh/react'
-import { E400MissingDefaultExportAsComponent } from './error.ts'
-import type { RouterURL } from './types.ts'
-import util, { reMDExt, reModuleExt } from './util.ts'
+import type { DependencyDescriptor } from '../../server/types.ts'
+import { reMDExt, reModuleExt } from '../../shared/constants.ts'
+import util from '../../shared/util.ts'
+import type { RouterURL } from '../../types.ts'
+import events from './events.ts'
export interface Route {
path: string
@@ -10,17 +11,12 @@ export interface Route {
}
export interface RouteModule {
- readonly id: string
+ readonly url: string
readonly hash: string
- readonly deps?: { url: string, hash: string, isStyle?: boolean, isData?: boolean }[]
+ readonly deps?: DependencyDescriptor[]
}
-export interface PageProps {
- Page: ComponentType
| null
- pageProps: Partial & { name?: string }
-}
-
-const ghostRoute: Route = { path: '', module: { id: '', hash: '' } }
+const ghostRoute: Route = { path: '', module: { url: '', hash: '' } }
export class Routing {
private _routes: Route[]
@@ -56,7 +52,7 @@ export class Routing {
}
update(module: RouteModule) {
- const newRoute: Route = { path: getPagePath(module.id), module }
+ const newRoute: Route = { path: getPagePath(module.url), module }
const dirtyRoutes: Set = new Set()
let exists = false
let targetRoutes = this._routes
@@ -64,7 +60,7 @@ export class Routing {
const path = routePath.map(r => r.path).join('')
const route = routePath[routePath.length - 1]
const parentRoute = routePath[routePath.length - 2]
- if (route.module.id === module.id) {
+ if (route.module.url === module.url) {
Object.assign(route.module, module)
exists = true
return false
@@ -95,10 +91,10 @@ export class Routing {
targetRoutes.push(newRoute)
}
- removeRoute(moduleId: string) {
+ removeRoute(url: string) {
this._lookup(path => {
const route = path[path.length - 1]
- if (route.module.id === moduleId) {
+ if (route.module.url === url) {
const parentRoute = path[path.length - 2]
const routes = parentRoute ? parentRoute.children! : this._routes
const index = routes.indexOf(route)
@@ -175,9 +171,9 @@ export class Routing {
}
}
-export function getPagePath(moduleId: string): string {
- const id = util.trimSuffix(moduleId, '.js').replace(reMDExt, '').toLowerCase().replace(/^\/pages\//, '/').replace(/\/?index$/, '/')
- return id.startsWith('/api/') ? id : id.replace(/\s+/g, '-')
+export function getPagePath(url: string): string {
+ const pathname = url.replace(reModuleExt, '').replace(reMDExt, '').toLowerCase().replace(/^\/pages\//, '/').replace(/\/?index$/, '/')
+ return pathname.startsWith('/api/') ? pathname : pathname.replace(/\s+/g, '-')
}
function matchPath(routePath: string, locPath: string): [Record, boolean] {
@@ -211,36 +207,32 @@ function matchPath(routePath: string, locPath: string): [Record,
return [params, true]
}
-export function createPageProps(componentTree: { id: string, Component?: ComponentType }[]): PageProps {
- const pageProps: PageProps = {
- Page: null,
- pageProps: {}
+export async function redirect(url: string, replace?: boolean) {
+ const { location, history } = window as any
+
+ if (!util.isNEString(url)) {
+ return
}
- if (componentTree.length > 0) {
- Object.assign(pageProps, _createPagePropsSegment(componentTree[0]))
+
+ if (isHttpUrl(url)) {
+ location.href = url
+ return
}
- if (componentTree.length > 1) {
- componentTree.slice(1).reduce((p, seg) => {
- const c = _createPagePropsSegment(seg)
- p.pageProps = c
- return c
- }, pageProps)
+
+ url = util.cleanPath(url)
+ if (replace) {
+ history.replaceState(null, '', url)
+ } else {
+ history.pushState(null, '', url)
}
- return pageProps
+ events.emit('popstate', { type: 'popstate', resetScroll: true })
}
-function _createPagePropsSegment(seg: { id: string, Component?: ComponentType }): PageProps {
- const pageProps: PageProps = {
- Page: null,
- pageProps: {}
- }
- if (seg.Component) {
- if (util.isLikelyReactComponent(seg.Component)) {
- pageProps.Page = seg.Component
- } else {
- pageProps.Page = E400MissingDefaultExportAsComponent
- pageProps.pageProps = { name: 'Page:' + seg.id.replace(reModuleExt, '') }
- }
+export function isHttpUrl(url: string) {
+ try {
+ const { protocol } = new URL(url)
+ return protocol === 'https:' || protocol === 'http:'
+ } catch (error) {
+ return false
}
- return pageProps
}
diff --git a/framework/react/anchor.ts b/framework/react/anchor.ts
new file mode 100644
index 000000000..800234ec5
--- /dev/null
+++ b/framework/react/anchor.ts
@@ -0,0 +1,123 @@
+import type { AnchorHTMLAttributes, CSSProperties, MouseEvent, PropsWithChildren } from 'https://esm.sh/react'
+import { createElement, useCallback, useEffect, useMemo } from 'https://esm.sh/react'
+import util from '../../shared/util.ts'
+import events from '../core/events.ts'
+import { isHttpUrl, redirect } from '../core/routing.ts'
+import { useRouter } from './hooks.ts'
+
+const prefetchedPages = new Set()
+
+interface AnchorDataProps {
+ 'data-active-className'?: string
+ 'data-active-style'?: CSSProperties
+}
+
+type AnchorProps = PropsWithChildren & AnchorDataProps>
+
+/**
+ * Anchor Component to link between pages.
+ */
+export default function Anchor(props: AnchorProps) {
+ const {
+ rel,
+ href: propHref,
+ ['aria-current']: propAriaCurrent,
+ ['data-active-className']: activeClassName,
+ ['data-active-style']: activeStyle,
+ className: propClassName,
+ style: propStyle,
+ children,
+ ...rest
+ } = props
+ const relKeys = useMemo(() => rel ? rel.split(' ') : [], [rel])
+ const prefetching = useMemo(() => relKeys.includes('prefetch'), [relKeys])
+ const replace = useMemo(() => relKeys.includes('replace'), [relKeys])
+ const isNav = useMemo(() => relKeys.includes('nav'), [relKeys])
+ const { pathname: currentPathname, query: currentQuery } = useRouter()
+ const currentHref = useMemo(() => {
+ return [currentPathname, currentQuery.toString()].filter(Boolean).join('?')
+ }, [currentPathname, currentQuery])
+ const href = useMemo(() => {
+ if (!util.isNEString(propHref)) {
+ return ''
+ }
+ if (isHttpUrl(propHref)) {
+ return propHref
+ }
+ let [pathname, search] = util.splitBy(propHref, '?')
+ if (pathname.startsWith('/')) {
+ pathname = util.cleanPath(pathname)
+ } else {
+ pathname = util.cleanPath(currentPathname + '/' + pathname)
+ }
+ return [pathname, search].filter(Boolean).join('?')
+ }, [currentPathname, propHref])
+ const className = useMemo(() => {
+ if (!isNav || currentHref !== href) {
+ return propClassName
+ }
+ return [propClassName, activeClassName].filter(util.isNEString).map(n => n.trim()).filter(Boolean).join(' ')
+ }, [propClassName, activeClassName, currentHref, href, isNav])
+ const style = useMemo(() => {
+ if (!isNav || currentHref !== href) {
+ return propStyle
+ }
+ return Object.assign({}, propStyle, activeStyle)
+ }, [propStyle, activeStyle, currentHref, href, isNav])
+ const ariaCurrent = useMemo(() => {
+ if (util.isNEString(propAriaCurrent)) {
+ return propAriaCurrent
+ }
+ if (href.startsWith('/')) {
+ return 'page'
+ }
+ return undefined
+ }, [href, propAriaCurrent])
+ const prefetch = useCallback(() => {
+ if (href && !isHttpUrl(href) && href !== currentHref && !prefetchedPages.has(href)) {
+ events.emit('fetch-page-module', { href })
+ prefetchedPages.add(href)
+ }
+ }, [href, currentHref])
+ const onMouseEnter = useCallback((e: MouseEvent) => {
+ if (util.isFunction(props.onMouseEnter)) {
+ props.onMouseEnter(e)
+ }
+ if (e.defaultPrevented) {
+ return
+ }
+ prefetch()
+ }, [prefetch])
+ const onClick = useCallback((e: MouseEvent) => {
+ if (util.isFunction(props.onMouseEnter)) {
+ props.onMouseEnter(e)
+ }
+ if (e.defaultPrevented) {
+ return
+ }
+ e.preventDefault()
+ if (href && href !== currentHref) {
+ redirect(href, replace)
+ }
+ }, [href, currentHref, replace])
+
+ useEffect(() => {
+ if (prefetching) {
+ prefetch()
+ }
+ }, [prefetching, prefetch])
+
+ return createElement(
+ 'a',
+ {
+ ...rest,
+ className,
+ style,
+ href,
+ onClick,
+ onMouseEnter,
+ 'aria-current': ariaCurrent
+ },
+ children
+ )
+}
diff --git a/framework/react/bootstrap.ts b/framework/react/bootstrap.ts
new file mode 100644
index 000000000..cde8b7f95
--- /dev/null
+++ b/framework/react/bootstrap.ts
@@ -0,0 +1,75 @@
+import type { ComponentType } from 'https://esm.sh/react'
+import { createElement } from 'https://esm.sh/react'
+import { hydrate, render } from 'https://esm.sh/react-dom'
+import { reModuleExt } from '../../shared/constants.ts'
+import { Route, RouteModule, Routing } from '../core/routing.ts'
+import AlephAppRoot from './root.ts'
+import { importModule } from './util.ts'
+
+type BootstrapConfig = {
+ baseUrl: string
+ defaultLocale: string
+ locales: string[]
+ routes: Route[]
+ preloadModules: RouteModule[],
+ renderMode: 'ssr' | 'spa'
+}
+
+export default async function bootstrap({ baseUrl, defaultLocale, locales, routes, preloadModules, renderMode }: BootstrapConfig) {
+ const { document } = window as any
+ const ssrDataEl = document.querySelector('#ssr-data')
+ const routing = new Routing(routes, baseUrl, defaultLocale, locales)
+ const [url, pageModuleTree] = routing.createRouter()
+ const customComponents: Record = {}
+ const pageComponentTree: { url: string, Component?: ComponentType }[] = pageModuleTree.map(({ url }) => ({ url }))
+
+ await Promise.all([...preloadModules, ...pageModuleTree].map(async mod => {
+ const { default: C } = await importModule(baseUrl, mod)
+ switch (mod.url.replace(reModuleExt, '')) {
+ case '/404':
+ customComponents['E404'] = C
+ break
+ case '/app':
+ customComponents['App'] = C
+ break
+ default:
+ const pc = pageComponentTree.find(pc => pc.url === mod.url)
+ if (pc) {
+ pc.Component = C
+ }
+ break
+ }
+ }))
+
+ if (ssrDataEl) {
+ const ssrData = JSON.parse(ssrDataEl.innerText)
+ for (const key in ssrData) {
+ Object.assign(window, { [`useDeno://${url.pathname}#${key}`]: ssrData[key] })
+ }
+ }
+
+ const rootEl = createElement(
+ AlephAppRoot,
+ {
+ url,
+ routing,
+ customComponents,
+ pageComponentTree
+ }
+ )
+ const mountPoint = document.querySelector('main')
+ if (renderMode === 'ssr') {
+ hydrate(rootEl, mountPoint)
+ } else {
+ render(rootEl, mountPoint)
+ }
+
+ // remove ssr head elements, set a timmer to avoid the tab title flash
+ setTimeout(() => {
+ Array.from(document.head.children).forEach((el: any) => {
+ if (el.hasAttribute('ssr') && el.tagName.toLowerCase() !== 'style') {
+ document.head.removeChild(el)
+ }
+ })
+ }, 0)
+}
diff --git a/context.ts b/framework/react/context.ts
similarity index 92%
rename from context.ts
rename to framework/react/context.ts
index 3ac88fd39..a6d3c775c 100644
--- a/context.ts
+++ b/framework/react/context.ts
@@ -1,5 +1,5 @@
import { createContext } from 'https://esm.sh/react'
-import type { RouterURL } from './types.ts'
+import type { RouterURL } from '../../types.ts'
export const RouterContext = createContext({
locale: 'en',
diff --git a/framework/react/error.ts b/framework/react/error.ts
new file mode 100644
index 000000000..9b656af42
--- /dev/null
+++ b/framework/react/error.ts
@@ -0,0 +1,120 @@
+import { Component, createElement, CSSProperties } from 'https://esm.sh/react'
+
+export class ErrorBoundary extends Component {
+ state: { stack: string | null }
+
+ constructor(props: any) {
+ super(props)
+ this.state = { stack: null }
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ // Update state so the next render will show the fallback UI.
+ return { stack: error?.stack || null }
+ }
+
+ componentDidCatch(error: any, errorInfo: any) {
+ this.state = { stack: error?.stack || null }
+ }
+
+ render() {
+ if (this.state.stack) {
+ return (
+ createElement(
+ 'pre',
+ null,
+ this.state.stack
+ )
+ )
+ }
+
+ return this.props.children
+ }
+}
+
+
+export function E404Page() {
+ return createElement(
+ StatusError,
+ {
+ status: 404,
+ message: 'Page Not Found'
+ }
+ )
+}
+
+export function E400MissingComponent({ name }: { name: string }) {
+ return createElement(
+ StatusError,
+ {
+ status: 400,
+ message: `Module "${name}" should export a React Component as default`,
+ showRefreshButton: true
+ }
+ )
+}
+
+const resetStyle: CSSProperties = {
+ padding: 0,
+ margin: 0,
+ lineHeight: 1.5,
+ fontSize: 15,
+ fontWeight: 400,
+ color: '#333',
+}
+
+export function StatusError({ status, message }: { status: number, message: string }) {
+ return (
+ createElement(
+ 'div',
+ {
+ style: {
+ ...resetStyle,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'column',
+ width: '100vm',
+ height: '100vh',
+ }
+ },
+ createElement(
+ 'p',
+ {
+ style: {
+ ...resetStyle,
+ fontWeight: 500,
+ }
+ },
+ createElement(
+ 'code',
+ {
+ style: {
+ ...resetStyle,
+ fontWeight: 700,
+ }
+ },
+ status
+ ),
+ createElement(
+ 'small',
+ {
+ style: {
+ ...resetStyle,
+ fontSize: 4,
+ color: '#999'
+ }
+ },
+ ' - '
+ ),
+ createElement(
+ 'span',
+ null,
+ message
+ )
+ )
+ )
+ )
+}
+
+export class AsyncUseDenoError extends Error { }
diff --git a/head.ts b/framework/react/head.ts
similarity index 77%
rename from head.ts
rename to framework/react/head.ts
index 144606bc4..83da28408 100644
--- a/head.ts
+++ b/framework/react/head.ts
@@ -1,8 +1,8 @@
-import React, { Children, createElement, isValidElement, PropsWithChildren, ReactElement, ReactNode, useContext, useEffect } from 'https://esm.sh/react'
+import type { PropsWithChildren, ReactElement, ReactNode } from 'https://esm.sh/react'
+import { Children, createElement, Fragment, isValidElement, useContext, useEffect } from 'https://esm.sh/react'
+import util from '../../shared/util.ts'
import { RendererContext } from './context.ts'
-import util from './util.ts'
-
-export const serverStyles: Map = new Map()
+import Script from './script.ts'
export default function Head(props: PropsWithChildren<{}>) {
const renderer = useContext(RendererContext)
@@ -62,22 +62,6 @@ export default function Head(props: PropsWithChildren<{}>) {
return null
}
-export function Scripts(props: PropsWithChildren<{}>) {
- const renderer = useContext(RendererContext)
-
- if (window.Deno) {
- parse(props.children).forEach(({ type, props }, key) => {
- if (type === 'script') {
- renderer.cache.scriptsElements.set(key, { type, props })
- }
- })
- }
-
- // todo: insert page scripts in browser
-
- return null
-}
-
interface SEOProps {
title?: string
description?: string
@@ -136,51 +120,23 @@ export function Viewport(props: ViewportProps) {
)
}
-export function applyCSS(id: string, css: string, asLink: boolean = false) {
- if (window.Deno) {
- serverStyles.set(id, { css, asLink })
- } else {
- const { document } = (window as any)
- const styleEl = document.createElement(asLink ? 'link' : 'style')
- const prevStyleEls = Array.from(document.head.children).filter((el: any) => el.getAttribute('data-module-id') === id)
- if (asLink) {
- styleEl.rel = 'stylesheet'
- styleEl.href = css
- } else {
- styleEl.type = 'text/css'
- styleEl.appendChild(document.createTextNode(css))
- }
- styleEl.setAttribute('data-module-id', id)
- document.head.appendChild(styleEl)
- if (prevStyleEls.length > 0) {
- if (asLink) {
- styleEl.addEventListener('load', () => {
- prevStyleEls.forEach(el => document.head.removeChild(el))
- })
- } else {
- setTimeout(() => {
- prevStyleEls.forEach(el => document.head.removeChild(el))
- }, 0)
- }
- }
- }
-}
-
function parse(node: ReactNode, els: Map }> = new Map()) {
Children.forEach(node, child => {
if (!isValidElement(child)) {
return
}
- const { type, props } = child
+ let { type, props } = child
switch (type) {
- case React.Fragment:
+ case Fragment:
parse(props.children, els)
break
case SEO:
case Viewport:
parse((type(props) as ReactElement).props.children, els)
break
+ case Script:
+ type = "script"
case 'base':
case 'title':
case 'meta':
@@ -221,4 +177,3 @@ function parse(node: ReactNode, els: Map{pathname}
+ * }
+ * ```
+ */
+export function useRouter(): RouterURL {
+ return useContext(RouterContext)
+}
+
+/**
+ * `withRouter` allows you to use `useRouter` hook with class component.
+ *
+ * ```javascript
+ * class MyComponent extends React.Component {
+ * render() {
+ * return {this.props.version.deno}
+ * }
+ * }
+ * export default withRouter(MyComponent)
+ * ```
+ */
+export function withRouter(Component: ComponentType
) {
+ return function WithRouter(props: P) {
+ const router = useRouter()
+ return createElement(Component, { ...props, ...router })
+ }
+}
+
+/**
+ * `useDeno` allows you to fetch data in build time(SSR) with Deno runtime.
+ *
+ * ```javascript
+ * export default function App() {
+ * const version = useDeno(() => Deno.version)
+ * return
{version.deno}
+ * }
+ * ```
+ */
+export function useDeno(callback: () => (T | Promise)): T {
+ const id = arguments[2] // generated by compiler
+ const { pathname, query } = useRouter()
+ const [data, setData] = useState(() => {
+ const global = window as any
+ const useDenoUrl = `useDeno://${pathname}`
+ const { [`__asyncData_${useDenoUrl}`]: asyncData } = global
+ const key = `${useDenoUrl}#${id}`
+ if (asyncData && key in asyncData) {
+ return asyncData[key]
+ } else if (typeof Deno !== 'undefined' && Deno.version.deno) {
+ const ret = callback()
+ if (ret instanceof Promise) {
+ events.emit(useDenoUrl, id, ret.then(data => {
+ if (asyncData) {
+ asyncData[key] = data
+ }
+ events.emit(useDenoUrl, id, data)
+ }), true)
+ throw new AsyncUseDenoError('async useDeno')
+ } else {
+ if (asyncData) {
+ asyncData[key] = ret
+ }
+ events.emit(useDenoUrl, id, ret)
+ return ret
+ }
+ }
+ return global[key] || null
+ })
+
+ useEffect(() => {
+
+ }, [])
+
+ return data
+}
+
+/**
+ * `withDeno` allows you to use `useDeno` hook with class component.
+ *
+ * ```javascript
+ * class MyComponent extends React.Component {
+ * render() {
+ * return {this.props.version.deno}
+ * }
+ * }
+ * export default withDeno(() => Deno.version)(MyComponent)
+ * ```
+ */
+export function withDeno(callback: () => (T | Promise)) {
+ return function (Component: ComponentType
): ComponentType> {
+ return function WithDeno(props: Exclude) {
+ const denoProps = useDeno(callback)
+ if (typeof denoProps === 'object') {
+ return createElement(Component, { ...props, ...denoProps })
+ }
+ return createElement(Component, props)
+ }
+ }
+}
diff --git a/framework/react/link.ts b/framework/react/link.ts
new file mode 100644
index 000000000..74d10f797
--- /dev/null
+++ b/framework/react/link.ts
@@ -0,0 +1,63 @@
+import type { ComponentType, LinkHTMLAttributes } from 'https://esm.sh/react'
+import { createElement, useEffect, useState } from 'https://esm.sh/react'
+import util from '../../shared/util.ts'
+import { removeCSS } from './style.ts'
+import { isLikelyReactComponent } from './util.ts'
+
+type LinkProps = LinkHTMLAttributes<{}> & {
+ ['data-fallback']?: JSX.Element
+ ['data-props']?: any
+ ['data-export-name']?: string
+ __url?: string
+ __base?: string
+}
+
+export default function Link({
+ rel,
+ href,
+ ['data-fallback']: fallback,
+ ['data-props']: compProps,
+ ['data-export-name']: exportName,
+ __url,
+ __base
+}: LinkProps) {
+ const [error, setError] = useState(null)
+ const [mod, setMod] = useState<{ Component: ComponentType | null }>({ Component: null })
+
+ useEffect(() => {
+ // todo: resolve baseUrl
+ let fixedHref = util.cleanPath('/_aleph/' + (__base || '') + '/' + href)
+ if (rel === 'component') {
+ setMod({ Component: null })
+ import(fixedHref)
+ .then(mod => {
+ const Component = mod[exportName || 'default']
+ if (isLikelyReactComponent(Component)) {
+ setMod({ Component })
+ } else {
+ setError(`component${exportName ? ` '${exportName}'` : ''} not found`)
+ }
+ })
+ .catch((err: Error) => {
+ setError(err.message)
+ })
+ } else if (rel === 'style' || rel === 'stylesheet') {
+ import(fixedHref)
+ return () => __url ? removeCSS(__url) : void 0
+ }
+ }, [rel, href, exportName, __url, __base])
+
+ if (error) {
+ return createElement('div', { style: { color: 'red' } }, error)
+ }
+
+ if (mod.Component) {
+ return createElement(mod.Component, compProps)
+ }
+
+ if (rel === 'component' && fallback) {
+ return fallback
+ }
+
+ return null
+}
diff --git a/framework/react/refresh.ts b/framework/react/refresh.ts
new file mode 100644
index 000000000..2e1e8dbf3
--- /dev/null
+++ b/framework/react/refresh.ts
@@ -0,0 +1,13 @@
+import runtime from 'https://esm.sh/react-refresh@0.8.3/runtime'
+import util from '../../shared/util.ts'
+
+// react-refresh
+// @link https://github.com/facebook/react/issues/16604#issuecomment-528663101
+runtime.injectIntoGlobalHook(window)
+Object.assign(window, {
+ $RefreshReg$: () => { },
+ $RefreshSig$: () => (type: any) => type
+})
+
+export const performReactRefresh = util.debounce(runtime.performReactRefresh, 30)
+export const RefreshRuntime = runtime
diff --git a/renderer.ts b/framework/react/renderer.ts
similarity index 64%
rename from renderer.ts
rename to framework/react/renderer.ts
index a8c7f754b..382c5c6a9 100644
--- a/renderer.ts
+++ b/framework/react/renderer.ts
@@ -1,12 +1,14 @@
-import React, { ComponentType, ReactElement } from 'https://esm.sh/react'
+import type { ComponentType, ReactElement } from 'https://esm.sh/react'
+import { createElement } from 'https://esm.sh/react'
import { renderToString } from 'https://esm.sh/react-dom/server'
+import { hashShort, reHttp } from '../../shared/constants.ts'
+import util from '../../shared/util.ts'
+import type { RouterURL } from '../../types.ts'
+import events from '../core/events.ts'
import { RendererContext, RouterContext } from './context.ts'
-import { AsyncUseDenoError, E400MissingDefaultExportAsComponent, E404Page } from './error.ts'
-import events from './events.ts'
-import { serverStyles } from './head.ts'
-import { createPageProps } from './routing.ts'
-import type { RouterURL } from './types.ts'
-import util, { hashShort, reHttp } from './util.ts'
+import { AsyncUseDenoError, E400MissingComponent, E404Page } from './error.ts'
+import { serverStyles } from './style.ts'
+import { createPageProps, isLikelyReactComponent } from './util.ts'
interface RenderResult {
head: string[]
@@ -19,36 +21,30 @@ export async function renderPage(
url: RouterURL,
App: ComponentType | undefined,
E404: ComponentType | undefined,
- pageComponentTree: { id: string, Component?: any }[],
+ pageComponentTree: { url: string, Component?: any }[],
styles?: { url: string, hash: string }[]
): Promise {
let el: ReactElement
const pageProps = createPageProps(pageComponentTree)
if (App) {
- if (util.isLikelyReactComponent(App)) {
- el = React.createElement(App, pageProps)
+ if (isLikelyReactComponent(App)) {
+ el = createElement(App, pageProps)
} else {
- el = React.createElement(
- E400MissingDefaultExportAsComponent,
- { name: 'Custom App' }
- )
+ el = createElement(E400MissingComponent, { name: 'Custom App' })
}
} else {
if (pageProps.Page == null) {
if (E404) {
- if (util.isLikelyReactComponent(E404)) {
- el = React.createElement(E404)
+ if (isLikelyReactComponent(E404)) {
+ el = createElement(E404)
} else {
- el = React.createElement(
- E400MissingDefaultExportAsComponent,
- { name: 'Custom 404' }
- )
+ el = createElement(E400MissingComponent, { name: 'Custom 404' })
}
} else {
- el = React.createElement(E404Page)
+ el = createElement(E404Page)
}
} else {
- el = React.createElement(pageProps.Page, pageProps.pageProps)
+ el = createElement(pageProps.Page, pageProps.pageProps)
}
}
@@ -62,33 +58,16 @@ export async function renderPage(
headElements: new Map(),
scriptsElements: new Map()
}
- const { __createHTMLDocument } = (window as any)
const buildMode = Deno.env.get('__buildMode')
- const buildTarget = Deno.env.get('__buildTarget')
const data: Record = {}
- const useDenEvent = `useDeno://${url.pathname + '?' + url.query.toString()}`
+ const useDenUrl = `useDeno://${url.pathname}`
const useDenoAsyncCalls: Array> = []
- Object.assign(window, {
- [`__asyncData_${useDenEvent}`]: {},
- document: __createHTMLDocument(),
- location: {
- protocol: 'http:',
- host: 'localhost',
- hostname: 'localhost',
- port: '',
- href: 'https://localhost' + url.pathname + url.query.toString(),
- origin: 'https://localhost',
- pathname: url.pathname,
- search: url.query.toString(),
- hash: '',
- reload() { },
- replace() { },
- toString() { return this.href },
- }
+ Object.assign(globalThis, {
+ [`__asyncData_${useDenUrl}`]: {},
})
- events.on(useDenEvent, (id: string, ret: any, async: boolean) => {
+ events.on(useDenUrl, (id: string, ret: any, async: boolean) => {
if (async) {
useDenoAsyncCalls.push(ret)
} else {
@@ -104,10 +83,10 @@ export async function renderPage(
await Promise.all(iter)
}
ret.body = renderToString(
- React.createElement(
+ createElement(
RendererContext.Provider,
{ value: { cache: rendererCache } },
- React.createElement(
+ createElement(
RouterContext.Provider,
{ value: url },
el
@@ -120,13 +99,13 @@ export async function renderPage(
continue
}
console.log(error)
- Object.assign(window, { [`__asyncData_${useDenEvent}`]: null })
+ Object.assign(window, { [`__asyncData_${useDenUrl}`]: null })
throw error
}
}
- Object.assign(window, { [`__asyncData_${useDenEvent}`]: null })
- events.removeAllListeners(useDenEvent)
+ Object.assign(window, { [`__asyncData_${useDenUrl}`]: null })
+ events.removeAllListeners(useDenUrl)
if (Object.keys(data).length > 0) {
ret.data = data
}
@@ -167,17 +146,17 @@ export async function renderPage(
rendererCache.headElements.clear()
rendererCache.scriptsElements.clear()
- await Promise.all(styles?.map(({ url, hash }) => {
+ await Promise.all(styles?.filter(({ url }) => !url.startsWith("#inline-style-")).map(({ url, hash }) => {
const path = reHttp.test(url) ? url.replace(reHttp, '/-/') : `${url}.${hash.slice(0, hashShort)}`
- return import('file://' + util.cleanPath(`${Deno.cwd()}/.aleph/${buildMode}.${buildTarget}/${path}.js`))
+ return import('file://' + util.cleanPath(`${Deno.cwd()}/.aleph/${buildMode}/${path}.js`))
}) || [])
styles?.forEach(({ url }) => {
if (serverStyles.has(url)) {
const { css, asLink } = serverStyles.get(url)!
if (asLink) {
- ret.head.push(` `)
+ ret.head.push(` `)
} else {
- ret.head.push(``)
+ ret.head.push(``)
}
}
})
diff --git a/framework/react/root.ts b/framework/react/root.ts
new file mode 100644
index 000000000..a9969df18
--- /dev/null
+++ b/framework/react/root.ts
@@ -0,0 +1,193 @@
+import type { ComponentType } from 'https://esm.sh/react'
+import { createElement, useCallback, useEffect, useState } from 'https://esm.sh/react'
+import util from '../../shared/util.ts'
+import type { RouterURL } from '../../types.ts'
+import events from '../core/events.ts'
+import { RouteModule, Routing } from '../core/routing.ts'
+import { RouterContext } from './context.ts'
+import { E400MissingComponent, E404Page, ErrorBoundary } from './error.ts'
+import { createPageProps, importModule, isLikelyReactComponent } from './util.ts'
+
+export default function AlephAppRoot({
+ url,
+ routing,
+ customComponents,
+ pageComponentTree,
+}: {
+ url: RouterURL
+ routing: Routing
+ customComponents: Record<'E404' | 'App', ComponentType>
+ pageComponentTree: { url: string, Component?: any }[]
+}) {
+ const [e404, setE404] = useState<{ Component: ComponentType, props?: Record }>(() => {
+ const { E404 } = customComponents
+ if (E404) {
+ if (isLikelyReactComponent(E404)) {
+ return { Component: E404 }
+ }
+ return { Component: E400MissingComponent, props: { name: 'Custom 404 Page' } }
+ }
+ return { Component: E404Page }
+ })
+ const [app, setApp] = useState<{ Component: ComponentType | null, props?: Record }>(() => {
+ const { App } = customComponents
+ if (App) {
+ if (isLikelyReactComponent(App)) {
+ return { Component: App }
+ }
+ return { Component: E400MissingComponent, props: { name: 'Custom App' } }
+ }
+ return { Component: null }
+ })
+ const [route, setRoute] = useState(() => ({ ...createPageProps(pageComponentTree), url }))
+ const onpopstate = useCallback(async (e: any) => {
+ const { baseUrl } = routing
+ const [url, pageModuleTree] = routing.createRouter()
+ if (url.pagePath !== '') {
+ const ctree: { url: string, Component?: ComponentType }[] = pageModuleTree.map(({ url }) => ({ url }))
+ const imports = pageModuleTree.map(async mod => {
+ const { default: C } = await importModule(baseUrl, mod, e.forceRefetch)
+ if (mod.deps && mod.deps.filter(({ isData, url }) => !!isData && url.startsWith('#useDeno-')).length > 0) {
+ const { default: data } = await fetch(`/_aleph/data${url.pathname === '/' ? '/index' : url.pathname}.json`).then(resp => resp.json())
+ if (util.isPlainObject(data)) {
+ for (const key in data) {
+ const useDenoUrl = `useDeno://${url.pathname}#${key}`
+ Object.assign(window, { [useDenoUrl]: data[key] })
+ }
+ }
+ }
+ const pc = ctree.find(pc => pc.url === mod.url)
+ if (pc) {
+ pc.Component = C
+ }
+ })
+ await Promise.all(imports)
+ setRoute({ ...createPageProps(ctree), url })
+ if (e.resetScroll) {
+ (window as any).scrollTo(0, 0)
+ }
+ } else {
+ setRoute({ Page: null, pageProps: {}, url })
+ }
+ }, [])
+
+ useEffect(() => {
+ window.addEventListener('popstate', onpopstate)
+ events.on('popstate', onpopstate)
+
+ return () => {
+ window.removeEventListener('popstate', onpopstate)
+ events.off('popstate', onpopstate)
+ }
+ }, [onpopstate])
+
+ useEffect(() => {
+ const { baseUrl } = routing
+ const onAddModule = async (mod: RouteModule) => {
+ switch (mod.url) {
+ case '/404.js': {
+ const { default: Component } = await importModule(baseUrl, mod, true)
+ if (isLikelyReactComponent(Component)) {
+ setE404({ Component })
+ } else {
+ setE404({ Component: E404Page })
+ }
+ break
+ }
+ case '/app.js': {
+ const { default: Component } = await importModule(baseUrl, mod, true)
+ if (isLikelyReactComponent(Component)) {
+ setApp({ Component })
+ } else {
+ setApp({ Component: E400MissingComponent, props: { name: 'Custom App' } })
+ }
+ break
+ }
+ default: {
+ if (mod.url.startsWith('/pages/')) {
+ routing.update(mod)
+ events.emit('popstate', { type: 'popstate', forceRefetch: true })
+ }
+ break
+ }
+ }
+ }
+ const onRemoveModule = (url: string) => {
+ switch (url) {
+ case '/404.js':
+ setE404({ Component: E404Page })
+ break
+ case '/app.js':
+ setApp({ Component: null })
+ break
+ default:
+ if (url.startsWith('/pages/')) {
+ routing.removeRoute(url)
+ events.emit('popstate', { type: 'popstate' })
+ }
+ break
+ }
+ }
+ const onFetchPageModule = async ({ href }: { href: string }) => {
+ const [pathname, search] = href.split('?')
+ const [url, pageModuleTree] = routing.createRouter({ pathname, search })
+ if (url.pagePath !== '') {
+ const imports = pageModuleTree.map(async mod => {
+ await importModule(baseUrl, mod)
+ if (mod.deps && mod.deps.filter(({ isData, url }) => !!isData && url.startsWith('#useDeno-')).length > 0) {
+ const { default: data } = await fetch(`/_aleph/data${url.pathname === '/' ? '/index' : url.pathname}.json`).then(resp => resp.json())
+ if (util.isPlainObject(data)) {
+ for (const key in data) {
+ const useDenoUrl = `useDeno://${url.pathname}#${key}`
+ Object.assign(window, { [useDenoUrl]: data[key] })
+ }
+ }
+ }
+ })
+ await Promise.all(imports)
+ }
+ }
+
+ events.on('add-module', onAddModule)
+ events.on('remove-module', onRemoveModule)
+ events.on('fetch-page-module', onFetchPageModule)
+
+ return () => {
+ events.off('add-module', onAddModule)
+ events.off('remove-module', onRemoveModule)
+ events.off('fetch-page-module', onFetchPageModule)
+ }
+ }, [])
+
+ useEffect(() => {
+ const win = window as any
+ const { location, document, scrollX, scrollY, hashAnchorScroll } = win
+ if (location.hash) {
+ const anchor = document.getElementById(location.hash.slice(1))
+ if (anchor) {
+ const { left, top } = anchor.getBoundingClientRect()
+ win.scroll({
+ top: top + scrollY - (hashAnchorScroll?.offset?.top || 0),
+ left: left + scrollX - (hashAnchorScroll?.offset?.left || 0),
+ behavior: hashAnchorScroll?.behavior
+ })
+ }
+ }
+ }, [route])
+
+ return (
+ createElement(
+ ErrorBoundary,
+ null,
+ createElement(
+ RouterContext.Provider,
+ { value: route.url },
+ ...[
+ (route.Page && app.Component) && createElement(app.Component, Object.assign({}, app.props, { Page: route.Page, pageProps: route.pageProps })),
+ (route.Page && !app.Component) && createElement(route.Page, route.pageProps),
+ !route.Page && createElement(e404.Component, e404.props)
+ ].filter(Boolean),
+ )
+ )
+ )
+}
diff --git a/framework/react/script.ts b/framework/react/script.ts
new file mode 100644
index 000000000..37236926a
--- /dev/null
+++ b/framework/react/script.ts
@@ -0,0 +1,16 @@
+import type { PropsWithChildren, ScriptHTMLAttributes } from 'https://esm.sh/react'
+import { useContext } from 'https://esm.sh/react'
+import { RendererContext } from './context.ts'
+
+export default function Script(props: PropsWithChildren>) {
+ const renderer = useContext(RendererContext)
+
+ if (window.Deno) {
+ const key = 'script-' + (renderer.cache.scriptsElements.size + 1)
+ renderer.cache.scriptsElements.set(key, { type: 'script', props })
+ }
+
+ // todo: insert page scripts in browser
+
+ return null
+}
diff --git a/framework/react/style.ts b/framework/react/style.ts
new file mode 100644
index 000000000..8b8c72cad
--- /dev/null
+++ b/framework/react/style.ts
@@ -0,0 +1,89 @@
+import type { StyleHTMLAttributes } from 'https://esm.sh/react'
+import { useEffect } from 'https://esm.sh/react'
+
+export const serverStyles: Map = new Map()
+
+type StyleProps = StyleHTMLAttributes<{}> & { __styleId?: string }
+
+export default function Style({ children, __styleId: id }: StyleProps) {
+ const css = children?.toLocaleString()
+
+ if (window.Deno) {
+ if (css && id) {
+ serverStyles.set('#' + id, { css })
+ }
+ }
+
+ useEffect(() => {
+ const { document } = (window as any)
+ const styleEl = document.createElement('style')
+ const ssrStyleEls = Array.from(document.head.children).filter((el: any) => {
+ return el.getAttribute('data-module-id') === '#' + id && el.hasAttribute('ssr')
+ })
+ styleEl.type = 'text/css'
+ styleEl.setAttribute('data-module-id', '#' + id)
+ styleEl.appendChild(document.createTextNode(css))
+ document.head.appendChild(styleEl)
+ if (ssrStyleEls.length > 0) {
+ setTimeout(() => {
+ ssrStyleEls.forEach(el => document.head.removeChild(el))
+ }, 0)
+ }
+ return () => {
+ document.head.removeChild(styleEl)
+ console.log('remove', id)
+ }
+ }, [css])
+
+ return null
+}
+
+const removeTimers = new Map()
+
+export function removeCSS(id: string) {
+ const { document } = (window as any)
+ const styleEls = Array.from(document.head.children).filter((el: any) => {
+ return el.getAttribute('data-module-id') === id
+ })
+
+ removeTimers.set(id, setTimeout(() => {
+ removeTimers.delete(id)
+ styleEls.forEach(el => document.head.removeChild(el))
+ }, 500))
+}
+
+export function applyCSS(id: string, css: string, asLink: boolean = false) {
+ if (window.Deno) {
+ serverStyles.set(id, { css, asLink })
+ } else {
+ if (removeTimers.has(id)) {
+ clearTimeout(removeTimers.get(id))
+ removeTimers.delete(id)
+ }
+ const { document } = (window as any)
+ const styleEl = document.createElement(asLink ? 'link' : 'style')
+ const prevStyleEls = Array.from(document.head.children).filter((el: any) => {
+ return el.getAttribute('data-module-id') === id
+ })
+ if (asLink) {
+ styleEl.rel = 'stylesheet'
+ styleEl.href = css
+ } else {
+ styleEl.type = 'text/css'
+ styleEl.appendChild(document.createTextNode(css))
+ }
+ styleEl.setAttribute('data-module-id', id)
+ document.head.appendChild(styleEl)
+ if (prevStyleEls.length > 0) {
+ if (asLink) {
+ styleEl.addEventListener('load', () => {
+ prevStyleEls.forEach(el => document.head.removeChild(el))
+ })
+ } else {
+ setTimeout(() => {
+ prevStyleEls.forEach(el => document.head.removeChild(el))
+ }, 0)
+ }
+ }
+ }
+}
diff --git a/framework/react/util.ts b/framework/react/util.ts
new file mode 100644
index 000000000..c52679f09
--- /dev/null
+++ b/framework/react/util.ts
@@ -0,0 +1,109 @@
+import type { ComponentType } from 'https://esm.sh/react'
+import { hashShort, reModuleExt } from '../../shared/constants.ts'
+import util from '../../shared/util.ts'
+import { RouteModule } from '../core/routing.ts'
+import { E400MissingComponent } from './error.ts'
+
+const symbolFor = typeof Symbol === 'function' && Symbol.for
+const REACT_FORWARD_REF_TYPE = symbolFor ? Symbol.for('react.forward_ref') : 0xead0
+const REACT_MEMO_TYPE = symbolFor ? Symbol.for('react.memo') : 0xead3
+
+export interface PageProps {
+ Page: ComponentType | null
+ pageProps: Partial & { name?: string }
+}
+
+export function importModule(baseUrl: string, mod: RouteModule, forceRefetch = false): Promise {
+ const { __ALEPH, document } = window as any
+ if (!__ALEPH || mod.url.startsWith('/pages/')) {
+ const src = util.cleanPath(baseUrl + '/_aleph/' + mod.url.replace(reModuleExt, '') + `.${mod.hash.slice(0, hashShort)}.js`) + (forceRefetch ? `?t=${Date.now()}` : '')
+ if (__ALEPH) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.onload = () => {
+ resolve(__ALEPH.pack[mod.url])
+ }
+ script.onerror = (err: Error) => {
+ reject(err)
+ }
+ script.src = src
+ document.body.appendChild(script)
+ })
+ } else {
+ return import(src)
+ }
+ } else if (__ALEPH && mod.url in __ALEPH.pack) {
+ return Promise.resolve(__ALEPH.pack[mod.url])
+ } else {
+ return Promise.reject(new Error(`Module '${mod.url}' not found`))
+ }
+}
+
+export function isLikelyReactComponent(type: any): Boolean {
+ switch (typeof type) {
+ case 'function':
+ if (type.prototype != null) {
+ if (type.prototype.isReactComponent) {
+ return true
+ }
+ const ownNames = Object.getOwnPropertyNames(type.prototype)
+ if (ownNames.length > 1 || ownNames[0] !== 'constructor') {
+ return false
+ }
+ }
+ const { __ALEPH } = window as any
+ if (__ALEPH) {
+ // in bundle mode, component name will be compressed
+ return true
+ }
+ const name = type.name || type.displayName
+ return typeof name === 'string' && /^[A-Z]/.test(name)
+ case 'object':
+ if (type != null) {
+ switch (type.$$typeof) {
+ case REACT_FORWARD_REF_TYPE:
+ case REACT_MEMO_TYPE:
+ return true
+ default:
+ return false
+ }
+ }
+ return false
+ default:
+ return false
+ }
+}
+
+export function createPageProps(componentTree: { url: string, Component?: ComponentType }[]): PageProps {
+ const pageProps: PageProps = {
+ Page: null,
+ pageProps: {}
+ }
+ if (componentTree.length > 0) {
+ Object.assign(pageProps, _createPagePropsSegment(componentTree[0]))
+ }
+ if (componentTree.length > 1) {
+ componentTree.slice(1).reduce((p, seg) => {
+ const c = _createPagePropsSegment(seg)
+ p.pageProps = c
+ return c
+ }, pageProps)
+ }
+ return pageProps
+}
+
+function _createPagePropsSegment(seg: { url: string, Component?: ComponentType }): PageProps {
+ const pageProps: PageProps = {
+ Page: null,
+ pageProps: {}
+ }
+ if (seg.Component) {
+ if (isLikelyReactComponent(seg.Component)) {
+ pageProps.Page = seg.Component
+ } else {
+ pageProps.Page = E400MissingComponent
+ pageProps.pageProps = { name: 'Page: ' + util.trimPrefix(seg.url, '/pages').replace(reModuleExt, '') }
+ }
+ }
+ return pageProps
+}
diff --git a/fs.ts b/fs.ts
deleted file mode 100644
index e1b5d0bf6..000000000
--- a/fs.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { ensureDir, path } from './std.ts'
-
-export async function existsDir(path: string) {
- try {
- const fi = await Deno.lstat(path)
- if (fi.isDirectory) {
- return true
- }
- return false
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) {
- return false
- }
- throw err
- }
-}
-
-export function existsDirSync(path: string) {
- try {
- const fi = Deno.lstatSync(path)
- if (fi.isDirectory) {
- return true
- }
- return false
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) {
- return false
- }
- throw err
- }
-}
-
-export async function existsFile(path: string) {
- try {
- const fi = await Deno.lstat(path)
- if (fi.isFile) {
- return true
- }
- return false
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) {
- return false
- }
- throw err
- }
-}
-
-export function existsFileSync(path: string) {
- try {
- const fi = Deno.lstatSync(path)
- if (fi.isFile) {
- return true
- }
- return false
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) {
- return false
- }
- throw err
- }
-}
-
-/** ensure and write a text file */
-export async function ensureTextFile(name: string, content: string) {
- const dir = path.dirname(name)
- await ensureDir(dir)
- await Deno.writeTextFile(name, content)
-}
diff --git a/hooks.ts b/hooks.ts
deleted file mode 100644
index 07b74c66f..000000000
--- a/hooks.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import React, { useContext, useEffect, useState } from 'https://esm.sh/react'
-import { RouterContext } from './context.ts'
-import { AsyncUseDenoError } from './error.ts'
-import events from './events.ts'
-import type { RouterURL } from './types.ts'
-
-export function useRouter(): RouterURL {
- return useContext(RouterContext)
-}
-
-export function useDeno(callback: () => (T | Promise), browser?: boolean, deps?: ReadonlyArray): T {
- const id = arguments[3] // generated by compiler
- const { pathname, query } = useRouter()
- const [data, setData] = useState(() => {
- const global = window as any
- const useDenoUrl = `useDeno://${pathname}?${query.toString()}`
- const { [`__asyncData_${useDenoUrl}`]: asyncData } = global
- const key = `${useDenoUrl}#${id}`
- if (asyncData && key in asyncData) {
- return asyncData[key]
- } else if (typeof Deno !== 'undefined' && Deno.version.deno) {
- const ret = callback()
- if (ret instanceof Promise) {
- events.emit(useDenoUrl, id, ret.then(data => {
- if (asyncData) {
- asyncData[key] = data
- }
- events.emit(useDenoUrl, id, data)
- }), true)
- throw new AsyncUseDenoError('async useDeno')
- } else {
- if (asyncData) {
- asyncData[key] = ret
- }
- events.emit(useDenoUrl, id, ret)
- return ret
- }
- }
- return global[key] || null
- })
-
- useEffect(() => {
- if (browser) {
- const ret = callback()
- if (ret instanceof Promise) {
- ret.then(setData)
- } else {
- setData(ret)
- }
- }
- }, deps)
-
- return data
-}
-
-/**
- * `withDeno` allows you to use `useDeno` hook with class component.
- *
- * ```javascript
- * class MyComponent extends React.Component {
- * render() {
- * return {this.props.version.deno}
- * }
- * }
- * export default withDeno(() => Deno.version)(MyComponent)
- * ```
- */
-export function withDeno(callback: () => (T | Promise), browser?: boolean, deps?: ReadonlyArray) {
- return function (Component: React.ComponentType
): React.ComponentType> {
- return function WithDeno(props: Exclude) {
- const denoProps = useDeno(callback, browser, deps)
- if (typeof denoProps === 'object') {
- return React.createElement(Component, { ...props, ...denoProps })
- }
- return React.createElement(Component, props)
- }
- }
-}
diff --git a/html.ts b/html.ts
deleted file mode 100644
index ef55a23f1..000000000
--- a/html.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import util from './util.ts'
-
-export function createHtml({
- lang = 'en',
- head = [],
- scripts = [],
- body,
- minify = false
-}: {
- lang?: string,
- head?: string[],
- scripts?: (string | { id?: string, type?: string, src?: string, innerText?: string, nomodule?: boolean, async?: boolean, preload?: boolean })[],
- body: string,
- minify?: boolean
-}) {
- const eol = minify ? '' : '\n'
- const indent = minify ? '' : ' '.repeat(4)
- const headTags = head.map(tag => tag.trim())
- .concat(scripts.map(v => {
- if (!util.isString(v) && util.isNEString(v.src)) {
- if (v.type === 'module') {
- return ` `
- } else if (v.async === true) {
- return ` `
- }
- }
- return ''
- })).filter(Boolean)
- const scriptTags = scripts.map(v => {
- if (util.isString(v)) {
- return ``
- } else if (util.isNEString(v.innerText)) {
- const { innerText, ...rest } = v
- return ``
- } else if (util.isNEString(v.src) && !v.preload) {
- return ``
- } else {
- return ''
- }
- }).filter(Boolean)
-
- return [
- '',
- ``,
- '
',
- indent + ' ',
- ...headTags.map(tag => indent + tag),
- '',
- '',
- indent + body,
- ...scriptTags.map(tag => indent + tag),
- '',
- ''
- ].join(eol)
-}
-
-function attrString(v: any): string {
- return Object.keys(v).map(k => {
- if (v[k] === true) {
- return ` ${k}`
- } else {
- return ` ${k}=${JSON.stringify(String(v[k]))}`
- }
- }).join('')
-}
diff --git a/import.ts b/import.ts
deleted file mode 100644
index 3a71722f0..000000000
--- a/import.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, { ComponentType, ReactElement, useEffect, useState } from 'https://esm.sh/react'
-import util, { reModuleExt } from './util.ts'
-
-interface ImportProps {
- from: string
- name?: string // default is 'default'
- props?: Record
- fallback?: ReactElement
-}
-
-export default function Import(props: ImportProps) {
- const { __importer, __sourceFile } = (props as any)
- const [error, setError] = useState(null)
- const [mod, setMod] = useState<{ Component: ComponentType | null }>({ Component: null })
-
- useEffect(() => {
- if (reModuleExt.test(__sourceFile)) {
- const p = util.splitPath(__importer)
- p.pop()
- import(util.cleanPath('/_aleph/' + p.join('/') + '/' + props.from))
- .then(mod => {
- const Component = mod[props.name || 'default']
- if (util.isLikelyReactComponent(Component)) {
- setMod({ Component })
- } else {
- setError(`component${props.name ? ` '${props.name}'` : ''} not found`)
- }
- })
- .catch((err: Error) => {
- setError(err.message)
- })
- }
- }, [__importer, __sourceFile])
-
- if (error) {
- return React.createElement('div', { style: { color: 'red' } }, error)
- }
-
- if (mod.Component) {
- return React.createElement(mod.Component, props.props)
- }
-
- if (reModuleExt.test(__sourceFile) && props.fallback) {
- return props.fallback
- }
-
- return null
-}
diff --git a/import_map.json b/import_map.json
index a651f5503..853dc1bed 100644
--- a/import_map.json
+++ b/import_map.json
@@ -1,5 +1,9 @@
{
"imports": {
- "https://deno.land/x/aleph/": "http://localhost:9006/"
+ "https://deno.land/x/aleph/": "http://localhost:9006/",
+ "aleph": "http://localhost:9006/mod.ts",
+ "aleph/": "http://localhost:9006/",
+ "react": "https://esm.sh/react@17.0.1",
+ "react-dom": "https://esm.sh/react-dom@17.0.1"
}
}
\ No newline at end of file
diff --git a/link.ts b/link.ts
deleted file mode 100644
index 407870021..000000000
--- a/link.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import React, { Children, cloneElement, ComponentType, CSSProperties, isValidElement, MouseEvent, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState } from 'https://esm.sh/react'
-import { redirect } from './aleph.ts'
-import events from './events.ts'
-import { useRouter } from './hooks.ts'
-import util, { reModuleExt } from './util.ts'
-
-const prefetchedPageModules = new Set()
-
-interface LinkProps {
- to: string
- replace?: boolean
- prefetch?: boolean
- className?: string
- style?: CSSProperties
-}
-
-export default function Link(props: PropsWithChildren) {
- const { to, replace = false, prefetch: prefetchNow = false, className, style, children } = props
- const { pathname: currentPathname, query: currentQuery } = useRouter()
- const currentHref = useMemo(() => {
- return [currentPathname, currentQuery.toString()].filter(Boolean).join('?')
- }, [currentPathname, currentQuery])
- const href = useMemo(() => {
- if (util.isHttpUrl(to)) {
- return to
- }
- let [pathname, search] = util.splitBy(to, '?')
- if (pathname.startsWith('/')) {
- pathname = util.cleanPath(pathname)
- } else {
- pathname = util.cleanPath(currentPathname + '/' + pathname)
- }
- return [pathname, search].filter(Boolean).join('?')
- }, [currentPathname, to])
- const prefetch = useCallback(() => {
- if (!util.isHttpUrl(href) && href !== currentHref && !prefetchedPageModules.has(href)) {
- events.emit('fetch-page-module', { href })
- prefetchedPageModules.add(href)
- }
- }, [href, currentHref])
- const onClick = useCallback((e: MouseEvent) => {
- e.preventDefault()
- if (href !== currentHref) {
- redirect(href, replace)
- }
- }, [href, currentHref, replace])
-
- useEffect(() => {
- if (prefetchNow) {
- prefetch()
- }
- }, [prefetchNow, prefetch])
-
- if (Children.count(children) === 1) {
- const child = Children.toArray(children)[0]
- if (isValidElement(child) && child.type === 'a') {
- const { props } = child
- return cloneElement(child, {
- ...props,
- className: [className, props.className].filter(util.isNEString).join(' ') || undefined,
- style: Object.assign({}, style, props.style),
- href,
- 'aria-current': props['aria-current'] || 'page',
- onClick: (e: MouseEvent) => {
- if (util.isFunction(props.onClick)) {
- props.onClick(e)
- }
- if (!e.defaultPrevented) {
- onClick(e)
- }
- },
- onMouseEnter: (e: MouseEvent) => {
- if (util.isFunction(props.onMouseEnter)) {
- props.onMouseEnter(e)
- }
- if (!e.defaultPrevented) {
- prefetch()
- }
- }
- })
- }
- }
-
- return React.createElement(
- 'a',
- {
- className,
- style,
- href,
- onClick,
- onMouseEnter: prefetch,
- 'aria-current': 'page'
- },
- children
- )
-}
-
-interface NavLinkProps extends LinkProps {
- activeClassName?: string
- activeStyle?: CSSProperties
-}
-
-export function NavLink(props: PropsWithChildren) {
- const { activeClassName = 'active', activeStyle, to, ...rest } = props
- const { pathname: currentPathname } = useRouter()
- const pathname = useMemo(() => {
- if (util.isHttpUrl(to)) {
- return to
- }
- let [pathname] = util.splitBy(to, '?')
- if (pathname.startsWith('/')) {
- pathname = util.cleanPath(pathname)
- } else {
- pathname = util.cleanPath(currentPathname + '/' + pathname)
- }
- return pathname
- }, [currentPathname, to])
-
- if (currentPathname === pathname) {
- return React.createElement(
- Link,
- {
- ...rest,
- to,
- className: [rest.className?.trim(), activeClassName.trim()].filter(Boolean).join(' '),
- style: Object.assign({}, rest.style, activeStyle)
- }
- )
- }
-
- return React.createElement(Link, { ...rest, to })
-}
diff --git a/mod.ts b/mod.ts
index ebd8652a8..d6899a312 100644
--- a/mod.ts
+++ b/mod.ts
@@ -1,7 +1,2 @@
-export { redirect } from './aleph.ts'
-export * from './context.ts'
-export { default as Head, Scripts, SEO, Viewport } from './head.ts'
-export * from './hooks.ts'
-export { default as Import } from './import.ts'
-export { default as Link, NavLink } from './link.ts'
-
+export { redirect } from './framework/core/routing.ts';
+export * from './framework/react/hooks.ts';
diff --git a/plugins/sass_test.ts b/plugins/sass_test.ts
index e6c2e3847..81538f5bb 100644
--- a/plugins/sass_test.ts
+++ b/plugins/sass_test.ts
@@ -1,12 +1,7 @@
-import { assertEquals } from 'https://deno.land/std/testing/asserts.ts';
+import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts';
import plugin from './sass.ts';
Deno.test('project scss loader plugin', () => {
- Object.assign(window, {
- location: {
- href: 'https://localhost/'
- }
- })
const { code, loader } = plugin.transform(
(new TextEncoder).encode('$someVar: 123px; .some-selector { width: $someVar; }'),
'test.scss'
@@ -19,11 +14,6 @@ Deno.test('project scss loader plugin', () => {
})
Deno.test('project sass loader plugin', () => {
- Object.assign(window, {
- location: {
- href: 'https://localhost/'
- }
- })
let ret = plugin.transform(
(new TextEncoder).encode('$someVar: 123px\n.some-selector\n width: 123px'),
'test.sass'
diff --git a/plugins/wasm_test.ts b/plugins/wasm_test.ts
index 728c9ab29..82c240bdb 100644
--- a/plugins/wasm_test.ts
+++ b/plugins/wasm_test.ts
@@ -1,4 +1,4 @@
-import { assertEquals } from 'https://deno.land/std/testing/asserts.ts';
+import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts';
import plugin from './wasm.ts';
Deno.test('project wasm loader plugin', async () => {
diff --git a/project.ts b/project.ts
deleted file mode 100644
index d58fc5f33..000000000
--- a/project.ts
+++ /dev/null
@@ -1,1607 +0,0 @@
-import CleanCSS from 'https://esm.sh/clean-css@4.2.3?no-check'
-import marked from 'https://esm.sh/marked@1.2.0'
-import postcss, { AcceptedPlugin } from 'https://esm.sh/postcss@8.1.4'
-import { minify } from 'https://esm.sh/terser@5.3.2'
-import { safeLoadFront } from 'https://esm.sh/yaml-front-matter@4.1.0'
-import { Request } from './api.ts'
-import { EventEmitter } from './events.ts'
-import { ensureTextFile, existsDirSync, existsFileSync } from './fs.ts'
-import { createHtml } from './html.ts'
-import log from './log.ts'
-import { getPagePath, RouteModule, Routing } from './routing.ts'
-import { colors, ensureDir, fromStreamReader, path, ServerRequest, Sha1, walk } from './std.ts'
-import { compile } from './tsc/compile.ts'
-import type { APIHandler, Config, RouterURL } from './types.ts'
-import util, { hashShort, MB, reHashJs, reHttp, reLocaleID, reMDExt, reModuleExt, reStyleModuleExt } from './util.ts'
-import { createHTMLDocument } from './vendor/deno-dom/document.ts'
-import less from './vendor/less/less.js'
-import { VERSION } from './version.ts'
-
-interface Module {
- id: string
- url: string
- loader: string
- isRemote: boolean
- sourceFilePath: string
- sourceHash: string
- deps: Dep[]
- jsFile: string
- jsContent: string
- jsSourceMap: string
- hash: string
-}
-
-interface Dep {
- url: string
- hash: string
- isStyle?: boolean
- isData?: boolean
- external?: boolean
-}
-
-interface RenderResult {
- url: RouterURL
- status: number
- head: string[]
- scripts: Record[]
- body: string
- data: Record | null
-}
-
-/**
- * A Project to manage the Aleph.js appliaction.
- * core functions include:
- * - compile source code
- * - manage deps
- * - apply plugins
- * - map page/API routes
- * - watch file changes
- * - call APIs
- * - SSR/SSG
- */
-export class Project {
- readonly appRoot: string
- readonly mode: 'development' | 'production'
- readonly config: Readonly> & { __file?: string }
- readonly importMap: Readonly<{ imports: Record }>
- readonly ready: Promise
-
- #modules: Map = new Map()
- #routing: Routing = new Routing()
- #apiRouting: Routing = new Routing()
- #fsWatchListeners: Array = []
- #renderer: { renderPage: CallableFunction } = { renderPage: () => void 0 }
- #rendered: Map> = new Map()
- #postcssPlugins: Record = {}
- #cleanCSS = new CleanCSS({ compatibility: '*' /* Internet Explorer 10+ */ })
-
- constructor(appDir: string, mode: 'development' | 'production', reload = false) {
- this.appRoot = path.resolve(appDir)
- this.mode = mode
- this.config = {
- srcDir: existsDirSync(path.join(this.appRoot, '/src/pages')) ? '/src' : '/',
- outputDir: '/dist',
- baseUrl: '/',
- defaultLocale: 'en',
- env: {},
- locales: [],
- ssr: {
- fallback: '_fallback.html'
- },
- buildTarget: mode === 'development' ? 'es2018' : 'es2015',
- sourceMap: false,
- reactUrl: 'https://esm.sh/react@17.0.1',
- reactDomUrl: 'https://esm.sh/react-dom@17.0.1',
- plugins: [],
- postcss: {
- plugins: [
- 'autoprefixer'
- ]
- }
- }
- this.importMap = { imports: {} }
- this.ready = (async () => {
- const t = performance.now()
- await this._loadConfig()
- await this._init(reload)
- log.debug('init project in ' + Math.round(performance.now() - t) + 'ms')
- })()
- }
-
- get isDev() {
- return this.mode === 'development'
- }
-
- get srcDir() {
- return path.join(this.appRoot, this.config.srcDir)
- }
-
- get buildDir() {
- return path.join(this.appRoot, '.aleph', this.mode + '.' + this.config.buildTarget)
- }
-
- isHMRable(moduleID: string) {
- if (!this.isDev) {
- return false
- }
- if (reHttp.test(moduleID)) {
- return false
- }
- if (reStyleModuleExt.test(moduleID)) {
- return true
- }
- if (reMDExt.test(moduleID)) {
- return moduleID.startsWith('/pages/')
- }
- if (reModuleExt.test(moduleID)) {
- return moduleID === '/404.js' ||
- moduleID === '/app.js' ||
- moduleID.startsWith('/pages/') ||
- moduleID.startsWith('/components/')
- }
- const plugin = this.config.plugins.find(p => p.test.test(moduleID))
- if (plugin?.acceptHMR) {
- return true
- }
- return false
- }
-
- isSSRable(pathname: string): boolean {
- const { ssr } = this.config
- if (util.isPlainObject(ssr)) {
- if (ssr.include) {
- for (let r of ssr.include) {
- if (!r.test(pathname)) {
- return false
- }
- }
- }
- if (ssr.exclude) {
- for (let r of ssr.exclude) {
- if (r.test(pathname)) {
- return false
- }
- }
- }
- return true
- }
- return ssr
- }
-
- getModule(id: string): Module | null {
- if (this.#modules.has(id)) {
- return this.#modules.get(id)!
- }
- return null
- }
-
- getModuleByPath(pathname: string): Module | null {
- const { baseUrl } = this.config
- let modId = pathname
- if (baseUrl !== '/') {
- modId = util.trimPrefix(modId, baseUrl)
- }
- if (modId.startsWith('/_aleph/')) {
- modId = util.trimPrefix(modId, '/_aleph')
- }
- if (modId.startsWith('/-/')) {
- modId = '//' + util.trimSuffix(util.trimPrefix(modId, '/-/'), '.js')
- if (!reStyleModuleExt.test(modId)) {
- modId = modId + '.js'
- }
- } else if (modId.endsWith('.js')) {
- let id = modId.slice(0, modId.length - 3)
- if (reHashJs.test(modId)) {
- id = modId.slice(0, modId.length - (1 + hashShort + 3))
- }
- if (reMDExt.test(id) || reStyleModuleExt.test(id)) {
- modId = id
- } else {
- modId = id + '.js'
- }
- }
- if (!this.#modules.has(modId) && modId.endsWith('.js')) {
- modId = util.trimSuffix(modId, '.js')
- }
- if (!this.#modules.has(modId)) {
- log.warn(`can't get the module by path '${pathname}(${modId})'`)
- }
- return this.getModule(modId)
- }
-
- createFSWatcher(): EventEmitter {
- const e = new EventEmitter()
- this.#fsWatchListeners.push(e)
- return e
- }
-
- removeFSWatcher(e: EventEmitter) {
- e.removeAllListeners()
- const index = this.#fsWatchListeners.indexOf(e)
- if (index > -1) {
- this.#fsWatchListeners.splice(index, 1)
- }
- }
-
- async callAPI(req: ServerRequest, loc: { pathname: string, search?: string }): Promise {
- const [url] = this.#apiRouting.createRouter({ ...loc, pathname: decodeURI(loc.pathname) })
- if (url.pagePath != '') {
- const moduleID = url.pagePath + '.js'
- if (this.#modules.has(moduleID)) {
- try {
- const { default: handle } = await import('file://' + this.#modules.get(moduleID)!.jsFile)
- if (util.isFunction(handle)) {
- await handle(new Request(req, url.pathname, url.params, url.query))
- } else {
- req.respond({
- status: 500,
- headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
- body: JSON.stringify({ error: { status: 404, message: "handle not found" } })
- }).catch(err => log.warn('ServerRequest.respond:', err.message))
- }
- } catch (err) {
- req.respond({
- status: 500,
- headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
- body: JSON.stringify({ error: { status: 500, message: err.message } })
- }).catch(err => log.warn('ServerRequest.respond:', err.message))
- log.error('callAPI:', err)
- }
- }
- } else {
- req.respond({
- status: 404,
- headers: new Headers({ 'Content-Type': 'application/javascript; charset=utf-8' }),
- body: JSON.stringify({ error: { status: 404, message: 'page not found' } })
- }).catch(err => log.warn('ServerRequest.respond:', err.message))
- }
- return null
- }
-
- async getSSRData(loc: { pathname: string, search?: string }): Promise<[number, any]> {
- if (!this.isSSRable(loc.pathname)) {
- return [404, null]
- }
-
- const { status, data } = await this._renderPage(loc)
- return [status, data]
- }
-
- getPreloadScripts() {
- const { baseUrl } = this.config
- const scripts = [
- 'deno.land/x/aleph/aleph.js',
- 'deno.land/x/aleph/context.js',
- 'deno.land/x/aleph/error.js',
- 'deno.land/x/aleph/events.js',
- 'deno.land/x/aleph/routing.js',
- 'deno.land/x/aleph/util.js'
- ]
- return scripts.map(src => ({ src: `${baseUrl}_aleph/-/${src}`, type: 'module', preload: true }))
- }
-
- async getPageHtml(loc: { pathname: string, search?: string }): Promise<[number, string, Record | null]> {
- if (!this.isSSRable(loc.pathname)) {
- const [url] = this.#routing.createRouter(loc)
- return [url.pagePath === '' ? 404 : 200, await this.getSPAIndexHtml(), null]
- }
-
- const { baseUrl } = this.config
- const mainModule = this.#modules.get('/main.js')!
- const { url, status, head, scripts, body, data } = await this._renderPage(loc)
- const html = createHtml({
- lang: url.locale,
- head: head,
- scripts: [
- data ? { type: 'application/json', innerText: JSON.stringify(data), id: 'ssr-data' } : '',
- { src: util.cleanPath(`${baseUrl}/_aleph/main.${mainModule.hash.slice(0, hashShort)}.js`), type: 'module' },
- ...this.getPreloadScripts(),
- ...scripts
- ],
- body,
- minify: !this.isDev
- })
- return [status, html, data]
- }
-
- async getSPAIndexHtml() {
- const { baseUrl, defaultLocale } = this.config
- const mainModule = this.#modules.get('/main.js')!
- const customLoading = await this._renderLoadingPage()
- const html = createHtml({
- lang: defaultLocale,
- scripts: [
- { src: util.cleanPath(`${baseUrl}/_aleph/main.${mainModule.hash.slice(0, hashShort)}.js`), type: 'module' },
- { src: util.cleanPath(`${baseUrl}/_aleph/-/deno.land/x/aleph/nomodule.js${this.isDev ? '?dev' : ''}`), nomodule: true },
- ...this.getPreloadScripts()
- ],
- head: customLoading?.head || [],
- body: `${customLoading?.body || ''} `,
- minify: !this.isDev
- })
- return html
- }
-
- /** build the application to a static site(SSG) */
- async build() {
- const start = performance.now()
- const outputDir = path.join(this.srcDir, this.config.outputDir)
- const distDir = path.join(outputDir, '_aleph')
- const outputModules = new Set()
- const lookup = (moduleID: string) => {
- if (this.#modules.has(moduleID) && !outputModules.has(moduleID)) {
- outputModules.add(moduleID)
- const { deps } = this.#modules.get(moduleID)!
- deps.forEach(({ url }) => {
- const { id } = this._moduleFromURL(url)
- lookup(id)
- })
- }
- }
-
- // wait for project ready
- await this.ready
-
- // lookup output modules
- this.#routing.lookup(path => path.forEach(r => lookup(r.module.id)))
- lookup('/main.js')
- lookup('/404.js')
- lookup('/app.js')
- lookup('//deno.land/x/aleph/nomodule.js')
- lookup('//deno.land/x/aleph/tsc/tslib.js')
-
- if (existsDirSync(outputDir)) {
- await Deno.remove(outputDir, { recursive: true })
- }
- await ensureDir(outputDir)
- await ensureDir(distDir)
-
- // ssg
- const { ssr } = this.config
- const SPAIndexHtml = await this.getSPAIndexHtml()
- if (ssr) {
- log.info(colors.bold('- Pages (SSG)'))
- const paths = new Set(this.#routing.paths)
- if (typeof ssr === 'object' && ssr.staticPaths) {
- ssr.staticPaths.forEach(path => paths.add(path))
- }
- await Promise.all(Array.from(paths).map(async pathname => {
- if (this.isSSRable(pathname)) {
- const [status, html, data] = await this.getPageHtml({ pathname })
- if (status == 200) {
- const htmlFile = path.join(outputDir, pathname, 'index.html')
- await ensureTextFile(htmlFile, html)
- if (data) {
- const dataFile = path.join(outputDir, '_aleph/data', pathname, 'data.js')
- await ensureTextFile(dataFile, `export default ` + JSON.stringify(data))
- }
- log.info(' ○', pathname, colors.dim('• ' + util.bytesString(html.length)))
- } else if (status == 404) {
- log.info(' ○', colors.dim(pathname), colors.red(`Page not found`))
- } else if (status == 500) {
- log.info(' ○', colors.dim(pathname), colors.red(`Error 505`))
- }
- }
- }))
- const fbHtmlFile = path.join(outputDir, util.isPlainObject(ssr) && ssr.fallback ? ssr.fallback : '_fallback.html')
- await ensureTextFile(fbHtmlFile, SPAIndexHtml)
- } else {
- await ensureTextFile(path.join(outputDir, 'index.html'), SPAIndexHtml)
- }
-
- // write 404 page
- const { baseUrl } = this.config
- const { url, head, scripts, body, data } = await this._render404Page()
- const mainModule = this.#modules.get('/main.js')!
- const e404PageHtml = createHtml({
- lang: url.locale,
- head: head,
- scripts: [
- data ? { type: 'application/json', innerText: JSON.stringify(data), id: 'ssr-data' } : '',
- { src: util.cleanPath(`${baseUrl}/_aleph/main.${mainModule.hash.slice(0, hashShort)}.js`), type: 'module' },
- { src: util.cleanPath(`${baseUrl}/_aleph/-/deno.land/x/aleph/nomodule.js${this.isDev ? '?dev' : ''}`), nomodule: true },
- ...this.getPreloadScripts(),
- ...scripts
- ],
- body,
- minify: !this.isDev
- })
- await ensureTextFile(path.join(outputDir, '404.html'), e404PageHtml)
-
- // copy public assets
- const publicDir = path.join(this.appRoot, 'public')
- if (existsDirSync(publicDir)) {
- log.info(colors.bold('- Public Assets'))
- for await (const { path: p } of walk(publicDir, { includeDirs: false, skip: [/\.DS_Store$/] })) {
- const rp = util.trimPrefix(p, publicDir)
- const fp = path.join(outputDir, rp)
- const fi = await Deno.lstat(p)
- await ensureDir(path.dirname(fp))
- await Deno.copyFile(p, fp)
- log.info(' ✹', rp.split('\\').join('/'), colors.dim('•'), colorfulBytesString(fi.size))
- }
- }
-
- const moduleState = {
- deps: { bytes: 0, count: 0 },
- modules: { bytes: 0, count: 0 },
- styles: { bytes: 0, count: 0 }
- }
- const logModule = (key: 'deps' | 'modules' | 'styles', size: number) => {
- moduleState[key].bytes += size
- moduleState[key].count++
- }
-
- // write modules
- const { sourceMap } = this.config
- await Promise.all(Array.from(outputModules).map((moduleID) => {
- const { sourceFilePath, loader, isRemote, jsContent, jsSourceMap, hash } = this.#modules.get(moduleID)!
- const saveDir = path.join(distDir, path.dirname(sourceFilePath))
- const name = path.basename(sourceFilePath).replace(reModuleExt, '')
- const jsFile = path.join(saveDir, name + (isRemote ? '' : '.' + hash.slice(0, hashShort))) + '.js'
- if (isRemote) {
- logModule('deps', jsContent.length)
- } else {
- if (loader === 'css') {
- logModule('styles', jsContent.length)
- } else {
- logModule('modules', jsContent.length)
- }
- }
- return Promise.all([
- ensureTextFile(jsFile, jsContent),
- sourceMap && jsSourceMap ? ensureTextFile(jsFile + '.map', jsSourceMap) : Promise.resolve(),
- ])
- }))
-
- const { deps, modules, styles } = moduleState
- log.info(colors.bold('- Modules'))
- log.info(' {}', colors.bold(deps.count.toString()), 'deps', colors.dim(`• ${util.bytesString(deps.bytes)} (mini, uncompress)`))
- log.info(' {}', colors.bold(modules.count.toString()), 'modules', colors.dim(`• ${util.bytesString(modules.bytes)} (mini, uncompress)`))
- log.info(' {}', colors.bold(styles.count.toString()), 'styles', colors.dim(`• ${util.bytesString(styles.bytes)} (mini, uncompress)`))
-
- log.info(`Done in ${Math.round(performance.now() - start)}ms`)
- }
-
- private async _loadConfig() {
- const importMapFile = path.join(this.appRoot, 'import_map.json')
- if (existsFileSync(importMapFile)) {
- const { imports } = JSON.parse(await Deno.readTextFile(importMapFile))
- Object.assign(this.importMap, { imports: Object.assign({}, this.importMap.imports, imports) })
- }
-
- const { ALEPH_IMPORT_MAP, navigator } = globalThis as any
- if (ALEPH_IMPORT_MAP) {
- const { imports } = ALEPH_IMPORT_MAP
- Object.assign(this.importMap, { imports: Object.assign({}, this.importMap.imports, imports) })
- }
-
- const config: Record = {}
- for (const name of Array.from(['aleph.config', 'config']).map(name => ['ts', 'js', 'mjs', 'json'].map(ext => `${name}.${ext}`)).flat()) {
- const p = path.join(this.appRoot, name)
- if (existsFileSync(p)) {
- if (name.endsWith('.json')) {
- const conf = JSON.parse(await Deno.readTextFile(p))
- if (util.isPlainObject(conf)) {
- Object.assign(config, conf)
- Object.assign(this.config, { __file: name })
- }
- } else {
- let { default: conf } = await import('file://' + p)
- if (util.isFunction(conf)) {
- conf = await conf()
- }
- if (util.isPlainObject(conf)) {
- Object.assign(config, conf)
- Object.assign(this.config, { __file: name })
- }
- }
- break
- }
- }
-
- const {
- srcDir,
- ouputDir,
- baseUrl,
- buildTarget,
- sourceMap,
- defaultLocale,
- locales,
- ssr,
- env,
- plugins,
- postcss,
- } = config
- if (util.isNEString(srcDir)) {
- Object.assign(this.config, { srcDir: util.cleanPath(srcDir) })
- }
- if (util.isNEString(ouputDir)) {
- Object.assign(this.config, { ouputDir: util.cleanPath(ouputDir) })
- }
- if (util.isNEString(baseUrl)) {
- Object.assign(this.config, { baseUrl: util.cleanPath(encodeURI(baseUrl)) })
- }
- if (/^es(20\d{2}|next)$/i.test(buildTarget)) {
- Object.assign(this.config, { buildTarget: buildTarget.toLowerCase() })
- }
- if (typeof sourceMap === 'boolean') {
- Object.assign(this.config, { sourceMap })
- }
- if (util.isNEString(defaultLocale)) {
- navigator.language = defaultLocale
- Object.assign(this.config, { defaultLocale })
- }
- if (util.isArray(locales)) {
- Object.assign(this.config, { locales: Array.from(new Set(locales.filter(l => reLocaleID.test(l)))) })
- locales.filter(l => !reLocaleID.test(l)).forEach(l => log.warn(`invalid locale ID '${l}'`))
- }
- if (typeof ssr === 'boolean') {
- Object.assign(this.config, { ssr })
- } else if (util.isPlainObject(ssr)) {
- const fallback = util.isNEString(ssr.fallback) ? util.ensureExt(ssr.fallback, '.html') : '404.html'
- const include = util.isArray(ssr.include) ? ssr.include.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : []
- const exclude = util.isArray(ssr.exclude) ? ssr.exclude.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : []
- const staticPaths = util.isArray(ssr.staticPaths) ? ssr.staticPaths.map(v => util.cleanPath(v)) : []
- Object.assign(this.config, { ssr: { fallback, include, exclude, staticPaths } })
- }
- if (util.isPlainObject(env)) {
- Object.assign(this.config, { env })
- }
- if (util.isNEArray(plugins)) {
- Object.assign(this.config, { plugins })
- }
- if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) {
- Object.assign(this.config, { postcss })
- } else if (existsFileSync(path.join(this.appRoot, 'postcss.config.json'))) {
- const text = await Deno.readTextFile(path.join(this.appRoot, 'postcss.config.json'))
- try {
- const postcss = JSON.parse(text)
- if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) {
- Object.assign(this.config, { postcss })
- }
- } catch (e) {
- log.warn('bad postcss.config.json', e.message)
- }
- }
- // update routing
- this.#routing = new Routing([], this.config.baseUrl, this.config.defaultLocale, this.config.locales)
- }
-
- private async _init(reload: boolean) {
- const walkOptions = { includeDirs: false, exts: ['.js', '.ts', '.mjs'], skip: [/^\./, /\.d\.ts$/i, /\.(test|spec|e2e)\.m?(j|t)sx?$/i] }
- const apiDir = path.join(this.srcDir, 'api')
- const pagesDir = path.join(this.srcDir, 'pages')
-
- if (!(existsDirSync(pagesDir))) {
- log.fatal(`'pages' directory not found.`)
- }
-
- if (reload) {
- if (existsDirSync(this.buildDir)) {
- await Deno.remove(this.buildDir, { recursive: true })
- }
- await ensureDir(this.buildDir)
- }
-
- // import postcss plugins
- await Promise.all(this.config.postcss.plugins.map(async p => {
- let name: string
- if (typeof p === 'string') {
- name = p
- } else {
- name = p.name
- }
- const { default: Plugin } = await import(`https://esm.sh/${name}?external=postcss@8.1.4&no-check`)
- this.#postcssPlugins[name] = Plugin
- }))
-
- // inject virtual browser gloabl objects
- Object.assign(globalThis, {
- __createHTMLDocument: () => createHTMLDocument(),
- document: createHTMLDocument(),
- navigator: {
- connection: {
- downlink: 1.5,
- effectiveType: "3g",
- onchange: null,
- rtt: 300,
- saveData: false,
- },
- cookieEnabled: false,
- deviceMemory: 0,
- hardwareConcurrency: 0,
- language: 'en',
- maxTouchPoints: 0,
- onLine: true,
- userAgent: `Deno/${Deno.version.deno}`,
- vendor: "Deno Land",
- },
- location: {
- protocol: 'http:',
- host: 'localhost',
- hostname: 'localhost',
- port: '',
- href: 'https://localhost/',
- origin: 'https://localhost',
- pathname: '/',
- search: '',
- hash: '',
- reload() { },
- replace() { },
- toString() { return this.href },
- },
- innerWidth: 1920,
- innerHeight: 1080,
- devicePixelRatio: 1,
- $RefreshReg$: () => { },
- $RefreshSig$: () => (type: any) => type,
- })
-
- // inject env variables
- Object.entries({
- ...this.config.env,
- __version: VERSION,
- __buildMode: this.mode,
- __buildTarget: this.config.buildTarget,
- }).forEach(([key, value]) => Deno.env.set(key, value))
-
- // change current work dir to appDoot
- Deno.chdir(this.appRoot)
-
- for await (const { path: p, } of walk(this.srcDir, { ...walkOptions, maxDepth: 1, exts: [...walkOptions.exts, '.jsx', '.tsx'] })) {
- const name = path.basename(p)
- switch (name.replace(reModuleExt, '')) {
- case 'app':
- case '404':
- case 'loading':
- await this._compile('/' + name)
- break
- }
- }
-
- if (existsDirSync(apiDir)) {
- for await (const { path: p } of walk(apiDir, walkOptions)) {
- const mod = await this._compile('/api' + util.trimPrefix(p, apiDir).split('\\').join('/'))
- this.#apiRouting.update(this._getRouteModule(mod))
- }
- }
-
- for await (const { path: p } of walk(pagesDir, { ...walkOptions, exts: [...walkOptions.exts, '.jsx', '.tsx', '.md'] })) {
- const rp = util.trimPrefix(p, pagesDir).split('\\').join('/')
- const mod = await this._compile('/pages' + rp)
- this.#routing.update(this._getRouteModule(mod))
- }
-
- const precompileUrls = [
- 'https://deno.land/x/aleph/bootstrap.ts',
- 'https://deno.land/x/aleph/nomodule.ts',
- 'https://deno.land/x/aleph/tsc/tslib.js',
- ]
- if (this.isDev) {
- precompileUrls.push('https://deno.land/x/aleph/hmr.ts')
- }
- for (const url of precompileUrls) {
- await this._compile(url)
- }
- await this._compile('https://deno.land/x/aleph/renderer.ts', { forceTarget: 'es2020' })
- await this._createMainModule()
-
- const { renderPage } = await import('file://' + this.#modules.get('//deno.land/x/aleph/renderer.js')!.jsFile)
- this.#renderer = { renderPage }
-
- log.info(colors.bold(`Aleph.js v${VERSION}`))
- if (this.config.__file) {
- log.info(colors.bold('- Config'))
- log.info(' ▲', this.config.__file)
- }
- log.info(colors.bold('- Global'))
- if (this.#modules.has('/app.js')) {
- log.info(' ✓', 'Custom App')
- }
- if (this.#modules.has('/404.js')) {
- log.info(' ✓', 'Custom 404 Page')
- }
- if (this.#modules.has('/loading.js')) {
- log.info(' ✓', 'Custom Loading Page')
- }
-
- if (this.isDev) {
- if (this.#apiRouting.paths.length > 0) {
- log.info(colors.bold('- APIs'))
- }
- for (const path of this.#apiRouting.paths) {
- log.info(' λ', path)
- }
- log.info(colors.bold('- Pages'))
- for (const path of this.#routing.paths) {
- const isIndex = path == '/'
- log.info(' ○', path, isIndex ? colors.dim('(index)') : '')
- }
- }
-
- if (this.isDev) {
- this._watch()
- }
- }
-
- private async _watch() {
- const w = Deno.watchFs(this.srcDir, { recursive: true })
- log.info('Start watching code changes...')
- for await (const event of w) {
- for (const p of event.paths) {
- const path = util.cleanPath(util.trimPrefix(p, this.srcDir))
- // handle `api` dir remove directly
- const validated = (() => {
- // ignore `.aleph` and output directories
- if (path.startsWith('/.aleph/') || path.startsWith(this.config.outputDir)) {
- return false
- }
- if (reModuleExt.test(path)) {
- switch (path.replace(reModuleExt, '')) {
- case '/404':
- case '/app':
- return true
- default:
- if (path.startsWith('/api/')) {
- return true
- }
- }
- }
- if (path.startsWith('/pages/') && (reModuleExt.test(path) || reMDExt.test(path))) {
- return true
- }
- let isDep = false
- for (const { deps } of this.#modules.values()) {
- if (deps.findIndex(dep => dep.url === path) > -1) {
- isDep = true
- break
- }
- }
- if (isDep) {
- return true
- }
- return this.config.plugins.findIndex(p => p.test.test(path)) > -1
- })()
- if (validated) {
- const moduleID = path.replace(reModuleExt, '.js')
- util.debounceX(moduleID, () => {
- const shouldUpdateMainModule = (() => {
- switch (moduleID) {
- case '/404.js':
- case '/app.js':
- return true
- default:
- if (moduleID.startsWith('/pages/')) {
- return true
- }
- return false
- }
- })()
- if (existsFileSync(p)) {
- let type = 'modify'
- if (!this.#modules.has(moduleID)) {
- type = 'add'
- }
- log.info(type, path)
- this._compile(path, { forceCompile: true }).then(mod => {
- const hmrable = this.isHMRable(mod.id)
- if (hmrable) {
- if (type === 'add') {
- this.#fsWatchListeners.forEach(e => e.emit('add', mod.id, mod.hash))
- } else {
- this.#fsWatchListeners.forEach(e => e.emit('modify-' + mod.id, mod.hash))
- }
- }
- if (moduleID === '/app.js') {
- this.#rendered.clear()
- } else if (moduleID.startsWith('/pages/')) {
- this.#rendered.delete(getPagePath(moduleID))
- this.#routing.update(this._getRouteModule(mod))
- } else if (moduleID.startsWith('/api/')) {
- this.#apiRouting.update(this._getRouteModule(mod))
- }
- if (shouldUpdateMainModule) {
- this._createMainModule()
- }
- this._updateDependency(path, mod.hash, ({ id, hash }) => {
- if (id.startsWith('/pages/')) {
- this.#rendered.delete(getPagePath(id))
- }
- if (!hmrable && this.isHMRable(id)) {
- this.#fsWatchListeners.forEach(e => e.emit('modify-' + id, hash))
- }
- })
- }).catch(err => {
- log.error(`compile(${path}):`, err.message)
- })
- } else if (this.#modules.has(moduleID)) {
- if (moduleID === '/app.js') {
- this.#rendered.clear()
- } else if (moduleID.startsWith('/pages/')) {
- this.#rendered.delete(getPagePath(moduleID))
- this.#routing.removeRoute(moduleID)
- } else if (moduleID.startsWith('/api/')) {
- this.#apiRouting.removeRoute(moduleID)
- }
- if (shouldUpdateMainModule) {
- this._createMainModule()
- }
- this.#modules.delete(moduleID)
- if (this.isHMRable(moduleID)) {
- this.#fsWatchListeners.forEach(e => e.emit('remove', moduleID))
- }
- log.info('remove', path)
- }
- }, 150)
- }
- }
- }
- }
-
- private _getRouteModule({ id, hash }: Module): RouteModule {
- const deps = this._lookupDeps(id).filter(({ isData, isStyle }) => !!isData || !!isStyle).map(({ external, ...rest }) => rest)
- return { id, hash, deps: deps.length > 0 ? deps : undefined }
- }
-
- private _moduleFromURL(url: string): Module {
- const isRemote = reHttp.test(url) || (url in this.importMap.imports && reHttp.test(this.importMap.imports[url]))
- const sourceFilePath = fixImportUrl(url)
- const id = (isRemote ? '//' + util.trimPrefix(sourceFilePath, '/-/') : sourceFilePath).replace(reModuleExt, '.js')
- let loader = ''
- if (reStyleModuleExt.test(url)) {
- loader = 'css'
- } else if (reMDExt.test(url)) {
- loader = 'markdown'
- } else if (reModuleExt.test(url)) {
- loader = 'js'
- } else if (isRemote) {
- loader = 'js'
- }
- return {
- id,
- url,
- loader,
- isRemote,
- sourceFilePath,
- sourceHash: '',
- deps: [],
- jsFile: '',
- jsContent: '',
- jsSourceMap: '',
- hash: '',
- } as Module
- }
-
- private async _createMainModule(): Promise {
- const { baseUrl, defaultLocale } = this.config
- const config: Record = {
- baseUrl,
- defaultLocale,
- locales: [],
- routes: this.#routing.routes,
- preloadModules: ['/404.js', '/app.js'].filter(id => this.#modules.has(id)).map(id => {
- return this._getRouteModule(this.#modules.get(id)!)
- }),
- renderMode: this.config.ssr ? 'ssr' : 'spa'
- }
- const module = this._moduleFromURL('/main.js')
- const metaFile = path.join(this.buildDir, 'main.meta.json')
-
- module.jsContent = [
- this.isDev && 'import "./-/deno.land/x/aleph/hmr.js"',
- 'import "./-/deno.land/x/aleph/aleph.js"',
- 'import "./-/deno.land/x/aleph/context.js"',
- 'import "./-/deno.land/x/aleph/error.js"',
- 'import "./-/deno.land/x/aleph/events.js"',
- 'import "./-/deno.land/x/aleph/routing.js"',
- 'import "./-/deno.land/x/aleph/util.js"',
- 'import bootstrap from "./-/deno.land/x/aleph/bootstrap.js"',
- `bootstrap(${JSON.stringify(config, undefined, this.isDev ? 4 : undefined)})`
- ].filter(Boolean).join(this.isDev ? '\n' : ';')
- module.hash = getHash(module.jsContent)
- module.jsFile = path.join(this.buildDir, `main.${module.hash.slice(0, hashShort)}.js`)
- module.deps = [
- this.isDev && 'https://deno.land/x/aleph/hmr.ts',
- 'https://deno.land/x/aleph/bootstrap.ts'
- ].filter(Boolean).map(url => ({
- url: String(url),
- hash: this.#modules.get(String(url).replace(reHttp, '//').replace(reModuleExt, '.js'))?.hash || ''
- }))
-
- await cleanupCompilation(module.jsFile)
- await Promise.all([
- ensureTextFile(module.jsFile, module.jsContent),
- ensureTextFile(metaFile, JSON.stringify({
- url: '/main.js',
- sourceHash: module.hash,
- hash: module.hash,
- deps: module.deps,
- }, undefined, 4))
- ])
- this.#modules.set(module.id, module)
-
- return module
- }
-
- // todo: force recompile remote modules which URL don't specify version
- private async _compile(url: string, options?: { forceCompile?: boolean, forceTarget?: string }) {
- const mod = this._moduleFromURL(url)
- if (this.#modules.has(mod.id) && !options?.forceCompile) {
- return this.#modules.get(mod.id)!
- }
-
- const name = path.basename(mod.sourceFilePath).replace(reModuleExt, '')
- const saveDir = path.join(this.buildDir, path.dirname(mod.sourceFilePath))
- const metaFile = path.join(saveDir, `${name}.meta.json`)
-
- if (existsFileSync(metaFile)) {
- const { sourceHash, hash, deps } = JSON.parse(await Deno.readTextFile(metaFile))
- const jsFile = path.join(saveDir, name + (mod.isRemote ? '' : '.' + hash.slice(0, hashShort))) + '.js'
- if (util.isNEString(sourceHash) && util.isNEString(hash) && util.isArray(deps) && existsFileSync(jsFile)) {
- try {
- mod.jsContent = await Deno.readTextFile(jsFile)
- if (existsFileSync(jsFile + '.map')) {
- mod.jsSourceMap = await Deno.readTextFile(jsFile + '.map')
- }
- mod.jsFile = jsFile
- mod.hash = hash
- mod.deps = deps
- mod.sourceHash = sourceHash
- } catch (e) { }
- }
- }
-
- let sourceContent = new Uint8Array()
- let shouldCompile = false
- let fsync = false
-
- if (mod.isRemote) {
- let dlUrl = url
- const { imports } = this.importMap
- for (const importPath in imports) {
- const alias = imports[importPath]
- if (importPath === url) {
- dlUrl = alias
- break
- } else if (importPath.endsWith('/') && url.startsWith(importPath)) {
- dlUrl = util.trimSuffix(alias, '/') + '/' + util.trimPrefix(url, importPath)
- break
- }
- }
- if (/^(https?:\/\/[0-9a-z\.\-]+)?\/react(@[0-9a-z\.\-]+)?\/?$/i.test(dlUrl)) {
- dlUrl = this.config.reactUrl
- }
- if (/^(https?:\/\/[0-9a-z\.\-]+)?\/react\-dom(@[0-9a-z\.\-]+)?(\/server)?\/?$/i.test(dlUrl)) {
- dlUrl = this.config.reactDomUrl
- if (/\/server\/?$/i.test(url)) {
- dlUrl += '/server'
- }
- }
- if (dlUrl.startsWith('https://esm.sh/')) {
- const u = new URL(dlUrl)
- u.searchParams.set('target', this.config.buildTarget)
- if (this.isDev && !u.searchParams.has('dev')) {
- u.searchParams.set('dev', '')
- }
- dlUrl = u.toString().replace(/=(&|$)/, '$1')
- } else if (dlUrl.startsWith('https://deno.land/x/aleph/')) {
- dlUrl = `https://deno.land/x/aleph@v${VERSION}/` + util.trimPrefix(dlUrl, 'https://deno.land/x/aleph/')
- }
- if (mod.sourceHash === '') {
- log.info('Download', url, dlUrl != url ? colors.dim(`• ${dlUrl}`) : '')
- try {
- const resp = await fetch(dlUrl)
- if (resp.status != 200) {
- throw new Error(`Download ${url}: ${resp.status} - ${resp.statusText}`)
- }
- sourceContent = await Deno.readAll(fromStreamReader(resp.body!.getReader()))
- mod.sourceHash = getHash(sourceContent)
- shouldCompile = true
- } catch (err) {
- throw new Error(`Download ${url}: ${err.message}`)
- }
- } else if (/^https?:\/\/(localhost|127.0.0.1)(:\d+)?\//.test(dlUrl)) {
- try {
- const resp = await fetch(dlUrl)
- if (resp.status != 200) {
- throw new Error(`${resp.status} - ${resp.statusText}`)
- }
- sourceContent = await Deno.readAll(fromStreamReader(resp.body!.getReader()))
- const sourceHash = getHash(sourceContent, true)
- if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
- mod.sourceHash = sourceHash
- shouldCompile = true
- }
- } catch (err) {
- throw new Error(`Download ${url}: ${err.message}`)
- }
- }
- } else {
- const filepath = path.join(this.srcDir, url)
- sourceContent = await Deno.readFile(filepath)
- const sourceHash = getHash(sourceContent, true)
- if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
- mod.sourceHash = sourceHash
- shouldCompile = true
- }
- }
-
- // compile source code
- if (shouldCompile) {
- const t = performance.now()
- let sourceCode = (new TextDecoder).decode(sourceContent)
- for (const plugin of this.config.plugins) {
- if (plugin.test.test(url) && plugin.transform) {
- const { code, loader = 'js' } = await plugin.transform(sourceContent, url)
- sourceCode = code
- mod.loader = loader
- break
- }
- }
- mod.deps = []
- if (mod.loader === 'css') {
- let css: string = sourceCode
- if (mod.id.endsWith('.less')) {
- try {
- const output = await less.render(sourceCode || '/* empty content */')
- css = output.css
- } catch (error) {
- throw new Error(`less: ${error}`);
- }
- }
- const plugins = this.config.postcss.plugins.map(p => {
- if (typeof p === 'string') {
- return this.#postcssPlugins[p]
- } else {
- const Plugin = this.#postcssPlugins[p.name] as Function
- return Plugin(p.options)
- }
- })
- css = (await postcss(plugins).process(css).async()).content
- if (this.isDev) {
- css = css.trim()
- } else {
- const output = this.#cleanCSS.minify(css)
- css = output.styles
- }
- mod.jsContent = [
- `import { applyCSS } from ${JSON.stringify(getRelativePath(
- path.dirname(fixImportUrl(mod.url)),
- '/-/deno.land/x/aleph/head.js'
- ))};`,
- `applyCSS(${JSON.stringify(url)}, ${JSON.stringify(this.isDev ? `\n${css}\n` : css)});`,
- ].join(this.isDev ? '\n' : '')
- mod.jsSourceMap = '' // todo: sourceMap
- mod.hash = getHash(css)
- } else if (mod.loader === 'markdown') {
- const { __content, ...props } = safeLoadFront(sourceCode)
- const html = marked.parse(__content)
- mod.jsContent = [
- this.isDev && `const _s = $RefreshSig$();`,
- `import React, { useEffect, useRef } from ${JSON.stringify(getRelativePath(path.dirname(mod.sourceFilePath), '/-/esm.sh/react.js'))};`,
- `import { redirect } from ${JSON.stringify(getRelativePath(path.dirname(mod.sourceFilePath), '/-/deno.land/x/aleph/aleph.js'))};`,
- `export default function MarkdownPage() {`,
- this.isDev && ` _s();`,
- ` const ref = useRef(null);`,
- ` useEffect(() => {`,
- ` const anchors = [];`,
- ` const onClick = e => {`,
- ` e.preventDefault();`,
- ` redirect(e.currentTarget.getAttribute("href"));`,
- ` };`,
- ` if (ref.current) {`,
- ` ref.current.querySelectorAll("a").forEach(a => {`,
- ` const href = a.getAttribute("href");`,
- ` if (href && !/^[a-z0-9]+:/i.test(href)) {`,
- ` a.addEventListener("click", onClick, false);`,
- ` anchors.push(a);`,
- ` }`,
- ` });`,
- ` }`,
- ` return () => anchors.forEach(a => a.removeEventListener("click", onClick));`,
- ` }, []);`,
- ` return React.createElement("div", {className: "markdown-page", ref, dangerouslySetInnerHTML: {__html: ${JSON.stringify(html)}}});`,
- `}`,
- `MarkdownPage.meta = ${JSON.stringify(props, undefined, this.isDev ? 4 : undefined)};`,
- this.isDev && `_s(MarkdownPage, "useRef{ref}\\nuseEffect{}");`,
- this.isDev && `$RefreshReg$(MarkdownPage, "MarkdownPage");`,
- ].filter(Boolean).map(l => !this.isDev ? String(l).trim() : l).join(this.isDev ? '\n' : '')
- mod.jsSourceMap = ''
- mod.hash = getHash(mod.jsContent)
- } else if (mod.loader === 'js' || mod.loader === 'ts' || mod.loader === 'jsx' || mod.loader === 'tsx') {
- const useDenos: string[] = []
- const compileOptions = {
- mode: this.mode,
- target: options?.forceTarget || this.config.buildTarget,
- reactRefresh: this.isDev && !mod.isRemote,
- rewriteImportPath: (path: string) => this._resolveImportURL(mod, path),
- signUseDeno: (id: string) => {
- const sig = 'useDeno.' + (new Sha1()).update(id).update(VERSION).update(Date.now().toString()).hex().slice(0, hashShort)
- useDenos.push(sig)
- return sig
- }
- }
- const { diagnostics, outputText, sourceMapText } = compile(mod.sourceFilePath, sourceCode, compileOptions)
- if (diagnostics && diagnostics.length > 0) {
- throw new Error(`compile ${url}: ${diagnostics.map(d => d.messageText).join('\n')}`)
- }
- const jsContent = outputText.replace(/import\s*{([^}]+)}\s*from\s*("|')tslib("|');?/g, 'import {$1} from ' + JSON.stringify(getRelativePath(
- path.dirname(mod.sourceFilePath),
- '/-/deno.land/x/aleph/tsc/tslib.js'
- )) + ';')
- if (this.isDev) {
- mod.jsContent = jsContent
- mod.jsSourceMap = sourceMapText!
- } else {
- const { code, map } = await minify(jsContent, {
- compress: false,
- mangle: true,
- sourceMap: {
- content: sourceMapText!,
- }
- })
- if (code) {
- mod.jsContent = code
- } else {
- mod.jsContent = jsContent
- }
- if (util.isNEString(map)) {
- mod.jsSourceMap = map
- }
- }
- mod.hash = getHash(mod.jsContent)
- useDenos.forEach(sig => {
- mod.deps.push({ url: '#' + sig, hash: '', isData: true })
- })
- } else {
- throw new Error(`Unknown loader '${mod.loader}'`)
- }
-
- log.debug(`compile '${url}' in ${Math.round(performance.now() - t)}ms`)
-
- if (!fsync) {
- fsync = true
- }
- }
-
- this.#modules.set(mod.id, mod)
-
- // compile deps
- for (const dep of mod.deps.filter(({ url, external }) => !url.startsWith('#useDeno.') && !external)) {
- const depMod = await this._compile(dep.url)
- if (depMod.loader === 'css' && !dep.isStyle) {
- dep.isStyle = true
- }
- if (dep.hash !== depMod.hash) {
- dep.hash = depMod.hash
- if (!reHttp.test(dep.url)) {
- const depImportPath = getRelativePath(
- path.dirname(url),
- dep.url.replace(reModuleExt, '')
- )
- mod.jsContent = mod.jsContent.replace(/(import|Import|export)([\s\S]*?)(from\s*:?\s*|\(|)("|')([^'"]+)("|')(\)|;)?/g, (s, key, fields, from, ql, importPath, qr, end) => {
- if (
- reHashJs.test(importPath) &&
- importPath.slice(0, importPath.length - (hashShort + 4)) === depImportPath
- ) {
- return `${key}${fields}${from}${ql}${depImportPath}.${dep.hash.slice(0, hashShort)}.js${qr}${end || ''}`
- }
- return s
- })
- mod.hash = getHash(mod.jsContent)
- }
- if (!fsync) {
- fsync = true
- }
- }
- }
-
- if (fsync) {
- mod.jsFile = path.join(saveDir, name + (mod.isRemote ? '' : `.${mod.hash.slice(0, hashShort)}`)) + '.js'
- await cleanupCompilation(mod.jsFile)
- await Promise.all([
- ensureTextFile(metaFile, JSON.stringify({
- url,
- sourceHash: mod.sourceHash,
- hash: mod.hash,
- deps: mod.deps,
- }, undefined, 4)),
- ensureTextFile(mod.jsFile, mod.jsContent),
- mod.jsSourceMap !== '' ? ensureTextFile(mod.jsFile + '.map', mod.jsSourceMap) : Promise.resolve()
- ])
- }
-
- return mod
- }
-
- private _updateDependency(depPath: string, depHash: string, callback: (mod: Module) => void, tracing = new Set()) {
- this.#modules.forEach(mod => {
- mod.deps.forEach(dep => {
- if (dep.url === depPath && dep.hash !== depHash && !tracing?.has(mod.id)) {
- const depImportPath = getRelativePath(
- path.dirname(mod.url),
- dep.url.replace(reModuleExt, '')
- )
- dep.hash = depHash
- if (mod.id === '/main.js') {
- this._createMainModule()
- } else {
- mod.jsContent = mod.jsContent.replace(/(import|export)([^'"]*)("|')([^'"]+)("|')(\)|;)?/g, (s, key, from, ql, importPath, qr, end) => {
- if (
- reHashJs.test(importPath) &&
- importPath.slice(0, importPath.length - (hashShort + 4)) === depImportPath
- ) {
- return `${key}${from}${ql}${depImportPath}.${dep.hash.slice(0, hashShort)}.js${qr}${end || ''}`
- }
- return s
- })
- mod.hash = getHash(mod.jsContent)
- mod.jsFile = `${mod.jsFile.replace(reHashJs, '')}.${mod.hash.slice(0, hashShort)}.js`
- Promise.all([
- ensureTextFile(mod.jsFile.replace(reHashJs, '') + '.meta.json', JSON.stringify({
- sourceFile: mod.url,
- sourceHash: mod.sourceHash,
- hash: mod.hash,
- deps: mod.deps,
- }, undefined, 4)),
- ensureTextFile(mod.jsFile, mod.jsContent),
- mod.jsSourceMap !== '' ? ensureTextFile(mod.jsFile + '.map', mod.jsSourceMap) : Promise.resolve()
- ])
- }
- callback(mod)
- tracing.add(mod.id)
- log.debug('update dependency:', depPath, '->', mod.url)
- this._updateDependency(mod.url, mod.hash, callback, tracing)
- }
- })
- })
- }
-
- private _resolveImportURL(importer: Module, url: string): string {
- let rewrittenURL: string
- let pluginsResolveRet: { url: string, external?: boolean } | null = null
- for (const plugin of this.config.plugins) {
- if (plugin.test.test(url) && plugin.resolve) {
- pluginsResolveRet = plugin.resolve(url)
- break
- }
- }
-
- // when a plugin resolver returns an external path, do NOT rewrite the `url`
- if (pluginsResolveRet && pluginsResolveRet.external) {
- rewrittenURL = pluginsResolveRet.url
- } else {
- if (pluginsResolveRet) {
- url = pluginsResolveRet.url
- }
- if (url in this.importMap.imports) {
- url = this.importMap.imports[url]
- }
- if (reHttp.test(url)) {
- if (importer.isRemote) {
- rewrittenURL = getRelativePath(
- path.dirname(importer.url.replace(reHttp, '/-/').replace(/:(\d+)/, '/$1')),
- fixImportUrl(url)
- )
- } else {
- rewrittenURL = getRelativePath(
- path.dirname(importer.url),
- fixImportUrl(url)
- )
- }
- } else {
- if (importer.isRemote) {
- const modUrl = new URL(importer.url)
- let pathname = url
- if (!pathname.startsWith('/')) {
- pathname = util.cleanPath(path.dirname(modUrl.pathname) + '/' + url)
- }
- const importUrl = new URL(modUrl.protocol + '//' + modUrl.host + pathname)
- rewrittenURL = getRelativePath(
- path.dirname(importer.sourceFilePath),
- fixImportUrl(importUrl.toString())
- )
- } else {
- rewrittenURL = url.replace(reModuleExt, '') + '.' + 'x'.repeat(hashShort)
- }
- }
- }
-
- if (reHttp.test(url)) {
- importer.deps.push({ url, hash: '', external: pluginsResolveRet?.external })
- } else {
- if (importer.isRemote) {
- const sourceUrl = new URL(importer.url)
- let pathname = url
- if (!pathname.startsWith('/')) {
- pathname = util.cleanPath(path.dirname(sourceUrl.pathname) + '/' + url)
- }
- importer.deps.push({
- url: sourceUrl.protocol + '//' + sourceUrl.host + pathname,
- hash: '',
- external: pluginsResolveRet?.external
- })
- } else {
- importer.deps.push({
- url: util.cleanPath(path.dirname(importer.url) + '/' + url),
- hash: '',
- external: pluginsResolveRet?.external
- })
- }
- }
-
- if (reHttp.test(rewrittenURL)) {
- return rewrittenURL
- }
-
- if (!rewrittenURL.startsWith('.') && !rewrittenURL.startsWith('/')) {
- rewrittenURL = '/' + rewrittenURL
- }
- return rewrittenURL.replace(reModuleExt, '') + '.js'
- }
-
- private async _renderPage(loc: { pathname: string, search?: string }) {
- const start = performance.now()
- const [url, pageModuleTree] = this.#routing.createRouter(loc)
- const key = [url.pathname, url.query.toString()].filter(Boolean).join('?')
- if (url.pagePath !== '') {
- if (this.#rendered.has(url.pagePath)) {
- const cache = this.#rendered.get(url.pagePath)!
- if (cache.has(key)) {
- return cache.get(key)!
- }
- } else {
- this.#rendered.set(url.pagePath, new Map())
- }
- }
- const ret: RenderResult = { url, status: url.pagePath === '' ? 404 : 200, head: [], scripts: [], body: ' ', data: null }
- if (ret.status === 404) {
- if (this.isDev) {
- log.warn(`page '${url.pathname}' not found`)
- }
- return await this._render404Page(url)
- }
- try {
- const appModule = this.#modules.get('/app.js')
- const { default: App } = appModule ? await import('file://' + appModule.jsFile) : {} as any
- const pageComponentTree: { id: string, Component?: any }[] = pageModuleTree.map(({ id }) => ({ id }))
- const imports = pageModuleTree.map(async ({ id }) => {
- const mod = this.#modules.get(id)!
- const { default: C } = await import('file://' + mod.jsFile)
- const pc = pageComponentTree.find(pc => pc.id === mod.id)
- if (pc) {
- pc.Component = C
- }
- })
- await Promise.all(imports)
- const {
- head,
- body,
- data,
- scripts
- } = await this.#renderer.renderPage(
- url,
- App,
- undefined,
- pageComponentTree,
- [
- appModule ? this._lookupDeps(appModule.id).filter(dep => !!dep.isStyle) : [],
- ...pageModuleTree.map(({ id }) => this._lookupDeps(id).filter(dep => !!dep.isStyle)).flat()
- ].flat()
- )
- ret.head = head
- ret.scripts = await Promise.all(scripts.map(async (script: Record) => {
- if (script.innerText && !this.isDev) {
- return { ...script, innerText: (await minify(script.innerText)).code }
- }
- return script
- }))
- ret.body = `${body} `
- ret.data = data
- this.#rendered.get(url.pagePath)!.set(key, ret)
- if (this.isDev) {
- log.debug(`render '${url.pathname}' in ${Math.round(performance.now() - start)}ms`)
- }
- } catch (err) {
- ret.status = 500
- ret.head = ['Error 500 - Aleph.js ']
- ret.body = `${colors.stripColor(err.stack)} `
- log.error(err)
- }
- return ret
- }
-
- private async _render404Page(url: RouterURL = { locale: this.config.defaultLocale, pagePath: '', pathname: '/', params: {}, query: new URLSearchParams() }) {
- const ret: RenderResult = { url, status: 404, head: [], scripts: [], body: ' ', data: null }
- try {
- const e404Module = this.#modules.get('/404.js')
- const { default: E404 } = e404Module ? await import('file://' + e404Module.jsFile) : {} as any
- const {
- head,
- body,
- data,
- scripts
- } = await this.#renderer.renderPage(
- url,
- undefined,
- E404,
- [],
- e404Module ? this._lookupDeps(e404Module.id).filter(dep => !!dep.isStyle) : []
- )
- ret.head = head
- ret.scripts = await Promise.all(scripts.map(async (script: Record) => {
- if (script.innerText && !this.isDev) {
- return { ...script, innerText: (await minify(script.innerText)).code }
- }
- return script
- }))
- ret.body = `${body} `
- ret.data = data
- } catch (err) {
- ret.status = 500
- ret.head = ['Error 500 - Aleph.js ']
- ret.body = `${colors.stripColor(err.stack)} `
- log.error(err)
- }
- return ret
- }
-
- private async _renderLoadingPage() {
- if (this.#modules.has('/loading.js')) {
- const loadingModule = this.#modules.get('/loading.js')!
- const { default: Loading } = await import('file://' + loadingModule.jsFile)
- const url = { locale: this.config.defaultLocale, pagePath: '', pathname: '/', params: {}, query: new URLSearchParams() }
-
- const {
- head,
- body
- } = await this.#renderer.renderPage(
- url,
- undefined,
- undefined,
- [{ id: '/loading.js', Component: Loading }],
- [
- this._lookupDeps(loadingModule.id).filter(dep => !!dep.isStyle)
- ].flat()
- )
- return {
- head,
- body: `${body} `
- } as Pick
- }
- return null
- }
-
- private _lookupDeps(moduleID: string, __deps: Dep[] = [], __tracing: Set = new Set()) {
- const mod = this.getModule(moduleID)
- if (!mod) {
- return __deps
- }
- if (__tracing.has(moduleID)) {
- return __deps
- }
- __tracing.add(moduleID)
- __deps.push(...mod.deps.filter(({ url }) => __deps.findIndex(i => i.url === url) === -1))
- mod.deps.forEach(({ url }) => {
- if (reModuleExt.test(url) && !reHttp.test(url)) {
- this._lookupDeps(url.replace(reModuleExt, '.js'), __deps, __tracing)
- }
- })
- return __deps
- }
-}
-
-/** inject HMR and React Fast Referesh helper code */
-export function injectHmr({ id, sourceFilePath, jsContent }: Module): string {
- let hmrImportPath = getRelativePath(
- path.dirname(sourceFilePath),
- '/-/deno.land/x/aleph/hmr.js'
- )
- if (!hmrImportPath.startsWith('.') && !hmrImportPath.startsWith('/')) {
- hmrImportPath = './' + hmrImportPath
- }
-
- const lines = [
- `import { createHotContext, RefreshRuntime, performReactRefresh } from ${JSON.stringify(hmrImportPath)};`,
- `import.meta.hot = createHotContext(${JSON.stringify(id)});`
- ]
- const reactRefresh = id.endsWith('.js') || id.endsWith('.md') || id.endsWith('.mdx')
- if (reactRefresh) {
- lines.push('')
- lines.push(
- `const prevRefreshReg = window.$RefreshReg$;`,
- `const prevRefreshSig = window.$RefreshSig$;`,
- `Object.assign(window, {`,
- ` $RefreshReg$: (type, id) => RefreshRuntime.register(type, ${JSON.stringify(id)} + " " + id),`,
- ` $RefreshSig$: RefreshRuntime.createSignatureFunctionForTransform`,
- `});`,
- )
- }
- lines.push('')
- lines.push(jsContent)
- lines.push('')
- if (reactRefresh) {
- lines.push(
- 'window.$RefreshReg$ = prevRefreshReg;',
- 'window.$RefreshSig$ = prevRefreshSig;',
- 'import.meta.hot.accept(performReactRefresh);'
- )
- } else {
- lines.push('import.meta.hot.accept();')
- }
- return lines.join('\n')
-}
-
-/** get relative the path of `to` to `from` */
-function getRelativePath(from: string, to: string): string {
- let r = path.relative(from, to).split('\\').join('/')
- if (!r.startsWith('.') && !r.startsWith('/')) {
- r = './' + r
- }
- return r
-}
-
-/** fix import url */
-function fixImportUrl(importUrl: string): string {
- const isRemote = reHttp.test(importUrl)
- const url = new URL(isRemote ? importUrl : 'file://' + importUrl)
- let ext = path.extname(path.basename(url.pathname)) || '.js'
- if (isRemote && !reModuleExt.test(ext) && !reStyleModuleExt.test(ext) && !reMDExt.test(ext)) {
- ext = '.js'
- }
- let pathname = util.trimSuffix(url.pathname, ext)
- let search = Array.from(url.searchParams.entries()).map(([key, value]) => value ? `${key}=${value}` : key)
- if (search.length > 0) {
- pathname += '@' + search.join(',')
- }
- if (isRemote) {
- return '/-/' + url.hostname + (url.port ? '/' + url.port : '') + pathname + ext
- }
- const result = pathname + ext
- return !isRemote && importUrl.startsWith('/api/') ? decodeURI(result) : result;
-}
-
-/** get hash(sha1) of the content, mix current aleph.js version when the second parameter is `true` */
-function getHash(content: string | Uint8Array, checkVersion = false) {
- const sha1 = new Sha1()
- sha1.update(content)
- if (checkVersion) {
- sha1.update(VERSION)
- }
- return sha1.hex()
-}
-
-/**
- * colorful the bytes string
- * - dim: 0 - 1MB
- * - yellow: 1MB - 10MB
- * - red: > 10MB
- */
-function colorfulBytesString(bytes: number) {
- let cf = colors.dim
- if (bytes > 10 * MB) {
- cf = colors.red
- } else if (bytes > MB) {
- cf = colors.yellow
- }
- return cf(util.bytesString(bytes))
-}
-
-/** cleanup the previous compilation cache */
-async function cleanupCompilation(jsFile: string) {
- const dir = path.dirname(jsFile)
- const jsFileName = path.basename(jsFile)
- if (!reHashJs.test(jsFile) || !existsDirSync(dir)) {
- return
- }
- const jsName = jsFileName.split('.').slice(0, -2).join('.') + '.js'
- for await (const entry of Deno.readDir(dir)) {
- if (entry.isFile && (entry.name.endsWith('.js') || entry.name.endsWith('.js.map'))) {
- const _jsName = util.trimSuffix(entry.name, '.map').split('.').slice(0, -2).join('.') + '.js'
- if (_jsName === jsName && jsFileName !== entry.name) {
- await Deno.remove(path.join(dir, entry.name))
- }
- }
- }
-}
diff --git a/project_test.ts b/project_test.ts
deleted file mode 100644
index 7b5cd6c9e..000000000
--- a/project_test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { assertEquals } from 'https://deno.land/std/testing/asserts.ts'
-import { Project } from './project.ts'
-import { path } from './std.ts'
-
-Deno.test({
- name: 'project build(hello world)',
- async fn() {
- const output: string[] = []
- const appDir = path.resolve('./examples/hello-world')
- const project = new Project(appDir, 'production')
- await project.build()
- for await (const entry of Deno.readDir(path.resolve(appDir, 'dist'))) {
- output.push(entry.name)
- }
- output.sort()
- assertEquals(output, [
- '404.html',
- '_aleph',
- '_fallback.html',
- 'favicon.ico',
- 'index.html',
- 'logo.svg'
- ])
- },
- sanitizeResources: false,
- sanitizeOps: false
-})
diff --git a/api.ts b/server/api.ts
similarity index 77%
rename from api.ts
rename to server/api.ts
index 6a2c06e84..1a04b0e38 100644
--- a/api.ts
+++ b/server/api.ts
@@ -1,11 +1,10 @@
-import { compress as brotli } from 'https://deno.land/x/brotli@v0.1.4/mod.ts'
-import { gzipEncode } from 'https://deno.land/x/wasm_gzip@v1.0.0/mod.ts'
+import { brotli, bufio, gzipEncode, Response, ServerRequest } from '../deps.ts'
+import type { APIRequest, FormDataBody } from '../types.ts'
import log from './log.ts'
import { multiParser } from './multiparser.ts'
-import { ServerRequest } from './std.ts'
-import type { APIRequest, FormDataBody } from './types.ts'
-export class Request extends ServerRequest implements APIRequest {
+export class Request implements APIRequest {
+ #req: ServerRequest
#pathname: string
#params: Record
#query: URLSearchParams
@@ -20,17 +19,7 @@ export class Request extends ServerRequest implements APIRequest {
}
constructor(req: ServerRequest, pathname: string, params: Record, query: URLSearchParams) {
- super()
- this.conn = req.conn
- this.r = req.r
- this.w = req.w
- this.method = req.method
- this.url = req.url
- this.proto = req.proto
- this.protoMinor = req.protoMinor
- this.protoMajor = req.protoMajor
- this.headers = req.headers
- this.done = req.done
+ this.#req = req
this.#pathname = pathname
this.#params = params
this.#query = query
@@ -44,6 +33,62 @@ export class Request extends ServerRequest implements APIRequest {
this.#cookies = cookies
}
+ get url(): string {
+ return this.#req.url
+ }
+
+ get method(): string {
+ return this.#req.method
+ }
+
+ get proto(): string {
+ return this.#req.proto
+ }
+
+ get protoMinor(): number {
+ return this.#req.protoMinor
+ }
+
+ get protoMajor(): number {
+ return this.#req.protoMajor
+ }
+
+ get headers(): Headers {
+ return this.#req.headers
+ }
+
+ get conn(): Deno.Conn {
+ return this.#req.conn
+ }
+
+ get r(): bufio.BufReader {
+ return this.#req.r
+ }
+
+ get w(): bufio.BufWriter {
+ return this.#req.w
+ }
+
+ get done(): Promise {
+ return this.#req.done
+ }
+
+ get contentLength(): number | null {
+ return this.#req.contentLength
+ }
+
+ get body(): Deno.Reader {
+ return this.#req.body
+ }
+
+ async respond(r: Response): Promise {
+ return this.#req.respond(r)
+ }
+
+ async finalize(): Promise {
+ return this.#req.finalize()
+ }
+
get pathname(): string {
return this.#pathname
}
@@ -145,7 +190,7 @@ export class Request extends ServerRequest implements APIRequest {
if (this.headers.get('accept-encoding')?.includes('br')) {
this.#resp.headers.set('Vary', 'Origin')
this.#resp.headers.set('Content-Encoding', 'br')
- body = brotli(body)
+ body = brotli.compress(body)
} else if (this.headers.get('accept-encoding')?.includes('gzip')) {
this.#resp.headers.set('Vary', 'Origin')
this.#resp.headers.set('Content-Encoding', 'gzip')
@@ -158,6 +203,6 @@ export class Request extends ServerRequest implements APIRequest {
status: this.#resp.status,
headers: this.#resp.headers,
body
- }).catch(err => log.warn('ServerRequest.respond:', err.message))
+ }).catch((err: Error) => log.warn('ServerRequest.respond:', err.message))
}
}
diff --git a/log.ts b/server/log.ts
similarity index 97%
rename from log.ts
rename to server/log.ts
index 5d5443e7d..dd77199ed 100644
--- a/log.ts
+++ b/server/log.ts
@@ -1,4 +1,4 @@
-import { colors } from './std.ts'
+import { colors } from '../deps.ts'
export enum Level {
Debug = 0,
diff --git a/mime.ts b/server/mime.ts
similarity index 100%
rename from mime.ts
rename to server/mime.ts
diff --git a/multiparser.ts b/server/multiparser.ts
old mode 100644
new mode 100755
similarity index 89%
rename from multiparser.ts
rename to server/multiparser.ts
index 91fc69d3e..c3cccae8a
--- a/multiparser.ts
+++ b/server/multiparser.ts
@@ -1,5 +1,5 @@
-import { bytes } from "./std.ts";
-import { FormDataBody, FormFile } from "./types.ts";
+import { bytes } from "../deps.ts";
+import { FormDataBody, FormFile } from "../types.ts";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -84,7 +84,7 @@ function getForm(pieces: Uint8Array[]) {
}
function getHeaders(headerByte: Uint8Array) {
- let contentTypeIndex = bytes.findIndex(headerByte, encode.contentType);
+ let contentTypeIndex = bytes.indexOf(headerByte, encode.contentType);
// no contentType, it may be a string field, return name only
if (contentTypeIndex < 0) {
@@ -118,7 +118,7 @@ function getHeaderNContentType(
function getHeaderOnly(headerLineByte: Uint8Array) {
let headers: Record = {};
- let filenameIndex = bytes.findIndex(headerLineByte, encode.filename);
+ let filenameIndex = bytes.indexOf(headerLineByte, encode.filename);
if (filenameIndex < 0) {
headers.name = getNameOnly(headerLineByte);
} else {
@@ -141,7 +141,7 @@ function getNameNFilename(headerLineByte: Uint8Array, filenameIndex: number) {
}
function getNameOnly(headerLineByte: Uint8Array) {
- let nameIndex = bytes.findIndex(headerLineByte, encode.name);
+ let nameIndex = bytes.indexOf(headerLineByte, encode.name);
// jump and get string inside double quote => "string"
let nameByte = headerLineByte.slice(
@@ -153,7 +153,7 @@ function getNameOnly(headerLineByte: Uint8Array) {
}
function splitPiece(piece: Uint8Array) {
- const contentIndex = bytes.findIndex(piece, encode.returnNewline2);
+ const contentIndex = bytes.indexOf(piece, encode.returnNewline2);
const headerByte = piece.slice(0, contentIndex);
const contentByte = piece.slice(contentIndex + 4);
@@ -169,10 +169,10 @@ function getFieldPieces(
const pieces = [];
- while (!bytes.hasPrefix(buf, endBoundaryByte)) {
+ while (!bytes.startsWith(buf, endBoundaryByte)) {
// jump over boundary + '\r\n'
buf = buf.slice(startBoundaryByte.byteLength + 2);
- let boundaryIndex = bytes.findIndex(buf, startBoundaryByte);
+ let boundaryIndex = bytes.indexOf(buf, startBoundaryByte);
// get field content piece
pieces.push(buf.slice(0, boundaryIndex - 1));
@@ -184,7 +184,7 @@ function getFieldPieces(
function getBoundary(contentType: string): Uint8Array | undefined {
let contentTypeByte = encoder.encode(contentType);
- let boundaryIndex = bytes.findIndex(contentTypeByte, encode.boundaryEqual);
+ let boundaryIndex = bytes.indexOf(contentTypeByte, encode.boundaryEqual);
if (boundaryIndex >= 0) {
// jump over 'boundary=' to get the real boundary
diff --git a/server/project.ts b/server/project.ts
new file mode 100644
index 000000000..b2506d506
--- /dev/null
+++ b/server/project.ts
@@ -0,0 +1,1680 @@
+import { initWasm, SWCOptions, TransformOptions, transpileSync } from '../compiler/mod.ts'
+import type { AcceptedPlugin, ECMA, ServerRequest } from '../deps.ts'
+import { CleanCSS, colors, ensureDir, less, marked, minify, path, postcss, safeLoadFront, Sha1, Sha256, walk } from '../deps.ts'
+import { EventEmitter } from '../framework/core/events.ts'
+import { getPagePath, RouteModule, Routing } from '../framework/core/routing.ts'
+import { hashShort, reFullVersion, reHashJs, reHashResolve, reHttp, reLocaleID, reMDExt, reModuleExt, reStyleModuleExt } from '../shared/constants.ts'
+import util from '../shared/util.ts'
+import type { APIHandler, Config, RouterURL } from '../types.ts'
+import { VERSION } from '../version.ts'
+import { Request } from './api.ts'
+import log from './log.ts'
+import type { DependencyDescriptor, ImportMap, Module, RenderResult } from './types.ts'
+import { AlephRuntimeCode, cleanupCompilation, colorfulBytesString, createHtml, ensureTextFile, existsDirSync, existsFileSync, fixImportMap, fixImportUrl, getAlephPkgUrl, getRelativePath, newModule } from './util.ts'
+
+/**
+ * A Project to manage the Aleph.js appliaction.
+ * core functions include:
+ * - manage deps
+ * - compile & bundle
+ * - apply plugins
+ * - map page/API routes
+ * - watch file changes
+ * - call APIs
+ * - SSR/SSG
+ */
+export class Project {
+ readonly appRoot: string
+ readonly mode: 'development' | 'production'
+ readonly config: Readonly>
+ readonly importMap: Readonly<{ imports: ImportMap, scopes: Record }>
+ readonly ready: Promise
+
+ #denoCacheDir = ''
+ #modules: Map = new Map()
+ #pageRouting: Routing = new Routing()
+ #apiRouting: Routing = new Routing()
+ #fsWatchListeners: Array = []
+ #renderer: { renderPage: CallableFunction } = { renderPage: () => { } }
+ #rendered: Map> = new Map()
+ #postcssPlugins: Record = {}
+ #cleanCSS = new CleanCSS({ compatibility: '*' /* Internet Explorer 10+ */ })
+ #swcReady: Promise | null = null
+ #postcssReady: Promise | null = null
+ #reloading = false
+
+ constructor(appDir: string, mode: 'development' | 'production', reload = false) {
+ this.appRoot = path.resolve(appDir)
+ this.mode = mode
+ this.config = {
+ framework: 'react',
+ srcDir: existsDirSync(path.join(this.appRoot, '/src/pages')) ? '/src' : '/',
+ outputDir: '/dist',
+ baseUrl: '/',
+ defaultLocale: 'en',
+ env: {},
+ locales: [],
+ ssr: {
+ fallback: '_fallback_spa.html'
+ },
+ buildTarget: 'es5',
+ reactVersion: '17.0.1',
+ plugins: [],
+ postcss: {
+ plugins: [
+ 'autoprefixer'
+ ]
+ }
+ }
+ this.importMap = { imports: {}, scopes: {} }
+ this.ready = this._init(reload)
+ }
+
+ get isDev() {
+ return this.mode === 'development'
+ }
+
+ get srcDir() {
+ return path.join(this.appRoot, this.config.srcDir)
+ }
+
+ get buildDir() {
+ return path.join(this.appRoot, '.aleph', this.mode)
+ }
+
+ get outputDir() {
+ return path.join(this.appRoot, this.config.outputDir)
+ }
+
+ isHMRable(url: string) {
+ if (!this.isDev) {
+ return false
+ }
+ if (reStyleModuleExt.test(url)) {
+ return true
+ }
+ if (reMDExt.test(url)) {
+ return url.startsWith('/pages/')
+ }
+ if (reModuleExt.test(url)) {
+ return url.startsWith('/pages/') ||
+ url.startsWith('/components/') ||
+ url.replace(reModuleExt, '') === "/app" ||
+ url.replace(reModuleExt, '') === "/404"
+ }
+ const plugin = this.config.plugins.find(p => p.test.test(url))
+ if (plugin?.acceptHMR) {
+ return true
+ }
+ return false
+ }
+
+ isSSRable(pathname: string): boolean {
+ const { ssr } = this.config
+ if (util.isPlainObject(ssr)) {
+ if (ssr.include) {
+ for (let r of ssr.include) {
+ if (!r.test(pathname)) {
+ return false
+ }
+ }
+ }
+ if (ssr.exclude) {
+ for (let r of ssr.exclude) {
+ if (r.test(pathname)) {
+ return false
+ }
+ }
+ }
+ return true
+ }
+ return ssr
+ }
+
+ getModule(url: string): Module | null {
+ if (this.#modules.has(url)) {
+ return this.#modules.get(url)!
+ }
+ return null
+ }
+
+ createFSWatcher(): EventEmitter {
+ const e = new EventEmitter()
+ this.#fsWatchListeners.push(e)
+ return e
+ }
+
+ removeFSWatcher(e: EventEmitter) {
+ e.removeAllListeners()
+ const index = this.#fsWatchListeners.indexOf(e)
+ if (index > -1) {
+ this.#fsWatchListeners.splice(index, 1)
+ }
+ }
+
+ async callAPI(req: ServerRequest, loc: { pathname: string, search?: string }): Promise {
+ const [url, chain] = this.#apiRouting.createRouter({
+ ...loc,
+ pathname: decodeURI(loc.pathname)
+ })
+ if (url.pagePath != '') {
+ const { url: moduleUrl } = chain[chain.length - 1]
+ if (this.#modules.has(moduleUrl)) {
+ try {
+ const { default: handle } = await import('file://' + this.#modules.get(moduleUrl)!.jsFile)
+ if (util.isFunction(handle)) {
+ await handle(new Request(req, url.pathname, url.params, url.query))
+ } else {
+ req.respond({
+ status: 500,
+ headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
+ body: JSON.stringify({ error: { status: 404, message: "handle not found" } })
+ }).catch((err: Error) => log.warn('ServerRequest.respond:', err.message))
+ }
+ } catch (err) {
+ req.respond({
+ status: 500,
+ headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
+ body: JSON.stringify({ error: { status: 500, message: err.message } })
+ }).catch((err: Error) => log.warn('ServerRequest.respond:', err.message))
+ log.error('callAPI:', err)
+ }
+ }
+ } else {
+ req.respond({
+ status: 404,
+ headers: new Headers({ 'Content-Type': 'application/javascript; charset=utf-8' }),
+ body: JSON.stringify({ error: { status: 404, message: 'page not found' } })
+ }).catch((err: Error) => log.warn('ServerRequest.respond:', err.message))
+ }
+ return null
+ }
+
+ async getSSRData(loc: { pathname: string, search?: string }): Promise<[number, any]> {
+ if (!this.isSSRable(loc.pathname)) {
+ return [404, null]
+ }
+
+ const { status, data } = await this._renderPage(loc)
+ return [status, data]
+ }
+
+ async getPageHtml(loc: { pathname: string, search?: string }): Promise<[number, string, Record | null]> {
+ if (!this.isSSRable(loc.pathname)) {
+ const [url] = this.#pageRouting.createRouter(loc)
+ return [url.pagePath === '' ? 404 : 200, await this.getSPAIndexHtml(), null]
+ }
+
+ const { url, status, head, scripts, body, data } = await this._renderPage(loc)
+ const html = createHtml({
+ lang: url.locale,
+ head: head,
+ scripts: [
+ data ? { type: 'application/json', innerText: JSON.stringify(data), id: 'ssr-data' } : '',
+ ...this._getPreloadScripts(),
+ ...scripts
+ ],
+ body,
+ minify: !this.isDev
+ })
+ return [status, html, data]
+ }
+
+ async getSPAIndexHtml() {
+ const { defaultLocale } = this.config
+ const customLoading = await this._renderLoadingPage()
+ const html = createHtml({
+ lang: defaultLocale,
+ scripts: [
+ ...this._getPreloadScripts()
+ ],
+ head: customLoading?.head || [],
+ body: `${customLoading?.body || ''} `,
+ minify: !this.isDev
+ })
+ return html
+ }
+
+ /** build the application to a static site(SSG) */
+ async build() {
+ const start = performance.now()
+ const outputDir = this.outputDir
+ const distDir = path.join(outputDir, '_aleph')
+
+ // wait for project ready
+ await this.ready
+
+ // clean old build
+ if (existsDirSync(outputDir)) {
+ for await (const entry of Deno.readDir(outputDir)) {
+ await Deno.remove(path.join(outputDir, entry.name), { recursive: entry.isDirectory })
+ }
+ }
+
+ // ensure output dir
+ await ensureDir(distDir)
+
+ // ssg, bundle & optimizing
+ await this._bundle()
+ await this._optimize()
+ await this._ssg()
+
+ // copy public assets
+ const publicDir = path.join(this.appRoot, 'public')
+ if (existsDirSync(publicDir)) {
+ let n = 0
+ for await (const { path: p } of walk(publicDir, { includeDirs: false, skip: [/(^|\/)\.DS_Store$/] })) {
+ const rp = util.trimPrefix(p, publicDir)
+ const fp = path.join(outputDir, rp)
+ const fi = await Deno.lstat(p)
+ await ensureDir(path.dirname(fp))
+ await Deno.copyFile(p, fp)
+ if (n === 0) {
+ log.info(colors.bold('- Public Assets'))
+ }
+ log.info(' ∆', rp.split('\\').join('/'), colors.dim('•'), colorfulBytesString(fi.size))
+ n++
+ }
+ }
+
+ log.info(`Done in ${Math.round(performance.now() - start)}ms`)
+ }
+
+ /** inject HMR helper code */
+ injectHmr(url: string, content: string): string {
+ const { __ALEPH_DEV_PORT: devPort } = globalThis as any
+ const alephModuleLocalUrlPreifx = devPort ? `http_localhost_${devPort}` : `deno.land/x/aleph@v${VERSION}`
+ const localUrl = fixImportUrl(url)
+ const hmrImportPath = getRelativePath(
+ path.dirname(localUrl),
+ `/-/${alephModuleLocalUrlPreifx}/framework/core/hmr.js`
+ )
+ const lines = [
+ `import { createHotContext } from ${JSON.stringify(hmrImportPath)};`,
+ `import.meta.hot = createHotContext(${JSON.stringify(url)});`
+ ]
+ const reactRefresh = this.config.framework === 'react' && (reModuleExt.test(url) || reMDExt.test(url))
+ if (reactRefresh) {
+ const refreshImportPath = getRelativePath(
+ path.dirname(localUrl),
+ `/-/${alephModuleLocalUrlPreifx}/framework/react/refresh.js`
+ )
+ lines.push(`import { RefreshRuntime, performReactRefresh } from ${JSON.stringify(refreshImportPath)};`)
+ lines.push('')
+ lines.push(
+ `const prevRefreshReg = window.$RefreshReg$;`,
+ `const prevRefreshSig = window.$RefreshSig$;`,
+ `Object.assign(window, {`,
+ ` $RefreshReg$: (type, id) => RefreshRuntime.register(type, ${JSON.stringify(url)} + " " + id),`,
+ ` $RefreshSig$: RefreshRuntime.createSignatureFunctionForTransform`,
+ `});`,
+ )
+ }
+ lines.push('')
+ lines.push(content)
+ lines.push('')
+ if (reactRefresh) {
+ lines.push(
+ 'window.$RefreshReg$ = prevRefreshReg;',
+ 'window.$RefreshSig$ = prevRefreshSig;',
+ 'import.meta.hot.accept(performReactRefresh);'
+ )
+ } else {
+ lines.push('import.meta.hot.accept();')
+ }
+ return lines.join('\n')
+ }
+
+ private _getPreloadScripts() {
+ const { baseUrl } = this.config
+ const mainModule = this.#modules.get('/main.ts')!
+ const depsModule = this.#modules.get('/deps.bundling.js')
+ const sharedModule = this.#modules.get('/shared.bundling.js')
+ const polyfillModule = this.#modules.get('/polyfill.js')
+
+ return [
+ polyfillModule ? { src: util.cleanPath(`${baseUrl}/_aleph/polyfill.${polyfillModule.hash.slice(0, hashShort)}.js`) } : {},
+ depsModule ? { src: util.cleanPath(`${baseUrl}/_aleph/deps.${depsModule.hash.slice(0, hashShort)}.js`), } : {},
+ sharedModule ? { src: util.cleanPath(`${baseUrl}/_aleph/shared.${sharedModule.hash.slice(0, hashShort)}.js`), } : {},
+ !this.isDev ? { src: util.cleanPath(`${baseUrl}/_aleph/main.${mainModule.sourceHash.slice(0, hashShort)}.js`), } : {},
+ this.isDev ? { src: util.cleanPath(`${baseUrl}/_aleph/main.${mainModule.hash.slice(0, hashShort)}.js`), type: 'module' } : {},
+ this.isDev ? { src: util.cleanPath(`${baseUrl}/_aleph/-/deno.land/x/aleph/nomodule.js`), nomodule: true } : {},
+ ]
+ }
+
+ /** load config from `aleph.config.(json|mjs|js|ts)` */
+ private async _loadConfig() {
+ const importMapFile = path.join(this.appRoot, 'import_map.json')
+ if (existsFileSync(importMapFile)) {
+ const importMap = JSON.parse(await Deno.readTextFile(importMapFile))
+ const imports: ImportMap = fixImportMap(importMap.imports)
+ const scopes: Record = {}
+ if (util.isPlainObject(importMap.scopes)) {
+ Object.entries(importMap.scopes).forEach(([key, imports]) => {
+ scopes[key] = fixImportMap(imports)
+ })
+ }
+ Object.assign(this.importMap, { imports, scopes })
+ }
+
+ const config: Record = {}
+
+ for (const name of Array.from(['ts', 'js', 'mjs', 'json']).map(ext => `aleph.config.${ext}`)) {
+ const p = path.join(this.appRoot, name)
+ if (existsFileSync(p)) {
+ log.info(' ✓', name)
+ if (name.endsWith('.json')) {
+ const conf = JSON.parse(await Deno.readTextFile(p))
+ if (util.isPlainObject(conf)) {
+ Object.assign(config, conf)
+ }
+ } else {
+ let { default: conf } = await import('file://' + p)
+ if (util.isFunction(conf)) {
+ conf = await conf()
+ }
+ if (util.isPlainObject(conf)) {
+ Object.assign(config, conf)
+ }
+ }
+ break
+ }
+ }
+
+ const {
+ srcDir,
+ ouputDir,
+ baseUrl,
+ buildTarget,
+ sourceMap,
+ defaultLocale,
+ locales,
+ ssr,
+ env,
+ plugins,
+ postcss,
+ } = config
+ if (util.isNEString(srcDir)) {
+ Object.assign(this.config, { srcDir: util.cleanPath(srcDir) })
+ }
+ if (util.isNEString(ouputDir)) {
+ Object.assign(this.config, { ouputDir: util.cleanPath(ouputDir) })
+ }
+ if (util.isNEString(baseUrl)) {
+ Object.assign(this.config, { baseUrl: util.cleanPath(encodeURI(baseUrl)) })
+ }
+ if (/^es(20\d{2}|5)$/i.test(buildTarget)) {
+ Object.assign(this.config, { buildTarget: buildTarget.toLowerCase() })
+ }
+ if (typeof sourceMap === 'boolean') {
+ Object.assign(this.config, { sourceMap })
+ }
+ if (util.isNEString(defaultLocale)) {
+ Object.assign(this.config, { defaultLocale })
+ }
+ if (util.isArray(locales)) {
+ Object.assign(this.config, { locales: Array.from(new Set(locales.filter(l => reLocaleID.test(l)))) })
+ locales.filter(l => !reLocaleID.test(l)).forEach(l => log.warn(`invalid locale ID '${l}'`))
+ }
+ if (typeof ssr === 'boolean') {
+ Object.assign(this.config, { ssr })
+ } else if (util.isPlainObject(ssr)) {
+ const fallback = util.isNEString(ssr.fallback) ? util.ensureExt(ssr.fallback, '.html') : '404.html'
+ const include = util.isArray(ssr.include) ? ssr.include.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : []
+ const exclude = util.isArray(ssr.exclude) ? ssr.exclude.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : []
+ const staticPaths = util.isArray(ssr.staticPaths) ? ssr.staticPaths.map(v => util.cleanPath(v.split('?')[0])) : []
+ Object.assign(this.config, { ssr: { fallback, include, exclude, staticPaths } })
+ }
+ if (util.isPlainObject(env)) {
+ Object.assign(this.config, { env })
+ }
+ if (util.isNEArray(plugins)) {
+ Object.assign(this.config, { plugins })
+ }
+ if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) {
+ Object.assign(this.config, { postcss })
+ } else {
+ for (const name of Array.from(['ts', 'js', 'mjs', 'json']).map(ext => `postcss.config.${ext}`)) {
+ const p = path.join(this.appRoot, name)
+ if (existsFileSync(p)) {
+ log.info(' ✓', name)
+ if (name.endsWith('.json')) {
+ const postcss = JSON.parse(await Deno.readTextFile(p))
+ if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) {
+ Object.assign(this.config, { postcss })
+ }
+ } else {
+ let { default: postcss } = await import('file://' + p)
+ if (util.isFunction(postcss)) {
+ postcss = await postcss()
+ }
+ if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) {
+ Object.assign(this.config, { postcss })
+ }
+ }
+ break
+ }
+ }
+ }
+
+ // update import map
+ const { __ALEPH_DEV_PORT: devPort } = globalThis as any
+ if (devPort) {
+ const alias = `http://localhost:${devPort}/`
+ const imports = {
+ 'https://deno.land/x/aleph/': [alias],
+ [`https://deno.land/x/aleph@v${VERSION}/`]: [alias],
+ 'aleph': [`${alias}mod.ts`],
+ 'aleph/': [alias],
+ }
+ Object.assign(this.importMap, { imports: Object.assign({}, this.importMap.imports, imports) })
+ }
+ Object.assign(this.importMap, {
+ imports: Object.assign({}, {
+ 'react': [`https://esm.sh/react@${this.config.reactVersion}`],
+ 'react-dom': [`https://esm.sh/react-dom@${this.config.reactVersion}`],
+ }, this.importMap.imports)
+ })
+
+ // update routing
+ this.#pageRouting = new Routing([], this.config.baseUrl, this.config.defaultLocale, this.config.locales)
+ }
+
+ /** initialize project */
+ private async _init(reload: boolean) {
+ const t = performance.now()
+ const alephPkgUrl = getAlephPkgUrl()
+ const walkOptions = { includeDirs: false, exts: ['.ts', '.js', '.mjs'], skip: [/^\./, /\.d\.ts$/i, /\.(test|spec|e2e)\.m?(j|t)sx?$/i] }
+ const apiDir = path.join(this.srcDir, 'api')
+ const pagesDir = path.join(this.srcDir, 'pages')
+
+ if (!(existsDirSync(pagesDir))) {
+ log.fatal(`'pages' directory not found.`)
+ }
+
+ const p = Deno.run({
+ cmd: ['deno', 'info'],
+ stdout: 'piped',
+ stderr: 'null'
+ })
+ this.#denoCacheDir = (new TextDecoder).decode(await p.output()).split('"')[1]
+ p.close()
+ if (!existsDirSync(this.#denoCacheDir)) {
+ log.fatal('invalid deno cache dir')
+ }
+
+ if (reload) {
+ this.#reloading = true
+ if (existsDirSync(this.buildDir)) {
+ await Deno.remove(this.buildDir, { recursive: true })
+ }
+ await ensureDir(this.buildDir)
+ }
+
+ log.info(colors.bold(`Aleph.js v${VERSION}`))
+ log.info(colors.bold('- Global'))
+ await this._loadConfig()
+
+ // change current work dir to appDoot
+ Deno.chdir(this.appRoot)
+
+ // inject env variables
+ Object.entries({
+ ...this.config.env,
+ __version: VERSION,
+ __buildMode: this.mode,
+ }).forEach(([key, value]) => Deno.env.set(key, value))
+
+ // add react refresh helpers for ssr
+ if (this.isDev) {
+ Object.assign(globalThis, {
+ $RefreshReg$: () => { },
+ $RefreshSig$: () => (type: any) => type,
+ })
+ }
+
+ // check custom components
+ for await (const { path: p, } of walk(this.srcDir, { ...walkOptions, maxDepth: 1, exts: [...walkOptions.exts, '.tsx', '.jsx'] })) {
+ const name = path.basename(p)
+ let isCustom = true
+ switch (name.replace(reModuleExt, '')) {
+ case 'app':
+ log.info(' ✓', 'Custom App')
+ break
+ case '404':
+ log.info(' ✓', 'Custom 404 Page')
+ break
+ case 'loading':
+ log.info(' ✓', 'Custom Loading Page')
+ break
+ default:
+ isCustom = false
+ break
+ }
+ if (isCustom) {
+ await this._compile('/' + name)
+ }
+ }
+
+ // create api routing
+ if (existsDirSync(apiDir)) {
+ for await (const { path: p } of walk(apiDir, walkOptions)) {
+ const mod = await this._compile(util.cleanPath('/api/' + util.trimPrefix(p, apiDir)))
+ this.#apiRouting.update(this._getRouteModule(mod))
+ }
+ }
+
+ // create page routing
+ for await (const { path: p } of walk(pagesDir, { ...walkOptions, exts: [...walkOptions.exts, '.tsx', '.jsx', '.md'] })) {
+ const mod = await this._compile(util.cleanPath('/pages/' + util.trimPrefix(p, pagesDir)))
+ this.#pageRouting.update(this._getRouteModule(mod))
+ }
+
+ // create main module
+ await this._createMainModule()
+
+ // pre-compile some modules
+ if (this.isDev) {
+ for (const mod of [
+ 'hmr.ts',
+ 'nomodule.ts',
+ ]) {
+ await this._compile(`${alephPkgUrl}/framework/core/${mod}`)
+ }
+ }
+
+ // import renderer
+ const rendererUrl = `${alephPkgUrl}/framework/${this.config.framework}/renderer.ts`
+ await this._compile(rendererUrl)
+ const { renderPage } = await import('file://' + this.#modules.get(rendererUrl)!.jsFile)
+ this.#renderer = { renderPage }
+
+
+ // reload end
+ if (reload) {
+ this.#reloading = false
+ }
+
+ if (this.isDev) {
+ if (this.#apiRouting.paths.length > 0) {
+ log.info(colors.bold('- APIs'))
+ }
+ for (const path of this.#apiRouting.paths) {
+ log.info(' λ', path)
+ }
+ log.info(colors.bold('- Pages'))
+ for (const path of this.#pageRouting.paths) {
+ const isIndex = path == '/'
+ log.info(' ○', path, isIndex ? colors.dim('(index)') : '')
+ }
+ }
+
+ log.debug('init project in ' + Math.round(performance.now() - t) + 'ms')
+
+ if (this.isDev) {
+ this._watch()
+ }
+ }
+
+ /** watch file changes, re-compile modules and send HMR signal. */
+ private async _watch() {
+ const w = Deno.watchFs(this.srcDir, { recursive: true })
+ log.info('Start watching code changes...')
+ for await (const event of w) {
+ for (const p of event.paths) {
+ const path = util.cleanPath(util.trimPrefix(p, this.srcDir))
+ // handle `api` dir remove directly
+ const validated = (() => {
+ // ignore `.aleph` and output directories
+ if (path.startsWith('/.aleph/') || path.startsWith(this.config.outputDir)) {
+ return false
+ }
+ if (reModuleExt.test(path)) {
+ switch (path.replace(reModuleExt, '')) {
+ case '/404':
+ case '/app':
+ return true
+ default:
+ if (path.startsWith('/api/')) {
+ return true
+ }
+ }
+ }
+ if (path.startsWith('/pages/') && (reModuleExt.test(path) || reMDExt.test(path))) {
+ return true
+ }
+ let isDep = false
+ for (const { deps } of this.#modules.values()) {
+ if (deps.findIndex(dep => dep.url === path) > -1) {
+ isDep = true
+ break
+ }
+ }
+ if (isDep) {
+ return true
+ }
+ return this.config.plugins.findIndex(p => p.test.test(path)) > -1
+ })()
+ if (validated) {
+ util.debounceX(path, () => {
+ const shouldUpdateMainModule = (() => {
+ switch (path.replace(reModuleExt, '')) {
+ case '/404':
+ case '/app':
+ return true
+ default:
+ if (path.startsWith('/pages/')) {
+ return true
+ }
+ return false
+ }
+ })()
+ if (existsFileSync(p)) {
+ let type = 'modify'
+ if (!this.#modules.has(path)) {
+ type = 'add'
+ }
+ log.info(type, path)
+ this._compile(path, { forceCompile: true }).then(mod => {
+ const hmrable = this.isHMRable(mod.url)
+ if (hmrable) {
+ if (type === 'add') {
+ this.#fsWatchListeners.forEach(e => e.emit('add', mod.url, mod.hash))
+ } else {
+ this.#fsWatchListeners.forEach(e => e.emit('modify-' + mod.url, mod.hash))
+ }
+ }
+ if (path.replace(reModuleExt, '') === '/app') {
+ this.#rendered.clear()
+ } else if (path.startsWith('/pages/')) {
+ this.#rendered.delete(getPagePath(path))
+ this.#pageRouting.update(this._getRouteModule(mod))
+ } else if (path.startsWith('/api/')) {
+ this.#apiRouting.update(this._getRouteModule(mod))
+ }
+ if (shouldUpdateMainModule) {
+ this._createMainModule()
+ }
+ this._updateHash(path, mod.hash, ({ url, hash }) => {
+ if (url.startsWith('/pages/')) {
+ this.#rendered.delete(getPagePath(url))
+ }
+ if (!hmrable && this.isHMRable(url)) {
+ this.#fsWatchListeners.forEach(e => e.emit('modify-' + url, hash))
+ }
+ })
+ }).catch(err => {
+ log.error(`compile(${path}):`, err.message)
+ })
+ } else if (this.#modules.has(path)) {
+ if (path.replace(reModuleExt, '') === '/app') {
+ this.#rendered.clear()
+ } else if (path.startsWith('/pages/')) {
+ this.#rendered.delete(getPagePath(path))
+ this.#pageRouting.removeRoute(path)
+ } else if (path.startsWith('/api/')) {
+ this.#apiRouting.removeRoute(path)
+ }
+ if (shouldUpdateMainModule) {
+ this._createMainModule()
+ }
+ this.#modules.delete(path)
+ if (this.isHMRable(path)) {
+ this.#fsWatchListeners.forEach(e => e.emit('remove', path))
+ }
+ log.info('remove', path)
+ }
+ }, 150)
+ }
+ }
+ }
+ }
+
+ /** returns the route module by given module url and hash. */
+ private _getRouteModule({ url, hash }: Module): RouteModule {
+ const deps = this._lookupDeps(url).filter(({ isData }) => !!isData)
+ return { url, hash, deps: deps.length > 0 ? deps : undefined }
+ }
+
+ /** create re-compiled main module. */
+ private async _createMainModule(): Promise {
+ const alephPkgUrl = getAlephPkgUrl()
+ const { baseUrl, defaultLocale, framework } = this.config
+ const config: Record = {
+ baseUrl,
+ defaultLocale,
+ locales: [],
+ routes: this.#pageRouting.routes,
+ preloadModules: Array.from(this.#modules.keys())
+ .filter(url => {
+ const name = url.replace(reModuleExt, '')
+ return name == '/404' || name == '/app'
+ })
+ .map(url => this._getRouteModule(this.#modules.get(url)!)),
+ renderMode: this.config.ssr ? 'ssr' : 'spa'
+ }
+ const sourceCode = [
+ (this.config.framework === 'react' && this.isDev) && `import "${alephPkgUrl}/framework/react/refresh.ts"`,
+ `import bootstrap from "${alephPkgUrl}/framework/${framework}/bootstrap.ts"`,
+ `bootstrap(${JSON.stringify(config)})`
+ ].filter(Boolean).join('\n')
+ await this._compile('/main.ts', { sourceCode })
+ if (!this.isDev) {
+ await this._compile('/main.ts', { sourceCode, bundleMode: true })
+ }
+ }
+
+ /** preprocess css with postcss plugins */
+ private async _preprocessCSS(sourceCode: string, isLess: boolean) {
+ let css: string = sourceCode
+ if (isLess) {
+ try {
+ const output = await less.render(sourceCode || '/* empty content */')
+ css = output.css
+ } catch (error) {
+ throw new Error(`less: ${error}`)
+ }
+ }
+
+
+ if (this.#postcssReady === null) {
+ this.#postcssReady = Promise.all(this.config.postcss.plugins.map(async p => {
+ let name: string | null = null
+ if (util.isNEString(p)) {
+ name = p
+ } else if (Array.isArray(p) && util.isNEString(p[0])) {
+ name = p[0]
+ }
+ if (name) {
+ const { default: Plugin } = await import(`https://esm.sh/${name}?external=postcss@8.1.4&no-check`)
+ this.#postcssPlugins[name] = Plugin
+ }
+ }))
+ }
+ await this.#postcssReady
+ css = (await postcss(this.config.postcss.plugins.map(p => {
+ if (typeof p === 'string') {
+ return this.#postcssPlugins[p]
+ } else if (Array.isArray(p)) {
+ const [plugin, options] = p
+ if (util.isNEString(plugin)) {
+ const _plugin = this.#postcssPlugins[plugin]
+ if (util.isFunction(_plugin)) {
+ let fn = _plugin as Function
+ return fn(options)
+ } else {
+ return plugin
+ }
+ } else {
+ plugin(options)
+ }
+ } else {
+ return p
+ }
+ })).process(css).async()).content
+ if (!this.isDev) {
+ const output = this.#cleanCSS.minify(css)
+ css = output.styles
+ }
+ return css
+ }
+
+ /** transpile code without types checking. */
+ private async _transpile(sourceCode: string, options: TransformOptions) {
+ let t: number | null = null
+ if (this.#swcReady === null) {
+ t = performance.now()
+ this.#swcReady = initWasm(this.#denoCacheDir)
+ }
+ await this.#swcReady
+ if (t) {
+ log.debug('init compiler wasm in ' + Math.round(performance.now() - t) + 'ms')
+ }
+
+ return transpileSync(sourceCode, options)
+ }
+
+ /** download and compile a moudle by given url, then cache on the disk. */
+ private async _compile(
+ url: string,
+ options?: {
+ sourceCode?: string,
+ sourceHash?: string,
+ loader?: string,
+ forceCompile?: boolean,
+ bundleMode?: boolean,
+ bundledPaths?: string[]
+ }
+ ): Promise {
+ const alephPkgUrl = getAlephPkgUrl()
+ const isRemote = reHttp.test(url)
+ const localUrl = fixImportUrl(url)
+ const name = path.basename(localUrl).replace(reModuleExt, '')
+ const saveDir = path.join(this.buildDir, path.dirname(localUrl))
+ const metaFile = path.join(saveDir, `${name}.meta.json`)
+
+ let mod: Module
+ if (this.#modules.has(url)) {
+ mod = this.#modules.get(url)!
+ if (!options?.forceCompile && !options?.sourceCode && !(options?.bundleMode && mod.bundlingFile === '')) {
+ return mod
+ }
+ } else {
+ mod = newModule(url)
+ try {
+ if (existsFileSync(metaFile)) {
+ const { url, sourceHash, hash, deps } = JSON.parse(await Deno.readTextFile(metaFile))
+ if (url === url && util.isNEString(sourceHash) && util.isNEString(hash) && util.isArray(deps)) {
+ mod.sourceHash = sourceHash
+ mod.hash = hash
+ mod.deps = deps
+ } else {
+ log.warn(`invalid metadata ('${name}.meta.json')`)
+ Deno.remove(metaFile)
+ }
+ }
+ } catch (e) { }
+ }
+
+ let sourceContent = new Uint8Array()
+ let shouldCompile = false
+ let fsync = false
+ let jsContent = ''
+ let jsMap: string | null = null
+
+ if (options?.sourceCode) {
+ sourceContent = (new TextEncoder).encode(options.sourceCode)
+ const sourceHash = options?.sourceHash || (new Sha1).update(sourceContent).hex()
+ if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
+ mod.sourceHash = sourceHash
+ shouldCompile = true
+ }
+ } else if (isRemote) {
+ if (/^https?:\/\/localhost(:\d+)?\//.test(url)) {
+ try {
+ const content = await fetch(url).then(resp => resp.text())
+ sourceContent = (new TextEncoder).encode(content)
+ const sourceHash = (new Sha1).update(sourceContent).hex()
+ if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
+ mod.sourceHash = sourceHash
+ shouldCompile = true
+ }
+ } catch (err) {
+ throw new Error(`Download ${url}: ${err.message}`)
+ }
+ } else {
+ try {
+ sourceContent = await this._loadDependency(url)
+ const sourceHash = (new Sha1).update(sourceContent).hex()
+ if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
+ mod.sourceHash = sourceHash
+ shouldCompile = true
+ }
+ } catch (err) {
+ log.error(`dependency '${url}' not found`)
+ mod.error = err
+ return mod
+ }
+ }
+ } else {
+ const filepath = path.join(this.srcDir, url)
+ try {
+ sourceContent = await Deno.readFile(filepath)
+ const sourceHash = (new Sha1).update(sourceContent).hex()
+ if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) {
+ mod.sourceHash = sourceHash
+ shouldCompile = true
+ }
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ log.error(`module '${filepath}' not found`)
+ mod.error = err
+ return mod
+ }
+ throw err
+ }
+ }
+
+ // check previous compile output
+ if (!shouldCompile) {
+ if (!options?.bundleMode) {
+ let jsFile = path.join(saveDir, name + (isRemote ? '' : `.${mod.hash.slice(0, hashShort)}`) + '.js')
+ if (existsFileSync(jsFile)) {
+ mod.jsFile = jsFile
+ } else {
+ shouldCompile = true
+ }
+ } else {
+ let bundlingFile = path.join(saveDir, `${name}.bundling.${mod.sourceHash.slice(0, hashShort)}.js`)
+ if (existsFileSync(bundlingFile)) {
+ mod.bundlingFile = bundlingFile
+ } else {
+ shouldCompile = true
+ }
+ }
+ }
+
+ // compile source code
+ if (shouldCompile) {
+ let sourceCode = (new TextDecoder).decode(sourceContent)
+ let loader = mod.loader
+ if (!!options?.loader) {
+ loader = options.loader
+ } else {
+ for (const plugin of this.config.plugins) {
+ if (plugin.test.test(url) && util.isFunction(plugin.transform)) {
+ const { code, loader: pluginLodaer = 'js' } = await plugin.transform(sourceContent, url)
+ sourceCode = code
+ loader = pluginLodaer
+ mod.loader = pluginLodaer
+ break
+ }
+ }
+ }
+
+ if (loader === 'css') {
+ const css = await this._preprocessCSS(sourceCode, url.endsWith('.less'))
+ return await this._compile(url, {
+ ...options,
+ loader: 'js',
+ sourceCode: (`
+ import { applyCSS } from "${alephPkgUrl}/framework/${this.config.framework}/style.ts";\
+ applyCSS(${JSON.stringify(url)}, ${JSON.stringify(this.isDev ? `\n${css.trim()}\n` : css)});
+ `).replaceAll(' '.repeat(24), '').trim(),
+ sourceHash: ''
+ })
+ } else if (loader === 'markdown') {
+ const { __content, ...props } = safeLoadFront(sourceCode)
+ const html = marked.parse(__content)
+ return await this._compile(url, {
+ ...options,
+ loader: 'js',
+ sourceCode: (`
+ import React, { useEffect, useRef } from "https://esm.sh/react@${JSON.stringify(this.config.reactVersion)}";
+ import { redirect } from "${alephPkgUrl}/framework/${this.config.framework}/anchor.ts";
+ export default function MarkdownPage() {
+ const ref = useRef(null);
+ useEffect(() => {
+ const anchors = [];
+ const onClick = e => {
+ e.preventDefault();
+ redirect(e.currentTarget.getAttribute("href"));
+ };
+ if (ref.current) {
+ ref.current.querySelectorAll("a").forEach(a => {
+ const href = a.getAttribute("href");
+ if (href && !/^[a-z0-9]+:/i.test(href)) {
+ a.addEventListener("click", onClick, false);
+ anchors.push(a);
+ }
+ });
+ }
+ return () => anchors.forEach(a => a.removeEventListener("click", onClick));
+ }, []);
+ return React.createElement("div", {
+ className: "markdown-page",
+ ref,
+ dangerouslySetInnerHTML: {__html: ${JSON.stringify(html)}}
+ });
+ }
+ MarkdownPage.meta = ${JSON.stringify(props, undefined, 4)};
+ `).replaceAll(' '.repeat(24), '').trim(),
+ sourceHash: ''
+ })
+ } else if (loader === 'js' || loader === 'ts' || loader === 'jsx' || loader === 'tsx') {
+ const t = performance.now()
+ const swcOptions: SWCOptions = {
+ target: 'es2020',
+ sourceType: loader,
+ sourceMap: true,
+ }
+ const { code, map, deps, inlineStyles } = await this._transpile(sourceCode, {
+ url,
+ swcOptions,
+ importMap: this.importMap,
+ reactVersion: this.config.reactVersion,
+ isDev: this.isDev,
+ bundleMode: options?.bundleMode,
+ bundledPaths: options?.bundledPaths
+ })
+
+ jsContent = code
+ jsMap = map!
+
+ await Promise.all(Object.entries(inlineStyles).map(async ([key, style]) => {
+ let type = style.type
+ let tpl = style.quasis.reduce((css, quais, i, a) => {
+ css += quais
+ if (i < a.length - 1) {
+ css += `%%aleph-inline-style-expr-${i}%%`
+ }
+ return css
+ }, '')
+ .replace(/\:\s*%%aleph-inline-style-expr-(\d+)%%/g, (_, id) => `: var(--aleph-inline-style-expr-${id})`)
+ .replace(/%%aleph-inline-style-expr-(\d+)%%/g, (_, id) => `/*%%aleph-inline-style-expr-${id}%%*/`)
+ if (type !== 'css' && type !== 'less') {
+ for (const plugin of this.config.plugins) {
+ if (plugin.test.test(`${key}.${type}`) && util.isFunction(plugin.transform)) {
+ const { code, loader } = await plugin.transform((new TextEncoder).encode(tpl), url)
+ if (loader === 'css') {
+ tpl = code
+ type = 'css'
+ }
+ break
+ }
+ }
+ }
+ if (type === 'css' || type === 'less') {
+ tpl = await this._preprocessCSS(tpl, type === 'less')
+ tpl = tpl.replace(
+ /\: var\(--aleph-inline-style-expr-(\d+)\)/g,
+ (_, id) => ': ${' + style.exprs[parseInt(id)] + '}'
+ ).replace(
+ /\/\*%%aleph-inline-style-expr-(\d+)%%\*\//g,
+ (_, id) => '${' + style.exprs[parseInt(id)] + '}'
+ )
+ jsContent = jsContent.replace(`"%%${key}-placeholder%%"`, '`' + tpl + '`')
+ }
+ }))
+
+ mod.deps = deps.map(({ specifier, isDynamic }) => {
+ const dep: DependencyDescriptor = { url: specifier, hash: '' }
+ if (isDynamic) {
+ dep.isDynamic = true
+ }
+ if (dep.url.startsWith('#useDeno-')) {
+ dep.isData = true
+ dep.hash = util.trimPrefix(dep.url, '#useDeno-')
+ }
+ if (dep.url.startsWith('#inline-style-')) {
+ dep.isStyle = true
+ dep.hash = util.trimPrefix(dep.url, '#inline-style-')
+ }
+ return dep
+ })
+
+ fsync = true
+
+ log.debug(`compile '${url}' in ${Math.round(performance.now() - t)}ms ${!!options?.bundleMode ? '(bundle mode)' : ''}`)
+ } else {
+ throw new Error(`Unknown loader '${loader}'`)
+ }
+ }
+
+ // compile deps
+ const deps = mod.deps.filter(({ url }) => {
+ return !url.startsWith('#') && (!options?.bundleMode || (!reHttp.test(url) && !options?.bundledPaths?.includes(url)))
+ })
+ for (const dep of deps) {
+ const depMod = await this._compile(dep.url, { bundleMode: options?.bundleMode, bundledPaths: options?.bundledPaths })
+ if (depMod.loader === 'css' && !dep.isStyle) {
+ dep.isStyle = true
+ }
+ if (dep.hash === '' || dep.hash !== depMod.hash) {
+ dep.hash = depMod.hash
+ if (!reHttp.test(dep.url)) {
+ const depImportPath = getRelativePath(
+ path.dirname(url),
+ dep.url.replace(reModuleExt, '')
+ )
+ if (!shouldCompile) {
+ jsContent = await Deno.readTextFile(mod.jsFile)
+ }
+ jsContent = jsContent.replace(reHashResolve, (s, key, spaces, ql, importPath, qr) => {
+ if (importPath.slice(0, - (hashShort + 4)) === depImportPath) {
+ if (!options?.bundleMode) {
+ return `${key}${spaces}${ql}${depImportPath}.${dep.hash.slice(0, hashShort)}.js${qr}`
+ } else {
+ return `${key}${spaces}${ql}${depImportPath}.bundling.${depMod.sourceHash.slice(0, hashShort)}.js${qr}`
+ }
+ }
+ return s
+ })
+ if (!fsync) {
+ fsync = true
+ }
+ }
+ }
+ }
+
+ if (fsync) {
+ if (!options?.bundleMode) {
+ mod.hash = (new Sha1).update(jsContent).hex()
+ mod.jsFile = path.join(saveDir, name + (isRemote ? '' : `.${mod.hash.slice(0, hashShort)}`) + '.js')
+ await cleanupCompilation(mod.jsFile)
+ await Promise.all([
+ ensureTextFile(mod.jsFile, jsContent),
+ jsMap ? ensureTextFile(mod.jsFile + '.map', jsMap) : Promise.resolve(),
+ ensureTextFile(metaFile, JSON.stringify({
+ url,
+ sourceHash: mod.sourceHash,
+ hash: mod.hash,
+ deps: mod.deps,
+ }, undefined, 4)),
+ ])
+ } else {
+ mod.bundlingFile = path.join(saveDir, `${name}.bundling.${mod.sourceHash.slice(0, hashShort)}.js`)
+ await cleanupCompilation(mod.bundlingFile)
+ await Promise.all([
+ await ensureTextFile(mod.bundlingFile, jsContent),
+ await ensureTextFile(metaFile, JSON.stringify({
+ url,
+ sourceHash: mod.sourceHash,
+ hash: mod.hash || mod.sourceHash,
+ deps: mod.deps,
+ }, undefined, 4))
+ ])
+ }
+ }
+
+ if (!this.#modules.has(url)) {
+ this.#modules.set(url, mod)
+ }
+
+ return mod
+ }
+
+ /** update module hash since the dependency changed. */
+ private _updateHash(depUrl: string, depHash: string, callback: (mod: Module) => void) {
+ this.#modules.forEach(mod => {
+ for (const dep of mod.deps) {
+ if (dep.url === depUrl) {
+ if (dep.hash !== depHash) {
+ dep.hash = depHash
+ if (mod.url === '/main.ts') {
+ this._createMainModule()
+ } else {
+ const depImportPath = getRelativePath(
+ path.dirname(mod.url),
+ dep.url.replace(reModuleExt, '')
+ )
+ Deno.readTextFile(mod.jsFile).then(jsContent => {
+ jsContent = jsContent.replace(reHashResolve, (s, key, spaces, ql, importPath, qr) => {
+ if (importPath.slice(0, - (hashShort + 4)) === depImportPath) {
+ return `${key}${spaces}${ql}${depImportPath}.${dep.hash.slice(0, hashShort)}.js${qr}`
+ }
+ return s
+ })
+ mod.hash = (new Sha1).update(jsContent).hex()
+ mod.jsFile = `${mod.jsFile.replace(reHashJs, '')}.${mod.hash.slice(0, hashShort)}.js`
+ cleanupCompilation(mod.jsFile).then(() => {
+ Promise.all([
+ ensureTextFile(mod.jsFile.replace(reHashJs, '') + '.meta.json', JSON.stringify({
+ url: mod.url,
+ sourceHash: mod.sourceHash,
+ hash: mod.hash,
+ deps: mod.deps,
+ }, undefined, 4)),
+ ensureTextFile(mod.jsFile, jsContent)
+ ])
+ })
+ })
+ }
+ callback(mod)
+ log.debug('update dependency:', mod.url, '<-', depUrl)
+ this._updateHash(mod.url, mod.hash, callback)
+ }
+ break
+ }
+ }
+ })
+ }
+
+ /** load dependency conentent, use deno builtin cache system */
+ private async _loadDependency(url: string): Promise {
+ const u = new URL(url)
+ if (url.startsWith('https://esm.sh/')) {
+ if (this.isDev && !u.searchParams.has('dev')) {
+ u.searchParams.set('dev', '')
+ }
+ u.search = u.search.replace(/\=(&|$)/, '$1')
+ }
+
+ const { protocol, hostname, port, pathname, search } = u
+ const versioned = reFullVersion.test(pathname)
+ const dir = path.join(this.#denoCacheDir, 'deps', util.trimSuffix(protocol, ':'), hostname + (port ? '_PORT' + port : ''))
+ const filename = path.join(dir, (new Sha256()).update(pathname + search).hex())
+
+ if (versioned && !this.#reloading && existsFileSync(filename)) {
+ return await Deno.readFile(filename)
+ }
+
+ const p = Deno.run({
+ cmd: [
+ 'deno',
+ 'cache',
+ this.#reloading || !versioned ? '--reload' : '',
+ u.toString()
+ ].filter(Boolean),
+ stdout: 'piped',
+ stderr: 'piped'
+ })
+ await Deno.stderr.write(await p.output())
+ await Deno.stderr.write(await p.stderrOutput())
+ p.close()
+
+ if (existsFileSync(filename)) {
+ return await Deno.readFile(filename)
+ } else {
+ throw new Error(`not found`)
+ }
+ }
+
+ /** bundle modules for production. */
+ private async _bundle() {
+ const alephPkgUrl = getAlephPkgUrl()
+ const refCounter = new Map()
+ const lookup = (url: string) => {
+ if (this.#modules.has(url)) {
+ const { deps } = this.#modules.get(url)!
+ deps.forEach(({ url }) => {
+ if (!refCounter.has(url)) {
+ refCounter.set(url, 1)
+ } else {
+ refCounter.set(url, refCounter.get(url)! + 1)
+ }
+ })
+ }
+ }
+ const appModule = Array.from(this.#modules.keys())
+ .filter(url => url.replace(reModuleExt, '') === '/app')
+ .map(url => this.#modules.get(url))[0]
+ const e404Module = Array.from(this.#modules.keys())
+ .filter(url => url.replace(reModuleExt, '') === '/404')
+ .map(url => this.#modules.get(url))[0]
+ const pageModules: Module[] = []
+
+ lookup('/main.ts')
+ if (appModule) {
+ await this._compile(appModule.url, { bundleMode: true })
+ lookup(appModule.url)
+ }
+ if (e404Module) {
+ await this._compile(e404Module.url, { bundleMode: true })
+ lookup(e404Module.url)
+ }
+ this.#pageRouting.lookup(routes => routes.forEach(({ module: { url } }) => {
+ const mod = this.getModule(url)
+ if (mod) {
+ lookup(url)
+ pageModules.push(mod)
+ }
+ }))
+
+ const remoteDepList: string[] = []
+ const localDepList: string[] = []
+ Array.from(refCounter.entries()).forEach(([url, count]) => {
+ if (reHttp.test(url)) {
+ remoteDepList.push(url)
+ } else if (!url.startsWith('#') && !url.startsWith('/pages/') && count > 1) {
+ localDepList.push(url)
+ }
+ })
+ if (appModule) {
+ localDepList.push(appModule.url)
+ }
+ if (e404Module) {
+ localDepList.push(e404Module.url)
+ }
+
+ log.info('- Bundle')
+ await this._createChunkBundle('deps', remoteDepList)
+ if (localDepList.length > 0) {
+ await this._createChunkBundle('shared', localDepList)
+ }
+
+ // copy main module
+ const mainModule = this.getModule('/main.ts')!
+ const mainJSFile = path.join(this.outputDir, '_aleph', `main.${mainModule.sourceHash.slice(0, hashShort)}.js`)
+ const mainJSConent = await Deno.readTextFile(mainModule.bundlingFile)
+ await Deno.writeTextFile(mainJSFile, mainJSConent)
+
+ // create and copy polyfill
+ const polyfillMode = newModule('/polyfill.js')
+ polyfillMode.hash = polyfillMode.sourceHash = (new Sha1).update(AlephRuntimeCode).update(`${this.config.buildTarget}-${VERSION}`).hex()
+ const polyfillFile = path.join(this.buildDir, `polyfill.${polyfillMode.hash.slice(0, hashShort)}.js`)
+ if (!existsFileSync(polyfillFile)) {
+ const rawPolyfillFile = `${alephPkgUrl}/compiler/polyfills/${this.config.buildTarget}/polyfill.js`
+ await this._runDenoBundle(rawPolyfillFile, polyfillFile, AlephRuntimeCode, true)
+ }
+ Deno.copyFile(polyfillFile, path.join(this.outputDir, '_aleph', `polyfill.${polyfillMode.hash.slice(0, hashShort)}.js`))
+ this.#modules.set(polyfillMode.url, polyfillMode)
+
+ // bundle and copy page moudles
+ await Promise.all(pageModules.map(async mod => this._createPageBundle(mod, localDepList, AlephRuntimeCode)))
+ }
+
+ /** create chunk bundle. */
+ private async _createChunkBundle(name: string, list: string[], header = '') {
+ const imports = list.map((url, i) => {
+ const mod = this.#modules.get(url)
+ if (mod) {
+ return [
+ `import * as ${name}_mod_${i} from ${JSON.stringify(reHttp.test(mod.url) ? mod.jsFile : mod.bundlingFile)}`,
+ `__ALEPH.pack[${JSON.stringify(url)}] = ${name}_mod_${i}`
+ ]
+ }
+ }).flat().join('\n')
+ const bundlingCode = imports
+ const mod = newModule(`/${name}.bundling.js`)
+ mod.hash = (new Sha1).update(header).update(bundlingCode).hex()
+ const bundlingFile = path.join(this.buildDir, mod.url)
+ const bundleFile = path.join(this.buildDir, `${name}.bundle.${mod.hash.slice(0, hashShort)}.js`)
+ const saveAs = path.join(this.outputDir, `_aleph/${name}.${mod.hash.slice(0, hashShort)}.js`)
+
+ if (existsFileSync(bundleFile)) {
+ this.#modules.set(mod.url, mod)
+ await Deno.copyFile(bundleFile, saveAs)
+ return
+ }
+
+ await Deno.writeTextFile(bundlingFile, bundlingCode)
+ const n = await this._runDenoBundle(bundlingFile, bundleFile, header)
+ if (n > 0) {
+ log.info(` {} ${name}.js ${colors.dim('• ' + util.bytesString(n))}`)
+ }
+
+ this.#modules.set(mod.url, mod)
+ await Deno.copyFile(bundleFile, saveAs)
+ Deno.remove(bundlingFile)
+ }
+
+ /** create page bundle. */
+ private async _createPageBundle(mod: Module, bundledPaths: string[], header = '') {
+ const { bundlingFile, hash } = await this._compile(mod.url, { bundleMode: true, bundledPaths })
+ const _tmp = util.trimSuffix(bundlingFile.replace(reHashJs, ''), '.bundling')
+ const _tmp_bundlingFile = _tmp + `.bundling.js`
+ const bundleFile = _tmp + `.bundle.${hash.slice(0, hashShort)}.js`
+ const saveAs = path.join(this.outputDir, `/_aleph/`, util.trimPrefix(_tmp, this.buildDir) + `.${hash.slice(0, hashShort)}.js`)
+
+ if (existsFileSync(bundleFile)) {
+ await ensureDir(path.dirname(saveAs))
+ await Deno.copyFile(bundleFile, saveAs)
+ return
+ }
+
+ const bundlingCode = [
+ `import * as mod from ${JSON.stringify(bundlingFile)}`,
+ `__ALEPH.pack[${JSON.stringify(mod.url)}] = mod`
+ ].join('\n')
+ await Deno.writeTextFile(_tmp_bundlingFile, bundlingCode)
+ await this._runDenoBundle(_tmp_bundlingFile, bundleFile, header)
+ await ensureDir(path.dirname(saveAs))
+ await Deno.copyFile(bundleFile, saveAs)
+ Deno.remove(_tmp_bundlingFile)
+ }
+
+ /** run deno bundle and compess the output with terser. */
+ private async _runDenoBundle(bundlingFile: string, bundleFile: string, header = '', reload = false) {
+ const p = Deno.run({
+ cmd: ['deno', 'bundle', '--no-check', reload ? '--reload' : '', bundlingFile, bundleFile].filter(Boolean),
+ stdout: 'null',
+ stderr: 'piped'
+ })
+ const data = await p.stderrOutput()
+ p.close()
+ if (!existsFileSync(bundleFile)) {
+ const msg = (new TextDecoder).decode(data).replaceAll('file://', '').replaceAll(this.buildDir, '/aleph.js')
+ await Deno.stderr.write((new TextEncoder).encode(msg))
+ Deno.exit(1)
+ }
+
+ // transpile bundle code to `buildTarget`
+ let { code } = await this._transpile(await Deno.readTextFile(bundleFile), {
+ url: '/bundle.js',
+ swcOptions: {
+ target: this.config.buildTarget
+ },
+ })
+
+ // workaround for https://github.com/denoland/deno/issues/9212
+ code = code.replace(' _ = l.baseState, ', ' var _ = l.baseState, ')
+
+ // IIFEify
+ code = [
+ '(() => {',
+ header,
+ code,
+ '})()'
+ ].join('\n')
+
+ // minify code
+ const ret = await minify(code, {
+ compress: true,
+ mangle: true,
+ ecma: parseInt(util.trimPrefix(this.config.buildTarget, 'es')) as ECMA,
+ sourceMap: false
+ })
+ if (ret.code) {
+ code = ret.code
+ }
+
+ await cleanupCompilation(bundleFile)
+ await Deno.writeTextFile(bundleFile, code)
+ return code.length
+ }
+
+ /** optimize images for production. */
+ private async _optimize() {
+
+ }
+
+ /** render all pages in routing. */
+ private async _ssg() {
+ const { ssr } = this.config
+ const outputDir = this.outputDir
+
+ if (ssr) {
+ log.info(colors.bold('- Pages (SSG)'))
+ const paths = new Set(this.#pageRouting.paths)
+ if (typeof ssr === 'object' && ssr.staticPaths) {
+ ssr.staticPaths.forEach(path => paths.add(path))
+ }
+ await Promise.all(Array.from(paths).map(async pathname => {
+ if (this.isSSRable(pathname)) {
+ const [status, html, data] = await this.getPageHtml({ pathname })
+ if (status == 200) {
+ const htmlFile = path.join(outputDir, pathname, 'index.html')
+ await ensureTextFile(htmlFile, html)
+ if (data) {
+ const dataFile = path.join(outputDir, '_aleph/data', (pathname === '/' ? 'index' : pathname) + '.json')
+ await ensureTextFile(dataFile, JSON.stringify(data))
+ }
+ log.info(' ○', pathname, colors.dim('• ' + util.bytesString(html.length)))
+ } else if (status == 404) {
+ log.info(' ○', colors.dim(pathname), colors.red('Page not found'))
+ } else if (status == 500) {
+ log.info(' ○', colors.dim(pathname), colors.red('Error 500'))
+ }
+ }
+ }))
+ const fallbackSpaHtmlFile = path.join(outputDir, util.isPlainObject(ssr) && ssr.fallback ? ssr.fallback : '_fallback_spa.html')
+ await ensureTextFile(fallbackSpaHtmlFile, await this.getSPAIndexHtml())
+ } else {
+ await ensureTextFile(path.join(outputDir, 'index.html'), await this.getSPAIndexHtml())
+ }
+
+ // write 404 page
+ const { url, head, scripts, body, data } = await this._render404Page()
+ const e404PageHtml = createHtml({
+ lang: url.locale,
+ head: head,
+ scripts: [
+ data ? { type: 'application/json', innerText: JSON.stringify(data), id: 'ssr-data' } : '',
+ ...this._getPreloadScripts(),
+ ...scripts
+ ],
+ body,
+ minify: !this.isDev
+ })
+ await ensureTextFile(path.join(outputDir, '404.html'), e404PageHtml)
+ if (data) {
+ const dataFile = path.join(outputDir, '_aleph/data/_404.json')
+ await ensureTextFile(dataFile, JSON.stringify(data))
+ }
+ }
+
+ /** render page base the given location. */
+ private async _renderPage(loc: { pathname: string, search?: string }) {
+ const start = performance.now()
+ const [url, pageModuleTree] = this.#pageRouting.createRouter(loc)
+ const key = [url.pathname, url.query.toString()].filter(Boolean).join('?')
+ if (url.pagePath !== '') {
+ if (this.#rendered.has(url.pagePath)) {
+ const cache = this.#rendered.get(url.pagePath)!
+ if (cache.has(key)) {
+ return cache.get(key)!
+ }
+ } else {
+ this.#rendered.set(url.pagePath, new Map())
+ }
+ }
+ const ret: RenderResult = {
+ url,
+ status: url.pagePath === '' ? 404 : 200,
+ head: [],
+ scripts: [],
+ body: ' ',
+ data: null,
+ }
+ if (ret.status === 404) {
+ if (this.isDev) {
+ log.warn(`page '${url.pathname}' not found`)
+ }
+ return await this._render404Page(url)
+ }
+ try {
+ const appModule = Array.from(this.#modules.keys())
+ .filter(url => url.replace(reModuleExt, '') == '/app')
+ .map(url => this.#modules.get(url))[0]
+ const { default: App } = appModule ? await import('file://' + appModule.jsFile) : {} as any
+ const pageComponentTree: { url: string, Component?: any }[] = pageModuleTree.map(({ url }) => ({ url }))
+ const imports = pageModuleTree.map(async ({ url }) => {
+ const mod = this.#modules.get(url)!
+ const { default: C } = await import('file://' + mod.jsFile)
+ const pc = pageComponentTree.find(pc => pc.url === mod.url)
+ if (pc) {
+ pc.Component = C
+ }
+ })
+ await Promise.all(imports)
+ const {
+ head,
+ body,
+ data,
+ scripts
+ } = await this.#renderer.renderPage(
+ url,
+ App,
+ undefined,
+ pageComponentTree,
+ [
+ appModule ? this._lookupDeps(appModule.url).filter(dep => !!dep.isStyle) : [],
+ ...pageModuleTree.map(({ url }) => this._lookupDeps(url).filter(dep => !!dep.isStyle)).flat()
+ ].flat()
+ )
+ ret.head = head
+ ret.scripts = await Promise.all(scripts.map(async (script: Record) => {
+ if (script.innerText && !this.isDev) {
+ return { ...script, innerText: (await minify(script.innerText)).code }
+ }
+ return script
+ }))
+ ret.body = `${body} `
+ ret.data = data
+ this.#rendered.get(url.pagePath)!.set(key, ret)
+ if (this.isDev) {
+ log.debug(`render '${url.pathname}' in ${Math.round(performance.now() - start)}ms`)
+ }
+ } catch (err) {
+ ret.status = 500
+ ret.head = ['Error 500 - Aleph.js ']
+ ret.body = `${colors.stripColor(err.stack)} `
+ log.error(err)
+ }
+ return ret
+ }
+
+ /** render custom 404 page. */
+ private async _render404Page(url: RouterURL = { locale: this.config.defaultLocale, pagePath: '', pathname: '/', params: {}, query: new URLSearchParams() }) {
+ const ret: RenderResult = { url, status: 404, head: [], scripts: [], body: ' ', data: null }
+ try {
+ const e404Module = Array.from(this.#modules.keys())
+ .filter(url => url.replace(reModuleExt, '') == '/404')
+ .map(url => this.#modules.get(url))[0]
+ const { default: E404 } = e404Module ? await import('file://' + e404Module.jsFile) : {} as any
+ const { head, body, data, scripts } = await this.#renderer.renderPage(
+ url,
+ undefined,
+ E404,
+ [],
+ e404Module ? this._lookupDeps(e404Module.url).filter(dep => !!dep.isStyle) : []
+ )
+ ret.head = head
+ ret.scripts = await Promise.all(scripts.map(async (script: Record) => {
+ if (script.innerText && !this.isDev) {
+ return { ...script, innerText: (await minify(script.innerText)).code }
+ }
+ return script
+ }))
+ ret.body = `${body} `
+ ret.data = data
+ } catch (err) {
+ ret.status = 500
+ ret.head = ['Error 500 - Aleph.js ']
+ ret.body = `${colors.stripColor(err.stack)} `
+ log.error(err)
+ }
+ return ret
+ }
+
+ /** render custom loading page for SPA mode. */
+ private async _renderLoadingPage() {
+ const loadingModule = Array.from(this.#modules.keys())
+ .filter(url => url.replace(reModuleExt, '') == '/loading')
+ .map(url => this.#modules.get(url))[0]
+ if (loadingModule) {
+ const { default: Loading } = await import('file://' + loadingModule.jsFile)
+ const router = {
+ locale: this.config.defaultLocale,
+ pagePath: '',
+ pathname: '/',
+ params: {},
+ query: new URLSearchParams()
+ }
+ const {
+ head,
+ body
+ } = await this.#renderer.renderPage(
+ router,
+ undefined,
+ undefined,
+ [{ url: loadingModule.url, Component: Loading }],
+ this._lookupDeps(loadingModule.url).filter(dep => !!dep.isStyle)
+ )
+ return {
+ head,
+ body: `${body} `
+ } as Pick
+ }
+ return null
+ }
+
+ private _lookupDeps(url: string, __deps: DependencyDescriptor[] = [], __tracing: Set = new Set()) {
+ const mod = this.getModule(url)
+ if (!mod) {
+ return __deps
+ }
+ if (__tracing.has(url)) {
+ return __deps
+ }
+ __tracing.add(url)
+ __deps.push(...mod.deps.filter(({ url }) => __deps.findIndex(i => i.url === url) === -1))
+ mod.deps.forEach(({ url }) => {
+ if (reModuleExt.test(url) && !reHttp.test(url)) {
+ this._lookupDeps(url, __deps, __tracing)
+ }
+ })
+ return __deps
+ }
+}
diff --git a/server.ts b/server/server.ts
similarity index 62%
rename from server.ts
rename to server/server.ts
index 6e7da259c..0a41fa9dc 100644
--- a/server.ts
+++ b/server/server.ts
@@ -1,11 +1,11 @@
+import { path, serve, ws } from '../deps.ts'
+import { hashShort, reHashJs, reModuleExt } from '../shared/constants.ts'
+import util from '../shared/util.ts'
import { Request } from './api.ts'
-import { existsFileSync } from './fs.ts'
-import { createHtml } from './html.ts'
import log from './log.ts'
import { getContentType } from './mime.ts'
-import { injectHmr, Project } from './project.ts'
-import { path, serve, ws } from './std.ts'
-import util, { hashShort } from './util.ts'
+import { Project } from './project.ts'
+import { createHtml, existsFileSync } from './util.ts'
export async function start(appDir: string, hostname: string, port: number, isDev = false, reload = false) {
const project = new Project(appDir, isDev ? 'development' : 'production', reload)
@@ -26,30 +26,30 @@ export async function start(appDir: string, hostname: string, port: number, isDe
const { conn, r: bufReader, w: bufWriter, headers } = req
ws.acceptWebSocket({ conn, bufReader, bufWriter, headers }).then(async socket => {
const watcher = project.createFSWatcher()
- watcher.on('add', (moduleId: string, hash: string) => socket.send(JSON.stringify({
+ watcher.on('add', (url: string, hash: string) => socket.send(JSON.stringify({
type: 'add',
- moduleId,
+ url,
hash
})))
- watcher.on('remove', (moduleId: string) => {
- watcher.removeAllListeners('modify-' + moduleId)
+ watcher.on('remove', (url: string) => {
+ watcher.removeAllListeners('modify-' + url)
socket.send(JSON.stringify({
type: 'remove',
- moduleId
+ url
}))
})
for await (const e of socket) {
if (util.isNEString(e)) {
try {
const data = JSON.parse(e)
- if (data.type === 'hotAccept' && util.isNEString(data.id)) {
- const mod = project.getModule(data.id)
+ if (data.type === 'hotAccept' && util.isNEString(data.url)) {
+ const mod = project.getModule(data.url)
if (mod) {
- watcher.on('modify-' + mod.id, (hash: string) => socket.send(JSON.stringify({
+ watcher.on('modify-' + mod.url, (hash: string) => socket.send(JSON.stringify({
type: 'update',
- moduleId: mod.id,
+ url: mod.url,
+ updateUrl: util.cleanPath(`${project.config.baseUrl}/_aleph/${mod.url.replace(reModuleExt, '')}.${hash!.slice(0, hashShort)}.js`),
hash,
- updateUrl: util.cleanPath(`${project.config.baseUrl}/_aleph/${mod.id.replace(/\.js$/, '')}.${hash!.slice(0, hashShort)}.js`)
})))
}
}
@@ -87,46 +87,53 @@ export async function start(appDir: string, hostname: string, port: number, isDe
// serve dist files
if (pathname.startsWith('/_aleph/')) {
- if (pathname.startsWith('/_aleph/data/') && pathname.endsWith('/data.js')) {
- const [p, s] = util.splitBy(util.trimSuffix(util.trimPrefix(pathname, '/_aleph/data'), '/data.js'), '@')
- const [status, data] = await project.getSSRData({ pathname: p, search: s })
+ if (pathname.startsWith('/_aleph/data/') && pathname.endsWith('.json')) {
+ let p = util.trimSuffix(util.trimPrefix(pathname, '/_aleph/data'), '.json')
+ if (p === '/index') {
+ p = '/'
+ }
+ const [status, data] = await project.getSSRData({ pathname: p })
if (status === 200) {
- resp.send(`export default ` + JSON.stringify(data), 'application/javascript; charset=utf-8')
+ resp.send(JSON.stringify(data), 'application/json; charset=utf-8')
} else {
resp.status(status).send('')
}
continue
- } else if (pathname.endsWith('.css')) {
- const filePath = path.join(project.buildDir, util.trimPrefix(pathname, '/_aleph/'))
- if (existsFileSync(filePath)) {
- const body = await Deno.readFile(filePath)
- resp.send(body, 'text/css; charset=utf-8')
- continue
- }
} else {
- const reqSourceMap = pathname.endsWith('.js.map')
- const mod = project.getModuleByPath(reqSourceMap ? pathname.slice(0, -4) : pathname)
- if (mod) {
- const etag = req.headers.get('If-None-Match')
- if (etag && etag === mod.hash) {
- resp.status(304).send('')
- continue
- }
-
- let body = ''
- if (reqSourceMap) {
- body = mod.jsSourceMap
- } else {
- body = mod.jsContent
- if (project.isHMRable(mod.id)) {
- body = injectHmr({ ...mod, jsContent: body })
+ const reqMap = pathname.endsWith('.js.map')
+ const fixedPath = util.trimPrefix(reqMap ? pathname.slice(0, -4) : pathname, '/_aleph/')
+ const metaFile = path.join(project.buildDir, util.trimSuffix(fixedPath.replace(reHashJs, ''), '.js') + '.meta.json')
+ if (existsFileSync(metaFile)) {
+ const { url } = JSON.parse(await Deno.readTextFile(metaFile))
+ const mod = project.getModule(url)
+ if (mod) {
+ const etag = req.headers.get('If-None-Match')
+ if (etag && etag === mod.hash) {
+ resp.status(304).send('')
+ continue
}
+ let body = ''
+ if (reqMap) {
+ if (existsFileSync(mod.jsFile + '.map')) {
+ body = await Deno.readTextFile(mod.jsFile + '.map')
+ } else {
+ resp.status(404).send('file not found')
+ continue
+ }
+ } else {
+ body = await Deno.readTextFile(mod.jsFile)
+ if (project.isHMRable(mod.url)) {
+ body = project.injectHmr(mod.url, body)
+ }
+ }
+ resp.setHeader('ETag', mod.hash)
+ resp.send(body, `application/${reqMap ? 'json' : 'javascript'}; charset=utf-8`)
+ continue
}
- resp.setHeader('ETag', mod.hash)
- resp.send(body, `application/${reqSourceMap ? 'json' : 'javascript'}; charset=utf-8`)
- continue
}
}
+ resp.status(404).send('file not found')
+ continue
}
// ssr
diff --git a/server/types.ts b/server/types.ts
new file mode 100644
index 000000000..93ade0187
--- /dev/null
+++ b/server/types.ts
@@ -0,0 +1,31 @@
+import type { RouterURL } from '../types.ts'
+
+export interface Module {
+ url: string
+ loader: string
+ sourceHash: string
+ hash: string
+ deps: DependencyDescriptor[]
+ jsFile: string
+ bundlingFile: string
+ error: Error | null
+}
+
+export interface DependencyDescriptor {
+ url: string
+ hash: string
+ isDynamic?: boolean
+ isStyle?: boolean
+ isData?: boolean
+}
+
+export type ImportMap = Record>
+
+export interface RenderResult {
+ url: RouterURL
+ status: number
+ head: string[]
+ scripts: Record[]
+ body: string
+ data: Record | null
+}
diff --git a/server/util.ts b/server/util.ts
new file mode 100644
index 000000000..a301ddacc
--- /dev/null
+++ b/server/util.ts
@@ -0,0 +1,311 @@
+import { colors, ensureDir, path } from '../deps.ts'
+import { MB, reHashJs, reHttp, reMDExt, reModuleExt, reStyleModuleExt } from '../shared/constants.ts'
+import util from '../shared/util.ts'
+import { VERSION } from '../version.ts'
+import { ImportMap, Module } from './types.ts'
+
+export const AlephRuntimeCode = `
+var __ALEPH = window.__ALEPH || (window.__ALEPH = {
+ pack: {},
+ exportFrom: function(specifier, url, exports) {
+ if (url in this.pack) {
+ var mod = this.pack[url]
+ if (!(specifier in this.pack)) {
+ this.pack[specifier] = {}
+ }
+ if (exports === '*') {
+ for (var k in mod) {
+ this.pack[specifier][k] = mod[k]
+ }
+ } else if (typeof exports === 'object' && exports !== null) {
+ for (var k in exports) {
+ this.pack[specifier][exports[k]] = mod[k]
+ }
+ }
+ }
+ },
+ require: function(name) {
+ switch (name) {
+ case 'regenerator-runtime':
+ return regeneratorRuntime
+ default:
+ throw new Error("module name is undefined")
+ }
+ },
+});
+`.replaceAll(' '.repeat(12), '')
+
+export function getAlephPkgUrl() {
+ let url = `https://deno.land/x/aleph@v${VERSION}`
+ const { __ALEPH_DEV_PORT: devPort } = globalThis as any
+ if (devPort) {
+ url = `http://localhost:${devPort}`
+ }
+ return url
+}
+
+/* check whether or not the given path exists as a directory */
+export async function existsDir(path: string) {
+ try {
+ const fi = await Deno.lstat(path)
+ if (fi.isDirectory) {
+ return true
+ }
+ return false
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return false
+ }
+ throw err
+ }
+}
+
+/* check whether or not the given path exists as a directory */
+export function existsDirSync(path: string) {
+ try {
+ const fi = Deno.lstatSync(path)
+ if (fi.isDirectory) {
+ return true
+ }
+ return false
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return false
+ }
+ throw err
+ }
+}
+
+/* check whether or not the given path exists as regular file */
+export async function existsFile(path: string) {
+ try {
+ const fi = await Deno.lstat(path)
+ if (fi.isFile) {
+ return true
+ }
+ return false
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return false
+ }
+ throw err
+ }
+}
+
+/* check whether or not the given path exists as regular file */
+export function existsFileSync(path: string) {
+ try {
+ const fi = Deno.lstatSync(path)
+ if (fi.isFile) {
+ return true
+ }
+ return false
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return false
+ }
+ throw err
+ }
+}
+
+/** ensure and write a text file */
+export async function ensureTextFile(name: string, content: string) {
+ const dir = path.dirname(name)
+ await ensureDir(dir)
+ await Deno.writeTextFile(name, content)
+}
+
+
+/** returns a module by given url. */
+export function newModule(url: string): Module {
+ const isRemote = reHttp.test(url)
+ let loader = ''
+ if (reStyleModuleExt.test(url)) {
+ loader = 'css'
+ } else if (reMDExt.test(url)) {
+ loader = 'markdown'
+ } else if (reModuleExt.test(url)) {
+ loader = url.split('.').pop()!
+ if (loader === 'mjs') {
+ loader = 'js'
+ }
+ } else if (isRemote) {
+ loader = 'js'
+ }
+ return {
+ url,
+ loader,
+ sourceHash: '',
+ hash: '',
+ deps: [],
+ jsFile: '',
+ bundlingFile: '',
+ error: null,
+ }
+}
+
+/** fix import map */
+export function fixImportMap(v: any) {
+ const imports: ImportMap = {}
+ if (util.isPlainObject(v)) {
+ Object.entries(v).forEach(([key, value]) => {
+ if (key == "" || key == "/") {
+ return
+ }
+ const isPrefix = key.endsWith('/')
+ const tmp: string[] = []
+ if (util.isNEString(value)) {
+ if (isPrefix && !value.endsWith('/')) {
+ return
+ }
+ tmp.push(value)
+ } else if (util.isNEArray(value)) {
+ value.forEach(v => {
+ if (util.isNEString(v)) {
+ if (isPrefix && !v.endsWith('/')) {
+ return
+ }
+ tmp.push(v)
+ }
+ })
+ }
+ imports[key] = tmp
+ })
+ }
+ return imports
+}
+
+/** get relative the path of `to` to `from` */
+export function getRelativePath(from: string, to: string): string {
+ let r = path.relative(from, to).split('\\').join('/')
+ if (!r.startsWith('.') && !r.startsWith('/')) {
+ r = './' + r
+ }
+ return r
+}
+
+/** fix import url */
+export function fixImportUrl(importUrl: string): string {
+ const isRemote = reHttp.test(importUrl)
+ const url = new URL(isRemote ? importUrl : 'file://' + importUrl)
+ let ext = path.extname(path.basename(url.pathname)) || '.js'
+ if (isRemote && !reModuleExt.test(ext) && !reStyleModuleExt.test(ext) && !reMDExt.test(ext)) {
+ ext = '.js'
+ }
+ let pathname = util.trimSuffix(url.pathname, ext)
+ let search = Array.from(url.searchParams.entries()).map(([key, value]) => value ? `${key}=${value}` : key)
+ if (search.length > 0) {
+ pathname += '_' + search.join(',')
+ }
+ if (isRemote) {
+ return [
+ '/-/',
+ (url.protocol === 'http:' ? 'http_' : ''),
+ url.hostname,
+ (url.port ? '_' + url.port : ''),
+ pathname,
+ ext
+ ].join('')
+ }
+ const result = pathname + ext
+ return !isRemote && importUrl.startsWith('/api/') ? decodeURI(result) : result
+}
+
+/**
+ * colorful the bytes string
+ * - dim: 0 - 1MB
+ * - yellow: 1MB - 10MB
+ * - red: > 10MB
+ */
+export function colorfulBytesString(bytes: number) {
+ let cf = colors.dim
+ if (bytes > 10 * MB) {
+ cf = colors.red
+ } else if (bytes > MB) {
+ cf = colors.yellow
+ }
+ return cf(util.bytesString(bytes))
+}
+
+/** cleanup the previous compilation cache */
+export async function cleanupCompilation(jsFile: string) {
+ const dir = path.dirname(jsFile)
+ const jsFileName = path.basename(jsFile)
+ if (!reHashJs.test(jsFile) || !existsDirSync(dir)) {
+ return
+ }
+ const jsName = jsFileName.split('.').slice(0, -2).join('.') + '.js'
+ for await (const entry of Deno.readDir(dir)) {
+ if (entry.isFile && (entry.name.endsWith('.js') || entry.name.endsWith('.js.map'))) {
+ const _jsName = util.trimSuffix(entry.name, '.map').split('.').slice(0, -2).join('.') + '.js'
+ if (_jsName === jsName && jsFileName !== entry.name) {
+ await Deno.remove(path.join(dir, entry.name))
+ }
+ }
+ }
+}
+
+/** crate html content by given arguments */
+export function createHtml({
+ lang = 'en',
+ head = [],
+ scripts = [],
+ body,
+ minify = false
+}: {
+ lang?: string,
+ head?: string[],
+ scripts?: (string | { id?: string, type?: string, src?: string, innerText?: string, nomodule?: boolean, async?: boolean, preload?: boolean })[],
+ body: string,
+ minify?: boolean
+}) {
+ const eol = minify ? '' : '\n'
+ const indent = minify ? '' : ' '.repeat(4)
+ const headTags = head.map(tag => tag.trim())
+ .concat(scripts.map(v => {
+ if (!util.isString(v) && util.isNEString(v.src)) {
+ if (v.type === 'module') {
+ return ` `
+ } else if (v.async === true) {
+ return ` `
+ }
+ }
+ return ''
+ })).filter(Boolean)
+ const scriptTags = scripts.map(v => {
+ if (util.isString(v)) {
+ return ``
+ } else if (util.isNEString(v.innerText)) {
+ const { innerText, ...rest } = v
+ return ``
+ } else if (util.isNEString(v.src) && !v.preload) {
+ return ``
+ } else {
+ return ''
+ }
+ }).filter(Boolean)
+
+ return [
+ '',
+ ``,
+ '',
+ indent + ' ',
+ ...headTags.map(tag => indent + tag),
+ '',
+ '',
+ indent + body,
+ ...scriptTags.map(tag => indent + tag),
+ '',
+ ''
+ ].join(eol)
+}
+
+function attrString(v: any): string {
+ return Object.keys(v).filter(k => !!v[k]).map(k => {
+ if (v[k] === true) {
+ return ` ${k}`
+ } else {
+ return ` ${k}=${JSON.stringify(String(v[k]))}`
+ }
+ }).join('')
+}
diff --git a/shared/constants.ts b/shared/constants.ts
new file mode 100644
index 000000000..7772d28e1
--- /dev/null
+++ b/shared/constants.ts
@@ -0,0 +1,14 @@
+export const KB = 1024
+export const MB = KB ** 2
+export const GB = KB ** 3
+export const TB = KB ** 4
+export const PB = KB ** 5
+export const hashShort = 9
+export const reHttp = /^https?:\/\//i
+export const reModuleExt = /\.(js|jsx|mjs|ts|tsx)$/i
+export const reStyleModuleExt = /\.(css|less)$/i
+export const reMDExt = /\.(md|markdown)$/i
+export const reLocaleID = /^[a-z]{2}(-[a-zA-Z0-9]+)?$/
+export const reFullVersion = /@v?\d+\.\d+\.\d+/i
+export const reHashJs = new RegExp(`\\.[0-9a-fx]{${hashShort}}\\.js$`, 'i')
+export const reHashResolve = new RegExp(`(import|import\\s*\\(|from|href\\s*:)(\\s*)("|')([^'"]+.[0-9a-fx]{${hashShort}}\\.js)("|')`, 'g')
diff --git a/util.ts b/shared/util.ts
similarity index 58%
rename from util.ts
rename to shared/util.ts
index 3093c5085..9c0e2528f 100644
--- a/util.ts
+++ b/shared/util.ts
@@ -1,20 +1,4 @@
-const symbolFor = typeof Symbol === 'function' && Symbol.for
-const REACT_FORWARD_REF_TYPE = symbolFor ? Symbol.for('react.forward_ref') : 0xead0
-const REACT_MEMO_TYPE = symbolFor ? Symbol.for('react.memo') : 0xead3
-
-export const hashShort = 9
-export const reHttp = /^https?:\/\//i
-export const reModuleExt = /\.(js|jsx|mjs|ts|tsx)$/i
-export const reStyleModuleExt = /\.(css|less)$/i
-export const reMDExt = /\.(md|markdown)$/i
-export const reLocaleID = /^[a-z]{2}(-[a-zA-Z0-9]+)?$/
-export const reHashJs = new RegExp(`\\.[0-9a-fx]{${hashShort}}\\.js$`, 'i')
-
-export const KB = 1024
-export const MB = KB ** 2
-export const GB = KB ** 3
-export const TB = KB ** 4
-export const PB = KB ** 5
+import { GB, KB, MB, PB, TB } from './constants.ts'
export default {
isNumber(a: any): a is number {
@@ -47,43 +31,6 @@ export default {
isFunction(a: any): a is Function {
return typeof a === 'function'
},
- isLikelyReactComponent(type: any): Boolean {
- switch (typeof type) {
- case 'function':
- if (type.prototype != null) {
- if (type.prototype.isReactComponent) {
- return true
- }
- const ownNames = Object.getOwnPropertyNames(type.prototype);
- if (ownNames.length > 1 || ownNames[0] !== 'constructor') {
- return false
- }
- }
- const name = type.name || type.displayName
- return typeof name === 'string' && /^[A-Z]/.test(name)
- case 'object':
- if (type != null) {
- switch (type.$$typeof) {
- case REACT_FORWARD_REF_TYPE:
- case REACT_MEMO_TYPE:
- return true
- default:
- return false
- }
- }
- return false
- default:
- return false
- }
- },
- isHttpUrl(url: string) {
- try {
- const { protocol } = new URL(url)
- return protocol === 'https:' || protocol === 'http:'
- } catch (error) {
- return false
- }
- },
trimPrefix(s: string, prefix: string): string {
if (prefix !== '' && s.startsWith(prefix)) {
return s.slice(prefix.length)
@@ -117,19 +64,19 @@ export default {
return Math.ceil(bytes / KB) + 'KB'
}
if (bytes < GB) {
- return (bytes / MB).toFixed(1).replace(/\.0$/, '') + 'MB'
+ return this.trimSuffix((bytes / MB).toFixed(1), '.0') + 'MB'
}
if (bytes < TB) {
- return (bytes / GB).toFixed(1).replace(/\.0$/, '') + 'GB'
+ return this.trimSuffix((bytes / GB).toFixed(1), '.0') + 'GB'
}
if (bytes < PB) {
- return (bytes / TB).toFixed(1).replace(/\.0$/, '') + 'TB'
+ return this.trimSuffix((bytes / TB).toFixed(1), '.0') + 'TB'
}
- return (bytes / PB).toFixed(1).replace(/\.0$/, '') + 'PB'
+ return this.trimSuffix((bytes / PB).toFixed(1), '.0') + 'PB'
},
splitPath(path: string): string[] {
return path
- .split(/[\/\\]/g)
+ .split(/[\/\\]+/g)
.map(p => p.trim())
.filter(p => p !== '' && p !== '.')
.reduce((path, p) => {
diff --git a/multiparser_test.ts b/test/multiparser_test.ts
old mode 100644
new mode 100755
similarity index 65%
rename from multiparser_test.ts
rename to test/multiparser_test.ts
index 07210d6b6..7a684f5d9
--- a/multiparser_test.ts
+++ b/test/multiparser_test.ts
@@ -1,9 +1,9 @@
-import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
-import { multiParser } from "./multiparser.ts";
+import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts';
+import { multiParser } from '../server/multiparser.ts';
const encoder = new TextEncoder();
-const contentType = "multipart/form-data; boundary=ALEPH-BOUNDARY";
+const contentType = 'multipart/form-data; boundary=ALEPH-BOUNDARY';
const simpleString = '--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="string_1"\r\n\r\nsimple string here\r--ALEPH-BOUNDARY--';
const complexString = 'some text to be ignored\r\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="id"\r\n\r\n666\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="title"\r\n\r\nHello World\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="multiline"\r\n\r\nworld,\n hello\r--ALEPH-BOUNDARY\rContent-Disposition: form-data; name="file1"; filename="file_name.ext"\rContent-Type: video/mp2t\r\n\r\nsome random data\r--ALEPH-BOUNDARY--\rmore text to be ignored to be ignored\r';
@@ -11,7 +11,7 @@ Deno.test(`basic multiparser string`, async () => {
const buff = new Deno.Buffer(encoder.encode(simpleString));
const multiForm = await multiParser(buff, contentType);
- assertEquals(multiForm.get("string_1"), "simple string here");
+ assertEquals(multiForm.get('string_1'), 'simple string here');
});
Deno.test(`complex multiparser string`, async () => {
@@ -19,16 +19,17 @@ Deno.test(`complex multiparser string`, async () => {
const multiFrom = await multiParser(buff, contentType);
// Asseting multiple string values
- assertEquals(multiFrom.get("id"), "666");
- assertEquals(multiFrom.get("title"), "Hello World");
- assertEquals(multiFrom.get("multiline"), "world,\n hello");
+ assertEquals(multiFrom.get('id'), '666');
+ assertEquals(multiFrom.get('title'), 'Hello World');
+ assertEquals(multiFrom.get('multiline'), 'world,\n hello');
// Asserting a file information
- const file = multiFrom.getFile("file1");
+ const file = multiFrom.getFile('file1');
+ if (!file) {
+ return
+ }
- if (!file) { return }
-
- assertEquals(file.name, "file1");
- assertEquals(file.contentType, "video/mp2t");
+ assertEquals(file.name, 'file1');
+ assertEquals(file.contentType, 'video/mp2t');
assertEquals(file.size, 16);
});
diff --git a/routing_test.ts b/test/routing_test.ts
similarity index 63%
rename from routing_test.ts
rename to test/routing_test.ts
index fe65fb976..6a1dbcab1 100644
--- a/routing_test.ts
+++ b/test/routing_test.ts
@@ -1,21 +1,21 @@
-import { assertEquals } from 'https://deno.land/std/testing/asserts.ts'
-import { Routing } from './routing.ts'
+import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts'
+import { Routing } from '../framework/core/routing.ts'
const routing = new Routing([], '/', 'en', ['en', 'zh-CN'])
Deno.test(`router #01`, () => {
- routing.update({ id: '/pages/index.js', hash: '' })
- routing.update({ id: '/pages/blog/index.js', hash: '' })
- routing.update({ id: '/pages/blog/[slug].js', hash: '' })
- routing.update({ id: '/pages/user/index.js', hash: '' })
- routing.update({ id: '/pages/user/[...all].js', hash: '' })
- routing.update({ id: '/pages/blog.js', hash: '' })
- routing.update({ id: '/pages/user.js', hash: '' })
- routing.update({ id: '/pages/blog/[slug]/subpage.js', hash: '' })
- routing.update({ id: '/pages/docs.js', hash: '' })
- routing.update({ id: '/pages/docs/get-started.md', hash: '' })
- routing.update({ id: '/pages/docs/installation.md', hash: '' })
- routing.update({ id: '/pages/index.js', hash: 'hsidfshy3yhfya49848' })
+ routing.update({ url: '/pages/index.js', hash: '' })
+ routing.update({ url: '/pages/blog/index.js', hash: '' })
+ routing.update({ url: '/pages/blog/[slug].js', hash: '' })
+ routing.update({ url: '/pages/user/index.js', hash: '' })
+ routing.update({ url: '/pages/user/[...all].js', hash: '' })
+ routing.update({ url: '/pages/blog.js', hash: '' })
+ routing.update({ url: '/pages/user.js', hash: '' })
+ routing.update({ url: '/pages/blog/[slug]/subpage.js', hash: '' })
+ routing.update({ url: '/pages/docs.js', hash: '' })
+ routing.update({ url: '/pages/docs/get-started.md', hash: '' })
+ routing.update({ url: '/pages/docs/installation.md', hash: '' })
+ routing.update({ url: '/pages/index.js', hash: 'hsidfshy3yhfya49848' })
assertEquals(routing.paths, [
'/',
'/blog',
@@ -34,7 +34,7 @@ Deno.test(`router #02`, () => {
assertEquals(router.locale, 'en')
assertEquals(router.pathname, '/')
assertEquals(router.pagePath, '/')
- assertEquals(tree, [{ id: '/pages/index.js', hash: 'hsidfshy3yhfya49848' }])
+ assertEquals(tree, [{ url: '/pages/index.js', hash: 'hsidfshy3yhfya49848' }])
})
Deno.test(`router #03`, () => {
@@ -42,7 +42,7 @@ Deno.test(`router #03`, () => {
assertEquals(router.locale, 'zh-CN')
assertEquals(router.pathname, '/')
assertEquals(router.pagePath, '/')
- assertEquals(tree, [{ id: '/pages/index.js', hash: 'hsidfshy3yhfya49848' }])
+ assertEquals(tree, [{ url: '/pages/index.js', hash: 'hsidfshy3yhfya49848' }])
})
Deno.test(`router #04`, () => {
@@ -50,7 +50,7 @@ Deno.test(`router #04`, () => {
assertEquals(router.locale, 'en')
assertEquals(router.pathname, '/blog')
assertEquals(router.pagePath, '/blog')
- assertEquals(tree.map(({ id }) => id), ['/pages/blog.js', '/pages/blog/index.js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/blog.js', '/pages/blog/index.js'])
})
Deno.test(`router #05`, () => {
@@ -58,7 +58,7 @@ Deno.test(`router #05`, () => {
assertEquals(router.locale, 'zh-CN')
assertEquals(router.pathname, '/blog')
assertEquals(router.pagePath, '/blog')
- assertEquals(tree.map(({ id }) => id), ['/pages/blog.js', '/pages/blog/index.js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/blog.js', '/pages/blog/index.js'])
})
Deno.test(`router #06`, () => {
@@ -66,7 +66,7 @@ Deno.test(`router #06`, () => {
assertEquals(router.pathname, '/blog/hello-world')
assertEquals(router.pagePath, '/blog/[slug]')
assertEquals(router.params, { slug: 'hello-world' })
- assertEquals(tree.map(({ id }) => id), ['/pages/blog.js', '/pages/blog/[slug].js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/blog.js', '/pages/blog/[slug].js'])
})
Deno.test(`router #07`, () => {
@@ -74,7 +74,7 @@ Deno.test(`router #07`, () => {
assertEquals(router.pathname, '/user')
assertEquals(router.pagePath, '/user')
assertEquals(router.params, {})
- assertEquals(tree.map(({ id }) => id), ['/pages/user.js', '/pages/user/index.js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/user.js', '/pages/user/index.js'])
})
Deno.test(`router #08`, () => {
@@ -82,7 +82,7 @@ Deno.test(`router #08`, () => {
assertEquals(router.pathname, '/user/projects')
assertEquals(router.pagePath, '/user/[...all]')
assertEquals(router.params, { all: 'projects' })
- assertEquals(tree.map(({ id }) => id), ['/pages/user.js', '/pages/user/[...all].js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/user.js', '/pages/user/[...all].js'])
})
Deno.test(`router #09`, () => {
@@ -90,7 +90,7 @@ Deno.test(`router #09`, () => {
assertEquals(router.pathname, '/user/settings/profile')
assertEquals(router.pagePath, '/user/[...all]')
assertEquals(router.params, { all: 'settings/profile' })
- assertEquals(tree.map(({ id }) => id), ['/pages/user.js', '/pages/user/[...all].js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/user.js', '/pages/user/[...all].js'])
})
Deno.test(`router #10`, () => {
@@ -98,7 +98,7 @@ Deno.test(`router #10`, () => {
assertEquals(router.pathname, '/user/settings/security')
assertEquals(router.pagePath, '/user/[...all]')
assertEquals(router.params, { all: 'settings/security' })
- assertEquals(tree.map(({ id }) => id), ['/pages/user.js', '/pages/user/[...all].js'])
+ assertEquals(tree.map(({ url }) => url), ['/pages/user.js', '/pages/user/[...all].js'])
})
Deno.test(`router #11`, () => {
diff --git a/tsc/compile.ts b/tsc/compile.ts
deleted file mode 100644
index 349f607f0..000000000
--- a/tsc/compile.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import reactRefreshTS from 'https://esm.sh/react-refresh-typescript@1.1.0?external=typescript@4.1.2'
-import ts from 'https://esm.sh/typescript@4.1.2'
-import transformImportPathRewrite from './transform-import-path-rewrite.ts'
-import transformReactJsx from './transform-react-jsx.ts'
-import transformReactUseDenoHook from './transform-react-use-deno-hook.ts'
-
-export interface CompileOptions {
- mode: 'development' | 'production'
- target: string
- reactRefresh: boolean
- rewriteImportPath: (importPath: string) => string
- signUseDeno: (id: string) => string
-}
-
-const allowTargets = [
- 'esnext',
- 'es2015',
- 'es2016',
- 'es2017',
- 'es2018',
- 'es2019',
- 'es2020',
-]
-
-export function compile(fileName: string, source: string, { mode, target: targetName, rewriteImportPath, reactRefresh, signUseDeno }: CompileOptions) {
- const target = allowTargets.indexOf(targetName.toLowerCase())
- const transformers: ts.CustomTransformers = { before: [], after: [] }
- if (reactRefresh) transformers.before!.push(reactRefreshTS())
- transformers.before!.push(createPlainTransformer(transformReactJsx, { mode, rewriteImportPath }))
- transformers.after!.push(createPlainTransformer(transformReactUseDenoHook, { index: 0, signUseDeno }))
- transformers.after!.push(createPlainTransformer(transformImportPathRewrite, rewriteImportPath))
-
- return ts.transpileModule(source, {
- fileName,
- reportDiagnostics: true,
- compilerOptions: {
- target: target < 0 ? ts.ScriptTarget.ES2015 : (target > 0 ? target + 1 : 99),
- module: ts.ModuleKind.ES2020,
- isolatedModules: true,
- allowJs: true,
- jsx: ts.JsxEmit.React,
- experimentalDecorators: true,
- importHelpers: true,
- importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
- alwaysStrict: true,
- sourceMap: true,
- inlineSources: true,
- },
- transformers,
- })
-}
-
-function createPlainTransformer(transform: (sf: ts.SourceFile, node: ts.Node, ...args: any[]) => ts.VisitResult, ...args: any[]): ts.TransformerFactory {
- function nodeVisitor(ctx: ts.TransformationContext, sf: ts.SourceFile) {
- const visitor: ts.Visitor = node => {
- const ret = transform(sf, node, ...args)
- if (ret != null) {
- return ret
- }
- return ts.visitEachChild(node, visitor, ctx)
- }
- return visitor
- }
-
- return ctx => sf => ts.visitNode(sf, nodeVisitor(ctx, sf))
-}
-
-function createTransformer(transform: (ctx: ts.TransformationContext, sf: ts.SourceFile, options?: any) => ts.SourceFile, options?: Record): ts.TransformerFactory {
- return ctx => sf => transform(ctx, sf, options)
-}
diff --git a/tsc/transform-import-path-rewrite.ts b/tsc/transform-import-path-rewrite.ts
deleted file mode 100644
index a55418e22..000000000
--- a/tsc/transform-import-path-rewrite.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import ts from 'https://esm.sh/typescript@4.1.2'
-
-/**
- * TS AST transformer to rewrite import path.
- *
- * @link https://github.com/dropbox/ts-transform-import-path-rewrite
- */
-export default function transformImportPathRewrite(sf: ts.SourceFile, node: ts.Node, rewriteImportPath: (importPath: string) => string): ts.VisitResult {
- let importPath = ''
- if (
- (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
- node.moduleSpecifier
- ) {
- const importPathWithQuotes = node.moduleSpecifier.getText(sf)
- importPath = importPathWithQuotes.substr(1, importPathWithQuotes.length - 2)
- } else if (isDynamicImport(node)) {
- const arg0 = node.arguments[0]
- if (ts.isStringLiteral(arg0)) {
- const importPathWithQuotes = arg0.getText(sf)
- importPath = importPathWithQuotes.substr(1, importPathWithQuotes.length - 2)
- }
- } else if (
- ts.isImportTypeNode(node) &&
- ts.isLiteralTypeNode(node.argument) &&
- ts.isStringLiteral(node.argument.literal)
- ) {
- // `.text` instead of `getText` bc this node doesn't map to sf (it's generated d.ts)
- importPath = node.argument.literal.text
- }
-
- if (importPath) {
- const rewrittenPath = rewriteImportPath(importPath)
- if (rewrittenPath !== importPath) {
- const newNode = ts.getMutableClone(node)
- if (ts.isImportDeclaration(newNode) || ts.isExportDeclaration(newNode)) {
- Object.assign(newNode, { moduleSpecifier: ts.createLiteral(rewrittenPath) })
- } else if (isDynamicImport(newNode)) {
- Object.assign(newNode, { arguments: ts.createNodeArray([ts.createStringLiteral(rewrittenPath)]) })
- } else if (ts.isImportTypeNode(newNode)) {
- Object.assign(newNode, { argument: ts.createLiteralTypeNode(ts.createStringLiteral(rewrittenPath)) })
- }
- return newNode
- }
- }
-}
-
-function isDynamicImport(node: ts.Node): node is ts.CallExpression {
- return ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
-}
diff --git a/tsc/transform-react-jsx.ts b/tsc/transform-react-jsx.ts
deleted file mode 100644
index 749cb5f22..000000000
--- a/tsc/transform-react-jsx.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import ts from 'https://esm.sh/typescript@4.1.2'
-import { path } from '../std.ts'
-
-export default function transformReactJsx(sf: ts.SourceFile, node: ts.Node, options: { mode: 'development' | 'production', rewriteImportPath: (importPath: string) => string }): ts.VisitResult {
- if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
- let props = Array.from(node.attributes.properties)
-
- if (node.tagName.getText() === 'Import') {
- for (let i = 0; i < props.length; i++) {
- const prop = props[i]
- if (ts.isJsxAttribute(prop) && prop.name.text === 'from' && prop.initializer && ts.isStringLiteral(prop.initializer)) {
- const url = options.rewriteImportPath(prop.initializer.text)
- props.splice(i, 1)
- props.unshift(
- ts.createJsxAttribute(
- ts.createIdentifier('from'),
- ts.createJsxExpression(undefined, ts.factory.createStringLiteral(url))
- ),
- ts.createJsxAttribute(
- ts.createIdentifier('__sourceFile'),
- ts.createJsxExpression(undefined, ts.factory.createStringLiteral(path.join(path.dirname(sf.fileName), prop.initializer.text)))
- ),
- ts.createJsxAttribute(
- ts.createIdentifier('__importer'),
- ts.createJsxExpression(undefined, ts.factory.createStringLiteral(sf.fileName))
- )
- )
- break
- }
- }
- }
-
- if (options.mode === 'development') {
- const fileNameAttr = ts.createPropertyAssignment(
- 'fileName',
- ts.createStringLiteral(sf.fileName)
- )
- const lineNumberAttr = ts.createPropertyAssignment(
- 'lineNumber',
- ts.createNumericLiteral((sf.getLineAndCharacterOfPosition(node.pos).line + 1).toString())
- )
- const prop = ts.createJsxAttribute(
- ts.createIdentifier('__source'),
- ts.createJsxExpression(undefined, ts.createObjectLiteral([fileNameAttr, lineNumberAttr]))
- )
- props.push(prop)
- }
-
- if (ts.isJsxSelfClosingElement(node)) {
- return ts.createJsxSelfClosingElement(
- node.tagName,
- node.typeArguments,
- ts.createJsxAttributes(props)
- )
- } else if (ts.isJsxOpeningElement(node)) {
- return ts.createJsxOpeningElement(
- node.tagName,
- node.typeArguments,
- ts.createJsxAttributes(props)
- )
- }
- }
-}
diff --git a/tsc/transform-react-use-deno-hook.ts b/tsc/transform-react-use-deno-hook.ts
deleted file mode 100644
index 0d59896ae..000000000
--- a/tsc/transform-react-use-deno-hook.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * TypeScript AST Transformer for useDeno hook.
- */
-
-import ts from 'https://esm.sh/typescript@4.1.2'
-
-const f = ts.factory
-
-export default function transformReactUseDenoHook(sf: ts.SourceFile, node: ts.Node, options: { index: number, signUseDeno: (id: string) => string }): ts.VisitResult {
- if (isUseDenoHookCallExpr(node)) {
- const args = node.arguments as unknown as Array
- const id = options.signUseDeno(`${sf.fileName}:useDeno#${options.index++}`)
- const arg3 = f.createStringLiteral(id)
- if (args.length === 1) {
- args.push(f.createFalse())
- }
- if (args.length === 2) {
- args.push(f.createVoidZero())
- }
- if (args.length === 3) {
- args.push(arg3)
- } else {
- args[3] = arg3
- }
- return node
- }
-}
-
-function isUseDenoHookCallExpr(node: ts.Node): node is ts.CallExpression {
- if (ts.isCallExpression(node)) {
- const { expression, arguments: [arg0] } = node
- if (ts.isFunctionLike(arg0)) {
- if (ts.isIdentifier(expression)) {
- return expression.text === 'useDeno'
- } else if (ts.isPropertyAccessExpression(expression)) {
- return expression.name.text === 'useDeno'
- }
- }
- }
- return false
-}
diff --git a/tsc/tslib.js b/tsc/tslib.js
deleted file mode 100644
index c60aa609f..000000000
--- a/tsc/tslib.js
+++ /dev/null
@@ -1,227 +0,0 @@
-/*! *****************************************************************************
-Copyright (c) Microsoft Corporation.
-
-Permission to use, copy, modify, and/or distribute this software for any
-purpose with or without fee is hereby granted.
-
-THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-PERFORMANCE OF THIS SOFTWARE.
-***************************************************************************** */
-/* global Reflect, Promise */
-
-var extendStatics = function(d, b) {
- extendStatics = Object.setPrototypeOf ||
- ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
- function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
- return extendStatics(d, b);
-};
-
-export function __extends(d, b) {
- extendStatics(d, b);
- function __() { this.constructor = d; }
- d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
-}
-
-export var __assign = function() {
- __assign = Object.assign || function __assign(t) {
- for (var s, i = 1, n = arguments.length; i < n; i++) {
- s = arguments[i];
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
- }
- return t;
- }
- return __assign.apply(this, arguments);
-}
-
-export function __rest(s, e) {
- var t = {};
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
- t[p] = s[p];
- if (s != null && typeof Object.getOwnPropertySymbols === "function")
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
- t[p[i]] = s[p[i]];
- }
- return t;
-}
-
-export function __decorate(decorators, target, key, desc) {
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
- return c > 3 && r && Object.defineProperty(target, key, r), r;
-}
-
-export function __param(paramIndex, decorator) {
- return function (target, key) { decorator(target, key, paramIndex); }
-}
-
-export function __metadata(metadataKey, metadataValue) {
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(metadataKey, metadataValue);
-}
-
-export function __awaiter(thisArg, _arguments, P, generator) {
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
- return new (P || (P = Promise))(function (resolve, reject) {
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
- step((generator = generator.apply(thisArg, _arguments || [])).next());
- });
-}
-
-export function __generator(thisArg, body) {
- var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
- return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
- function verb(n) { return function (v) { return step([n, v]); }; }
- function step(op) {
- if (f) throw new TypeError("Generator is already executing.");
- while (_) try {
- if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
- if (y = 0, t) op = [op[0] & 2, t.value];
- switch (op[0]) {
- case 0: case 1: t = op; break;
- case 4: _.label++; return { value: op[1], done: false };
- case 5: _.label++; y = op[1]; op = [0]; continue;
- case 7: op = _.ops.pop(); _.trys.pop(); continue;
- default:
- if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
- if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
- if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
- if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
- if (t[2]) _.ops.pop();
- _.trys.pop(); continue;
- }
- op = body.call(thisArg, _);
- } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
- if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
- }
-}
-
-export var __createBinding = Object.create ? (function(o, m, k, k2) {
- if (k2 === undefined) k2 = k;
- Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
-}) : (function(o, m, k, k2) {
- if (k2 === undefined) k2 = k;
- o[k2] = m[k];
-});
-
-export function __exportStar(m, o) {
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);
-}
-
-export function __values(o) {
- var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
- if (m) return m.call(o);
- if (o && typeof o.length === "number") return {
- next: function () {
- if (o && i >= o.length) o = void 0;
- return { value: o && o[i++], done: !o };
- }
- };
- throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
-}
-
-export function __read(o, n) {
- var m = typeof Symbol === "function" && o[Symbol.iterator];
- if (!m) return o;
- var i = m.call(o), r, ar = [], e;
- try {
- while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
- }
- catch (error) { e = { error: error }; }
- finally {
- try {
- if (r && !r.done && (m = i["return"])) m.call(i);
- }
- finally { if (e) throw e.error; }
- }
- return ar;
-}
-
-export function __spread() {
- for (var ar = [], i = 0; i < arguments.length; i++)
- ar = ar.concat(__read(arguments[i]));
- return ar;
-}
-
-export function __spreadArrays() {
- for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;
- for (var r = Array(s), k = 0, i = 0; i < il; i++)
- for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)
- r[k] = a[j];
- return r;
-};
-
-export function __await(v) {
- return this instanceof __await ? (this.v = v, this) : new __await(v);
-}
-
-export function __asyncGenerator(thisArg, _arguments, generator) {
- if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
- var g = generator.apply(thisArg, _arguments || []), i, q = [];
- return i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i;
- function verb(n) { if (g[n]) i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; }
- function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
- function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
- function fulfill(value) { resume("next", value); }
- function reject(value) { resume("throw", value); }
- function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
-}
-
-export function __asyncDelegator(o) {
- var i, p;
- return i = {}, verb("next"), verb("throw", function (e) { throw e; }), verb("return"), i[Symbol.iterator] = function () { return this; }, i;
- function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: n === "return" } : f ? f(v) : v; } : f; }
-}
-
-export function __asyncValues(o) {
- if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
- var m = o[Symbol.asyncIterator], i;
- return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
- function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
- function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
-}
-
-export function __makeTemplateObject(cooked, raw) {
- if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; }
- return cooked;
-};
-
-var __setModuleDefault = Object.create ? (function(o, v) {
- Object.defineProperty(o, "default", { enumerable: true, value: v });
-}) : function(o, v) {
- o["default"] = v;
-};
-
-export function __importStar(mod) {
- if (mod && mod.__esModule) return mod;
- var result = {};
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
- __setModuleDefault(result, mod);
- return result;
-}
-
-export function __importDefault(mod) {
- return (mod && mod.__esModule) ? mod : { default: mod };
-}
-
-export function __classPrivateFieldGet(receiver, privateMap) {
- if (!privateMap.has(receiver)) {
- throw new TypeError("attempted to get private field on non-instance");
- }
- return privateMap.get(receiver);
-}
-
-export function __classPrivateFieldSet(receiver, privateMap, value) {
- if (!privateMap.has(receiver)) {
- throw new TypeError("attempted to set private field on non-instance");
- }
- privateMap.set(receiver, value);
- return value;
-}
diff --git a/types.ts b/types.ts
index 9167313f9..e8286c37b 100644
--- a/types.ts
+++ b/types.ts
@@ -1,4 +1,4 @@
-import type { ServerRequest } from './std.ts';
+import type { AcceptedPlugin, bufio, Response } from './deps.ts';
/**
* A plugin for **Aleph.js** application.
@@ -10,23 +10,21 @@ export interface Plugin {
test: RegExp
/** `acceptHMR` accepts the HMR. */
acceptHMR?: boolean
- /** `resolve` resolves the import url, if the `external` returned the compilation will skip the import url. */
- resolve?(url: string): { url: string, external?: boolean }
/** `transform` transforms the source content. */
- transform?(content: Uint8Array, url: string): Promise<{ code: string, map?: string, loader?: 'js' | 'ts' | 'css' | 'markdown' }>
+ transform?(content: Uint8Array, url: string): Promise<{ code: string, map?: string, loader?: 'js' | 'ts' | 'jsx' | 'tsx' | 'css' | 'markdown' }>
}
/**
* The options for **SSR**.
*/
export interface SSROptions {
- /** The fallback html **dynamic routes** (default is '**_fallback.html**'). */
+ /** The fallback html **dynamic routes** (default is '**_fallback_spa.html**'). */
fallback?: string
/** A list of RegExp for paths to use **SSR**. */
include?: RegExp[]
/** A list of RegExp for paths to skip **SSR**. */
exclude?: RegExp[]
- /** A list of paths for **dynamic routes** in **SSR**. */
+ /** A list of paths for **dynamic routes** in **SSG**. */
staticPaths?: string[]
}
@@ -34,16 +32,16 @@ export interface SSROptions {
* Config for Aleph.js application.
*/
export interface Config {
+ /** `framework` to run your application (default is 'react'). */
+ framework?: 'alef' | 'react'
/** `srcDir` to put your application source code (default is '/'). */
srcDir?: string
/** `outputDir` specifies the output directory for `build` command (default is '**dist**'). */
outputDir?: string
/** `baseUrl` specifies the path prefix for the application (default is '/'). */
baseUrl?: string
- /** `reactUrl` specifies the **react** download URL (default is 'https://esm.sh/react@17.0.1'). */
- reactUrl?: string
- /** `reactDomUrl` specifies the **react-dom** download URL (default is 'https://esm.sh/react-dom@17.0.1'). */
- reactDomUrl?: string
+ /** `reactVersion` specifies the **react version** (default is '17.0.1'). */
+ reactVersion?: string
/** `defaultLocale` specifies the default locale of the application (default is '**en**'). */
defaultLocale?: string
/** A list of locales. */
@@ -53,11 +51,9 @@ export interface Config {
/** A list of plugin. */
plugins?: Plugin[]
/** A list of plugin of PostCSS. */
- postcss?: { plugins: (string | { name: string, options: Record })[] }
- /** `buildTarget` specifies the build target for **tsc** (possible values: '**ES2015**' - '**ES2020**' | '**ESNext**', default is **ES2015** for `production` and **ES2018** for `development`). */
- buildTarget?: string
- /** Enable sourceMap in **production** mode (default is **false**). */
- sourceMap?: boolean
+ postcss?: { plugins: (string | AcceptedPlugin | [string | ((options: Record) => AcceptedPlugin), Record])[] }
+ /** `buildTarget` specifies the build target for **swc** in production mode (default is **es5**). */
+ buildTarget?: 'es5' | 'es2015' | 'es2016' | 'es2017' | 'es2018' | 'es2019' | 'es2020'
/** `env` appends env variables (use `Deno.env.get(key)` to get an env variable) */
env?: Record
}
@@ -72,7 +68,27 @@ export interface APIHandler {
}
/**
- * The request object from api requests.
+ * The raw request object of http request.
+ */
+export interface ServerRequest {
+ readonly url: string
+ readonly method: string
+ readonly proto: string
+ readonly protoMinor: number
+ readonly protoMajor: number
+ readonly headers: Headers
+ readonly conn: Deno.Conn
+ readonly r: bufio.BufReader
+ readonly w: bufio.BufWriter
+ readonly done: Promise
+ readonly contentLength: number | null
+ readonly body: Deno.Reader
+ respond(r: Response): Promise
+ finalize(): Promise
+}
+
+/**
+ * The request object of api request.
*/
export interface APIRequest extends ServerRequest {
readonly pathname: string
@@ -81,11 +97,15 @@ export interface APIRequest extends ServerRequest {
readonly cookies: ReadonlyMap
/** `status` sets response status of the request. */
status(code: number): this
- /** `addHeader` adds a new value onto an existing response header of the request, or
- * adds the header if it does not already exist. */
+ /**
+ * `addHeader` adds a new value onto an existing response header of the request, or
+ * adds the header if it does not already exist.
+ */
addHeader(key: string, value: string): this
- /** `setHeader` sets a new value for an existing response header of the request, or adds
- * the header if it does not already exist. */
+ /**
+ * `setHeader` sets a new value for an existing response header of the request, or adds
+ * the header if it does not already exist.
+ */
setHeader(key: string, value: string): this
/** `removeHeader` removes the value for an existing response header of the request. */
removeHeader(key: string): this
@@ -100,7 +120,7 @@ export interface APIRequest extends ServerRequest {
}
/**
- * The Router object of the application routing, you can access it with `useRouter()`.
+ * The Router object of the routing, you can access it with `useRouter()` hook.
*/
export interface RouterURL {
readonly locale: string
diff --git a/vendor/deno-dom/LICENSE b/vendor/deno-dom/LICENSE
deleted file mode 100755
index 1b29ec228..000000000
--- a/vendor/deno-dom/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2020 b-fuze
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/vendor/deno-dom/README.md b/vendor/deno-dom/README.md
deleted file mode 100755
index 2f91e2e07..000000000
--- a/vendor/deno-dom/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Deno DOM
-
-An implementation of the browser DOM—primarily for SSR—in Deno. Implemented with
-Rust, WASM, and obviously, Deno/TypeScript.
-
-## Example
-```typescript
-import { DOMParser, Element } from "https://deno.land/x/deno_dom/deno-dom-wasm.ts";
-
-const doc = new DOMParser().parseFromString(`
- Hello World!
- Hello from Deno!
-`, "text/html")!;
-
-const p = doc.querySelector("p")!;
-
-console.log(p.textContent); // "Hello from Deno!"
-console.log(p.childNodes[1].textContent); // "Deno!"
-
-p.innerHTML = "DOM in Deno is pretty cool";
-console.log(p.children[0].outerHTML); // "Deno "
-```
-
-Deno DOM has **two** backends, WASM and native using Deno native plugins. Both
-APIs are **identical**, the difference being only in performance. The WASM
-backend works with all Deno restrictions, but the native backend requires
-the `--unstable --allow-plugin` flags. You can switch between them by
-importing either `deno-dom-wasm.ts` or `deno-dom-native.ts`.
-
-Deno DOM is still under development, but is fairly usable for basic HTML
-manipulation needs.
-
-## Goals
-
- - HTML parser in Deno
- - Fast
- - Mirror most\* supported DOM APIs as closely as possible
- - Provide specific APIs in addition to DOM APIs to make certain operations more efficient, like controlling Shadow DOM (see Open Questions)
- - Use cutting-edge JS features like private class members, optional chaining, etc
-
-## Non-Goals
-
- - Headless browser implementation
- - Ability to run JS embedded in documents (`