Convert to Astro (#18)
* Init astro * Show all feed items as a list * Add filter for recent items * Add category colors * Add dark mode * Add article counts
This commit is contained in:
parent
93326d6483
commit
2de8262704
17 changed files with 468 additions and 433 deletions
5
.astro/settings.json
Normal file
5
.astro/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"_variables": {
|
||||||
|
"lastUpdateCheck": 1724282769525
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.astro/types.d.ts
vendored
Normal file
1
.astro/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="astro/client" />
|
||||||
6
.editorconfig
Normal file
6
.editorconfig
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
tab_width = 2
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = tab
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,3 +4,5 @@ public/styles.css
|
||||||
dist/*
|
dist/*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
configs/feeds.json
|
configs/feeds.json
|
||||||
|
src-old/
|
||||||
|
.env
|
||||||
|
|
|
||||||
8
astro.config.js
Normal file
8
astro.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import tailwindIntegration from "@astrojs/tailwind";
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
output: "static",
|
||||||
|
integrations: [tailwindIntegration()],
|
||||||
|
});
|
||||||
29
biome.json
29
biome.json
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|
@ -8,5 +8,32 @@
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true,
|
||||||
|
"defaultBranch": "main"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": ["*.svelte", "*.astro", "*.vue"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useConst": "off",
|
||||||
|
"useImportType": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -6,10 +6,11 @@
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"build": "NODE_ENV=production bun src/index.ts",
|
|
||||||
"check": "biome check --write ./{src,config,public} ./*.{json,js} --no-errors-on-unmatched",
|
"check": "biome check --write ./{src,config,public} ./*.{json,js} --no-errors-on-unmatched",
|
||||||
"dev": "bun src/index.ts"
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "George Mandis",
|
"name": "George Mandis",
|
||||||
|
|
@ -26,7 +27,9 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@feelinglovelynow/get-relative-time": "^1.1.2",
|
"@feelinglovelynow/get-relative-time": "^1.1.2",
|
||||||
|
"astro": "^4.14.4",
|
||||||
"chalk": "^5.2.0",
|
"chalk": "^5.2.0",
|
||||||
"node-fetch": "^3.3.1",
|
"node-fetch": "^3.3.1",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
|
|
|
||||||
15
src/@types/bubo.d.ts
vendored
15
src/@types/bubo.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
||||||
export interface Feeds {
|
|
||||||
[key: string]: object[];
|
|
||||||
}
|
|
||||||
export interface FeedItem {
|
|
||||||
[key: string]: string | number | Date | boolean | FeedItem[];
|
|
||||||
items: FeedItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
//NEW WAY
|
|
||||||
export type JSONValue =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| { [x: string]: JSONValue }
|
|
||||||
| Array<JSONValue>;
|
|
||||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
208
src/index.ts
208
src/index.ts
|
|
@ -1,208 +0,0 @@
|
||||||
/*
|
|
||||||
* 🦉 Bubo Reader
|
|
||||||
* ====
|
|
||||||
* Dead simple feed reader (RSS + JSON) that renders an HTML
|
|
||||||
* page with links to content from feeds organized by site
|
|
||||||
*
|
|
||||||
* Code: https://github.com/georgemandis/bubo-rss
|
|
||||||
* Copyright (c) 2019 George Mandis (https://george.mand.is)
|
|
||||||
* Version: 1.0.1 (11/14/2021)
|
|
||||||
* Licensed under the MIT License (http://opensource.org/licenses/MIT)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { writeFile } from "node:fs/promises";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import fetch from "node-fetch";
|
|
||||||
import type { Response } from "node-fetch";
|
|
||||||
import Parser from "rss-parser";
|
|
||||||
import type { FeedItem, Feeds } from "./@types/bubo";
|
|
||||||
import { render } from "./renderer.js";
|
|
||||||
import {
|
|
||||||
buildCSS,
|
|
||||||
getBuboInfo,
|
|
||||||
getFeedList,
|
|
||||||
getLink,
|
|
||||||
getTimestamp,
|
|
||||||
getTitle,
|
|
||||||
parseFeed,
|
|
||||||
} from "./utilities.js";
|
|
||||||
|
|
||||||
const cssInput = "./public/styles.input.css";
|
|
||||||
const cssOutput = "./public/styles.css";
|
|
||||||
const minifyCss = process.env.NODE_ENV === "production";
|
|
||||||
await buildCSS(minifyCss, cssInput, cssOutput);
|
|
||||||
|
|
||||||
const buboInfo = await getBuboInfo();
|
|
||||||
const parser = new Parser();
|
|
||||||
const feedOptions: Parameters<typeof getFeedList>[0] = {
|
|
||||||
feeds: process.env.FEEDS,
|
|
||||||
feedFilePath: process.env.FEEDS
|
|
||||||
? ""
|
|
||||||
: process.env.FEED_FILE ?? "../config/feeds.json",
|
|
||||||
};
|
|
||||||
console.log("feedOptions", JSON.stringify(feedOptions, null, 2));
|
|
||||||
const feedList = await getFeedList(feedOptions);
|
|
||||||
const feedListLength =
|
|
||||||
Object.entries(feedList).flat(2).length - Object.keys(feedList).length;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* contentFromAllFeeds = Contains normalized, aggregated feed data and is passed to template renderer at the end
|
|
||||||
* errors = Contains errors from parsing feeds and is also passed to template.
|
|
||||||
*/
|
|
||||||
const contentFromAllFeeds: Feeds = {};
|
|
||||||
const errors: unknown[] = [];
|
|
||||||
|
|
||||||
// benchmarking data + utility
|
|
||||||
const initTime = Date.now();
|
|
||||||
const benchmark = (startTime: number) =>
|
|
||||||
chalk.cyanBright.bold(`${(Date.now() - startTime) / 1000} seconds`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* These values are used to control throttling/batching the fetches:
|
|
||||||
* - MAX_CONNECTION = max number of fetches to contain in a batch
|
|
||||||
* - DELAY_MS = the delay in milliseconds between batches
|
|
||||||
*/
|
|
||||||
const MAX_CONNECTIONS = Number.POSITIVE_INFINITY;
|
|
||||||
const DELAY_MS = 850;
|
|
||||||
|
|
||||||
const error = chalk.bold.red;
|
|
||||||
const success = chalk.bold.green;
|
|
||||||
|
|
||||||
// keeping tally of total feeds fetched and parsed so we can compare
|
|
||||||
// to feedListLength and know when we're finished.
|
|
||||||
let completed = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* finishBuild
|
|
||||||
* --
|
|
||||||
* function that gets called when all the feeds are through fetching
|
|
||||||
* and we want to build the static output.
|
|
||||||
*/
|
|
||||||
const finishBuild: () => void = async () => {
|
|
||||||
completed++;
|
|
||||||
// if this isn't the last feed, just return early
|
|
||||||
if (completed !== feedListLength) return;
|
|
||||||
|
|
||||||
process.stdout.write("\nDone fetching everything!\n");
|
|
||||||
|
|
||||||
// sort all the categories and the feeds alphabetically
|
|
||||||
const sortedFeeds: Feeds = {};
|
|
||||||
const sortedKeys = Object.keys(contentFromAllFeeds).sort((a, b) =>
|
|
||||||
a.localeCompare(b),
|
|
||||||
);
|
|
||||||
for (const key of sortedKeys) {
|
|
||||||
sortedFeeds[key] = contentFromAllFeeds[key].sort((a, b) =>
|
|
||||||
a.title.localeCompare(b.title),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const yazzyUrl = process.env.YAZZY_URL;
|
|
||||||
process.stdout.write(`\nUsing yazzy url: "${yazzyUrl}"\n`);
|
|
||||||
// generate the static HTML output from our template renderer
|
|
||||||
const output = render({
|
|
||||||
data: sortedFeeds,
|
|
||||||
errors: errors,
|
|
||||||
info: buboInfo,
|
|
||||||
yazzyUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// write the output to public/index.html
|
|
||||||
await writeFile("./public/index.html", output);
|
|
||||||
process.stdout.write(
|
|
||||||
`\nFinished writing to output:\n- ${feedListLength} feeds in ${benchmark(
|
|
||||||
initTime,
|
|
||||||
)}\n- ${errors.length} errors\n`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* processFeed
|
|
||||||
* --
|
|
||||||
* Process an individual feed and normalize its items
|
|
||||||
* @param { group, feed, startTime}
|
|
||||||
* @returns Promise<void>
|
|
||||||
*/
|
|
||||||
const processFeed =
|
|
||||||
({
|
|
||||||
group,
|
|
||||||
feed,
|
|
||||||
startTime,
|
|
||||||
}: {
|
|
||||||
group: string;
|
|
||||||
feed: string;
|
|
||||||
startTime: number;
|
|
||||||
}) =>
|
|
||||||
async (response: Response): Promise<void> => {
|
|
||||||
const body = await parseFeed(response);
|
|
||||||
//skip to the next one if this didn't work out
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contents: FeedItem = (
|
|
||||||
typeof body === "string" ? await parser.parseString(body) : body
|
|
||||||
) as FeedItem;
|
|
||||||
|
|
||||||
contents.feed = feed;
|
|
||||||
contents.title = getTitle(contents);
|
|
||||||
contents.link = getLink(contents);
|
|
||||||
|
|
||||||
// try to normalize date attribute naming
|
|
||||||
for (const item of contents.items) {
|
|
||||||
item.timestamp = getTimestamp(item);
|
|
||||||
item.title = getTitle(item);
|
|
||||||
item.link = getLink(item);
|
|
||||||
const timestamp = new Date(Number.parseInt(item.timestamp));
|
|
||||||
const eightHoursAgo = new Date();
|
|
||||||
eightHoursAgo.setHours(eightHoursAgo.getHours() - 8);
|
|
||||||
item.isRecent = timestamp > eightHoursAgo;
|
|
||||||
}
|
|
||||||
|
|
||||||
contents.hasRecent = contents.items.some((item) => item.isRecent);
|
|
||||||
|
|
||||||
contentFromAllFeeds[group].push(contents as object);
|
|
||||||
process.stdout.write(
|
|
||||||
`${success("Successfully fetched:")} ${feed} - ${benchmark(startTime)}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
process.stdout.write(
|
|
||||||
`${error("Error processing:")} ${feed} - ${benchmark(
|
|
||||||
startTime,
|
|
||||||
)}\n${err}\n`,
|
|
||||||
);
|
|
||||||
errors.push(`Error processing: ${feed}\n\t${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
finishBuild();
|
|
||||||
};
|
|
||||||
|
|
||||||
// go through each group of feeds and process
|
|
||||||
const processFeeds = () => {
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
for (const [group, feeds] of Object.entries(feedList)) {
|
|
||||||
contentFromAllFeeds[group] = [];
|
|
||||||
|
|
||||||
for (const feed of feeds) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
process.stdout.write(`Fetching: ${feed}...\n`);
|
|
||||||
|
|
||||||
fetch(feed)
|
|
||||||
.then(processFeed({ group, feed, startTime }))
|
|
||||||
.catch((err) => {
|
|
||||||
process.stdout.write(
|
|
||||||
error(`Error fetching ${feed} ${benchmark(startTime)}\n`),
|
|
||||||
);
|
|
||||||
errors.push(`Error fetching ${feed} ${err.toString()}\n`);
|
|
||||||
finishBuild();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(idx % (feedListLength / MAX_CONNECTIONS)) * DELAY_MS,
|
|
||||||
);
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processFeeds();
|
|
||||||
229
src/pages/index.astro
Normal file
229
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
---
|
||||||
|
import getAllFeedItems from "../services/feeds";
|
||||||
|
|
||||||
|
function isRecent(date: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const sixHours = 6 * 60 * 60 * 1000;
|
||||||
|
return now - date < sixHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedItems = await getAllFeedItems();
|
||||||
|
const recentCount = feedItems.contents.filter((item) =>
|
||||||
|
isRecent(item.pubIsoDate),
|
||||||
|
).length;
|
||||||
|
const categories = [
|
||||||
|
"All",
|
||||||
|
"Recent",
|
||||||
|
...Array.from(
|
||||||
|
new Set(feedItems.contents.map((item) => item.category)),
|
||||||
|
).sort(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryCounts = categories.reduce((acc, category) => {
|
||||||
|
if (category === "All") {
|
||||||
|
acc[category] = feedItems.contents.length;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[category] = feedItems.contents.filter(
|
||||||
|
(item) => item.category === category,
|
||||||
|
).length;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const accentColors = {
|
||||||
|
slate: {
|
||||||
|
bg: "bg-slate-200 dark:bg-slate-600",
|
||||||
|
bl: "border-l-slate-200 dark:border-l-slate-600",
|
||||||
|
},
|
||||||
|
stone: {
|
||||||
|
bg: "bg-stone-200 dark:bg-stone-600",
|
||||||
|
bl: "border-l-stone-200 dark:border-l-stone-600",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
bg: "bg-red-200 dark:bg-red-600",
|
||||||
|
bl: "border-l-red-200 dark:border-l-red-600",
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: "bg-amber-200 dark:bg-amber-600",
|
||||||
|
bl: "border-l-amber-200 dark:border-l-amber-600",
|
||||||
|
},
|
||||||
|
lime: {
|
||||||
|
bg: "bg-lime-200 dark:bg-lime-600",
|
||||||
|
bl: "border-l-lime-200 dark:border-l-lime-600",
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
bg: "bg-emerald-200 dark:bg-emerald-600",
|
||||||
|
bl: "border-l-emerald-200 dark:border-l-emerald-600",
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
bg: "bg-cyan-200 dark:bg-cyan-600",
|
||||||
|
bl: "border-l-cyan-200 dark:border-l-cyan-600",
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
bg: "bg-sky-200 dark:bg-sky-600",
|
||||||
|
bl: "border-l-sky-200 dark:border-l-sky-600",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
bg: "bg-indigo-200 dark:bg-indigo-600",
|
||||||
|
bl: "border-l-indigo-200 dark:border-l-indigo-600",
|
||||||
|
},
|
||||||
|
fuchsia: {
|
||||||
|
bg: "bg-fuchsia-200 dark:bg-fuchsia-600",
|
||||||
|
bl: "border-l-fuchsia-200 dark:border-l-fuchsia-600",
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
bg: "bg-pink-200 dark:bg-pink-600",
|
||||||
|
bl: "border-l-pink-200 dark:border-l-pink-600",
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
bg: "bg-rose-200 dark:bg-rose-600",
|
||||||
|
bl: "border-l-rose-200 dark:border-l-rose-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickRandomAccentColor = () => {
|
||||||
|
const accentColorsKeys = Object.keys(accentColors);
|
||||||
|
const index = Math.floor(Math.random() * accentColorsKeys.length);
|
||||||
|
return accentColors[accentColorsKeys[index]];
|
||||||
|
};
|
||||||
|
const categoryColors = categories.reduce((acc, category) => {
|
||||||
|
acc[category] = pickRandomAccentColor();
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const categoriesSelectorCss = categories
|
||||||
|
.filter((c) => c !== "All")
|
||||||
|
.map(
|
||||||
|
(c) => `
|
||||||
|
#category-picker:has(#${c}:checked) ~ main ul {
|
||||||
|
> .${c}-item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
> *:not(.${c}-item) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full overflow-auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>Carter's RSS Feeds</title>
|
||||||
|
<link rel="icon" href="/news-emoji.svg" sizes="any" type="image/svg+xml" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<style set:html={categoriesSelectorCss}></style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="font-system text-base md:grid grid-cols-12 auto-rows-min p-4 bg-orange-50 dark:bg-slate-900 dark:text-slate-300 text-stone-700 font-semibold"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="col-span-12 row-span-1 h-fit mb-4 flex flex-row gap-8 items-center"
|
||||||
|
>
|
||||||
|
<h1 class="">Carter's RSS Feeds</h1>
|
||||||
|
<hr class="flex-grow h-px border-0 border-b-2 border-b-stone-500" />
|
||||||
|
<p>
|
||||||
|
{recentCount} recent item(s) | {feedItems.contents.length} item(s) | {
|
||||||
|
feedItems.errors.length
|
||||||
|
} error(s)
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<nav
|
||||||
|
id="category-picker"
|
||||||
|
class="flex flex-row mb-2 md:flex-col gap-2 md:gap-1 col-span-2 md:sticky md:top-4 row-span-1"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
categories.map((category) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={category}
|
||||||
|
name="category"
|
||||||
|
class="hidden peer"
|
||||||
|
checked={category === "All"}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="cursor-pointer transition-all pr-4 opacity-25 peer-checked:opacity-100 hover:opacity-100 flex flex-row gap-2 items-center"
|
||||||
|
for={category}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
"w-4 h-4 rounded-full",
|
||||||
|
categoryColors[category].bg,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<span class="peer-checked:underline">{category}</span>
|
||||||
|
<span class="ml-auto">{categoryCounts[category]}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
<main class="col-span-10 row-span-11">
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{
|
||||||
|
feedItems.contents.map((item) => (
|
||||||
|
<li
|
||||||
|
class:list={[
|
||||||
|
`${item.category}-item`,
|
||||||
|
{
|
||||||
|
"Recent-item": isRecent(item.pubIsoDate),
|
||||||
|
},
|
||||||
|
"flex flex-row gap-4 items-center",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
class:list={[
|
||||||
|
"w-full",
|
||||||
|
"border-l-8",
|
||||||
|
categoryColors[item.category].bl,
|
||||||
|
"hover:bg-orange-200 dark:hover:bg-slate-600",
|
||||||
|
"transition-all",
|
||||||
|
"active:bg-orange-300 dark:active:bg-slate-500",
|
||||||
|
"bg-orange-100 dark:bg-slate-700",
|
||||||
|
"visited:opacity-50",
|
||||||
|
"py-1 px-2",
|
||||||
|
"rounded-xl",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div set:html={item.title} class="underline" />
|
||||||
|
<div class="no-underline text-sm font-normal">
|
||||||
|
{[
|
||||||
|
new Date(item.pubIsoDate).toDateString(),
|
||||||
|
item.feedName,
|
||||||
|
item.category,
|
||||||
|
].join(" | ")}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
<footer class="sticky bottom-4 mt-4 md:mt-0 text-sm row-end-13 row-span-1">
|
||||||
|
{
|
||||||
|
feedItems.errors.length === 0 ? (
|
||||||
|
<>No errors</>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{feedItems.errors.map((error) => (
|
||||||
|
<li>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/carterworks/rss-reader"
|
||||||
|
class="underline cursor-pointer">Github</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/*
|
|
||||||
* Return our renderer.
|
|
||||||
* Using Nunjucks out of the box.
|
|
||||||
* https://mozilla.github.io/nunjucks/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import nunjucks from "nunjucks";
|
|
||||||
const env: nunjucks.Environment = nunjucks.configure({ autoescape: true });
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { getRelativeTime } from "@feelinglovelynow/get-relative-time";
|
|
||||||
import type { Feeds, JSONValue } from "./@types/bubo";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global filters for my Nunjucks templates
|
|
||||||
*/
|
|
||||||
env.addFilter("relative", (dateString): string => {
|
|
||||||
const date: Date = new Date(Number.parseInt(dateString));
|
|
||||||
return !Number.isNaN(date.getTime()) ? getRelativeTime(date) : dateString;
|
|
||||||
});
|
|
||||||
|
|
||||||
env.addFilter("formatTime", (dateString): string => {
|
|
||||||
const date: Date = new Date(Number.parseInt(dateString));
|
|
||||||
return !Number.isNaN(date.getTime()) ? date.toLocaleTimeString() : dateString;
|
|
||||||
});
|
|
||||||
|
|
||||||
env.addFilter("formatDateTime", (dateString): string => {
|
|
||||||
const date: Date = new Date(Number.parseInt(dateString));
|
|
||||||
return !Number.isNaN(date.getTime()) ? date.toLocaleString() : dateString;
|
|
||||||
});
|
|
||||||
|
|
||||||
env.addGlobal("now", new Date().getTime());
|
|
||||||
|
|
||||||
// load the template
|
|
||||||
const template: string = (
|
|
||||||
await readFile(new URL("../config/template.html", import.meta.url))
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
// generate the static HTML output from our template renderer
|
|
||||||
const render = ({
|
|
||||||
data,
|
|
||||||
errors,
|
|
||||||
info,
|
|
||||||
yazzyUrl,
|
|
||||||
}: {
|
|
||||||
data: Feeds;
|
|
||||||
errors: unknown[];
|
|
||||||
info?: JSONValue;
|
|
||||||
yazzyUrl?: string;
|
|
||||||
}) => {
|
|
||||||
return env.renderString(template, {
|
|
||||||
data,
|
|
||||||
errors,
|
|
||||||
info,
|
|
||||||
yazzyUrl,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export { render };
|
|
||||||
178
src/services/feeds.ts
Normal file
178
src/services/feeds.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import Parser from "rss-parser";
|
||||||
|
|
||||||
|
interface FeedItem {
|
||||||
|
title: string;
|
||||||
|
feedName: string;
|
||||||
|
feedLink: string;
|
||||||
|
pubIsoDate: number;
|
||||||
|
link: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONNECTIONS = Number.POSITIVE_INFINITY;
|
||||||
|
const DELAY_MS = 850;
|
||||||
|
const parser = new Parser();
|
||||||
|
|
||||||
|
function readFeedCategoriesFromEnv(): Record<string, string[]> {
|
||||||
|
if (import.meta.env.FEEDS) {
|
||||||
|
return JSON.parse(import.meta.env.FEEDS);
|
||||||
|
}
|
||||||
|
throw new Error("FEEDS environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRawFeedContents(response: Response): Promise<unknown> {
|
||||||
|
const contentType = response.headers.get("content-type")?.split(";")[0];
|
||||||
|
if (!contentType) return {};
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"application/atom+xml",
|
||||||
|
"application/rss+xml",
|
||||||
|
"application/xml",
|
||||||
|
"text/xml",
|
||||||
|
"text/html",
|
||||||
|
].includes(contentType)
|
||||||
|
) {
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
if (["application/json", "application/feed+json"].includes(contentType)) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFeedItem {
|
||||||
|
creator?: string;
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pubDate: string;
|
||||||
|
"content:encoded"?: string;
|
||||||
|
"content:encodedSnippet"?: string;
|
||||||
|
"dc:creator"?: string;
|
||||||
|
comments?: string;
|
||||||
|
content: string;
|
||||||
|
contentSnippet: string;
|
||||||
|
guid: string;
|
||||||
|
categories: unknown[];
|
||||||
|
isoDate: string;
|
||||||
|
[other: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFeed {
|
||||||
|
items: RawFeedItem[];
|
||||||
|
feedUrl?: string;
|
||||||
|
image?: {
|
||||||
|
link: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
};
|
||||||
|
pagenationLinks?: {
|
||||||
|
self: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
generator: string;
|
||||||
|
link: string;
|
||||||
|
language?: string;
|
||||||
|
lastBuildDate?: string;
|
||||||
|
[other: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(item: RawFeed | RawFeedItem): string {
|
||||||
|
const titleValues: (keyof RawFeed | keyof RawFeedItem)[] = [
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
"link",
|
||||||
|
];
|
||||||
|
const keys = Object.keys(item);
|
||||||
|
const titleProperty = titleValues.find(
|
||||||
|
(titleValue) => keys.includes(titleValue) && item[titleValue],
|
||||||
|
);
|
||||||
|
return titleProperty ? (item[titleProperty] as string) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLink(item: RawFeed | RawFeedItem): string {
|
||||||
|
const linkValues: (keyof RawFeed | keyof RawFeedItem)[] = [
|
||||||
|
"link",
|
||||||
|
"url",
|
||||||
|
"guid",
|
||||||
|
"home_page_url",
|
||||||
|
];
|
||||||
|
const keys = Object.keys(item);
|
||||||
|
const linkProperty = linkValues.find((linkValue) => keys.includes(linkValue));
|
||||||
|
return linkProperty ? (item[linkProperty] as string) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimestamp(item: RawFeedItem): number {
|
||||||
|
const dateString =
|
||||||
|
item.pubDate || item.isoDate || item.date || item.date_published;
|
||||||
|
if (!dateString || typeof dateString !== "string") {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
const timestamp = new Date(dateString).getTime();
|
||||||
|
return Number.isNaN(timestamp) ? Date.now() : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFeedContents(
|
||||||
|
feedUrl: string,
|
||||||
|
category: string,
|
||||||
|
): Promise<FeedItem[]> {
|
||||||
|
console.log(`Fetching: ${feedUrl}...`);
|
||||||
|
const response = await fetch(feedUrl);
|
||||||
|
const body = await getRawFeedContents(response);
|
||||||
|
if (!body) {
|
||||||
|
throw new Error(`Failed to fetch feed: ${feedUrl}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rawFeed = (
|
||||||
|
typeof body === "string" ? await parser.parseString(body) : body
|
||||||
|
) as RawFeed;
|
||||||
|
const feedName = getTitle(rawFeed);
|
||||||
|
const feedLink = getLink(rawFeed);
|
||||||
|
const items: FeedItem[] = rawFeed.items.flatMap((item) => ({
|
||||||
|
feedName,
|
||||||
|
feedLink,
|
||||||
|
category,
|
||||||
|
title: item.title,
|
||||||
|
pubIsoDate: getTimestamp(item),
|
||||||
|
link: item.link,
|
||||||
|
}));
|
||||||
|
return items;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error processing: ${feedUrl}\n${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function getAllFeedItems(): Promise<{
|
||||||
|
contents: FeedItem[];
|
||||||
|
errors: Error[];
|
||||||
|
}> {
|
||||||
|
const feedCategories = readFeedCategoriesFromEnv();
|
||||||
|
|
||||||
|
const results = (
|
||||||
|
await Promise.allSettled(
|
||||||
|
Object.entries(feedCategories)
|
||||||
|
.flatMap(([category, feeds]) =>
|
||||||
|
feeds.map((feedUrl) => ({ category, feedUrl })),
|
||||||
|
)
|
||||||
|
.flatMap(({ category, feedUrl }) => {
|
||||||
|
return parseFeedContents(feedUrl, category);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).reduce(
|
||||||
|
(acc, result) => {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
acc.contents.push(...result.value);
|
||||||
|
} else {
|
||||||
|
acc.errors.push(result.reason);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ contents: [], errors: [] },
|
||||||
|
);
|
||||||
|
results.contents.sort((a, b) => b.pubIsoDate - a.pubIsoDate);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
113
src/utilities.ts
113
src/utilities.ts
|
|
@ -1,113 +0,0 @@
|
||||||
import { $ } from "bun";
|
|
||||||
/*
|
|
||||||
There's a little inconsistency with how feeds report certain things like
|
|
||||||
title, links and timestamps. These helpers try to normalize that bit and
|
|
||||||
provide an order-of-operations list of properties to look for.
|
|
||||||
|
|
||||||
Note: these are tightly-coupled to the template and a personal preference.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import type { Response } from "node-fetch";
|
|
||||||
import type { FeedItem, JSONValue } from "./@types/bubo";
|
|
||||||
|
|
||||||
export const getLink = (obj: FeedItem): string => {
|
|
||||||
const link_values: string[] = ["link", "url", "guid", "home_page_url"];
|
|
||||||
const keys: string[] = Object.keys(obj);
|
|
||||||
const link_property: string | undefined = link_values.find((link_value) =>
|
|
||||||
keys.includes(link_value),
|
|
||||||
);
|
|
||||||
return link_property ? (obj[link_property] as string) : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// fallback to URL for the title if not present
|
|
||||||
// (title -> url -> link)
|
|
||||||
export const getTitle = (obj: FeedItem): string => {
|
|
||||||
const title_values: string[] = ["title", "url", "link"];
|
|
||||||
const keys: string[] = Object.keys(obj);
|
|
||||||
|
|
||||||
// if title is empty for some reason, fall back on url or link
|
|
||||||
const title_property: string | undefined = title_values.find(
|
|
||||||
(title_value) => keys.includes(title_value) && obj[title_value],
|
|
||||||
);
|
|
||||||
return title_property ? (obj[title_property] as string) : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// More dependable way to get timestamps
|
|
||||||
export const getTimestamp = (obj: FeedItem): string => {
|
|
||||||
const dateString: string = (
|
|
||||||
obj.pubDate ||
|
|
||||||
obj.isoDate ||
|
|
||||||
obj.date ||
|
|
||||||
obj.date_published
|
|
||||||
).toString();
|
|
||||||
const timestamp: number = new Date(dateString).getTime();
|
|
||||||
return Number.isNaN(timestamp) ? dateString : timestamp.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// parse RSS/XML or JSON feeds
|
|
||||||
export async function parseFeed(response: Response): Promise<JSONValue> {
|
|
||||||
const contentType = response.headers.get("content-type")?.split(";")[0];
|
|
||||||
|
|
||||||
if (!contentType) return {};
|
|
||||||
|
|
||||||
const rssFeed = [contentType]
|
|
||||||
.map((item) =>
|
|
||||||
[
|
|
||||||
"application/atom+xml",
|
|
||||||
"application/rss+xml",
|
|
||||||
"application/xml",
|
|
||||||
"text/xml",
|
|
||||||
"text/html", // this is kind of a gamble
|
|
||||||
].includes(item)
|
|
||||||
? response.text()
|
|
||||||
: false,
|
|
||||||
)
|
|
||||||
.filter((_) => _)[0];
|
|
||||||
|
|
||||||
const jsonFeed = [contentType]
|
|
||||||
.map((item) =>
|
|
||||||
["application/json", "application/feed+json"].includes(item)
|
|
||||||
? (response.json() as Promise<JSONValue>)
|
|
||||||
: false,
|
|
||||||
)
|
|
||||||
.filter((_) => _)[0];
|
|
||||||
|
|
||||||
return (rssFeed && rssFeed) || (jsonFeed && jsonFeed) || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getFeedList = async ({
|
|
||||||
feedFilePath,
|
|
||||||
feeds,
|
|
||||||
}: { feedFilePath?: string; feeds?: string }): Promise<JSONValue> => {
|
|
||||||
if (feeds) {
|
|
||||||
return JSON.parse(feeds);
|
|
||||||
}
|
|
||||||
if (!feedFilePath) {
|
|
||||||
throw new Error("No feed list provided");
|
|
||||||
}
|
|
||||||
return JSON.parse(
|
|
||||||
(await readFile(new URL(feedFilePath, import.meta.url))).toString(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBuboInfo = async (): Promise<JSONValue> => {
|
|
||||||
return JSON.parse(
|
|
||||||
(await readFile(new URL("../package.json", import.meta.url))).toString(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildCSS = async (
|
|
||||||
minify: boolean,
|
|
||||||
input: string,
|
|
||||||
destination: string,
|
|
||||||
): Promise<void> => {
|
|
||||||
const output =
|
|
||||||
await $`bun x tailwindcss -i ${input} ${minify ? "--minify" : ""} -o ${destination}`;
|
|
||||||
if (output.exitCode !== 0) {
|
|
||||||
const err = new TextDecoder().decode(output.stderr);
|
|
||||||
throw new Error(`Building tailwind failed: ${err}`);
|
|
||||||
}
|
|
||||||
console.log(`Successfully built CSS to ${destination}`);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const plugin = require("tailwindcss/plugin");
|
const plugin = require("tailwindcss/plugin");
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./config/*.html"],
|
content: ["./src/**/*.astro"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|
@ -208,18 +208,7 @@ export default {
|
||||||
"'Segoe UI Symbol'",
|
"'Segoe UI Symbol'",
|
||||||
"'Noto Color Emoji'",
|
"'Noto Color Emoji'",
|
||||||
],
|
],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
plugin(({ addBase }) => {
|
|
||||||
addBase({
|
|
||||||
a: {
|
|
||||||
textDecoration: "underline",
|
|
||||||
transition: "color 0.2s",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,3 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"extends": "astro/tsconfigs/base"
|
||||||
"module": "esnext",
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"removeComments": true,
|
|
||||||
"strict": true,
|
|
||||||
"importHelpers": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"target": "ES2021",
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"sourceMap": false,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"typeRoots": ["src/@types"],
|
|
||||||
"paths": {
|
|
||||||
"*": ["node_modules/*", "src/@types"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue