diff --git a/.astro/settings.json b/.astro/settings.json new file mode 100644 index 0000000..b87bdf2 --- /dev/null +++ b/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1724282769525 + } +} \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts new file mode 100644 index 0000000..f964fe0 --- /dev/null +++ b/.astro/types.d.ts @@ -0,0 +1 @@ +/// diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..89c6d24 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +end_of_line = lf +insert_final_newline = true +tab_width = 2 +indent_size = 2 +indent_style = tab diff --git a/.gitignore b/.gitignore index 82c1586..e2f9250 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ public/styles.css dist/* .DS_Store configs/feeds.json +src-old/ +.env diff --git a/astro.config.js b/astro.config.js new file mode 100644 index 0000000..8f1af52 --- /dev/null +++ b/astro.config.js @@ -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()], +}); diff --git a/biome.json b/biome.json index 90c25b6..6d54bb3 100644 --- a/biome.json +++ b/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": { "enabled": true }, @@ -8,5 +8,32 @@ "rules": { "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" + } + } + } + } + ] } diff --git a/bun.lockb b/bun.lockb index 31329ee..2336624 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 33bdc0e..5cd6b05 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "main": "src/index.ts", "type": "module", "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", - "dev": "bun src/index.ts" + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" }, "author": { "name": "George Mandis", @@ -26,7 +27,9 @@ }, "license": "MIT", "dependencies": { + "@astrojs/tailwind": "^5.1.0", "@feelinglovelynow/get-relative-time": "^1.1.2", + "astro": "^4.14.4", "chalk": "^5.2.0", "node-fetch": "^3.3.1", "nunjucks": "^3.2.4", diff --git a/src/@types/bubo.d.ts b/src/@types/bubo.d.ts deleted file mode 100644 index 85dc9a5..0000000 --- a/src/@types/bubo.d.ts +++ /dev/null @@ -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; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..e16c13c --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 57ae71e..0000000 --- a/src/index.ts +++ /dev/null @@ -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[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 - */ -const processFeed = - ({ - group, - feed, - startTime, - }: { - group: string; - feed: string; - startTime: number; - }) => - async (response: Response): Promise => { - 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(); diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..e1c4fc0 --- /dev/null +++ b/src/pages/index.astro @@ -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"); +--- + + + + + + + + Carter's RSS Feeds + + + + + +
+

Carter's RSS Feeds

+
+

+ {recentCount} recent item(s) | {feedItems.contents.length} item(s) | { + feedItems.errors.length + } error(s) +

+
+ +
+ +
+
+ { + feedItems.errors.length === 0 ? ( + <>No errors + ) : ( +
    + {feedItems.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) + } + +
+ + diff --git a/src/renderer.ts b/src/renderer.ts deleted file mode 100644 index a038c5c..0000000 --- a/src/renderer.ts +++ /dev/null @@ -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 }; diff --git a/src/services/feeds.ts b/src/services/feeds.ts new file mode 100644 index 0000000..4dc3a59 --- /dev/null +++ b/src/services/feeds.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/utilities.ts b/src/utilities.ts deleted file mode 100644 index 3d9be1a..0000000 --- a/src/utilities.ts +++ /dev/null @@ -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 { - 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) - : false, - ) - .filter((_) => _)[0]; - - return (rssFeed && rssFeed) || (jsonFeed && jsonFeed) || {}; -} - -export const getFeedList = async ({ - feedFilePath, - feeds, -}: { feedFilePath?: string; feeds?: string }): Promise => { - 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 => { - return JSON.parse( - (await readFile(new URL("../package.json", import.meta.url))).toString(), - ); -}; - -export const buildCSS = async ( - minify: boolean, - input: string, - destination: string, -): Promise => { - 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; -}; diff --git a/tailwind.config.js b/tailwind.config.js index ebe918c..773b701 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ const plugin = require("tailwindcss/plugin"); /** @type {import('tailwindcss').Config} */ export default { - content: ["./config/*.html"], + content: ["./src/**/*.astro"], theme: { extend: { fontFamily: { @@ -208,18 +208,7 @@ export default { "'Segoe UI Symbol'", "'Noto Color Emoji'", ], - }, + } }, }, - plugins: [ - plugin(({ addBase }) => { - addBase({ - a: { - textDecoration: "underline", - transition: "color 0.2s", - cursor: "pointer", - }, - }); - }), - ], }; diff --git a/tsconfig.json b/tsconfig.json index 565a04d..5378929 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,3 @@ { - "compilerOptions": { - "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/**/*"] + "extends": "astro/tsconfigs/base" }