This commit is contained in:
MassiveBox 2025-03-17 17:43:53 +01:00
commit d6c414f887
Signed by: massivebox
GPG key ID: 9B74D3A59181947D
97 changed files with 17956 additions and 0 deletions

View file

@ -0,0 +1,58 @@
---
import Datetime from "./Datetime.astro";
import { Card, Heading } from "react-bulma-components";
import { slugifyStr } from '../utils/slugify.js';
import { SITE } from '../config.js';
import Tag from "./Tag.astro";
interface Frontmatter {
title: string;
pubDatetime: Date | string;
modDatetime?: Date | null | string;
description: string;
ogImage?: string | object;
tags: string[];
}
export interface Props {
href?: string;
frontmatter: Frontmatter;
secHeading?: boolean;
}
const { href, frontmatter } = Astro.props;
function getOgImageUrl(frontmatter: Frontmatter): string {
if (typeof frontmatter.ogImage === 'string') {
return frontmatter.ogImage !== ''
? frontmatter.ogImage
: SITE.ogImage;
}
if (typeof frontmatter.ogImage === 'object' && 'src' in frontmatter.ogImage) {
return (frontmatter.ogImage as { src: string }).src;
}
return SITE.ogImage;
}
---
<a href={href}>
<Card className={"view-transition-" + slugifyStr(frontmatter.title) + " mb-3" } >
<div class="card-image">
<figure class="image">
<img
src={getOgImageUrl(frontmatter)}
alt="Article image preview"
onerror='this.onerror = null; this.src="./og.webp"'
fetchpriority="low"
/>
</figure>
</div>
<Card.Content>
<Heading size={3}>{frontmatter.title}</Heading>
{frontmatter.tags.map(tag => <Tag tag={slugifyStr(tag)} />)}
<Datetime pubDatetime={frontmatter.pubDatetime} modDatetime={frontmatter.modDatetime} className="mb-1" />
<p>{frontmatter.description}</p>
</Card.Content>
</Card>
</a>

View file

@ -0,0 +1,72 @@
---
// Remove current url path and remove trailing slash if exists
const currentUrlPath = Astro.url.pathname.replace(/\/+$/, "");
// Get url array from path
// eg: /tags/tailwindcss => ['tags', 'tailwindcss']
const breadcrumbList = currentUrlPath.split("/").slice(1);
// if breadcrumb is Home > Posts > 1 <etc>
// replace Posts with Posts (page number)
breadcrumbList[0] === "blog" &&
breadcrumbList.splice(0, 2, `Posts (page ${breadcrumbList[1] || 1})`);
// if breadcrumb is Home > Tags > [tag] > [page] <etc>
// replace [tag] > [page] with [tag] (page number)
breadcrumbList[0] === "tags" &&
!isNaN(Number(breadcrumbList[2])) &&
breadcrumbList.splice(
1,
3,
`${breadcrumbList[1]} ${
Number(breadcrumbList[2]) === 1 ? "" : "(page " + breadcrumbList[2] + ")"
}`
);
---
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li>
<a href="/">Home</a>
<span aria-hidden="true">&raquo;</span>
</li>
{
breadcrumbList.map((breadcrumb, index) =>
index + 1 === breadcrumbList.length ? (
<li>
<span
class={`${index > 0 ? "lowercase" : "capitalize"}`}
aria-current="page"
>
{/* make the last part lowercase in Home > Tags > some-tag */}
{decodeURIComponent(breadcrumb)}
</span>
</li>
) : (
<li>
<a href={`/${breadcrumb}/`}>{breadcrumb}</a>
<span aria-hidden="true">&raquo;</span>
</li>
)
)
}
</ul>
</nav>
<style>
.breadcrumb {
@apply mx-auto mb-1 mt-8 w-full max-w-3xl px-4;
}
.breadcrumb ul li {
@apply inline;
}
.breadcrumb ul li a {
@apply capitalize opacity-70;
}
.breadcrumb ul li span {
@apply opacity-70;
}
.breadcrumb ul li:not(:last-child) a {
@apply hover:opacity-100;
}
</style>

View file

@ -0,0 +1,28 @@
---
import { LOCALE } from "@config";
import { Icon } from "astro-icon/components";
export interface Props {
modDatetime?: string | null | Date;
pubDatetime: string | Date;
className?: string;
}
const { modDatetime, pubDatetime, className = "" } = Astro.props;
function formatToMonthDayYear(timeString: string | Date) {
const date = new Date(timeString);
return date.toLocaleDateString(LOCALE.langTag, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
---
<div class={ className }>
<Icon name="fa6-solid:calendar-days" title="Published" class="mr-1" /> <span>{ formatToMonthDayYear(pubDatetime) }</span>
{ modDatetime && modDatetime > pubDatetime && (
<Icon name="fa6-solid:pencil" title="Modified" class="ml-2 mr-1" /> <span>{ formatToMonthDayYear(modDatetime) } </span>
) }
</div>

View file

@ -0,0 +1,27 @@
---
import { Footer as BulmaFooter, Container } from 'react-bulma-components';
import { Icon } from 'astro-icon/components'
const currentYear = new Date().getFullYear();
export interface Props {
noMarginTop?: boolean;
}
const { noMarginTop = false } = Astro.props;
---
<BulmaFooter style={{"marginTop": `${noMarginTop ? "0px" : "3rem"}`}}>
<Container>
<p style="text-align: center">
&copy; { currentYear } MassiveBox<br>
<a href="https://git.massive.box/massivebox/website" target="_blank" rel="noopener noreferrer">Source Code</a> | <a href="https://status.massive.box" target="_blank" rel="noopener noreferrer">Server Status</a> | <a href="/pages/www-legal">Privacy & Legal</a>
</p>
</Container>
</BulmaFooter>
<style>
a {
color: grey;
}
</style>

View file

@ -0,0 +1,45 @@
---
import { Navbar } from "react-bulma-components"
import { Icon } from 'astro-icon/components'
---
<Navbar id="navbar">
<Navbar.Brand>
<Navbar.Item href="/">
<img src="/assets/icon-64.webp" alt="Navigation bar logo" style="width: auto">
<div style="width: 15px"></div>
<p class="is-size-4"><b><i>MassiveBox</i></b></p>
</Navbar.Item>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</Navbar.Brand>
<Navbar.Menu id="navMenu">
<Navbar.Container align="right">
<Navbar.Item href="/"><span class="icon-text"><span class="icon"><Icon name="fa6-solid:house" /></span><span>Home</span></span></Navbar.Item>
<Navbar.Item href="/about"><span class="icon-text"><span class="icon"><Icon name="fa6-solid:user" /></span><span>About</span></span></Navbar.Item>
<Navbar.Item href="/blog"><span class="icon-text"><span class="icon"><Icon name="fa6-solid:rss" /></span><span>Blog</span></span></Navbar.Item>
<Navbar.Item id="theme-btn"><span class="icon-text"><span class="icon"><Icon name="fa6-solid:lightbulb" /></span><span class="is-hidden-desktop">Toggle Theme</span></span></Navbar.Item>
</Navbar.Container>
</Navbar.Menu>
</Navbar>
<script>
document.addEventListener('DOMContentLoaded', () => {
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const $target = document.getElementById(target);
el.classList.toggle('is-active');
if($target != null) {
$target.classList.toggle('is-active');
}
});
});
});
</script>

