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,102 +1,112 @@
--- ---
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>} {(article.author || datePublished) && (
{(article.author || datePublished) && ( <ul class="publish-info">
<ul class="publish-info"> {article.author && <li>{article.author} </li>}
{article.author && <li>{article.author} </li>} {datePublished && <li>{datePublished}</li>}
{datePublished && <li>{datePublished}</li>} </ul>
</ul> )}
)} <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;
}
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 {
margin: 0; margin: 0;
} }
}
content { h1,
p, table, ul, img { h2,
margin: 1em 0 !important; h3,
font-size: 20px; h4,
h5,
h6 {
line-height: 1.2;
} }
table { .source,
border-collapse: collapse; .publish-info {
font-size: smaller;
td, th { color: #555;
border: 1px solid #ccc; }
padding: 0.5em;
.source {
font-weight: bold;
}
.publish-info {
padding-left: 0;
margin: 0;
list-style: none;
li {
margin: 0;
} }
} }
pre { content {
white-space: pre-wrap; p,
&:has(code) { table,
padding: 1em; ul,
background: #f6f8fa; img {
border-radius: 5px; margin: 1em 0 !important;
font-size: 20px;
} }
}
table {
@media (max-width: 600px) { border-collapse: collapse;
p, table, ul, img {
font-size: 16px; 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 { 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;
@ -97,7 +94,7 @@ const { url } = Astro.props;
cursor: pointer; cursor: pointer;
} }
} }
.btn.primary { .btn.primary {
color: var(--accent); color: var(--accent);
} }
@ -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,67 +1,81 @@
--- ---
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>
<div class="social-links"> Want to get in touch? Send a mail to <a href={`mailto:${SITE_AUTHOR_EMAIL}`}
<a href={SITE_AUTHOR_MASTODON} target="_blank"> >Cozy at ayco.io</a
<span class="sr-only">Follow Ayo on Mastodon</span> >.
<svg </p>
viewBox="0 0 16 16" <div class="social-links">
aria-hidden="true" <a href={SITE_AUTHOR_MASTODON} target="_blank">
width="32" <span class="sr-only">Follow Ayo on Mastodon</span>
height="32" <svg
astro-icon="social/mastodon" viewBox="0 0 16 16"
><path aria-hidden="true"
fill="currentColor" width="32"
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" height="32"
></path></svg astro-icon="social/mastodon"
> ><path
</a> fill="currentColor"
<a href={SITE_PROJECT_REPO} target="_blank"> 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"
<span class="sr-only">Go to Cozy's GitHub repo</span> ></path></svg
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github" >
><path </a>
fill="currentColor" <a href={SITE_PROJECT_REPO} target="_blank">
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" <span class="sr-only">Go to Cozy's GitHub repo</span>
></path></svg <svg
> viewBox="0 0 16 16"
</a> aria-hidden="true"
</div> 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> </footer>
<style> <style>
footer { footer {
padding: 2em 1em 6em 1em; padding: 2em 1em 6em 1em;
background: linear-gradient(var(--gray-gradient)) no-repeat; background: linear-gradient(var(--gray-gradient)) no-repeat;
color: rgb(var(--gray)); color: rgb(var(--gray));
text-align: center; text-align: center;
& p { & p {
margin-bottom: 0; margin-bottom: 0;
& a { & a {
color: rgb(var(--gray)); color: rgb(var(--gray));
&:hover { &:hover {
color: rgb(var(--gray-dark)); color: rgb(var(--gray-dark));
} }
} }
} }
} }
.social-links { .social-links {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 1em; gap: 1em;
margin-top: 1em; margin-top: 1em;
} }
.social-links a { .social-links a {
text-decoration: none; text-decoration: none;
color: rgb(var(--gray)); color: rgb(var(--gray));
} }
.social-links a:hover { .social-links a:hover {
color: rgb(var(--gray-dark)); color: rgb(var(--gray-dark));
} }
</style> </style>

View file

@ -1,17 +1,17 @@
--- ---
interface Props { interface Props {
date: Date; date: Date
} }
const { date } = Astro.props; const { date } = Astro.props
--- ---
<time datetime={date.toISOString()}> <time datetime={date.toISOString()}>
{ {
date.toLocaleDateString('en-us', { date.toLocaleDateString('en-us', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}) })
} }
</time> </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> <header>
<nav> <nav>
<div class="site-title"> <div class="site-title">
<h2><a href="/blog">{SITE_TITLE}</a></h2> <h2><a href="/blog">{SITE_TITLE}</a></h2>
<small class="site-description">{SITE_DESCRIPTION}</small> <small class="site-description">{SITE_DESCRIPTION}</small>
</div> </div>
<div class="social-links"> <div class="social-links">
<a href="/"> <a href="/">
<span class="primary-btn">Get Cozy!</span> <span class="primary-btn">Get Cozy!</span>
</a> </a>
<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>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><path ><path
fill="currentColor" 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" 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 ></path></svg
> >
</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" <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
><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"
></path></svg ></path></svg
> >
</a> </a>
</div> </div>
</nav> </nav>
</header> </header>
<style> <style>
header { header {
margin: 0; margin: 0;
padding: 0 1em; padding: 0 1em;
background: white; background: white;
box-shadow: 0 2px 8px rgba(var(--black), 5%); box-shadow: 0 2px 8px rgba(var(--black), 5%);
width: 100%; width: 100%;
} }
h2 { h2 {
margin: 0; margin: 0;
font-size: x-large; font-size: x-large;
} }
h2 a, h2 a,
h2 a.active { h2 a.active {
text-decoration: none; text-decoration: none;
} }
nav { nav {
width: 900px; width: 900px;
max-width: 100%; max-width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1em 0.5em; padding: 1em 0.5em;
margin: 0 auto; margin: 0 auto;
& span.primary-btn { & span.primary-btn {
background-color: rgba(var(--black), 95%); background-color: rgba(var(--black), 95%);
box-shadow: 0 2px 8px rgba(var(--black), 5%); box-shadow: 0 2px 8px rgba(var(--black), 5%);
color: white; color: white;
border-radius: 5px; border-radius: 5px;
display: inline-block; display: inline-block;
text-align: center; text-align: center;
padding: 1px 0.5em 0; padding: 1px 0.5em 0;
transition: 0.2s ease; 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 { nav .site-title {
& span { display: flex;
background-color: var(--accent); }
box-shadow: 0 2px 8px var(--accent);
}
}
nav .site-title { nav a,
display: flex; nav .site-description {
} color: var(--black);
border-bottom: 4px solid transparent;
text-decoration: none;
}
nav a, nav a {
nav .site-description { padding: 0 0.5em;
color: var(--black); }
border-bottom: 4px solid transparent; nav a:hover {
text-decoration: none; color: var(--accent);
} }
nav a { nav a.active {
padding: 0 0.5em; text-decoration: none;
} border-bottom-color: var(--accent);
nav a:hover { }
color: var(--accent); .social-links,
} .social-links a {
display: flex;
}
nav a.active { @media (max-width: 700px) {
text-decoration: none; nav {
border-bottom-color: var(--accent); display: block;
} padding: 1em 0;
.social-links,
.social-links a {
display: flex;
}
@media (max-width: 700px) { & .site-description {
nav { font-size: 1rem;
display: block; }
padding: 1em 0;
& .site-description { & span.primary-btn {
font-size: 1rem; line-height: 1.5rem;
} height: 1.5rem;
}
& span.primary-btn { & .social-links a {
line-height: 1.5rem; font-size: small;
height: 1.5rem;
}
& .social-links a { svg {
font-size: small; height: 1.5rem;
}
svg { }
height: 1.5rem; }
} nav a {
} padding: 0.5em;
} padding-left: 0;
nav a { }
padding: 0.5em; }
padding-left: 0;
}
}
</style> </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 // 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}>
<slot /> <slot />
</a> </a>
<style> <style>
a { a {
display: inline-block; display: inline-block;
text-decoration: none; text-decoration: none;
} }
a.active { a.active {
font-weight: bolder; font-weight: bolder;
text-decoration: underline; text-decoration: underline;
} }
</style> </style>

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
@ -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: 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!
@ -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. 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({ const blog = defineCollection({
type: 'content', type: 'content',
// Type-check frontmatter using a schema // Type-check frontmatter using a schema
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
// Transform string to Date object // Transform string to Date object
pubDate: z.coerce.date(), pubDate: z.coerce.date(),
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

@ -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. 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 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

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

View file

@ -1,20 +1,17 @@
--- ---
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 </div>
</h1> <Library slot="library" skipSave />
</div> <Footer slot="footer" />
<Library slot="library" skipSave /> </App>
<Footer slot="footer" />
</App>

View file

@ -1,20 +1,20 @@
--- ---
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}>
<Content /> <Content />
</Blog> </Blog>

View file

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

View file

@ -1,33 +1,33 @@
--- ---
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">
<Post article={article} /> <Post article={article} />
</div> </div>
<Library slot="library" skipSave={article === null} /> <Library slot="library" skipSave={article === null} />
<Footer slot="footer" /> <Footer slot="footer" />
</App> </App>

View file

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

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)
--accent-dark: #203880; brightness(91%) contrast(90%);
--black: 15, 18, 25; --accent-dark: #203880;
--gray: 96, 115, 159; --black: 15, 18, 25;
--gray-light: 229, 233, 240; --gray: 96, 115, 159;
--gray-dark: 34, 41, 57; --gray-light: 229, 233, 240;
--gray-gradient: rgba(var(--gray-light), 50%), #fff; --gray-dark: 34, 41, 57;
--box-shadow: 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%), --gray-gradient: rgba(var(--gray-light), 50%), #fff;
0 16px 32px rgba(var(--gray), 33%); --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 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(
resources, 'failed to add resources to cache; make sure requests exists and that there are no duplicates',
error {
}) resources,
} 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
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 return responseFromCache
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; try {
} // Try to get the resource from the network for 5 seconds
const responseFromNetwork = await fetch(request.clone())
try { // response may be used only once
// Try to get the resource from the network for 5 seconds // we need to save clone to put one copy in cache
const responseFromNetwork = await fetch(request.clone()); // and serve second one
// response may be used only once putInCache(request, responseFromNetwork.clone())
// we need to save clone to put one copy in cache console.info('using network response', responseFromNetwork.url)
// and serve second one return responseFromNetwork
putInCache(request, responseFromNetwork.clone());
console.info('using network response', responseFromNetwork.url)
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,
// 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' },
});
} }
};
// 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 // 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...')
self.skipWaiting() // go straight to activate
console.info('installing service worker...') event.waitUntil(addResourcesToCache(__assets ?? []))
self.skipWaiting(); // go straight to activate })
event.waitUntil(
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,25 +2,23 @@ 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
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 newHtml = await render(ast)
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); return transform(newHtml, [
sanitize({
return transform(newHtml, [ dropElements: ['script'],
sanitize({ dropAttributes: {
dropElements: ['script'], target: ['a'],
dropAttributes: { },
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"')
}); })
}); })