mirror of
https://github.com/cupcakearmy/blaze.git
synced 2024-12-22 00:06:26 +00:00
proposal
This commit is contained in:
parent
f77ddf3c35
commit
5d7cbebceb
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
*
|
||||
!src
|
||||
!templates
|
||||
!package.json
|
||||
!pnpm-lock.yaml
|
||||
!tsconfig.json
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.env*
|
||||
dist
|
||||
node_modules
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.eta": "html"
|
||||
}
|
||||
}
|
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM node:20-slim AS base
|
||||
ENV CI=true
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod
|
||||
|
||||
FROM base AS build
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install
|
||||
COPY . /app
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/dist /app/dist
|
||||
COPY --from=build /app/templates /app/templates
|
||||
EXPOSE 8000
|
||||
CMD [ "pnpm", "start" ]
|
8
docker-compose.yaml
Normal file
8
docker-compose.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
env_file: .env
|
||||
ports:
|
||||
- 80:8000
|
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "blaze-this-page",
|
||||
"version": "1.1.0",
|
||||
"description": "The service to navigate the web also with bad connectivity",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc -w",
|
||||
"start": "node ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/compress": "^6.4.0",
|
||||
"@fastify/env": "^4.2.0",
|
||||
"@fastify/etag": "^4.2.0",
|
||||
"@fastify/static": "^6.10.2",
|
||||
"@fastify/view": "^8.0.0",
|
||||
"eta": "^3.1.1",
|
||||
"fastify": "^4.21.0",
|
||||
"fastify-minify": "^1.2.0",
|
||||
"node-html-parser": "^6.1.6",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@types/node": "^20.5.6",
|
||||
"pino-pretty": "^10.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.12"
|
||||
}
|
1113
pnpm-lock.yaml
generated
Normal file
1113
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
src/error.ts
Normal file
21
src/error.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export class BlazeError extends Error {
|
||||
constructor(public code: number, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export function init(app: FastifyInstance) {
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
let code = 500
|
||||
let message = 'Internal server error'
|
||||
if (err instanceof BlazeError) {
|
||||
code = err.code
|
||||
message = err.message
|
||||
} else {
|
||||
req.log.error(err)
|
||||
}
|
||||
return reply.code(code).view('error', { title: 'Error', code, message })
|
||||
})
|
||||
}
|
30
src/html.ts
Normal file
30
src/html.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { parse } from 'node-html-parser'
|
||||
|
||||
export function replaceLinks(raw: string): string {
|
||||
const html = parse(raw)
|
||||
html.querySelectorAll('a').forEach((el) => {
|
||||
if (el.hasAttribute('data-preserve-link')) return
|
||||
const href = el.getAttribute('href')
|
||||
if (href) {
|
||||
el.setAttribute('href', `/browse?${new URLSearchParams({ url: href }).toString()}`)
|
||||
}
|
||||
})
|
||||
return html.toString()
|
||||
}
|
||||
|
||||
export function cleanContent(raw: string, base: string): string {
|
||||
const html = parse(raw)
|
||||
|
||||
const tagsToRemove = ['script', 'style', 'img', 'picture', 'video', 'iframe', 'svg']
|
||||
for (const tag of tagsToRemove) {
|
||||
html.querySelectorAll(tag).forEach((el) => el.remove())
|
||||
}
|
||||
html.querySelectorAll('a').forEach((el) => {
|
||||
const href = el.getAttribute('href')
|
||||
if (href) {
|
||||
el.setAttribute('href', new URL(href, base).toString())
|
||||
}
|
||||
})
|
||||
|
||||
return html.querySelector('body')?.innerHTML ?? ''
|
||||
}
|
74
src/index.ts
Normal file
74
src/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Eta } from 'eta'
|
||||
import Fastify from 'fastify'
|
||||
import path from 'node:path'
|
||||
|
||||
import { replaceLinks } from './html.js'
|
||||
import { init as initRoutes } from './routes.js'
|
||||
import { init as initError } from './error.js'
|
||||
|
||||
const app = Fastify({
|
||||
logger:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? true
|
||||
: {
|
||||
level: 'debug',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname,remoteAddress',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
config: {
|
||||
BRAVE_KEY: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await app
|
||||
.register(import('@fastify/compress'))
|
||||
.register(import('@fastify/etag'))
|
||||
.register(import('fastify-minify'), { global: true })
|
||||
.register(import('@fastify/view'), {
|
||||
engine: {
|
||||
eta: new Eta({ tags: ['{{', '}}'] }),
|
||||
},
|
||||
layout: 'layout',
|
||||
templates: path.resolve('templates'),
|
||||
})
|
||||
.register(import('@fastify/env'), {
|
||||
dotenv: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['BRAVE_KEY'],
|
||||
properties: {
|
||||
BRAVE_KEY: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
|
||||
// Redirect to blaze
|
||||
app.addHook('onSend', async (req, reply, payload) => {
|
||||
if (reply.getHeader('content-type')?.toString().startsWith('text/html')) {
|
||||
return replaceLinks(payload as string)
|
||||
}
|
||||
return payload
|
||||
})
|
||||
|
||||
initError(app)
|
||||
initRoutes(app)
|
||||
|
||||
// START
|
||||
try {
|
||||
await app.listen({ port: 8000, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
38
src/routes.ts
Normal file
38
src/routes.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { BlazeError } from './error.js'
|
||||
import { cleanContent } from './html.js'
|
||||
import { Brave, SearchEngine } from './search/index.js'
|
||||
|
||||
export function init(app: FastifyInstance) {
|
||||
const engine: SearchEngine = new Brave(app.config.BRAVE_KEY)
|
||||
|
||||
const SearchSchema = z.strictObject({ q: z.string().min(1) })
|
||||
app.get('/search', async (req, reply) => {
|
||||
const query = SearchSchema.safeParse(req.query)
|
||||
if (query.success) {
|
||||
const { q } = query.data
|
||||
const results = await engine.search(q)
|
||||
return reply.view('results', { results, title: `Blaze - ${q}`, needle: q })
|
||||
} else {
|
||||
throw new BlazeError(401, query.error.message)
|
||||
}
|
||||
})
|
||||
|
||||
const BrowseSchema = z.strictObject({ url: z.string().url() })
|
||||
app.get('/browse', async (req, reply) => {
|
||||
const query = BrowseSchema.safeParse(req.query)
|
||||
if (query.success) {
|
||||
const { url } = query.data
|
||||
const raw = await fetch(url).then((r) => r.text())
|
||||
return reply.view('browse', { title: 'Blaze', content: cleanContent(raw, url), needle: '' })
|
||||
} else {
|
||||
throw new BlazeError(401, query.error.message)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/', (req, reply) => {
|
||||
return reply.view('home', { title: 'Blaze', needle: '' })
|
||||
})
|
||||
}
|
92
src/search/brave.ts
Normal file
92
src/search/brave.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { SearchEngine } from './index.js'
|
||||
|
||||
// https://transform.tools/json-to-typescript
|
||||
export interface Root {
|
||||
query: Query
|
||||
mixed: Mixed
|
||||
type: string
|
||||
web: Web
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
original: string
|
||||
show_strict_warning: boolean
|
||||
is_navigational: boolean
|
||||
is_news_breaking: boolean
|
||||
spellcheck_off: boolean
|
||||
country: string
|
||||
bad_results: boolean
|
||||
should_fallback: boolean
|
||||
postal_code: string
|
||||
city: string
|
||||
header_country: string
|
||||
more_results_available: boolean
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface Mixed {
|
||||
type: string
|
||||
main: Main[]
|
||||
top: any[]
|
||||
side: any[]
|
||||
}
|
||||
|
||||
export interface Main {
|
||||
type: string
|
||||
index: number
|
||||
all: boolean
|
||||
}
|
||||
|
||||
export interface Web {
|
||||
type: string
|
||||
results: Result[]
|
||||
family_friendly: boolean
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
title: string
|
||||
url: string
|
||||
is_source_local: boolean
|
||||
is_source_both: boolean
|
||||
description: string
|
||||
language: string
|
||||
family_friendly: boolean
|
||||
type: string
|
||||
subtype: string
|
||||
meta_url: MetaUrl
|
||||
age?: string
|
||||
}
|
||||
|
||||
export interface MetaUrl {
|
||||
scheme: string
|
||||
netloc: string
|
||||
hostname: string
|
||||
favicon: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export class Brave implements SearchEngine {
|
||||
private static URL = 'https://api.search.brave.com/res/v1/web/search'
|
||||
|
||||
constructor(private key: string) {}
|
||||
|
||||
async search(query: string) {
|
||||
const url = `${Brave.URL}?${new URLSearchParams({
|
||||
q: query,
|
||||
safesearch: 'moderate',
|
||||
result_filter: 'web',
|
||||
}).toString()}`
|
||||
|
||||
const data: Root = await fetch(url, {
|
||||
headers: { 'X-Subscription-Token': this.key },
|
||||
}).then((response) => response.json())
|
||||
|
||||
return data.web.results.map((result) => {
|
||||
return {
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
11
src/search/index.ts
Normal file
11
src/search/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type Result = {
|
||||
title: string
|
||||
url: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export abstract class SearchEngine {
|
||||
abstract search(query: string): Promise<Result[]>
|
||||
}
|
||||
|
||||
export * from './brave.js'
|
12
src/search/mock.ts
Normal file
12
src/search/mock.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Result, SearchEngine } from './index.js'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
export class Mock implements SearchEngine {
|
||||
async search(query: string): Promise<Result[]> {
|
||||
return [...new Array(20)].map((_) => ({
|
||||
title: faker.internet.displayName(),
|
||||
url: faker.internet.url(),
|
||||
description: faker.lorem.sentences(),
|
||||
}))
|
||||
}
|
||||
}
|
2
templates/browse.eta
Normal file
2
templates/browse.eta
Normal file
@ -0,0 +1,2 @@
|
||||
{{~ include("./search.eta", it) }}
|
||||
<section>{{~ it.content }}</section>
|
2
templates/error.eta
Normal file
2
templates/error.eta
Normal file
@ -0,0 +1,2 @@
|
||||
<h1>Error: {{= it.code }}</h1>
|
||||
<p>{{= it.message }}</p>
|
3
templates/home.eta
Normal file
3
templates/home.eta
Normal file
@ -0,0 +1,3 @@
|
||||
<div style="margin: 10rem auto">
|
||||
{{~ include("./search", it) }}
|
||||
</div>
|
136
templates/layout.eta
Normal file
136
templates/layout.eta
Normal file
@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{= it.title }}</title>
|
||||
<style>
|
||||
/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
*,
|
||||
::after,
|
||||
::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
}
|
||||
abbr[title] {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'],
|
||||
button {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--blaze-primary: #ff9400;
|
||||
--blaze-bg: #fff8ee;
|
||||
--blaze-text: #120c02;
|
||||
}
|
||||
|
||||
main {
|
||||
color: var(--blaze-text);
|
||||
max-width: 50rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>{{~ it.body }}</main>
|
||||
</body>
|
||||
</html>
|
42
templates/results.eta
Normal file
42
templates/results.eta
Normal file
@ -0,0 +1,42 @@
|
||||
{{~ include("./search.eta", it) }}
|
||||
<ol class="blaze_results">
|
||||
{{ it.results.forEach(function(result){ }}
|
||||
<li>
|
||||
<a href="{{= result.url }}">
|
||||
<article>
|
||||
<pre>{{= result.url }}</pre>
|
||||
<h2>{{= result.title }}</h2>
|
||||
<p>{{~ result.description }}</p>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
{{ }) }}
|
||||
</ol>
|
||||
<style>
|
||||
.blaze_results li {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.blaze_results a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.blaze_results pre {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.blaze_results p {
|
||||
margin: 0;
|
||||
}
|
||||
.blaze_results h2 {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ol.blaze_results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
48
templates/search.eta
Normal file
48
templates/search.eta
Normal file
@ -0,0 +1,48 @@
|
||||
<form class="blaze_form" action="/search" method="get">
|
||||
<a style="font-size: 2rem; font-weight: bold" href="/" data-preserve-link>Bla⚡️e</a>
|
||||
<input type="search" name="q" placeholder="search something..." value="{{= it.needle }}" />
|
||||
<button type="submit">Search 🔎</button>
|
||||
</form>
|
||||
<style>
|
||||
form.blaze_form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.5rem;
|
||||
border: 2px dashed black;
|
||||
}
|
||||
|
||||
.blaze_form a {
|
||||
text-decoration: inherit;
|
||||
color: inherit;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.blaze_form input,
|
||||
.blaze_form button {
|
||||
border-radius: 0;
|
||||
background-color: var(--blaze-bg);
|
||||
color: var(--blaze-text);
|
||||
border: 2px solid var(--blaze-primary);
|
||||
}
|
||||
.blaze_form input {
|
||||
padding: 0.25rem 0.25rem;
|
||||
min-width: 10rem;
|
||||
flex: 1;
|
||||
}
|
||||
.blaze_form button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 30rem) {
|
||||
form.blaze_form {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.blaze_form input,
|
||||
.blaze_form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user