12
src/components/Hr.astro Normal file
View file

@ -0,0 +1,12 @@
---
export interface Props {
noPadding?: boolean;
ariaHidden?: boolean;
}
const { noPadding = false, ariaHidden = true } = Astro.props;
---
<div class={`max-w-3xl mx-auto ${noPadding ? "px-0" : "px-4"}`}>
<hr class="border-skin-line" aria-hidden={ariaHidden} />
</div>

View file

@ -0,0 +1,33 @@
---
export interface Props {
href: string;
className?: string;
ariaLabel?: string;
title?: string;
disabled?: boolean;
}
const { href, className, ariaLabel, title, disabled = false } = Astro.props;
---
{
disabled ? (
<span
class={`group inline-block ${className}`}
aria-label={ariaLabel}
title={title}
aria-disabled={disabled}
>
<slot />
</span>
) : (
<a
{href}
class={`group inline-block hover:text-skin-accent ${className}`}
aria-label={ariaLabel}
title={title}
>
<slot />
</a>
)
}

View file

@ -0,0 +1,12 @@
<script is:inline>
var _paq = _paq || [];
_paq.push(["trackPageView"]), _paq.push(["enableLinkTracking"]),
function() {
_paq.push(["setTrackerUrl", "//stats.massive.box/sjprfvo.php"]);
_paq.push(["setSiteId", "1"]);
_paq.push(['disableAlwaysUseSendBeacon', 'true']);
var a = document, r = a.createElement("script"), s = a.getElementsByTagName("script")[0];
r.async = !0, r.defer = !0, r.src = "//stats.massive.box/fauqtkg.php", s.parentNode.insertBefore(r, s)
}();
</script>
<noscript><img src="//stats.massive.box/sjprfvo.php?dzp=1&opl=1" /></noscript>

View file

@ -0,0 +1,55 @@
---
import LinkButton from "./LinkButton.astro";
import { Icon } from 'astro-icon/components'
export interface Props {
currentPage: number;
totalPages: number;
prevUrl: string;
nextUrl: string;
}
const { currentPage, totalPages, prevUrl, nextUrl } = Astro.props;
const prev = currentPage > 1 ? "" : "disabled";
const next = currentPage < totalPages ? "" : "disabled";
---
{
totalPages > 1 && (
<nav class="pagination-wrapper" aria-label="Pagination">
<LinkButton
disabled={prev === "disabled"}
href={prevUrl}
className={`mr-4 select-none ${prev}`}
ariaLabel="Previous"
>
<Icon name="fa6-solid:arrow-left" />
Prev
</LinkButton>
{currentPage} / {totalPages}
<LinkButton
disabled={next === "disabled"}
href={nextUrl}
className={`ml-4 select-none ${next}`}
ariaLabel="Next"
>
Next
<Icon name="fa6-solid:arrow-right" />
</LinkButton>
</nav>
)
}
<style>
.pagination-wrapper {
margin-left: auto;
margin-right: auto;
}
.disabled {
@apply pointer-events-none select-none opacity-50 hover:text-skin-base group-hover:fill-skin-base;
}
.disabled-svg {
group-hover: !fill-skin-base;
}
</style>

View file

@ -0,0 +1,65 @@
---
import LinkButton from "./LinkButton.astro";
import { Icon } from "astro-icon/components";
const URL = Astro.url;
const shareLinks = [
{
name: "WhatsApp",
href: "https://wa.me/?text=",
icon: "simple-icons:whatsapp",
linkTitle: `Share this post via WhatsApp`,
},
{
name: "Facebook",
href: "https://www.facebook.com/sharer.php?u=",
icon: "simple-icons:facebook",
linkTitle: `Share this post on Facebook`,
},
{
name: "Twitter/X",
href: "https://twitter.com/intent/tweet?url=",
icon: "simple-icons:x",
linkTitle: `Tweet this post`,
},
{
name: "Telegram",
href: "https://t.me/share/url?url=",
icon: "simple-icons:telegram",
linkTitle: `Share this post via Telegram`,
},
{
name: "Pinterest",
href: "https://pinterest.com/pin/create/button/?url=",
icon: "simple-icons:pinterest",
linkTitle: `Share this post on Pinterest`,
},
{
name: "Mail",
href: "mailto:?subject=See%20this%20post&body=",
icon: "fa6-solid:envelope",
linkTitle: `Share this post via email`,
},
] as const;
---
<div class={`social-icons`}>
<span class="italic">Share this post on:</span>
<div class="text-center">
{
shareLinks.map(social => (
<LinkButton
href={`${social.href + URL}`}
className="link-button"
title={social.linkTitle}
>
<Icon name={ social.icon } title={social.linkTitle} />
</LinkButton>
))
}
</div>
</div>

41
src/components/Socials.astro Executable file
View file

@ -0,0 +1,41 @@
---
import { SOCIALS } from "@config";
import { Icon } from "astro-icon/components";
---
<table>
<tr>
{
SOCIALS.map(social => (
<td>
<a href={social.href} target="_blank" rel="noopener noreferrer">
<Icon name={social.icon} title={social.name} class="icon has-text-white" />
</a>
</td>
))
}
</tr>
</table>
<style>
table { width: 100% }
.icon {
max-width: 55px;
width: 100%;
height: auto;
flex-shrink: 1;
padding: 0 6px 0 6px;
display: block;
margin-left: auto;
margin-right: auto;
}
</style>

