chore: format code

This commit is contained in:
Ayo Ayco 2024-12-26 00:10:13 +01:00
parent d21e743569
commit 99315e639a
28 changed files with 1059 additions and 1027 deletions

View file

@ -37,7 +37,7 @@ export default [
parser: astroParser, parser: astroParser,
parserOptions: { parserOptions: {
parser: tseslint.parser, parser: tseslint.parser,
} },
}, },
}, },
] ]

View file

@ -1,16 +1,15 @@
--- ---
import Icon from 'astro-iconify' import Icon from 'astro-iconify'
import {VERSION} from '../consts'; import { VERSION } from '../consts'
--- ---
<footer> <footer>
<section> <section>Remove distractions. Save for later.</section>
Remove distractions. Save for later.
</section>
<section class="attribution"> <section class="attribution">
<a href="/blog/01-building-a-cozy-web/">Hand-crafted</a> with <Icon name="line-md:heart" /> by <a href="https://ayo.ayco.io">Ayo Ayco</a> <a href="/blog/01-building-a-cozy-web/">Hand-crafted</a> with <Icon
name="line-md:heart"
/> by <a href="https://ayo.ayco.io">Ayo Ayco</a>
<br /> <br />
<a href="/blog">Blog</a> • <a href="/blog">Blog</a> •
<a href="https://github.com/ayoayco/cozy">GitHub</a> • <a href="https://github.com/ayoayco/cozy">GitHub</a> •

View file

@ -1,12 +1,12 @@
--- ---
const { default: innerHTML } = await import(`/public/jumbotron.svg?raw`); const { default: innerHTML } = await import(`/public/jumbotron.svg?raw`)
--- ---
<header id="jumbotron"><a href="/"> <header id="jumbotron">
<a href="/">
<Fragment set:html={innerHTML} /> <Fragment set:html={innerHTML} />
</a>
</a></header> </header>
<style> <style>
#jumbotron { #jumbotron {
@ -19,7 +19,7 @@ const { default: innerHTML } = await import(`/public/jumbotron.svg?raw`);
} }
&:hover { &:hover {
filter: var(--svg-filter-accent) filter: var(--svg-filter-accent);
} }
} }
</style> </style>

View file

@ -1,6 +1,6 @@
--- ---
export interface Props { export interface Props {
skipSave?: boolean; skipSave?: boolean
} }
--- ---
@ -10,25 +10,25 @@ export interface Props {
</div> </div>
<script> <script>
import { getPostCard, renderPost } from "../utils/library"; import { getPostCard, renderPost } from '../utils/library'
import { cozify } from "../utils/sanitizer"; import { cozify } from '../utils/sanitizer'
const cache = await caches.open("cozy-reader"); const cache = await caches.open('cozy-reader')
const baseUrl = window.location.origin; const baseUrl = window.location.origin
let url = new URL(window.location.href); let url = new URL(window.location.href)
// only cached unencoded url param // only cached unencoded url param
const urlParam = url.searchParams.get("url"); const urlParam = url.searchParams.get('url')
if (urlParam) { if (urlParam) {
url = new URL(`${url.origin}/?url=${urlParam}`); url = new URL(`${url.origin}/?url=${urlParam}`)
} }
const preferencesEl = document.querySelector( const preferencesEl = document.querySelector(
"[data-preferences]" '[data-preferences]'
) as HTMLElement; ) as HTMLElement
const preferencesStr = preferencesEl?.dataset.preferences ?? ""; const preferencesStr = preferencesEl?.dataset.preferences ?? ''
const { skipSave } = JSON.parse(preferencesStr); const { skipSave } = JSON.parse(preferencesStr)
const routerOutlet = "router-outlet"; const routerOutlet = 'router-outlet'
const includesAppURL = urlParam?.includes(baseUrl) ?? false; const includesAppURL = urlParam?.includes(baseUrl) ?? false
try { try {
if ( if (
@ -36,110 +36,110 @@ export interface Props {
!skipSave && !skipSave &&
!includesAppURL !includesAppURL
) { ) {
console.info("adding one to cache", { console.info('adding one to cache', {
context: "cozy-reader", context: 'cozy-reader',
data: url, data: url,
}); })
await cache.add(url); await cache.add(url)
} }
} catch (error) { } catch (error) {
console.error("ERR", { context: "cozy-reader", data: error }); console.error('ERR', { context: 'cozy-reader', data: error })
} }
const cachedRequests = (await cache.keys()).filter((request) => { const cachedRequests = (await cache.keys()).filter((request) => {
const urlObj = new URL(request.url); const urlObj = new URL(request.url)
const urlParam = urlObj.searchParams.get("url"); const urlParam = urlObj.searchParams.get('url')
return ( return (
urlObj.search !== "" && urlObj.search !== '' &&
!urlParam?.startsWith(baseUrl) && !urlParam?.startsWith(baseUrl) &&
urlParam !== "" && urlParam !== '' &&
urlParam !== "null" urlParam !== 'null'
); )
}); })
if (cachedRequests?.length && routerOutlet !== null) { if (cachedRequests?.length && routerOutlet !== null) {
const list = document.querySelector("#post-list"); const list = document.querySelector('#post-list')
const heading = document.querySelector( const heading = document.querySelector(
"#library span#heading" '#library span#heading'
) as HTMLHeadingElement; ) as HTMLHeadingElement
heading.innerHTML = "History"; heading.innerHTML = 'History'
cachedRequests.reverse().forEach(async (request) => { cachedRequests.reverse().forEach(async (request) => {
const { url } = request; const { url } = request
const link = document.createElement("a"); const link = document.createElement('a')
let responseText; let responseText
const fullResponse = await cache.match(url); const fullResponse = await cache.match(url)
if ( if (
!fullResponse && !fullResponse &&
url.slice(0, url.length - 1) !== baseUrl && url.slice(0, url.length - 1) !== baseUrl &&
!skipSave && !skipSave &&
!includesAppURL !includesAppURL
) { ) {
console.info("updating cached", { context: "cozy-reader", data: url }); console.info('updating cached', { context: 'cozy-reader', data: url })
await cache.add(url); await cache.add(url)
} }
fullResponse?.text().then(async (data) => { fullResponse?.text().then(async (data) => {
responseText = data; responseText = data
const cleanedResponse = await cozify(responseText, baseUrl); const cleanedResponse = await cozify(responseText, baseUrl)
const html = document.createElement("html"); const html = document.createElement('html')
html.innerHTML = cleanedResponse; html.innerHTML = cleanedResponse
const title = html const title = html
.querySelector('meta[property="cozy:title"]') .querySelector('meta[property="cozy:title"]')
?.getAttribute("content"); ?.getAttribute('content')
if (title === "Something is not right") { if (title === 'Something is not right') {
cache.delete(url); cache.delete(url)
return; // temporary fix for deleting cached errors return // temporary fix for deleting cached errors
} }
const postCard = getPostCard(html); const postCard = getPostCard(html)
link.innerHTML = postCard; link.innerHTML = postCard
link.href = url; link.href = url
link.onclick = async (e) => { link.onclick = async (e) => {
e.preventDefault(); e.preventDefault()
localStorage.setItem("scrollPosition", window.scrollY.toString()); localStorage.setItem('scrollPosition', window.scrollY.toString())
scrollTo(0, 0); scrollTo(0, 0)
console.info("using cached response", { console.info('using cached response', {
context: "cozy-reader", context: 'cozy-reader',
data: url, data: url,
}); })
renderPost(cleanedResponse, url, routerOutlet); renderPost(cleanedResponse, url, routerOutlet)
}; }
const item = document.createElement("li"); const item = document.createElement('li')
item.appendChild(link); item.appendChild(link)
list?.appendChild(item); list?.appendChild(item)
}); })
}); })
window.addEventListener("popstate", async (data) => { window.addEventListener('popstate', async (data) => {
let url = data.state?.url; let url = data.state?.url
let isHome = false; let isHome = false
if (!url) { if (!url) {
url = window.location.href; url = window.location.href
isHome = true; isHome = true
} else { } else {
// replace scrollPosition // replace scrollPosition
localStorage.setItem("scrollPosition", window.scrollY.toString()); localStorage.setItem('scrollPosition', window.scrollY.toString())
} }
const fullResponse = await cache.match(url); const fullResponse = await cache.match(url)
fullResponse?.text().then(async (data) => { fullResponse?.text().then(async (data) => {
const responseText = data; const responseText = data
const cleanedResponse = await cozify(responseText, baseUrl); const cleanedResponse = await cozify(responseText, baseUrl)
console.info("using cached response", { console.info('using cached response', {
context: "cozy-reader", context: 'cozy-reader',
data: url, data: url,
}); })
renderPost(cleanedResponse, url, routerOutlet, true); renderPost(cleanedResponse, url, routerOutlet, true)
if (isHome) { if (isHome) {
const scrollPosition = localStorage.getItem("scrollPosition"); const scrollPosition = localStorage.getItem('scrollPosition')
scrollTo(0, scrollPosition ? parseInt(scrollPosition) : 0); scrollTo(0, scrollPosition ? parseInt(scrollPosition) : 0)
} }
}); })
}); })
} }
</script> </script>

View file

@ -1,27 +1,26 @@
--- ---
import { ArticleData } from "@extractus/article-extractor"; import { ArticleData } from '@extractus/article-extractor'
import { cozify } from "../utils/sanitizer" import { cozify } from '../utils/sanitizer'
export interface Props { export interface Props {
article: ArticleData | null; article: ArticleData | null
} }
const error: ArticleData = { const error: ArticleData = {
title: 'Something is not right', title: 'Something is not right',
content: '<p>The article extractor did not get any information.</p>', content: '<p>The article extractor did not get any information.</p>',
} }
let { article } = Astro.props; let { article } = Astro.props
article ??= error; article ??= error
const datePublished = const datePublished =
article?.published && new Date(article.published).toDateString(); article?.published && new Date(article.published).toDateString()
const cleanContent = await cozify(article.content ?? '', Astro.url.origin) const cleanContent = await cozify(article.content ?? '', Astro.url.origin)
--- ---
<!-- <!--
Changing anything inside the #post div can cause a difference in the cached version of the post in users' devices. For this reason, we should avoid changing the HTML and instead do it with CSS when possible. Changing anything inside the #post div can cause a difference in the cached version of the post in users' devices. For this reason, we should avoid changing the HTML and instead do it with CSS when possible.
--> -->{
{ article && article.url !== '/' && (
article && article.url !== '/' &&
<article id="post"> <article id="post">
{article.source && <span class="source">{article.source}</span>} {article.source && <span class="source">{article.source}</span>}
{article.title && <h1 class="title">{article.title}</h1>} {article.title && <h1 class="title">{article.title}</h1>}
@ -33,22 +32,27 @@ const cleanContent = await cozify(article.content ?? '', Astro.url.origin)
)} )}
<content set:html={cleanContent} /> <content set:html={cleanContent} />
</article> </article>
)
} }
<style> <style>
#post { #post {
h1.title { h1.title {
font-size: xx-large; font-size: xx-large;
margin: 0; margin: 0;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.2; line-height: 1.2;
} }
.source, .publish-info { .source,
.publish-info {
font-size: smaller; font-size: smaller;
color: #555; color: #555;
} }
@ -68,7 +72,10 @@ const cleanContent = await cozify(article.content ?? '', Astro.url.origin)
} }
content { content {
p, table, ul, img { p,
table,
ul,
img {
margin: 1em 0 !important; margin: 1em 0 !important;
font-size: 20px; font-size: 20px;
} }
@ -76,7 +83,8 @@ const cleanContent = await cozify(article.content ?? '', Astro.url.origin)
table { table {
border-collapse: collapse; border-collapse: collapse;
td, th { td,
th {
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 0.5em; padding: 0.5em;
} }
@ -92,11 +100,13 @@ const cleanContent = await cozify(article.content ?? '', Astro.url.origin)
} }
@media (max-width: 600px) { @media (max-width: 600px) {
p, table, ul, img { p,
table,
ul,
img {
font-size: 16px; font-size: 16px;
} }
} }
} }
} }
</style> </style>

View file

@ -1,12 +1,12 @@
--- ---
import Icon from "astro-iconify"; import Icon from 'astro-iconify'
export interface Props { export interface Props {
url: string | null; url: string | null
} }
const placeholder = "Type the article URL here"; const placeholder = 'Type the article URL here'
const { url } = Astro.props; const { url } = Astro.props
--- ---
<div id="address-bar"> <div id="address-bar">
@ -18,7 +18,7 @@ const { url } = Astro.props;
type="url" type="url"
id="app-url" id="app-url"
name="url" name="url"
value={url ?? ""} value={url ?? ''}
placeholder={placeholder} placeholder={placeholder}
required required
/> />
@ -39,7 +39,6 @@ const { url } = Astro.props;
> >
<Icon name="mdi:home" /> <Icon name="mdi:home" />
</a> </a>
</form> </form>
</div> </div>
@ -49,9 +48,7 @@ const { url } = Astro.props;
position: relative; position: relative;
} }
form:has( form:has(input[type='url']:focus) {
input[type="url"]:focus
) {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 1px 10px 0px var(--accent); box-shadow: 0 1px 10px 0px var(--accent);
} }
@ -66,11 +63,11 @@ const { url } = Astro.props;
box-shadow: 0 1px 3px 1px rgb(var(--gray-light)); box-shadow: 0 1px 3px 1px rgb(var(--gray-light));
display: flex; display: flex;
input[type="url"]:focus { input[type='url']:focus {
outline: none; outline: none;
} }
input[type="url"] { input[type='url'] {
flex: 3; flex: 3;
border: 0px; border: 0px;
border-radius: 30px; border-radius: 30px;
@ -131,7 +128,7 @@ const { url } = Astro.props;
color: blue !important; color: blue !important;
} }
.btn[disabled="true"] svg { .btn[disabled='true'] svg {
color: rgb(var(--gray-light)) !important; color: rgb(var(--gray-light)) !important;
cursor: default !important; cursor: default !important;
} }

View file

@ -1,21 +1,25 @@
--- ---
import '../../styles/reset.css'; import '../../styles/reset.css'
import '../../styles/variables.css'; import '../../styles/variables.css'
import '../../styles/blog.css'; import '../../styles/blog.css'
import { SITE_TITLE, SITE_AUTHOR, SITE_DESCRIPTION } from '../../consts'; import { SITE_TITLE, SITE_AUTHOR, SITE_DESCRIPTION } from '../../consts'
interface Props { interface Props {
title: string; title: string
description: string; description: string
isArticle?: boolean; isArticle?: boolean
image?: string; image?: string
} }
let {isArticle = false, title, description = 'default description', image = '/cozy.jpg' } = Astro.props; let {
isArticle = false,
title,
description = 'default description',
image = '/cozy.jpg',
} = Astro.props
description = title === SITE_TITLE description =
? SITE_DESCRIPTION title === SITE_TITLE ? SITE_DESCRIPTION : `${description} • ${SITE_TITLE}`
: `${description} • ${SITE_TITLE}`
--- ---
<!-- Global Metadata --> <!-- Global Metadata -->
@ -26,8 +30,20 @@ description = title === SITE_TITLE
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<!-- Font preloads --> <!-- Font preloads -->
<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin /> <link
<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin /> rel="preload"
href="/fonts/atkinson-regular.woff"
as="font"
type="font/woff"
crossorigin
/>
<link
rel="preload"
href="/fonts/atkinson-bold.woff"
as="font"
type="font/woff"
crossorigin
/>
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>{title} • {description}</title> <title>{title} • {description}</title>
@ -36,9 +52,11 @@ description = title === SITE_TITLE
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
{ {
isArticle isArticle ? (
? <meta property="og:type" content="article" /> <meta property="og:type" content="article" />
: <meta property="og:type" content="website" /> ) : (
<meta property="og:type" content="website" />
)
} }
<meta property="og:url" content={Astro.url} /> <meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
@ -46,5 +64,3 @@ description = title === SITE_TITLE
<meta property="og:image" content={new URL(image, Astro.url)} /> <meta property="og:image" content={new URL(image, Astro.url)} />
<meta property="og:site_name" content={SITE_TITLE} /> <meta property="og:site_name" content={SITE_TITLE} />
<meta property="article:author" content={SITE_AUTHOR} /> <meta property="article:author" content={SITE_AUTHOR} />

View file

