Skip to content

Commit

Permalink
Add new API for pages
Browse files Browse the repository at this point in the history
  • Loading branch information
ai committed Oct 19, 2024
1 parent 847e113 commit 250e961
Show file tree
Hide file tree
Showing 18 changed files with 1,010 additions and 45 deletions.
2 changes: 2 additions & 0 deletions core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export * from './loader/index.ts'
export * from './menu.ts'
export * from './messages/index.ts'
export * from './not-found.ts'
export * from './page.ts'
export * from './pages/index.ts'
export * from './post.ts'
export * from './posts-list.ts'
export * from './preview.ts'
Expand Down
75 changes: 75 additions & 0 deletions core/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { computed, type ReadableAtom, type WritableStore } from 'nanostores'

import { getEnvironment } from './environment.ts'
import { type Page, pages } from './pages/index.ts'
import { type Route, router } from './router.ts'

function isStore(store: unknown): store is WritableStore {
return typeof store === 'object' && store !== null && 'listen' in store
}

function eachParam<SomeRoute extends Route>(
page: Page<SomeRoute['route']>,
route: SomeRoute,
iterator: <Param extends keyof SomeRoute['params']>(
store: WritableStore<SomeRoute['params'][Param]>,
name: Param,
value: SomeRoute['params'][Param]
) => void
): void {
let params = route.params as SomeRoute['params']
for (let i in params) {
let name = i as keyof SomeRoute['params']
let value = params[name]
let store = page[name]
if (isStore(store)) {
iterator(store, name, value)
}
}
}

function changeRouteParam(
route: Route,
change: Partial<Route['params']>
): void {
getEnvironment().openRoute({
...route,
params: {
...route.params,
...change
}
} as Route)
}

let prevPage: Page | undefined
let unbinds: (() => void)[] = []

export const page: ReadableAtom<Page> = computed(router, route => {
let currentPage = pages[route.route]
if (currentPage !== prevPage) {
if (prevPage) {
for (let unbind of unbinds) unbind()
prevPage.destroy()
}
prevPage = currentPage

eachParam(currentPage, route, (store, param) => {
unbinds.push(
store.listen(newValue => {
let currentRoute = router.get()
if (currentRoute.route === currentPage.route) {
changeRouteParam(currentRoute, { [param]: newValue })
}
})
)
})
}

eachParam(currentPage, route, (store, param, value) => {
if (store.get() !== value) {
store.set(value)
}
})

return currentPage
})
252 changes: 252 additions & 0 deletions core/pages/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import debounce from 'just-debounce-it'
import { atom, computed, map } from 'nanostores'

import {
createDownloadTask,
type DownloadTask,
ignoreAbortError,
type TextResponse
} from '../download.ts'
import { type LoaderName, loaders } from '../loader/index.ts'
import { createPage } from './common.ts'