24
src/components/Tag.astro Normal file
View file

@ -0,0 +1,24 @@
---
export interface Props {
tag: string;
}
const { tag } = Astro.props;
---
<a
href={`/tags/${tag}/`}
transition:name={tag}
>
#<span>{tag}</span>
</a>
<style>
a {
color: grey;
}
a:hover {
color: blue;
}
</style>

51
src/config.ts Normal file
View file

@ -0,0 +1,51 @@
import type { GiteaProfile, Site, SocialObjects } from "./types";
export const SITE: Site = {
website: "https://massive.box/", // replace this with your deployed domain
author: "MassiveBox",
desc: "MassiveBox is a free time developer and FOSS enthusiast. This is his website.",
title: "MassiveBox",
ogImage: "/og.webp",
lightAndDarkMode: true,
postPerPage: 3,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
issoLocation: "https://isso.massive.box/", // empty to disable comments
};
export const LOCALE = {
lang: "en", // html lang code. Set this empty and default will be "en"
langTag: ["en-EN"], // BCP 47 Language Tags. Set this empty [] to use the environment default
} as const;
export const SOCIALS: SocialObjects = [
{
icon: "fa6-solid:envelope",
href: "/email",
name: "E-Mail"
},
{
icon: "simple-icons:matrix",
href: "https://matrix.to/#/@massivebox:massivebox.net",
name: "Matrix",
},
{
icon: "simple-icons:telegram",
href: "https://t.me/massivebox",
name: "Telegram",
},
{
icon: "simple-icons:forgejo",
href: "https://git.massive.box/massivebox",
name: "Forgejo (Git hosting)",
},
{
icon: "simple-icons:bluesky",
href: "https://bsky.app/profile/massive.box",
name: "Bluesky",
},
{
icon: "fa6-solid:key",
href: "https://keyoxide.org/box@massive.box",
name: "Keyoxide",
}
];

21
src/content/config.ts Normal file
View file

@ -0,0 +1,21 @@
import { SITE } from "@config";
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
author: z.string().default(SITE.author),
pubDatetime: z.date(),
modDatetime: z.date().optional().nullable(),
title: z.string(),
featured: z.boolean().optional(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(["others"]),
ogImage: image().optional(),
description: z.string(),
canonicalURL: z.string().optional(),
}),
});
export const collections = { blog };

2
src/env.d.ts vendored Normal file
View file

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

130
src/layouts/Layout.astro Normal file
View file