@ -1,11 +1,20 @@
--- ---
import { SITE_AUTHOR, SITE_AUTHOR_EMAIL, SITE_AUTHOR_MASTODON, SITE_PROJECT_REPO } from "../../consts"; import {
const today = new Date(); SITE_AUTHOR,
SITE_AUTHOR_EMAIL,
SITE_AUTHOR_MASTODON,
SITE_PROJECT_REPO,
} from '../../consts'
const today = new Date()
--- ---
<footer> <footer>
<p>&copy; {today.getFullYear()} {SITE_AUTHOR}. All rights reserved.</p> <p>&copy; {today.getFullYear()} {SITE_AUTHOR}. All rights reserved.</p>
<p>Want to get in touch? Send a mail to <a href={`mailto:${SITE_AUTHOR_EMAIL}`}>Cozy at ayco.io</a>.</p> <p>
Want to get in touch? Send a mail to <a href={`mailto:${SITE_AUTHOR_EMAIL}`}
>Cozy at ayco.io</a
>.
</p>
<div class="social-links"> <div class="social-links">
<a href={SITE_AUTHOR_MASTODON} target="_blank"> <a href={SITE_AUTHOR_MASTODON} target="_blank">
<span class="sr-only">Follow Ayo on Mastodon</span> <span class="sr-only">Follow Ayo on Mastodon</span>
@ -23,7 +32,12 @@ const today = new Date();
</a> </a>
<a href={SITE_PROJECT_REPO} target="_blank"> <a href={SITE_PROJECT_REPO} target="_blank">
<span class="sr-only">Go to Cozy's GitHub repo</span> <span class="sr-only">Go to Cozy's GitHub repo</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github" <svg
viewBox="0 0 16 16"
aria-hidden="true"
width="32"
height="32"
astro-icon="social/github"
><path ><path
fill="currentColor" fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"

View file

@ -1,9 +1,9 @@
--- ---
interface Props { interface Props {
date: Date; date: Date
} }
const { date } = Astro.props; const { date } = Astro.props
--- ---
<time datetime={date.toISOString()}> <time datetime={date.toISOString()}>

View file

@ -1,5 +1,10 @@
--- ---
import { SITE_AUTHOR_MASTODON, SITE_DESCRIPTION, SITE_PROJECT_REPO, SITE_TITLE } from '../../consts'; import {
SITE_AUTHOR_MASTODON,
SITE_DESCRIPTION,
SITE_PROJECT_REPO,
SITE_TITLE,
} from '../../consts'
--- ---
<header> <header>
@ -69,7 +74,6 @@ import { SITE_AUTHOR_MASTODON, SITE_DESCRIPTION, SITE_PROJECT_REPO, SITE_TITLE }
padding: 1px 0.5em 0; padding: 1px 0.5em 0;
transition: 0.2s ease; transition: 0.2s ease;
} }
} }
nav .social-links a:hover { nav .social-links a:hover {

View file

@ -1,14 +1,14 @@
--- ---
import type { HTMLAttributes } from 'astro/types'; import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'a'>; type Props = HTMLAttributes<'a'>
const { href, class: className, ...props } = Astro.props; const { href, class: className, ...props } = Astro.props
const { pathname } = Astro.url; const { pathname } = Astro.url
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
const subpath = pathname.match(/[^\/]+/g); const subpath = pathname.match(/[^\/]+/g)
const isActive = href === pathname || href === '/' + subpath?.[0]; const isActive = href === pathname || href === '/' + subpath?.[0]
--- ---
<a href={href} class:list={[className, { active: isActive }]} {...props}> <a href={href} class:list={[className, { active: isActive }]} {...props}>

View file

@ -1,9 +1,9 @@
export const SITE_TITLE = 'Cozy Blog'; export const SITE_TITLE = 'Cozy Blog'
export const SITE_AUTHOR = 'Ayo Ayco'; export const SITE_AUTHOR = 'Ayo Ayco'
export const SITE_AUTHOR_URL = 'https://ayo.ayco.io'; export const SITE_AUTHOR_URL = 'https://ayo.ayco.io'
export const SITE_AUTHOR_EMAIL = 'cozy@ayco.io'; export const SITE_AUTHOR_EMAIL = 'cozy@ayco.io'
export const SITE_AUTHOR_MASTODON = 'https://social.ayco.io/@ayo'; export const SITE_AUTHOR_MASTODON = 'https://social.ayco.io/@ayo'
export const SITE_PROJECT_REPO = 'https://github.com/ayoayco/Cozy'; export const SITE_PROJECT_REPO = 'https://github.com/ayoayco/Cozy'
export const SITE_DESCRIPTION = 'The Web is Yours.'; export const SITE_DESCRIPTION = 'The Web is Yours.'
export const VERSION = 'Yummy-Ydrasil'; export const VERSION = 'Yummy-Ydrasil'

View file

@ -30,6 +30,7 @@ The project and the road map for features are all public on my [GitHub](https://
Right now, it successfully extracts the content and delivers a clean page to your browser. Right now, it successfully extracts the content and delivers a clean page to your browser.
I'm working toward bringing the following in the coming weeks: I'm working toward bringing the following in the coming weeks:
1. Save favorites to a library 1. Save favorites to a library
2. Offline access 2. Offline access
3. Smart Insights about the article 3. Smart Insights about the article
@ -50,7 +51,7 @@ javascript:(function(){ window.open('https://cozy.pub/?url=%27 + window.location
This is possible on all major browsers, including Safari on iOS (where I personally use this often). Some screenshots: This is possible on all major browsers, including Safari on iOS (where I personally use this often). Some screenshots:
| Firefox | Chrome | | Firefox | Chrome |
| --- | --- | | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| ![Screenshot from 2023-05-13 08-31-41](https://github.com/ayoayco/cozy-reader/assets/4262489/9b296d4f-2722-483a-bbc2-431c6b2ae996) | ![Screenshot from 2023-05-12 23-32-08](https://github.com/ayoayco/cozy-reader/assets/4262489/144b74f8-3949-46b9-849c-351e4af0ac12) | | ![Screenshot from 2023-05-13 08-31-41](https://github.com/ayoayco/cozy-reader/assets/4262489/9b296d4f-2722-483a-bbc2-431c6b2ae996) | ![Screenshot from 2023-05-12 23-32-08](https://github.com/ayoayco/cozy-reader/assets/4262489/144b74f8-3949-46b9-849c-351e4af0ac12) |
## Join the Project! ## Join the Project!

View file

@ -1,4 +1,4 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content'
const blog = defineCollection({ const blog = defineCollection({
type: 'content', type: 'content',
@ -11,6 +11,6 @@ const blog = defineCollection({
updatedDate: z.coerce.date().optional(), updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(), heroImage: z.string().optional(),
}), }),
}); })
export const collections = { blog }; export const collections = { blog }

View file

@ -12,13 +12,10 @@ Since then, I've been using Cozy almost every time I read an article online. I h
You visit a news website, for example, and you just know the content are mostly just a bait You visit a news website, for example, and you just know the content are mostly just a bait
Browsers are not helping. AI Browsers are not helping. AI
Having a web page let's me skip all the noise that plague almost all modern websites Having a web page let's me skip all the noise that plague almost all modern websites
[Astro's on-demand rendering](https://docs.astro.build/en/guides/server-side-rendering/) and [JavaScript's Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) [Astro's on-demand rendering](https://docs.astro.build/en/guides/server-side-rendering/) and [JavaScript's Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache)
The goal for this site is to have a place where I publish about the new features of the [web app](/) and the web development techniques used to achieve them. The goal for this site is to have a place where I publish about the new features of the [web app](/) and the web development techniques used to achieve them.

View file

@ -1,14 +1,14 @@
--- ---
import BaseHead from "../components/blog/BaseHead.astro"; import BaseHead from '../components/blog/BaseHead.astro'
import Header from "../components/blog/Header.astro"; import Header from '../components/blog/Header.astro'
import Footer from "../components/blog/Footer.astro"; import Footer from '../components/blog/Footer.astro'
import FormattedDate from "../components/blog/FormattedDate.astro"; import FormattedDate from '../components/blog/FormattedDate.astro'
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from 'astro:content'
import { SITE_AUTHOR, SITE_AUTHOR_URL, SITE_AUTHOR_EMAIL } from "../consts"; import { SITE_AUTHOR, SITE_AUTHOR_URL, SITE_AUTHOR_EMAIL } from '../consts'
type Props = CollectionEntry<"blog">["data"]; type Props = CollectionEntry<'blog'>['data']
const { title, description, pubDate, updatedDate, heroImage } = Astro.props; const { title, description, pubDate, updatedDate, heroImage } = Astro.props
--- ---
<html lang="en"> <html lang="en">
@ -64,7 +64,6 @@ const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
color: var(--accent); color: var(--accent);
box-shadow: 0 2px 8px var(--accent); box-shadow: 0 2px 8px var(--accent);
} }
} }
} }
@ -138,16 +137,7 @@ const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
<main> <main>
<article> <article>
<div class="hero-image"> <div class="hero-image">
{ {heroImage && <img width={700} height={510} src={heroImage} alt="" />}
heroImage && (
<img
width={700}
height={510}
src={heroImage}
alt=""
/>
)
}
</div> </div>
<div class="prose"> <div class="prose">
<div class="title"> <div class="title">
@ -163,7 +153,12 @@ const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
</div> </div>
<h1>{title}</h1> <h1>{title}</h1>
<address style="display:inline"> <address style="display:inline">
By <img class="avatar" src="/ayoayco-avatar.jpg" alt="Ayo Ayco's Avatar" /> <a rel="author" href={ SITE_AUTHOR_URL }>{ SITE_AUTHOR }</a> By <img
class="avatar"
src="/ayoayco-avatar.jpg"
alt="Ayo Ayco's Avatar"
/>
<a rel="author" href={SITE_AUTHOR_URL}>{SITE_AUTHOR}</a>
</address> </address>
</div> </div>
<slot /> <slot />

