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,
parserOptions: {
parser: tseslint.parser,
}
},
},
},
]

View file

@ -1,16 +1,15 @@
---
import Icon from 'astro-iconify'
import {VERSION} from '../consts';
import { VERSION } from '../consts'
---
<footer>
<section>
Remove distractions. Save for later.
</section>
<section>Remove distractions. Save for later.</section>
<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 />
<a href="/blog">Blog</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="/">
<Fragment set:html={innerHTML} />
</a></header>
<header id="jumbotron">
<a href="/">
<Fragment set:html={innerHTML} />
</a>
</header>
<style>
#jumbotron {
@ -19,7 +19,7 @@ const { default: innerHTML } = await import(`/public/jumbotron.svg?raw`);
}
&:hover {
filter: var(--svg-filter-accent)
filter: var(--svg-filter-accent);
}
}
</style>

View file

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

View file

@ -1,102 +1,112 @@
---
import { ArticleData } from "@extractus/article-extractor";
import { cozify } from "../utils/sanitizer"
import { ArticleData } from '@extractus/article-extractor'
import { cozify } from '../utils/sanitizer'
export interface Props {
article: ArticleData | null;
article: ArticleData | null
}
const error: ArticleData = {
title: 'Something is not right',
content: '<p>The article extractor did not get any information.</p>',
}
let { article } = Astro.props;
article ??= error;
let { article } = Astro.props
article ??= error
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)
---
<!--
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 id="post">
{article.source && <span class="source">{article.source}</span>}
{article.title && <h1 class="title">{article.title}</h1>}
{(article.author || datePublished) && (
<ul class="publish-info">
{article.author && <li>{article.author} </li>}
{datePublished && <li>{datePublished}</li>}
</ul>
)}
<content set:html={cleanContent} />
</article>
}
-->{
article && article.url !== '/' && (
<article id="post">
{article.source && <span class="source">{article.source}</span>}
{article.title && <h1 class="title">{article.title}</h1>}
{(article.author || datePublished) && (
<ul class="publish-info">
{article.author && <li>{article.author} </li>}
{datePublished && <li>{datePublished}</li>}
</ul>
)}
<content set:html={cleanContent} />
</article>
)
}
<style>
#post {
h1.title {
font-size: xx-large;
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
}
.source, .publish-info {
font-size: smaller;
color: #555;
}
.source {
font-weight: bold;
}
.publish-info {
padding-left: 0;
margin: 0;
list-style: none;
li {
#post {
h1.title {
font-size: xx-large;
margin: 0;
}
}
content {
p, table, ul, img {
margin: 1em 0 !important;
font-size: 20px;
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.2;
}
table {
border-collapse: collapse;
td, th {
border: 1px solid #ccc;
padding: 0.5em;
.source,
.publish-info {
font-size: smaller;
color: #555;
}
.source {
font-weight: bold;
}
.publish-info {
padding-left: 0;
margin: 0;
list-style: none;
li {
margin: 0;
}
}
pre {
white-space: pre-wrap;
&:has(code) {
padding: 1em;
background: #f6f8fa;
border-radius: 5px;
content {
p,
table,
ul,
img {
margin: 1em 0 !important;
font-size: 20px;
}
}
@media (max-width: 600px) {
p, table, ul, img {
font-size: 16px;
table {
border-collapse: collapse;
td,
th {
border: 1px solid #ccc;
padding: 0.5em;
}
}
pre {
white-space: pre-wrap;
&:has(code) {
padding: 1em;
background: #f6f8fa;
border-radius: 5px;
}
}
@media (max-width: 600px) {
p,
table,
ul,
img {
font-size: 16px;
}
}
}
}
}
</style>
</style>

View file

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

View file

@ -1,21 +1,25 @@
---
import '../../styles/reset.css';
import '../../styles/variables.css';
import '../../styles/blog.css';
import { SITE_TITLE, SITE_AUTHOR, SITE_DESCRIPTION } from '../../consts';
import '../../styles/reset.css'
import '../../styles/variables.css'
import '../../styles/blog.css'
import { SITE_TITLE, SITE_AUTHOR, SITE_DESCRIPTION } from '../../consts'
interface Props {
title: string;
description: string;
isArticle?: boolean;
image?: string;
title: string
description: string
isArticle?: boolean
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
? SITE_DESCRIPTION
: `${description} • ${SITE_TITLE}`
description =
title === SITE_TITLE ? SITE_DESCRIPTION : `${description} • ${SITE_TITLE}`
---
<!-- Global Metadata -->
@ -26,8 +30,20 @@ description = title === SITE_TITLE
<meta name="generator" content={Astro.generator} />
<!-- Font preloads -->
<link 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 />
<link
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 -->
<title>{title} • {description}</title>
@ -36,9 +52,11 @@ description = title === SITE_TITLE
<!-- Open Graph / Facebook -->
{
isArticle
? <meta property="og:type" content="article" />
: <meta property="og:type" content="website" />
isArticle ? (
<meta property="og:type" content="article" />
) : (
<meta property="og:type" content="website" />
)
}
<meta property="og:url" content={Astro.url} />
<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:site_name" content={SITE_TITLE} />
<meta property="article:author" content={SITE_AUTHOR} />

View file

@ -1,67 +1,81 @@
---
import { SITE_AUTHOR, SITE_AUTHOR_EMAIL, SITE_AUTHOR_MASTODON, SITE_PROJECT_REPO } from "../../consts";
const today = new Date();
import {
SITE_AUTHOR,
SITE_AUTHOR_EMAIL,
SITE_AUTHOR_MASTODON,
SITE_PROJECT_REPO,
} from '../../consts'
const today = new Date()
---
<footer>
<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>
<div class="social-links">
<a href={SITE_AUTHOR_MASTODON} target="_blank">
<span class="sr-only">Follow Ayo on Mastodon</span>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
width="32"
height="32"
astro-icon="social/mastodon"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href={SITE_PROJECT_REPO} target="_blank">
<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"
><path
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"
></path></svg
>
</a>
</div>
<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>
<div class="social-links">
<a href={SITE_AUTHOR_MASTODON} target="_blank">
<span class="sr-only">Follow Ayo on Mastodon</span>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
width="32"
height="32"
astro-icon="social/mastodon"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href={SITE_PROJECT_REPO} target="_blank">
<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"
><path
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"
></path></svg
>
</a>
</div>
</footer>
<style>
footer {
padding: 2em 1em 6em 1em;
background: linear-gradient(var(--gray-gradient)) no-repeat;
color: rgb(var(--gray));
text-align: center;
footer {
padding: 2em 1em 6em 1em;
background: linear-gradient(var(--gray-gradient)) no-repeat;
color: rgb(var(--gray));
text-align: center;
& p {
margin-bottom: 0;
& p {
margin-bottom: 0;
& a {
color: rgb(var(--gray));
& a {
color: rgb(var(--gray));
&:hover {
color: rgb(var(--gray-dark));
}
}
}
}
.social-links {
display: flex;
justify-content: center;
gap: 1em;
margin-top: 1em;
}
.social-links a {
text-decoration: none;
color: rgb(var(--gray));
}
.social-links a:hover {
color: rgb(var(--gray-dark));
}
&:hover {
color: rgb(var(--gray-dark));
}
}
}
}
.social-links {
display: flex;
justify-content: center;
gap: 1em;
margin-top: 1em;
}
.social-links a {
text-decoration: none;
color: rgb(var(--gray));
}
.social-links a:hover {
color: rgb(var(--gray-dark));
}
</style>

View file

@ -1,17 +1,17 @@
---
interface Props {
date: Date;
date: Date
}
const { date } = Astro.props;
const { date } = Astro.props
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View file

@ -1,136 +1,140 @@
---
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>
<nav>
<div class="site-title">
<h2><a href="/blog">{SITE_TITLE}</a></h2>
<small class="site-description">{SITE_DESCRIPTION}</small>
</div>
<div class="social-links">
<a href="/">
<span class="primary-btn">Get Cozy!</span>
</a>
<a href={SITE_AUTHOR_MASTODON} target="_blank">
<span class="sr-only">Follow Ayo on Mastodon</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href={SITE_PROJECT_REPO} target="_blank">
<span class="sr-only">Go to Cozy's GitHub repo</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><path
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"
></path></svg
>
</a>
</div>
</nav>
<nav>
<div class="site-title">
<h2><a href="/blog">{SITE_TITLE}</a></h2>
<small class="site-description">{SITE_DESCRIPTION}</small>
</div>
<div class="social-links">
<a href="/">
<span class="primary-btn">Get Cozy!</span>
</a>
<a href={SITE_AUTHOR_MASTODON} target="_blank">
<span class="sr-only">Follow Ayo on Mastodon</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href={SITE_PROJECT_REPO} target="_blank">
<span class="sr-only">Go to Cozy's GitHub repo</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><path
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"
></path></svg
>
</a>
</div>
</nav>
</header>
<style>
header {
margin: 0;
padding: 0 1em;
background: white;
box-shadow: 0 2px 8px rgba(var(--black), 5%);
width: 100%;
}
h2 {
margin: 0;
font-size: x-large;
}
header {
margin: 0;
padding: 0 1em;
background: white;
box-shadow: 0 2px 8px rgba(var(--black), 5%);
width: 100%;
}
h2 {
margin: 0;
font-size: x-large;
}
h2 a,
h2 a.active {
text-decoration: none;
}
nav {
width: 900px;
max-width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1em 0.5em;
margin: 0 auto;
h2 a,
h2 a.active {
text-decoration: none;
}
nav {
width: 900px;
max-width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1em 0.5em;
margin: 0 auto;
& span.primary-btn {
background-color: rgba(var(--black), 95%);
box-shadow: 0 2px 8px rgba(var(--black), 5%);
color: white;
border-radius: 5px;
display: inline-block;
text-align: center;
padding: 1px 0.5em 0;
transition: 0.2s ease;
}
& span.primary-btn {
background-color: rgba(var(--black), 95%);
box-shadow: 0 2px 8px rgba(var(--black), 5%);
color: white;
border-radius: 5px;
display: inline-block;
text-align: center;
padding: 1px 0.5em 0;
transition: 0.2s ease;
}
}
}
nav .social-links a:hover {
& span {
background-color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
}
nav .social-links a:hover {
& span {
background-color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
}
nav .site-title {
display: flex;
}
nav .site-title {
display: flex;
}
nav a,
nav .site-description {
color: var(--black);
border-bottom: 4px solid transparent;
text-decoration: none;
}
nav a,
nav .site-description {
color: var(--black);
border-bottom: 4px solid transparent;
text-decoration: none;
}
nav a {
padding: 0 0.5em;
}
nav a:hover {
color: var(--accent);
}
nav a {
padding: 0 0.5em;
}
nav a:hover {
color: var(--accent);
}
nav a.active {
text-decoration: none;
border-bottom-color: var(--accent);
}
.social-links,
.social-links a {
display: flex;
}
nav a.active {
text-decoration: none;
border-bottom-color: var(--accent);
}
.social-links,
.social-links a {
display: flex;
}
@media (max-width: 700px) {
nav {
display: block;
padding: 1em 0;
@media (max-width: 700px) {
nav {
display: block;
padding: 1em 0;
& .site-description {
font-size: 1rem;
}
& .site-description {
font-size: 1rem;
}
& span.primary-btn {
line-height: 1.5rem;
height: 1.5rem;
}
& span.primary-btn {
line-height: 1.5rem;
height: 1.5rem;
}
& .social-links a {
font-size: small;
& .social-links a {
font-size: small;
svg {
height: 1.5rem;
}
}
}
nav a {
padding: 0.5em;
padding-left: 0;
}
}
svg {
height: 1.5rem;
}
}
}
nav a {
padding: 0.5em;
padding-left: 0;
}
}
</style>

View file

@ -1,26 +1,26 @@
---
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
const subpath = pathname.match(/[^\/]+/g);
const isActive = href === pathname || href === '/' + subpath?.[0];
const subpath = pathname.match(/[^\/]+/g)
const isActive = href === pathname || href === '/' + subpath?.[0]
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
<slot />
</a>
<style>
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
</style>

View file

@ -1,9 +1,9 @@
export const SITE_TITLE = 'Cozy Blog';
export const SITE_AUTHOR = 'Ayo Ayco';
export const SITE_AUTHOR_URL = 'https://ayo.ayco.io';
export const SITE_AUTHOR_EMAIL = 'cozy@ayco.io';
export const SITE_AUTHOR_MASTODON = 'https://social.ayco.io/@ayo';
export const SITE_PROJECT_REPO = 'https://github.com/ayoayco/Cozy';
export const SITE_DESCRIPTION = 'The Web is Yours.';
export const SITE_TITLE = 'Cozy Blog'
export const SITE_AUTHOR = 'Ayo Ayco'
export const SITE_AUTHOR_URL = 'https://ayo.ayco.io'
export const SITE_AUTHOR_EMAIL = 'cozy@ayco.io'
export const SITE_AUTHOR_MASTODON = 'https://social.ayco.io/@ayo'
export const SITE_PROJECT_REPO = 'https://github.com/ayoayco/Cozy'
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.
I'm working toward bringing the following in the coming weeks:
1. Save favorites to a library
2. Offline access
3. Smart Insights about the article
@ -49,8 +50,8 @@ 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:
| 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) |
## Join the Project!
@ -63,4 +64,4 @@ I think there's lots of good a simple tool could bring if it allows users to cut
This project is a groundwork for this experience.
Let's build the web we want! 🧸
Let's build the web we want! 🧸

View file

@ -1,16 +1,16 @@
import { defineCollection, z } from 'astro:content';
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
});
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
})
export const collections = { blog };
export const collections = { blog }

View file

@ -10,15 +10,12 @@ At the time, Cozy was just another fun weekend project I built as I played aroun
Since then, I've been using Cozy almost every time I read an article online. I have come to love the feeling of control, privacy, and ownership it gives -- something we have lost in almost all "modern" online experiences nowadays.
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
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)
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

@ -8,4 +8,4 @@ pubDate: 'Aug 19 2024'
New features since Jun 1 2023 -- 😄
-->
Ideally, I will have a post for each new app version deployed--for which I decided to do a naming convention: two words that [alliterate](https://cozy.pub/?url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FAlliteration), the first one being an adjective and the second a noun (e.g., <CurrentVersion />).
Ideally, I will have a post for each new app version deployed--for which I decided to do a naming convention: two words that [alliterate](https://cozy.pub/?url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FAlliteration), the first one being an adjective and the second a noun (e.g., <CurrentVersion />).

2
src/env.d.ts vendored
View file

@ -1 +1 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference path="../.astro/types.d.ts" />

View file

@ -1,183 +1,178 @@
---
import BaseHead from "../components/blog/BaseHead.astro";
import Header from "../components/blog/Header.astro";
import Footer from "../components/blog/Footer.astro";
import FormattedDate from "../components/blog/FormattedDate.astro";
import type { CollectionEntry } from "astro:content";
import { SITE_AUTHOR, SITE_AUTHOR_URL, SITE_AUTHOR_EMAIL } from "../consts";
import BaseHead from '../components/blog/BaseHead.astro'
import Header from '../components/blog/Header.astro'
import Footer from '../components/blog/Footer.astro'
import FormattedDate from '../components/blog/FormattedDate.astro'
import type { CollectionEntry } from 'astro:content'
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">
<head>
<BaseHead title={title} description={description} isArticle={true} />
<style>
main {
width: calc(100% - 2em);
max-width: 100%;
margin: 0 auto;
}
<head>
<BaseHead title={title} description={description} isArticle={true} />
<style>
main {
width: calc(100% - 2em);
max-width: 100%;
margin: 0 auto;
}
& .cta-wrapper {
width: 300px;
max-width: 100%;
text-align: center;
padding: 1em 0;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
& .cta-wrapper {
width: 300px;
max-width: 100%;
text-align: center;
padding: 1em 0;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
& a {
text-decoration: none;
color: rgb(var(--black));
transition: 0.2s ease;
& a {
text-decoration: none;
color: rgb(var(--black));
transition: 0.2s ease;
&:has(span) {
border-radius: 5px;
display: inline-block;
text-align: center;
padding: calc(0.5em + 4px) 0.5em 0.5em;
line-height: 1em;
}
&:has(span) {
border-radius: 5px;
display: inline-block;
text-align: center;
padding: calc(0.5em + 4px) 0.5em 0.5em;
line-height: 1em;
}
&:has(span.secondary-btn) {
border: 1px solid rgba(var(--black), 95%);
}
&:has(span.secondary-btn) {
border: 1px solid rgba(var(--black), 95%);
}
&:has(span.primary-btn) {
background-color: rgba(var(--black), 95%);
box-shadow: 0 2px 8px rgba(var(--black), 5%);
color: white;
}
&:has(span.primary-btn) {
background-color: rgba(var(--black), 95%);
box-shadow: 0 2px 8px rgba(var(--black), 5%);
color: white;
}
&:has(span.primary-btn:hover) {
background-color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
&:has(span.primary-btn:hover) {
background-color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
&:has(span.secondary-btn:hover) {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
&:has(span.secondary-btn:hover) {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 2px 8px var(--accent);
}
}
}
}
}
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
}
.prose {
width: 650px;
max-width: calc(100% - 2em);
margin: auto;
padding: 0 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
}
.prose {
width: 650px;
max-width: calc(100% - 2em);
margin: auto;
padding: 0 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
& .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline;
/* height: calc(1rem + 6px); */
margin: 0 0.5rem;
margin-bottom: -10px;
}
& .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline;
/* height: calc(1rem + 6px); */
margin: 0 0.5rem;
margin-bottom: -10px;
}
& a[rel='author'] {
color: rgb(var(--black));
}
& a[rel='author']:hover {
color: var(--accent);
}
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
@media (max-width: 700px) {
main {
width: 100%;
& a[rel='author'] {
color: rgb(var(--black));
}
& a[rel='author']:hover {
color: var(--accent);
}
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
@media (max-width: 700px) {
main {
width: 100%;
& .prose {
max-width: calc(100% - 1em);
padding: 0;
}
& .prose {
max-width: calc(100% - 1em);
padding: 0;
}
& .cta-wrapper {
width: 250px;
& a {
font-size: 0.75em;
}
}
}
}
</style>
</head>
& .cta-wrapper {
width: 250px;
& a {
font-size: 0.75em;
}
}
}
}
</style>
</head>
<body>
<Header />
<main>
<article>
<div class="hero-image">
{
heroImage && (
<img
width={700}
height={510}
src={heroImage}
alt=""
/>
)
}
</div>
<div class="prose">
<div class="title">
<div class="date">
<FormattedDate date={pubDate} />
{
updatedDate && (
<div class="last-updated-on">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</div>
<h1>{title}</h1>
<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>
</address>
</div>
<slot />
</div>
</article>
<div class="cta-wrapper">
<a href="/">
<span class="primary-btn">Get Cozy!</span>
</a>
<a href={`mailto:${SITE_AUTHOR_EMAIL}`}>
<span class="secondary-btn">Email Us</span>
</a>
</div>
</main>
<Footer />
</body>
<body>
<Header />
<main>
<article>
<div class="hero-image">
{heroImage && <img width={700} height={510} src={heroImage} alt="" />}
</div>
<div class="prose">
<div class="title">
<div class="date">
<FormattedDate date={pubDate} />
{
updatedDate && (
<div class="last-updated-on">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</div>
<h1>{title}</h1>
<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>
</address>
</div>
<slot />
</div>
</article>
<div class="cta-wrapper">
<a href="/">
<span class="primary-btn">Get Cozy!</span>
</a>
<a href={`mailto:${SITE_AUTHOR_EMAIL}`}>
<span class="secondary-btn">Email Us</span>
</a>
</div>
</main>
<Footer />
</body>
</html>

View file

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

View file

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

View file

@ -1,133 +1,129 @@
---
import BaseHead from '../../components/blog/BaseHead.astro';
import Header from '../../components/blog/Header.astro';
import Footer from '../../components/blog/Footer.astro';
import FormattedDate from '../../components/blog/FormattedDate.astro';
import { getCollection } from 'astro:content';
import {SITE_TITLE, SITE_DESCRIPTION} from '../../consts';
import BaseHead from '../../components/blog/BaseHead.astro'
import Header from '../../components/blog/Header.astro'
import Footer from '../../components/blog/Footer.astro'
import FormattedDate from '../../components/blog/FormattedDate.astro'
import { getCollection } from 'astro:content'
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts'
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>
<html lang="en">
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
<style>
main {
width: 700px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 2rem;
list-style-type: none;
margin: 0;
padding: 0;
}
ul li * {
text-decoration: none;
transition: 0.2s ease;
}
.card {
border: 1px solid rgb(var(--gray));
border-radius: 12px;
width: 100%;
padding: 1em;
margin-bottom: 1rem;
text-align: center;
color: rgb(var(--black));
position: relative;
background-color: white;
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
<style>
main {
width: 700px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 2rem;
list-style-type: none;
margin: 0;
padding: 0;
}
ul li * {
text-decoration: none;
transition: 0.2s ease;
}
.card {
border: 1px solid rgb(var(--gray));
border-radius: 12px;
width: 100%;
padding: 1em;
margin-bottom: 1rem;
text-align: center;
color: rgb(var(--black));
position: relative;
background-color: white;
& img {
width: 100%;
max-width: 800px;
}
& img {
width: 100%;
max-width: 800px;
}
& .title {
font-size: 2.369rem;
margin: 1rem;
color: rgb(var(--black));
line-height: 1;
}
& .title {
font-size: 2.369rem;
margin: 1rem;
color: rgb(var(--black));
line-height: 1;
}
& .description {
margin-bottom: 0;
}
& .description {
margin-bottom: 0;
}
& h4 a::after {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
cursor: pointer;
}
& h4 a::after {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
cursor: pointer;
}
& h4 {
text-decoration: underline !important;
color: var(--accent);
text-decoration-thickness: 2px !important;
& h4 {
text-decoration: underline !important;
color: var(--accent);
text-decoration-thickness: 2px !important;
& a {
color: rgb(var(--black));
text-decoration: none;
}
& a {
color: rgb(var(--black));
text-decoration: none;
}
&:hover a {
color: var(--accent);
}
}
}
&:hover a {
color: var(--accent);
}
}
}
.date {
margin: 0;
color: rgb(var(--gray));
}
@media (max-width: 700px) {
ul {
gap: 0.5em;
}
ul li {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<Header />
<main>
<section>
<ul>
{
posts.map((post) => (
<li class="card">
{/* {
.date {
margin: 0;
color: rgb(var(--gray));
}
@media (max-width: 700px) {
ul {
gap: 0.5em;
}
ul li {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<Header />
<main>
<section>
<ul>
{
posts.map((post) => (
<li class="card">
{/* {
post.data.heroImage
? <img width={700} height={360} src={post.data.heroImage} alt="" />
: <img width={700} height={360} src="/blog-placeholder-4.jpg" alt="" />
} */}
<small class="date">
<FormattedDate date={post.data.pubDate} />
</small>
<h4 class="title">
<a href={`/blog/${post.slug}/`}>
{post.data.title}
</a>
</h4>
<p class="description">
{post.data.description}
</p>
</li>
))
}
</ul>
</section>
</main>
<Footer />
</body>
<small class="date">
<FormattedDate date={post.data.pubDate} />
</small>
<h4 class="title">
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
</h4>
<p class="description">{post.data.description}</p>
</li>
))
}
</ul>
</section>
</main>
<Footer />
</body>
</html>

View file

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

View file

@ -5,41 +5,40 @@
*/
@font-face {
font-family: 'Atkinson';
src: url('/fonts/atkinson-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
font-family: 'Atkinson';
src: url('/fonts/atkinson-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Atkinson';
src: url('/fonts/atkinson-bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
font-family: 'Atkinson';
src: url('/fonts/atkinson-bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Atkinson', sans-serif;
margin: 0;
padding: 0;
text-align: left;
background: linear-gradient(var(--gray-gradient)) no-repeat;
background-size: 100% 600px;
word-wrap: break-word;
overflow-wrap: break-word;
color: rgb(var(--gray-dark));
font-size: 20px;
line-height: 1.7;
font-family: 'Atkinson', sans-serif;
margin: 0;
padding: 0;
text-align: left;
background: linear-gradient(var(--gray-gradient)) no-repeat;
background-size: 100% 600px;
word-wrap: break-word;
overflow-wrap: break-word;
color: rgb(var(--gray-dark));
font-size: 20px;
line-height: 1.7;
}
main {
width: 700px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
width: 700px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
}
h1,
@ -48,42 +47,42 @@ h3,
h4,
h5,
h6 {
margin: 0 0 0.5rem 0;
color: rgb(var(--black));
line-height: 1.2;
margin: 0 0 0.5rem 0;
color: rgb(var(--black));
line-height: 1.2;
}
h1 {
font-size: 2.441em;
font-size: 2.441em;
}
h2 {
font-size: 1.753em;
font-size: 1.753em;
}
h3 {
font-size: 1.563em;
font-size: 1.563em;
}
h4 {
font-size: 1.35em;
font-size: 1.35em;
}
h5 {
font-size: 1.15em;
font-size: 1.15em;
}
strong,
b {
font-weight: 700;
font-weight: 700;
}
a {
color: var(--accent);
color: var(--accent);
}
a:hover {
color: var(--accent);
color: var(--accent);
}
p,
@ -91,88 +90,88 @@ ul,
ol,
table,
pre.astro-code {
margin-bottom: 1em;
margin-bottom: 1em;
}
.prose {
& p,
& ul,
& ol,
& table {
margin-bottom: 1em;
}
& p,
& ul,
& ol,
& table {
margin-bottom: 1em;
}
}
textarea {
width: 100%;
font-size: 16px;
width: 100%;
font-size: 16px;
}
input {
font-size: 16px;
font-size: 16px;
}
table {
width: 100%;
width: 100%;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
max-width: 100%;
height: auto;
border-radius: 8px;
}
code {
padding: 2px 5px;
background-color: rgb(var(--gray-light));
border-radius: 2px;
padding: 2px 5px;
background-color: rgb(var(--gray-light));
border-radius: 2px;
}
pre {
padding: 1.5em;
border-radius: 8px;
padding: 1.5em;
border-radius: 8px;
}
pre>code {
all: unset;
pre > code {
all: unset;
}
blockquote {
border-left: 2px solid var(--accent);
padding: 0 0 0 1em;
margin: 0px;
font-size: 1em;
border-left: 2px solid var(--accent);
padding: 0 0 0 1em;
margin: 0px;
font-size: 1em;
}
hr {
border: none;
border-top: 1px solid rgb(var(--gray-light));
border: none;
border-top: 1px solid rgb(var(--gray-light));
}
@media (max-width: 700px) {
body {
font-size: 18px;
}
body {
font-size: 18px;
}
main {
padding: 1em;
}
main {
padding: 1em;
}
}
.sr-only {
border: 0;
padding: 0;
margin: 0;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
/* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
clip: rect(1px 1px 1px 1px);
/* maybe deprecated but we need to support legacy browsers */
clip: rect(1px, 1px, 1px, 1px);
/* modern browsers, clip-path works inwards from each corner */
clip-path: inset(50%);
/* added line to stop words getting smushed together (as they go onto separate lines and some screen readers do not understand line feeds as a space */
white-space: nowrap;
}
border: 0;
padding: 0;
margin: 0;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
/* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
clip: rect(1px 1px 1px 1px);
/* maybe deprecated but we need to support legacy browsers */
clip: rect(1px, 1px, 1px, 1px);
/* modern browsers, clip-path works inwards from each corner */
clip-path: inset(50%);
/* added line to stop words getting smushed together (as they go onto separate lines and some screen readers do not understand line feeds as a space */
white-space: nowrap;
}

View file

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

View file

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

View file

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

View file

@ -2,25 +2,23 @@ import { parse, render, transform, walkSync } from 'ultrahtml'
import sanitize from 'ultrahtml/transformers/sanitize'
export async function cozify(html: string, baseUrl: string): Promise<string> {
// remove target="_blank" from links
const ast = parse(html)
walkSync(ast, (node) => {
if (node.name === 'a') {
node.attributes.href = `${baseUrl}?url=${node.attributes.href}`
node.attributes.prefetch = true
}
})
// remove target="_blank" from links
const ast = parse(html)
walkSync(ast, (node) => {
if (node.name === 'a') {
node.attributes.href = `${baseUrl}?url=${node.attributes.href}`
node.attributes.prefetch = true
}
})
const newHtml = await render(ast)
const newHtml = await render(ast);
return transform(newHtml, [
sanitize({
dropElements: ['script'],
dropAttributes: {
target: ['a']
}
})
])
return transform(newHtml, [
sanitize({
dropElements: ['script'],
dropAttributes: {
target: ['a'],
},
}),
])
}

View file

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