@ -0,0 +1,130 @@
---
import { LOCALE, SITE } from "@config";
import "../../custom.scss";
import Matomo from "../components/Matomo.astro";
const googleSiteVerification = import.meta.env.PUBLIC_GOOGLE_SITE_VERIFICATION;
export interface Props {
pageTitle?: string;
title?: string;
author?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
pubDatetime?: Date;
modDatetime?: Date | null;
}
const {
pageTitle = "Home",
title = pageTitle + " - " +SITE.title,
author = SITE.author,
description = SITE.desc,
ogImage = SITE.ogImage,
canonicalURL = new URL(Astro.url.pathname, Astro.site).href,
pubDatetime,
modDatetime,
} = Astro.props;
const socialImageURL = new URL(
ogImage ?? SITE.ogImage ?? "og.png",
Astro.url.origin
).href;
---
<!doctype html>
<html
lang=`${LOCALE.lang ?? "en"}`
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="/favicon.ico" />
<link rel="canonical" href={canonicalURL} />
<meta name="generator" content={Astro.generator} />
<!-- General Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph / Facebook -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImageURL} />
<!-- Article Published/Modified time -->
{
pubDatetime && (
<meta
property="article:published_time"
content={pubDatetime.toISOString()}
/>
)
}
{
modDatetime && (
<meta
property="article:modified_time"
content={modDatetime.toISOString()}
/>
)
}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
<!-- Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#050732">
<meta name="theme-color" content="#050732">
{
// If PUBLIC_GOOGLE_SITE_VERIFICATION is set in the environment variable,
// include google-site-verification tag in the heading
// Learn more: https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag
googleSiteVerification && (
<meta
name="google-site-verification"
content={googleSiteVerification}
/>
)
}
<!-- ViewTransitions /-->
<script is:inline src="/toggle-theme.js"></script>
<Matomo />
</head>
<body>
<slot />
</body>
</html>
<style>
html {
height: 100%;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
html, body { max-width: 100%; overflow-x: hidden; }
</style>

40
src/layouts/Main.astro Normal file
View file

@ -0,0 +1,40 @@
---
import { Heading } from "react-bulma-components"
interface StringTitleProp {
pageTitle?: string;
pageDesc?: string;
fullWidth?: boolean;
}
interface ArrayTitleProp {
pageTitle?: [string, string];
titleTransition: string;
pageDesc?: string;
fullWidth?: boolean;
}
export type Props = StringTitleProp | ArrayTitleProp;
const { props } = Astro;
let maxWidth = props.fullWidth ? "84rem" : "" ;
---
<main id="main-content" style={{ maxWidth: maxWidth }}>
{
"titleTransition" in props ? (
<Heading>
{ props.pageTitle?.[0] }
<span transition:name={props.titleTransition}>
{ props.pageTitle?.[1] }
</span>
</Heading>
) : (
<Heading>{ props.pageTitle }</Heading>
)
}
<p class="my-2">{ props.pageDesc }</p>
<slot />
</main>

View file

@ -0,0 +1,29 @@
---
import { SITE } from "@config";
import Breadcrumbs from "@components/Breadcrumbs.astro";
import Footer from "@components/Footer.astro";
import Header from "@components/Header.astro";
import Layout from "./Layout.astro";
export interface Props {
frontmatter: {
title: string;
description?: string;
};
}
const { frontmatter } = Astro.props;
---
<Layout title={`${frontmatter.title} | ${SITE.title}`}>
<Header activeNav="about" />
<main id="main-content">
<section id="about" class="prose mb-28 max-w-3xl prose-img:border-0">
<h1 class="title mb-3">{frontmatter.title}</h1>
<div class="content">
<slot />
</div>
</section>
</main>
<Footer />
</Layout>

View file

@ -0,0 +1,129 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Tag from "@components/Tag.astro";
import Datetime from "@components/Datetime.astro";
import type { CollectionEntry } from "astro:content";
import { slugifyStr } from "@utils/slugify";
import ShareLinks from "@components/ShareLinks.astro";
import { SITE } from "@config";
import { Icon } from 'astro-icon/components'
import { Level, Button } from "react-bulma-components"
export interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
const {
title,
author,
description,
ogImage,
canonicalURL,
pubDatetime,
modDatetime,
tags,
} = post.data;
const { Content } = await post.render();
const ogImageUrl = typeof ogImage === "string" ? ogImage : ogImage?.src;
const ogUrl = new URL(
ogImageUrl ?? `/assets/og.webp`,
Astro.url.origin
).href;
const layoutProps = {
title: `${title} | ${SITE.title}`,
author,
description,
pubDatetime,
modDatetime,
canonicalURL,
ogImage: ogUrl,
scrollSmooth: true,
};
---
<Layout {...layoutProps}>
<Header />
<main id="main-content">
<h1 transition:name={slugifyStr(title)} class="title">{title}</h1>
<Datetime
pubDatetime={pubDatetime}
modDatetime={modDatetime}
className="my-1"
/>
{
ogImage && <img src={ogImageUrl} alt="Cover image">
}
<ul class="mb-4">
{
tags.map(tag => <Tag tag={slugifyStr(tag)} />)
}
</ul>
<article id="article" role="article">
<div class="content">
<Content />
</div>
</article>
<Level className="my-4" breakpoint="mobile">
<Level.Side>
<Button id="back-to-top">
<Icon name="fa6-solid:arrow-up" />
<span class="ml-2">Back to Top</span>
</Button>
</Level.Side>
<Level.Side align="right">
<ShareLinks />
</Level.Side>
</Level>
{
SITE.issoLocation != "" && (
<p><span class="is-size-3"><b>Comments</b></span> (<a target="_blank" rel="noopener noreferrer" href="/pages/blog-legal#comments-rules">rules</a>, <a target="_blank" rel="noopener noreferrer" href="/pages/blog-legal#comments-privacy">privacy</a>)</p>
<script is:inline data-isso={ SITE.issoLocation } src={ SITE.issoLocation + "/js/embed.min.js" } async></script>
<div id="isso-thread"><noscript>You need to enable JavaScript to see and post comments.</noscript></div>
)
}
</main>
<Footer />
</Layout>
<script is:inline>
function addHeadingLinks() {
let headings = Array.from(document.querySelectorAll("h2, h3, h4, h5, h6"));
for (let heading of headings) {
heading.classList.add("group");
let link = document.createElement("a");
link.innerText = "#";
link.className = "ml-2";
link.href = "#" + heading.id;
link.ariaHidden = "true";
heading.appendChild(link);
}
}
addHeadingLinks();
function backToTop() {
document.querySelector("#back-to-top")?.addEventListener("click", () => {
document.body.scrollTop = 0; // For Safari
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
});
}
backToTop();
</script>

47
src/layouts/Posts.astro Normal file
View file

@ -0,0 +1,47 @@
---
import type { CollectionEntry } from "astro:content";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Pagination from "@components/Pagination.astro";
import BlogCard from "../components/BlogCard.astro";
import { Button } from "react-bulma-components";
import { Icon } from "astro-icon/components";
export interface Props {
currentPage: number;
totalPages: number;
paginatedPosts: CollectionEntry<"blog">[];
}
const { currentPage, totalPages, paginatedPosts} = Astro.props;
---
<Layout pageTitle="Blog">
<Header activeNav="blog" />
<Main pageTitle="Blog" pageDesc="All the articles I've posted.">
<a href="/rss.xml">
<Button className="mb-5">
<Icon name="fa6-solid:rss" />
<span class="ml-2">RSS Feed</span>
</Button>
</a>
<div>
{
paginatedPosts.map(({ data, slug }) => (
<BlogCard href={`/blog/${slug}/`} frontmatter={data} />
))
}
</div>
</Main>
<Pagination
{currentPage}
{totalPages}
prevUrl={`/blog${currentPage - 1 !== 1 ? "/" + (currentPage - 1) : ""}/`}
nextUrl={`/blog/${currentPage + 1}/`}
/>
<Footer />
</Layout>

View file

@ -0,0 +1,49 @@
---
import { type CollectionEntry } from "astro:content";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import BlogCard from "../components/BlogCard.astro";
import Pagination from "@components/Pagination.astro";
import { SITE } from "@config";
export interface Props {
currentPage: number;
totalPages: number;
paginatedPosts: CollectionEntry<"blog">[];
tag: string;
tagName: string;
}
const { currentPage, totalPages, paginatedPosts, tag, tagName } = Astro.props;
---
<Layout title={`Tag: ${tagName} | ${SITE.title}`}>
<Header activeNav="tags" />
<Main
pageTitle={[`Tag:`, `${tagName}`]}
titleTransition={tag}
pageDesc={`All the articles with the tag "${tagName}".`}
>
<h1 slot="title" transition:name={tag}>{`Tag:${tag}`}</h1>
<ul>
{
paginatedPosts.map(({ data, slug }) => (
<BlogCard href={`/posts/${slug}/`} frontmatter={data} />
))
}
</ul>
</Main>
<Pagination
{currentPage}
{totalPages}
prevUrl={`/tags/${tag}${
currentPage - 1 !== 1 ? "/" + (currentPage - 1) : ""
}/`}
nextUrl={`/tags/${tag}/${currentPage + 1}/`}
/>
<Footer noMarginTop={totalPages > 1} />
</Layout>

35
src/pages/404.astro Normal file
View file

@ -0,0 +1,35 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import LinkButton from "@components/LinkButton.astro";
---
<Layout pageTitle="404 Not Found">
<Header />
<main id="main-content">
<div class="not-found-wrapper">
<h1 aria-label="404 Not Found" class="title is-1" style="font-size: 9rem;">404</h1>
<span aria-hidden="true" class="mb-3">¯\_(ツ)_/¯</span>
<p class="subtitle is-4">Page Not Found</p>
<LinkButton
href="/"
className="my-6 text-lg underline decoration-dashed underline-offset-8"
>
Go back home
</LinkButton>
</div>
</main>
<Footer />
</Layout>
<style>
.not-found-wrapper {
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
}
</style>

29
src/pages/about.md Normal file
View file

@ -0,0 +1,29 @@
---
layout: layouts/PageLayout.astro
title: "About"
---
Hello! I'm Matteo.
I've always loved programming, FOSS and decentralized systems. I'm currently a student at University of Brescia.
Over the years, I have developed a variety of personal projects, including:
- [This website](/) ([git](https://git.massive.box/massivebox/website)), built with the Astro framework and Bulma CSS,
- [EcoDash](https://ecodash.massive.box/) ([git](https://git.massive.box/massivebox/ecodash)), the extensible
dashboard for environmental transparency,
- [Go CatPrinter](https://git.massive.box/massivebox/go-catprinter), a Go library that uses a reverse-engineered
proprietary protocol to communicate with some cheap Bluetooth Low Energy thermal printers,
- [RetroFW JilJil](https://git.massive.box/massivebox/retrofw-jiljil), the port of an abandonware game to the RetroFW
operating system for MIPSel-based handheld consoles, written in C with SDL1,
- And many more! Check out my [Forgejo](https://git.massive.box/massivebox), [Codeberg](https://codeberg.org/massivebox)
and [GitHub](https://github.com/massivebox) profiles to get an idea of what I like working on.
I also run a server, which is currently powering this website and other services. You can see its status
[here](https://status.massive.box/), and a report on how much energy it consumes [here](https://ecodash.massive.box/).
It runs on Proxmox Virtual Environment.
You can find contact me with any of the links in the [homepage](/). If you're looking for my PGP key, take a look
[here](/email).
You can find my character's art, as well as branding kit, [here](https://cloud.massive.box/published/art).
Thanks for checking out my website!

View file

@ -0,0 +1,41 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import Posts from "@layouts/Posts.astro";
import PostDetails from "@layouts/PostDetails.astro";
import getSortedPosts from "@utils/getSortedPosts";
import getPageNumbers from "@utils/getPageNumbers";
import getPagination from "@utils/getPagination";
export interface Props {
post: CollectionEntry<"blog">;
}
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const postResult = posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
const pagePaths = getPageNumbers(posts.length).map(pageNum => ({
params: { slug: String(pageNum) },
}));
return [...postResult, ...pagePaths];
}
const { slug } = Astro.params;
const { post } = Astro.props;
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const pagination = getPagination({
posts: sortedPosts,
page: slug,
});
---
{post ? <PostDetails post={post} /> : <Posts {...pagination} />}

View file

@ -0,0 +1,18 @@
---
import { getCollection } from "astro:content";
import Posts from "@layouts/Posts.astro";
import getSortedPosts from "@utils/getSortedPosts";
import getPagination from "@utils/getPagination";
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const pagination = getPagination({
posts: sortedPosts,
page: 1,
isIndex: true,
});
---
<Posts {...pagination} />

View file

@ -0,0 +1,31 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Main from "@layouts/Main.astro";
import { Icon } from 'astro-icon/components'
---
<Layout pageTitle="Contact Me">
<Header />
<Main pageTitle="E-Mail and OpenPGP" pageDesc="It wasn't that hard of a captcha, right?" fullWidth={true}>
<ul>
<li>
<Icon name="fa6-solid:envelope" /> <b>Email:</b> <a href="mailto:box@massive.box" target="_blank" rel="noopener noreferrer">box@massive.box</a>
</li>
<li>
<Icon name="fa6-solid:fingerprint" /> <b>OpenPGP Fingerprint:</b> <code>9ED497F5C43FDA462AF084BC9B74D3A59181947D</code>
</li>
<li>
<Icon name="fa6-solid:id-card" /> <b>OpenPGP Key ID:</b> <code>9B74D3A59181947D</code>
</li>
<li>
<Icon name="fa6-solid:key" /> <b>OpenPGP Key:</b> available via WKD or <a href="https://massive.box/.well-known/openpgpkey/hu/a9ckpi3nw8sjwfz4hf1tq9nbti87ccmi">direct URL</a>
</li>
</ul>
</Main>
<Footer />
</Layout>

View file

@ -0,0 +1,22 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import { Button } from "react-bulma-components"
import Main from "@layouts/Main.astro";
---
<Layout pageTitle="Contact Me">
<Header />
<Main pageTitle="E-Mail and OpenPGP" pageDesc="In this page you can find my email and OpenPGP key." fullWidth={true}>
<p><b>Riddle Time:</b> Are you a robot?</p>
<!-- This is just a place to add a little easteregg, I know it's not a real captcha! -->
<Button.Group className="mt-2">
<Button color="success" renderAs="a" href="/email/robot">Yes</Button>
<Button color="danger" renderAs="a" href="/email/human">No</Button>
</Button.Group>
</Main>
<Footer />
</Layout>

View file

@ -0,0 +1,21 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import { Button } from "react-bulma-components"
import Main from "@layouts/Main.astro";
---
<Layout pageTitle="You're a robot!">
<Header />
<Main fullWidth={true}>
<img src="/assets/maurizio.jpg" alt="A tabby cat laying on a chair, looking at you menacingly">
<p style="margin-top: 0.5rem; margin-bottom: 0.5rem"><a href="https://t.me/ilgattomaurizio" target="_blank" rel="noopener noreferrer">Gatto Maurizio</a> doesn't look happy about you being a robot.</p>
<Button.Group>
<Button color="success" renderAs="a" href="/email">I'm not a robot anymore, Maurizio please spare me</Button>
</Button.Group>
</Main>
<Footer />
</Layout>

54
src/pages/index.astro Normal file
View file

@ -0,0 +1,54 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Socials from "../components/Socials.astro";
---
<Layout>
<Header />
<main id="main" class="is-justify-content-center is-background-main">
<div style="max-width: 100vw;">
<img src="assets/spinning.webp" alt="Spinning MassiveBox icon" class="spinning is-hidden-under-800">
<img src="assets/spinning-mobile.webp" alt="Spinning MassiveBox icon" class="spinning is-hidden-over-800">
<div class="mr-10 ml-10">
<Socials />
</div>
</div>
</main>
</Layout>
<style>
.spinning {
max-width: 800px;
flex-shrink: 1;
width: 100%;
height: auto;
margin-bottom: 10px;
}
#main {
height: 90vh;
display: flex;
align-items: center;
}
#main * {
color: white;
}
.is-hidden-over-800 { display: none; }
.is-hidden-under-800 { display: block; }
@media (max-width: 800px) {
.is-hidden-over-800 { display: block; }
.is-hidden-under-800 { display: none; }
}
@media (max-width: 1023px) {
#main {
height: 92vh;
}
}
</style>

View file

@ -0,0 +1,31 @@
---
layout: layouts/PageLayout.astro
title: "Blog Legal"
---
This blog is not a newspaper as it is updated without any periodicity. It can not, therefore, be considered an editorial product under Italian Law n° 62 of 7.03.2001 or similiar international laws.
I am not responsible for the content of comments and external sites linked in the blog.
Unless specifically stated otherwise, all Content in the blog except for the comments is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/).
## Referral Links Disclosure
As of right now, there are no referral links in the blog. This page will be updated if this changes.
## Comments Privacy
All data you submit via comments will be visible to any other visitor of the site. Comments are stored on my server, located in Italy. They will be sent once to me as a transactional mail via [Brevo](https://brevo.com) for notification and moderation purposes. Read their privacy policy [here](https://www.brevo.com/legal/privacypolicy/).
Comments are stored forever on my server, unless they are deleted. If the comment software doesn't show the delete button for a comment you made in the past and want to be deleted, contact me at [legal@massive.box](mailto:legal@massive.box). You will need to show that you're the original poster in some way, like using the same email address you submitted the content with.
## Comments Rules
All comments must respect the following rules:
- No spam
- No NSFW content
- No illegal content under Italian laws
- No personal insults, doxxing, violence threats etc.
I reserve the ultimate right to delete comments at any time, for any reason, without any warning.

View file

@ -0,0 +1,17 @@
---
layout: layouts/PageLayout.astro
title: "Unknown subdomain"
---
You have ended up on this page because you've opened a non-configured subdomain.
I've been changing some server-side things as of lately, so if you think this is a mistake, please
[let me know](/email).
If you've just typed something random before my domain and ended up here, good for you! And for me, since I won't have
to fix another 404 page.
You can continue browsing at:
- [The homepage](/)
- [Status page](https://status.massive.box)
- [A random lynx photo](https://api.tinyfox.dev/img?animal=lynx)

View file

@ -0,0 +1,24 @@
---
layout: layouts/PageLayout.astro
title: "Website Legal Notice"
---
This page is the place to find information on how your data is treated on this website, and what you can do with it.
## Related documents
- [Blog Legal Notice](/pages/blog-legal)
## Privacy policy
This website uses logs, which include information like IP addresses, requested paths and other HTTP request information to prevent abuse and find technical error.
This data is exclusively stored in my server which operates in Italy. It's never sent to any third party.
Logs can be preserved for up to 3 months. If you want to be removed from log files as per the GDPR, contact me at [legal@massive.box](mailto:legal@massive.box).
## Terms of service
It's forbidden to use the website for any illegal activities. If you find a bug which allows attackers to exploit the website for this purpose, contact me at [webmaster@massive.box](mailto:webmaster@massive.box).
The website is presented as-is, without any warranty. I reserve the ultimate right to block access to the website at any point for any reason to anyone without any warning.

14
src/pages/robots.txt.ts Normal file
View file

@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
import { SITE } from "@config";
const robots = `
User-agent: *
Allow: /
Sitemap: ${new URL("sitemap-index.xml", SITE.website).href}
`.trim();
export const GET: APIRoute = () =>
new Response(robots, {
headers: { "Content-Type": "text/plain" },
});

20
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,20 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import getSortedPosts from "@utils/getSortedPosts";
import { SITE } from "@config";
export async function GET() {
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
return rss({
title: SITE.title,
description: SITE.desc,
site: SITE.website,
items: sortedPosts.map(({ data, slug }) => ({
link: `posts/${slug}/`,
title: data.title,
description: data.description,
pubDate: new Date(data.modDatetime ?? data.pubDatetime),
})),
});
}

28
src/pages/search.astro Normal file
View file

@ -0,0 +1,28 @@
---
import { getCollection } from "astro:content";
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import getSortedPosts from "@utils/getSortedPosts";
// Retrieve all published articles
const posts = await getCollection("blog", ({ data }) => !data.draft);
const sortedPosts = getSortedPosts(posts);
// List of items to search in
const searchList = sortedPosts.map(({ data, slug }) => ({
title: data.title,
description: data.description,
data,
slug,
}));
---
<Layout title={`Search | ${SITE.title}`}>
<Header activeNav="search" />
<Main pageTitle="Search" pageDesc="Search any article ...">
</Main>
<Footer />
</Layout>

View file

@ -0,0 +1,44 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import TagPosts from "@layouts/TagPosts.astro";
import getUniqueTags from "@utils/getUniqueTags";
import getPostsByTag from "@utils/getPostsByTag";
import getPageNumbers from "@utils/getPageNumbers";
import getPagination from "@utils/getPagination";
export interface Props {
post: CollectionEntry<"blog">;
tag: string;
tagName: string;
}
export async function getStaticPaths() {
const posts = await getCollection("blog");
const tags = getUniqueTags(posts);
return tags.flatMap(({ tag, tagName }) => {
const tagPosts = getPostsByTag(posts, tag);
const totalPages = getPageNumbers(tagPosts.length);
return totalPages.map(page => ({
params: { tag, page },
props: { tag, tagName },
}));
});
}
const { page } = Astro.params;
const { tag, tagName } = Astro.props;
const posts = await getCollection("blog", ({ data }) => !data.draft);
const postsByTag = getPostsByTag(posts, tag);
const pagination = getPagination({
posts: postsByTag,
page,
});
---
<TagPosts {...pagination} {tag} {tagName} />

View file

@ -0,0 +1,32 @@
---
import { getCollection } from "astro:content";
import TagPosts from "@layouts/TagPosts.astro";
import getPostsByTag from "@utils/getPostsByTag";
import getPagination from "@utils/getPagination";
import getUniqueTags from "@utils/getUniqueTags";
export async function getStaticPaths() {
const posts = await getCollection("blog");
const tags = getUniqueTags(posts);
return tags.map(({ tag, tagName }) => {
return {
params: { tag },
props: { tag, tagName, posts },
};
});
}
const { tag, tagName, posts } = Astro.props;
const postsByTag = getPostsByTag(posts, tag);
const pagination = getPagination({
posts: postsByTag,
page: 1,
isIndex: true,
});
---
<TagPosts {...pagination} {tag} {tagName} />

View file

@ -0,0 +1,24 @@
---
import { getCollection } from "astro:content";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Tag from "@components/Tag.astro";
import getUniqueTags from "@utils/getUniqueTags";
import { SITE } from "@config";
const posts = await getCollection("blog");
let tags = getUniqueTags(posts);
---
<Layout title={`Tags | ${SITE.title}`}>
<Header activeNav="tags" />
<Main pageTitle="Tags" pageDesc="All the tags used in posts.">
<ul>
{tags.map(({ tag }) => <Tag {tag} />)}
</ul>
</Main>
<Footer />
</Layout>

32
src/types.ts Normal file
View file

@ -0,0 +1,32 @@
export type Site = {
website: string;
author: string;
desc: string;
title: string;
ogImage: string;
lightAndDarkMode: boolean;
postPerPage: number;
scheduledPostMargin: number;
issoLocation: string,
};
export type GiteaProfile = {
host: string;
username: string;
name: string;
};
export type GiteaRepo = {
url: string,
username: string,
name: string,
description: string,
language: string,
lastUpdated: Date,
};
export type SocialObjects = {
icon: string;
href: string;
name: string;
}[];

View file

@ -0,0 +1,14 @@
import { SITE } from "@config";
const getPageNumbers = (numberOfPosts: number) => {
const numberOfPages = numberOfPosts / Number(SITE.postPerPage);
let pageNumbers: number[] = [];
for (let i = 1; i <= Math.ceil(numberOfPages); i++) {
pageNumbers = [...pageNumbers, i];
}
return pageNumbers;
};
export default getPageNumbers;

View file

@ -0,0 +1,35 @@
import { SITE } from "@config";
import getPageNumbers from "./getPageNumbers";
interface GetPaginationProps<T> {
posts: T;
page: string | number;
isIndex?: boolean;
}
const getPagination = <T>({
posts,
page,
isIndex = false,
}: GetPaginationProps<T[]>) => {
const totalPagesArray = getPageNumbers(posts.length);
const totalPages = totalPagesArray.length;
const currentPage = isIndex
? 1
: page && !isNaN(Number(page)) && totalPagesArray.includes(Number(page))
? Number(page)
: 0;
const lastPost = isIndex ? SITE.postPerPage : currentPage * SITE.postPerPage;
const startPost = isIndex ? 0 : lastPost - SITE.postPerPage;
const paginatedPosts = posts.slice(startPost, lastPost);
return {
totalPages,
currentPage,
paginatedPosts,
};
};
export default getPagination;

View file

@ -0,0 +1,10 @@
import type { CollectionEntry } from "astro:content";
import getSortedPosts from "./getSortedPosts";
import { slugifyAll } from "./slugify";
const getPostsByTag = (posts: CollectionEntry<"blog">[], tag: string) =>
getSortedPosts(
posts.filter(post => slugifyAll(post.data.tags).includes(tag))
);
export default getPostsByTag;

View file

@ -0,0 +1,18 @@
import type { CollectionEntry } from "astro:content";
import postFilter from "./postFilter";
const getSortedPosts = (posts: CollectionEntry<"blog">[]) => {
return posts
.filter(postFilter)
.sort(
(a, b) =>
Math.floor(
new Date(b.data.modDatetime ?? b.data.pubDatetime).getTime() / 1000
) -
Math.floor(
new Date(a.data.modDatetime ?? a.data.pubDatetime).getTime() / 1000
)
);
};
export default getSortedPosts;

View file

@ -0,0 +1,23 @@
import { slugifyStr } from "./slugify";
import type { CollectionEntry } from "astro:content";
import postFilter from "./postFilter";
interface Tag {
tag: string;
tagName: string;
}
const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
const tags: Tag[] = posts
.filter(postFilter)
.flatMap(post => post.data.tags)
.map(tag => ({ tag: slugifyStr(tag), tagName: tag }))
.filter(
(value, index, self) =>
self.findIndex(tag => tag.tag === value.tag) === index
)
.sort((tagA, tagB) => tagA.tag.localeCompare(tagB.tag));
return tags;
};
export default getUniqueTags;

199
src/utils/joplin.js Normal file
View file

@ -0,0 +1,199 @@
const http = require('node:http');
const fs = require('fs');
const path = require('path');
async function fetch(url, method = "GET") {
return new Promise((resolve) => {
http.get(url, {method: method},(res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', () => resolve(""));
});
}
async function saveImage(url, outputPath) {
return new Promise((resolve, reject) => {
const request = http.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download image. Status code: ${response.statusCode}`));
return;
}
const writer = fs.createWriteStream(outputPath);
response.pipe(writer);
writer.on('finish', () => resolve(outputPath));
writer.on('error', (err) => {
fs.unlink(outputPath, () => reject(err));
});
});
request.on('error', (err) => reject(err));
});
}
async function findPort() {
for (let portToTest = 41184; portToTest <= 41194; portToTest++) {
const result = await fetch("http://localhost:" + portToTest + "/ping");
if (result === 'JoplinClipperServer') {
return portToTest
}
}
throw new Error("Joplin clipper server not found");
}
async function authenticate(host) {
// Step 1: Call POST /auth to get the auth_token
const authResponse = await fetch(host + "/auth", "POST");
const { auth_token } = JSON.parse(authResponse);
// Step 2: Poll GET /auth/check at regular intervals
const checkAuthStatus = async () => {
const checkResponse = await fetch(`${host}/auth/check?auth_token=${auth_token}`);
const { status, token } = JSON.parse(checkResponse);
if (status === "accepted") {
console.log("Authentication successful.");
return token;
} else if (status === "rejected") {
throw new Error("Authentication rejected by the user.");
} else {
console.log("Pending approval. Status:", status)
return new Promise(resolve => setTimeout(() => resolve(checkAuthStatus()), 1000));
}
};
return checkAuthStatus();
}
async function getNotebookNotes(apiToken, host) {
const foldersResponse = await fetch(`${host}/folders?token=${apiToken}`);
const folders = JSON.parse(foldersResponse);
let id = null;
for(let folder of folders.items) {
if(folder.title === "Blog") {
id = folder.id;
}
}
if(id == null) {
throw new Error("Blog notebook not found.");
}
const notesResponse = await fetch(`${host}/folders/${id}/notes?token=${apiToken}`);
const notes = JSON.parse(notesResponse);
let noteIds = [];
for(let note of notes.items) {
noteIds.push(note.id);
}
return noteIds
}
async function getNoteBody(apiToken, host, noteId) {
const noteResponse = await fetch(`${host}/notes/${noteId}?token=${apiToken}&fields=body`);
return JSON.parse(noteResponse).body;
}
async function getNoteResources(apiToken, host, noteId) {
const resourcesResponse = await fetch(`${host}/notes/${noteId}/resources?token=${apiToken}&fields=id,mime`);
let resources = [];
for(let resource of JSON.parse(resourcesResponse).items) {
resources.push([resource.id, resource.mime.replace(/^image\//, "")]);
}
return resources;
}
async function fixResourceLinks(resources, body) {
for(let resource of resources) {
// Replace markdown image links with ./ instead of :/
body = body.replaceAll(":/" + resource[0], "./" + resource[0])
// Add resource extension
body = body.replaceAll(resource[0], resource[0] + "." + resource[1])
}
return body;
}
async function otherReplaces(body) {
// Remove ogImage lines
const ogImageRegex = /^\s*!\[ogImage\].*$/gm;
body = body.replace(ogImageRegex, '');
// Remove spoiler blocks
const spoilerRegex = /^:\[\n(.*?)\n\n([\s\S]*?)\n\n\]:$/gm;
return body.replace(spoilerRegex, '');
}
async function getNoteSlug(body) {
let frontmatterSplt = body.split("---\n");
if(frontmatterSplt.length < 3) {
throw new Error("No frontmatter found in note.");
}
let frontmatter = frontmatterSplt[1];
for(let line of frontmatter.split("\n")) {
if(line.startsWith("slug: ")) {
return line.split("slug: ")[1].trim();
}
}
throw new Error("No slug found in frontmatter.");
}
async function saveNote(slug, noteBody) {
const dirPath = path.join(__dirname, '../content/blog', slug);
if (!fs.existsSync(dirPath)){
fs.mkdirSync(dirPath, { recursive: true });
}
const filePath = path.join(dirPath, 'index.md');
fs.writeFileSync(filePath, noteBody);
console.log(`Note saved to ${filePath}`);
}
async function saveResources(resources, apiToken, host, slug) {
for(let resource of resources) {
const resID = resource[0];
const extension = resource[1];
const filePath = path.join(__dirname, '../content/blog', slug, resID + "." + extension);
await saveImage(`${host}/resources/${resID}/file?token=${apiToken}&fields=data`, filePath)
console.log(`Resource saved to ${filePath}`);
}
}
async function clearBlogFolder() {
const blogFolderPath = path.join(__dirname, '../content/blog');
if (fs.existsSync(blogFolderPath)) {
fs.rmSync(blogFolderPath, { recursive: true, force: true });
console.log(`Cleared blog folder at ${blogFolderPath}`);
}
}
async function main() {
console.log("Finding JoplinClipperServer port...");
let host = "http://localhost:" + await findPort();
let apiToken = await authenticate(host);
//let apiToken = "";
let notes = await getNotebookNotes(apiToken, host);
await clearBlogFolder();
for(let noteId of notes) {
let noteBody = await getNoteBody(apiToken, host, noteId);
let slug = await getNoteSlug(noteBody);
let resources = await getNoteResources(apiToken, host, noteId);
noteBody = await fixResourceLinks(resources, noteBody);
noteBody = await otherReplaces(noteBody);
console.log("Saving note with slug:", slug, resources);
await saveNote(slug, noteBody);
await saveResources(resources, apiToken, host, slug)
}
}
main().then();

View file

@ -0,0 +1,96 @@
import { SITE } from "@config";
import type { CollectionEntry } from "astro:content";
export default (post: CollectionEntry<"blog">) => {
return (
<div
style={{
background: "#fefbfb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
top: "-1px",
right: "-1px",
border: "4px solid #000",
background: "#ecebeb",
opacity: "0.9",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2.5rem",
width: "88%",
height: "80%",
}}
/>
<div
style={{
border: "4px solid #000",
background: "#fefbfb",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2rem",
width: "88%",
height: "80%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
margin: "20px",
width: "90%",
height: "90%",
}}
>
<p
style={{
fontSize: 72,
fontWeight: "bold",
maxHeight: "84%",
overflow: "hidden",
}}
>
{post.data.title}
</p>
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
marginBottom: "8px",
fontSize: 28,
}}
>
<span>
by{" "}
<span
style={{
color: "transparent",
}}
>
"
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{post.data.author}
</span>
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{SITE.title}
</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,87 @@
import { SITE } from "@config";
export default () => {
return (
<div
style={{
background: "#fefbfb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
top: "-1px",
right: "-1px",
border: "4px solid #000",
background: "#ecebeb",
opacity: "0.9",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2.5rem",
width: "88%",
height: "80%",
}}
/>
<div
style={{
border: "4px solid #000",
background: "#fefbfb",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2rem",
width: "88%",
height: "80%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
margin: "20px",
width: "90%",
height: "90%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "90%",
maxHeight: "90%",
overflow: "hidden",
textAlign: "center",
}}
>
<p style={{ fontSize: 72, fontWeight: "bold" }}>{SITE.title}</p>
<p style={{ fontSize: 28 }}>{SITE.desc}</p>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
marginBottom: "8px",
fontSize: 28,
}}
>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{new URL(SITE.website).hostname}
</span>
</div>
</div>
</div>
</div>
);
};

11
src/utils/postFilter.ts Normal file
View file

@ -0,0 +1,11 @@
import { SITE } from "@config";
import type { CollectionEntry } from "astro:content";
const postFilter = ({ data }: CollectionEntry<"blog">) => {
const isPublishTimePassed =
Date.now() >
new Date(data.pubDatetime).getTime() - SITE.scheduledPostMargin;
return !data.draft && (import.meta.env.DEV || isPublishTimePassed);
};
export default postFilter;

5
src/utils/slugify.ts Normal file
View file

@ -0,0 +1,5 @@
import { slug as slugger } from "github-slugger";
export const slugifyStr = (str: string) => slugger(str);
export const slugifyAll = (arr: string[]) => arr.map(str => slugifyStr(str));