View file

@ -1,19 +1,16 @@
--- ---
import App from "../layouts/App.astro"; import App from '../layouts/App.astro'
import Library from "../components/Library.astro"; import Library from '../components/Library.astro'
import Footer from "../components/Footer.astro"; import Footer from '../components/Footer.astro'
import SimpleAddressBar from "../components/SimpleAddressBar.astro"; import SimpleAddressBar from '../components/SimpleAddressBar.astro'
export const prerender = false;
export const prerender = false
--- ---
<App article={null}> <App article={null}>
<SimpleAddressBar url='' /> <SimpleAddressBar url="" />
<div slot="post" id="router-outlet"> <div slot="post" id="router-outlet">
<h1> <h1>404: Not Found</h1>
404: Not Found
</h1>
</div> </div>
<Library slot="library" skipSave /> <Library slot="library" skipSave />
<Footer slot="footer" /> <Footer slot="footer" />

View file

@ -1,18 +1,18 @@
--- ---
import { type CollectionEntry, getCollection } from 'astro:content'; import { type CollectionEntry, getCollection } from 'astro:content'
import Blog from '../../layouts/Blog.astro'; import Blog from '../../layouts/Blog.astro'
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('blog'); const posts = await getCollection('blog')
return posts.map((post) => ({ return posts.map((post) => ({
params: { slug: post.slug }, params: { slug: post.slug },
props: post, props: post,
})); }))
} }
type Props = CollectionEntry<'blog'>; type Props = CollectionEntry<'blog'>
const post = Astro.props; const post = Astro.props
const { Content } = await post.render(); const { Content } = await post.render()
--- ---
<Blog {...post.data}> <Blog {...post.data}>

View file