const ALWAYS_HTTPS = [/^twitter\.com\//]

export type AddLinksValue = Record<
string,
| {
error: 'invalidUrl'
state: 'invalid'
}
| {
state: 'loading'
}
| {
state: 'processed'
}
| {
state: 'unknown'
}
| {
state: 'unloadable'
}
>

export interface AddCandidate {
loader: LoaderName
text?: TextResponse
title: string
url: string
}

export const add = createPage('add', () => {
let $url = atom<string | undefined>()

let $links = map<AddLinksValue>({})

let $candidates = atom<AddCandidate[]>([])

let $error = computed(
$links,
(links): 'invalidUrl' | 'unloadable' | undefined => {
let first = Object.keys(links)[0]
if (typeof first !== 'undefined') {
let link = links[first]!
if (link.state === 'invalid') {
return link.error
} else if (link.state === 'unloadable') {
return 'unloadable'
}
}
return undefined
}
)

let $sortedCandidates = computed($candidates, candidates => {
return candidates.sort((a, b) => {
return a.title.localeCompare(b.title)
})
})

let $candidatesLoading = computed($links, links => {
return Object.keys(links).some(url => links[url]!.state === 'loading')
})

let $noResults = computed(
[$candidatesLoading, $url, $candidates, $error],
(loading, url, candidates, error) => {
return !loading && !!url && candidates.length === 0 && !error
}
)

function destroy(): void {
$links.set({})
$candidates.set([])
prevTask?.abortAll()
}

let inputUrl = debounce((value: string) => {
if (value === '') {
destroy()
} else {
//TODO: currentCandidate.set(undefined)
setUrl(value)
}
}, 500)

let prevTask: DownloadTask | undefined
async function setUrl(url: string): Promise<void> {
if (prevTask) prevTask.abortAll()
if (url === $url.get()) return
inputUrl.cancel()
destroy()
prevTask = createDownloadTask()
await addLink(prevTask, url)
}

function getLoaderForUrl(url: string): AddCandidate | false {
let names = Object.keys(loaders) as LoaderName[]
let parsed = new URL(url)
for (let name of names) {
let title = loaders[name].isMineUrl(parsed)
// Until we will have loader for specific domain
/* c8 ignore start */
if (typeof title === 'string') {
return { loader: name, title, url }
}
/* c8 ignore end */
}
return false
}

function getLoaderForText(response: TextResponse): AddCandidate | false {
let names = Object.keys(loaders) as LoaderName[]
let parsed = new URL(response.url)
for (let name of names) {
if (loaders[name].isMineUrl(parsed) !== false) {
let title = loaders[name].isMineText(response)
if (title !== false) {
return {
loader: name,
text: response,
title: title.trim(),
url: response.url
}
}
}
}
return false
}

function getLinksFromText(response: TextResponse): string[] {
let names = Object.keys(loaders) as LoaderName[]
return names.reduce<string[]>((links, name) => {
return links.concat(loaders[name].getMineLinksFromText(response))
}, [])
}

function getSuggestedLinksFromText(response: TextResponse): string[] {
let names = Object.keys(loaders) as LoaderName[]
return names.reduce<string[]>((links, name) => {
return links.concat(loaders[name].getSuggestedLinksFromText(response))
}, [])
}

function addCandidate(url: string, candidate: AddCandidate): void {
if ($candidates.get().some(i => i.url === url)) return

$links.setKey(url, { state: 'processed' })
$candidates.set([...$candidates.get(), candidate])
}

async function addLink(
task: DownloadTask,
url: string,
deep = false
): Promise<void> {
url = url.trim()
if (url === '') return

if (url.startsWith('http://')) {
let methodLess = url.slice('http://'.length)
if (ALWAYS_HTTPS.some(i => i.test(methodLess))) {
url = 'https://' + methodLess
}
} else if (!url.startsWith('https://')) {
if (/^\w+:/.test(url)) {
$links.setKey(url, { error: 'invalidUrl', state: 'invalid' })
return
} else if (ALWAYS_HTTPS.some(i => i.test(url))) {
url = 'https://' + url
} else {
url = 'http://' + url
}
}

if ($links.get()[url]) return

if (!URL.canParse(url)) {
$links.setKey(url, { error: 'invalidUrl', state: 'invalid' })
return
}

let byUrl = getLoaderForUrl(url)

if (byUrl !== false) {
// Until we will have loader for specific domain
/* c8 ignore next */

addCandidate(url, byUrl)
} else {
$links.setKey(url, { state: 'loading' })
try {
let response
try {
response = await task.text(url)
} catch {
$links.setKey(url, { state: 'unloadable' })
return
}
if (!response.ok) {
$links.setKey(url, { state: 'unloadable' })
} else {
let byText = getLoaderForText(response)
if (byText) {
addCandidate(url, byText)
} else {
$links.setKey(url, { state: 'unknown' })
}
if (!deep) {
let links = getLinksFromText(response)
if (links.length > 0) {
await Promise.all(links.map(i => addLink(task, i, true)))
} else if ($candidates.get().length === 0) {
let suggested = getSuggestedLinksFromText(response)
await Promise.all(suggested.map(i => addLink(task, i, true)))
}
}
}
} catch (error) {
ignoreAbortError(error)
}
}
}

$links.listen(links => {
$url.set(Object.keys(links)[0] ?? undefined)
})

return {
candidate: atom<string | undefined>(), // TODO: Remove to popups
candidatesLoading: $candidatesLoading,
destroy,
error: $error,
inputUrl,
noResults: $noResults,
setUrl,
sortedCandidates: $sortedCandidates,
url: $url
}
})

export type AddPage = typeof add
37 changes: 37 additions & 0 deletions core/pages/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { atom, type ReadableAtom } from 'nanostores'

import type { ParamlessRouteName, RouteName, Routes } from '../router.ts'

type Extra = {
destroy?: () => void
}

type ParamStores<Name extends RouteName> = {
[Param in keyof Routes[Name]]-?: ReadableAtom<Routes[Name][Param]>
}

export type BasePage<Name extends RouteName = RouteName> = {
destroy(): void
readonly loading: ReadableAtom<boolean>
readonly route: Name
underConstruction?: boolean
} & ParamStores<Name>

export function createPage<Name extends RouteName, Rest extends Extra>(
route: Name,
builder: () => ParamStores<Name> & Rest
): BasePage<Name> & Rest {
let rest = builder()
return {
destroy: rest.destroy ?? ((): void => {}),
loading: atom(false),
route,
...rest
}
}

export function createSimplePage<Name extends ParamlessRouteName>(
route: Name
): BasePage<Name> {
return createPage(route, () => ({}) as ParamStores<Name>)
}
Loading

0 comments on commit 250e961

Please sign in to comment.