@ -1,14 +1,14 @@
--- ---
import BaseHead from '../../components/blog/BaseHead.astro'; import BaseHead from '../../components/blog/BaseHead.astro'
import Header from '../../components/blog/Header.astro'; import Header from '../../components/blog/Header.astro'
import Footer from '../../components/blog/Footer.astro'; import Footer from '../../components/blog/Footer.astro'
import FormattedDate from '../../components/blog/FormattedDate.astro'; import FormattedDate from '../../components/blog/FormattedDate.astro'
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content'
import {SITE_TITLE, SITE_DESCRIPTION} from '../../consts'; import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts'
const posts = (await getCollection('blog')).sort( const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
); )
--- ---
<!doctype html> <!doctype html>
@ -115,13 +115,9 @@ const posts = (await getCollection('blog')).sort(
<FormattedDate date={post.data.pubDate} /> <FormattedDate date={post.data.pubDate} />
</small> </small>
<h4 class="title"> <h4 class="title">
<a href={`/blog/${post.slug}/`}> <a href={`/blog/${post.slug}/`}>{post.data.title}</a>
{post.data.title}
</a>
</h4> </h4>
<p class="description"> <p class="description">{post.data.description}</p>
{post.data.description}
</p>
</li> </li>
)) ))
} }

View file

@ -1,28 +1,28 @@
--- ---
import { ArticleData, extract } from "@extractus/article-extractor"; import { ArticleData, extract } from '@extractus/article-extractor'
import SimpleAddressBar from "../components/SimpleAddressBar.astro"; import SimpleAddressBar from '../components/SimpleAddressBar.astro'
import Post from "../components/Post.astro"; import Post from '../components/Post.astro'
import App from "../layouts/App.astro"; import App from '../layouts/App.astro'
import Library from "../components/Library.astro"; import Library from '../components/Library.astro'
import Footer from "../components/Footer.astro"; import Footer from '../components/Footer.astro'
export const prerender = false; export const prerender = false
let url = Astro.url.searchParams.get('url'); let url = Astro.url.searchParams.get('url')
let article: ArticleData | null = {url: '/'}; let article: ArticleData | null = { url: '/' }
while (url?.startsWith(Astro.url.origin)) { while (url?.startsWith(Astro.url.origin)) {
url = new URL(url).searchParams.get('url'); url = new URL(url).searchParams.get('url')
} }
if (url) if (url)
try { try {
article = await extract(url); article = await extract(url)
} catch { } catch {
article = null; article = null
} }
--- ---
<App article={article}> <App article={article}>
<SimpleAddressBar url={url} /> <SimpleAddressBar url={url} />
<div slot="post" id="router-outlet"> <div slot="post" id="router-outlet">

View file

@ -32,7 +32,6 @@ body {
color: rgb(var(--gray-dark)); color: rgb(var(--gray-dark));
font-size: 20px; font-size: 20px;
line-height: 1.7; line-height: 1.7;
} }
main { main {

View file

@ -1,13 +1,13 @@
:root { :root {
--accent: #3054bf; --accent: #3054bf;
--svg-filter-accent: invert(25%) sepia(86%) saturate(1533%) hue-rotate(210deg) brightness(91%) contrast(90%); --svg-filter-accent: invert(25%) sepia(86%) saturate(1533%) hue-rotate(210deg)
brightness(91%) contrast(90%);
--accent-dark: #203880; --accent-dark: #203880;
--black: 15, 18, 25; --black: 15, 18, 25;
--gray: 96, 115, 159; --gray: 96, 115, 159;
--gray-light: 229, 233, 240; --gray-light: 229, 233, 240;
--gray-dark: 34, 41, 57; --gray-dark: 34, 41, 57;
--gray-gradient: rgba(var(--gray-light), 50%), #fff; --gray-gradient: rgba(var(--gray-light), 50%), #fff;
--box-shadow: 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%), --box-shadow: 0 2px 6px rgba(var(--gray), 25%),
0 16px 32px rgba(var(--gray), 33%); 0 8px 24px rgba(var(--gray), 33%), 0 16px 32px rgba(var(--gray), 33%);
} }

View file

@ -6,86 +6,88 @@
const cacheName = `${__prefix ?? 'app'}-v${__version ?? '000'}` const cacheName = `${__prefix ?? 'app'}-v${__version ?? '000'}`
const cleanOldCaches = async () => { const cleanOldCaches = async () => {
const allowCacheNames = ['cozy-reader', cacheName]; const allowCacheNames = ['cozy-reader', cacheName]
const allCaches = await caches.keys(); const allCaches = await caches.keys()
allCaches.forEach(key => { allCaches.forEach((key) => {
if (!allowCacheNames.includes(key)) { if (!allowCacheNames.includes(key)) {
console.info('Deleting old cache', key); console.info('Deleting old cache', key)
caches.delete(key); caches.delete(key)
} }
}); })
} }
const addResourcesToCache = async (resources) => { const addResourcesToCache = async (resources) => {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName)
console.info('adding resources to cache...', resources) console.info('adding resources to cache...', resources)
try { try {
await cache.addAll(resources); await cache.addAll(resources)
} catch (error) { } catch (error) {
console.error('failed to add resources to cache; make sure requests exists and that there are no duplicates', { console.error(
'failed to add resources to cache; make sure requests exists and that there are no duplicates',
{
resources, resources,
error error,
}) }
)
}
} }
};
const putInCache = async (request, response) => { const putInCache = async (request, response) => {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName)
if (response.ok) { if (response.ok) {
console.info('adding one response to cache...', request.url) console.info('adding one response to cache...', request.url)
// if exists, replace // if exists, replace
cache.keys().then( keys => { cache.keys().then((keys) => {
if (keys.includes(request)) { if (keys.includes(request)) {
cache.delete(request); cache.delete(request)
} }
}); })
cache.put(request, response); cache.put(request, response)
}
} }
};
const cacheAndRevalidate = async ({ request, fallbackUrl }) => { const cacheAndRevalidate = async ({ request, fallbackUrl }) => {
const cache = await caches.open(cacheName)
const cache = await caches.open(cacheName);
// Try get the resource from the cache // Try get the resource from the cache
const responseFromCache = await cache.match(request); const responseFromCache = await cache.match(request)
if (responseFromCache) { if (responseFromCache) {
console.info('using cached response...', responseFromCache.url); console.info('using cached response...', responseFromCache.url)
// get network response for revalidation of cached assets // get network response for revalidation of cached assets
fetch(request.clone()).then((responseFromNetwork) => { fetch(request.clone())
.then((responseFromNetwork) => {
if (responseFromNetwork) { if (responseFromNetwork) {
console.info('fetched updated resource...', responseFromNetwork.url) console.info('fetched updated resource...', responseFromNetwork.url)
putInCache(request, responseFromNetwork.clone()); putInCache(request, responseFromNetwork.clone())
} }
}).catch((error) => { })
.catch((error) => {
console.info('failed to fetch updated resource', error) console.info('failed to fetch updated resource', error)
}); })
return responseFromCache; return responseFromCache
} }
try { try {
// Try to get the resource from the network for 5 seconds // Try to get the resource from the network for 5 seconds
const responseFromNetwork = await fetch(request.clone()); const responseFromNetwork = await fetch(request.clone())
// response may be used only once // response may be used only once
// we need to save clone to put one copy in cache // we need to save clone to put one copy in cache
// and serve second one // and serve second one
putInCache(request, responseFromNetwork.clone()); putInCache(request, responseFromNetwork.clone())
console.info('using network response', responseFromNetwork.url) console.info('using network response', responseFromNetwork.url)
return responseFromNetwork; return responseFromNetwork
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) { } catch (error) {
// Try the fallback // Try the fallback
const fallbackResponse = await cache.match(fallbackUrl); const fallbackResponse = await cache.match(fallbackUrl)
if (fallbackResponse) { if (fallbackResponse) {
console.info('using fallback cached response...', fallbackResponse.url) console.info('using fallback cached response...', fallbackResponse.url)
return fallbackResponse; return fallbackResponse
} }
// when even the fallback response is not available, // when even the fallback response is not available,
@ -94,33 +96,30 @@ const cacheAndRevalidate = async ({ request, fallbackUrl }) => {
return new Response('Network error happened', { return new Response('Network error happened', {
status: 408, status: 408,
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
}); })
}
} }
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.info('activating service worker...') console.info('activating service worker...')
cleanOldCaches(); cleanOldCaches()
}); })
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
console.info('installing service worker...') console.info('installing service worker...')
self.skipWaiting(); // go straight to activate self.skipWaiting() // go straight to activate
event.waitUntil( event.waitUntil(addResourcesToCache(__assets ?? []))
addResourcesToCache(__assets ?? []) })
);
});
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
console.info('fetch happened', {data: event}); console.info('fetch happened', { data: event })
event.respondWith( event.respondWith(
cacheAndRevalidate({ cacheAndRevalidate({
request: event.request, request: event.request,
fallbackUrl: './', fallbackUrl: './',
}) })
); )
}); })

View file

@ -1,5 +1,5 @@
export function getPostCard(html: HTMLHtmlElement) { export function getPostCard(html: HTMLHtmlElement) {
const {title, description, image, source, published} = getPostMeta(html); const { title, description, image, source, published } = getPostMeta(html)
const postCard = ` const postCard = `
<div class="post-card"> <div class="post-card">
<div class="post-card__image"> <div class="post-card__image">
@ -17,99 +17,109 @@ export function getPostCard(html: HTMLHtmlElement) {
? ` ? `
<div class="post-card__meta"> <div class="post-card__meta">
${ ${
source source &&
&& ` `
<p class="post-card__source">${source}</p> <p class="post-card__source">${source}</p>
` `
} }
${ ${
published published &&
&& ` `
<p class="post-card__published">${ <p class="post-card__published">${
new Date(published)?.toLocaleDateString() || "" new Date(published)?.toLocaleDateString() || ''
}</p> }</p>
` `
} }
</div> </div>
` `
: "" : ''
} }
<h3 class="post-card__title">${title}</h3> <h3 class="post-card__title">${title}</h3>
${ ${
description description
? ` ? `
<p class="post-card__description">${description}</p>` <p class="post-card__description">${description}</p>`
: "" : ''
} }
</div> </div>
</div> </div>
`; `
return postCard; return postCard
} }
export function renderPost(responseText: string | null, url, postDivSelector: string, preventPushState = false) { export function renderPost(
const postDiv = document.querySelector<HTMLDivElement>(`#${postDivSelector}`); responseText: string | null,
let postText = ''; url,
let cozyUrl = '/'; postDivSelector: string,
let cozyTitle = 'Cozy'; preventPushState = false
) {
const postDiv = document.querySelector<HTMLDivElement>(`#${postDivSelector}`)
let postText = ''
let cozyUrl = '/'
let cozyTitle = 'Cozy'
if (responseText) { if (responseText) {
const html = document.createElement('html'); const html = document.createElement('html')
html.innerHTML = responseText; html.innerHTML = responseText
const newPost = html.querySelector('body')?.querySelector('#post'); const newPost = html.querySelector('body')?.querySelector('#post')
postText = newPost?.outerHTML || ''; postText = newPost?.outerHTML || ''
cozyUrl = html.querySelector('meta[property="cozy:url"]')?.getAttribute('content') ?? '/'; cozyUrl =
cozyTitle = `${getCozyTitle(html)} | Cozy`; html
.querySelector('meta[property="cozy:url"]')
?.getAttribute('content') ?? '/'
cozyTitle = `${getCozyTitle(html)} | Cozy`
} }
if (postDiv) { if (postDiv) {
postDiv.innerHTML = postText; postDiv.innerHTML = postText
const appUrl = document.getElementById('app-url') as HTMLInputElement; const appUrl = document.getElementById('app-url') as HTMLInputElement
const backBtn = document.querySelector<HTMLButtonElement>('#app-back'); const backBtn = document.querySelector<HTMLButtonElement>('#app-back')
const submitBtn = document.querySelector<HTMLButtonElement>('#submit'); const submitBtn = document.querySelector<HTMLButtonElement>('#submit')
if (cozyUrl !== '/') { if (cozyUrl !== '/') {
appUrl.value = cozyUrl || ''; appUrl.value = cozyUrl || ''
backBtn?.removeAttribute('disabled'); backBtn?.removeAttribute('disabled')
submitBtn?.removeAttribute('disabled'); submitBtn?.removeAttribute('disabled')
document.title = cozyTitle; document.title = cozyTitle
} else { } else {
appUrl.value = ''; appUrl.value = ''
backBtn?.setAttribute('disabled', 'true'); backBtn?.setAttribute('disabled', 'true')
submitBtn?.setAttribute('disabled', 'true'); submitBtn?.setAttribute('disabled', 'true')
document.title = `Cozy`; document.title = `Cozy`
} }
if (!preventPushState) { if (!preventPushState) {
window.history.pushState({url}, '', url); window.history.pushState({ url }, '', url)
} }
} }
} }
function getPostMeta(html: HTMLHtmlElement) { function getPostMeta(html: HTMLHtmlElement) {
const title = getCozyTitle(html); const title = getCozyTitle(html)
const description = html const description = html
.querySelector('meta[property="cozy:description"]') .querySelector('meta[property="cozy:description"]')
?.getAttribute("content"); ?.getAttribute('content')
const image = html const image = html
.querySelector('meta[property="cozy:image"]') .querySelector('meta[property="cozy:image"]')
?.getAttribute("content"); ?.getAttribute('content')
const source = html const source = html
.querySelector('meta[property="cozy:source"]') .querySelector('meta[property="cozy:source"]')
?.getAttribute("content"); ?.getAttribute('content')
const published = html const published = html
.querySelector('meta[property="cozy:published"]') .querySelector('meta[property="cozy:published"]')
?.getAttribute("content"); ?.getAttribute('content')
return {title, description, image, source, published}; return { title, description, image, source, published }
} }
function getCozyTitle(html: HTMLHtmlElement): string | undefined { function getCozyTitle(html: HTMLHtmlElement): string | undefined {
return html.querySelector('meta[property="cozy:title"]')?.getAttribute("content") return (
html
.querySelector('meta[property="cozy:title"]')
?.getAttribute('content') ??
/** /**
* backwards compatibility for stuff before we implemented cozy:meta tags * backwards compatibility for stuff before we implemented cozy:meta tags
* REMOVE ON V1 release * REMOVE ON V1 release
*/ */
?? html.querySelector("title")?.innerHTML html.querySelector('title')?.innerHTML?.replace('Cozy 🧸 | ', '')
?.replace("Cozy 🧸 | ", "") )
} }

View file

@ -2,7 +2,6 @@ import { parse, render, transform, walkSync } from 'ultrahtml'
import sanitize from 'ultrahtml/transformers/sanitize' import sanitize from 'ultrahtml/transformers/sanitize'
export async function cozify(html: string, baseUrl: string): Promise<string> { export async function cozify(html: string, baseUrl: string): Promise<string> {
// remove target="_blank" from links // remove target="_blank" from links
const ast = parse(html) const ast = parse(html)
walkSync(ast, (node) => { walkSync(ast, (node) => {
@ -12,15 +11,14 @@ export async function cozify(html: string, baseUrl: string): Promise<string> {
} }
}) })
const newHtml = await render(ast); const newHtml = await render(ast)
return transform(newHtml, [ return transform(newHtml, [
sanitize({ sanitize({
dropElements: ['script'], dropElements: ['script'],
dropAttributes: { dropAttributes: {
target: ['a'] target: ['a'],
} },
}) }),
]) ])
} }

View file

@ -1,31 +1,31 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from 'vitest'
import { cozify } from "../src/utils/sanitizer"; import { cozify } from '../src/utils/sanitizer'
describe("cozify()", async () => { describe('cozify()', async () => {
const baseUrl = "https://cozy.pub"; const baseUrl = 'https://cozy.pub'
test("should remove scripts", async () => { test('should remove scripts', async () => {
const html = "<h1>HELLO</h1><script>console.log()</script>"; const html = '<h1>HELLO</h1><script>console.log()</script>'
const result = await cozify(html, baseUrl); const result = await cozify(html, baseUrl)
expect(result).not.toContain("<script>"); expect(result).not.toContain('<script>')
}); })
test("should remove target=_blank from links", async () => { test('should remove target=_blank from links', async () => {
const html = "<a href=# target='_blank'>hey</a>"; const html = "<a href=# target='_blank'>hey</a>"
const result = await cozify(html, baseUrl); const result = await cozify(html, baseUrl)
expect(result).not.toContain("target"); expect(result).not.toContain('target')
console.log(result); console.log(result)
}); })
test("should add base url to href of links", async () => { test('should add base url to href of links', async () => {
const html = "<a href=#>hey</a>"; const html = '<a href=#>hey</a>'
const result = await cozify(html, baseUrl); const result = await cozify(html, baseUrl)
expect(result).toContain('href="https://cozy.pub?url=#"'); expect(result).toContain('href="https://cozy.pub?url=#"')
}); })
test("should add prefetch=true to links", async () => { test('should add prefetch=true to links', async () => {
const html = "<a href=#>hey</a>"; const html = '<a href=#>hey</a>'
const result = await cozify(html, baseUrl); const result = await cozify(html, baseUrl)
expect(result).toContain('prefetch="true"'); expect(result).toContain('prefetch="true"')
}); })
}); })