diff --git a/assets/style.css b/assets/style.css
new file mode 100644
index 0000000..13187d5
--- /dev/null
+++ b/assets/style.css
@@ -0,0 +1,38 @@
+body {
+ max-width: 640px;
+ margin: 0 0 0 auto;
+ padding: 8px;
+ font-family: sans-serif;
+}
+
+ul {
+ padding-inline-start: 30px;
+ list-style-type: none;
+}
+
+p {
+ padding: 30px;
+}
+
+img {
+ margin: 10px auto;
+ max-width: 100%;
+}
+
+p a, footer a {
+ float: right
+}
+
+hr {
+ clear: both
+}
+
+footer a {
+ padding-bottom: 10px;
+}
+
+@media (prefers-color-scheme: dark) {
+ body { background: #000; color: #eee }
+ a { color: #ccf }
+ hr { border-color: #555 }
+}
\ No newline at end of file
diff --git a/default/config.js b/default/config.js
index bc48a5c..6c4983c 100644
--- a/default/config.js
+++ b/default/config.js
@@ -105,7 +105,7 @@ const sources = {
]
}
-module.exports = {
+export default {
feeds,
sources,
courtesyWait,
diff --git a/index.js b/index.js
index 57bb71b..a9dd7ac 100644
--- a/index.js
+++ b/index.js
@@ -175,7 +175,7 @@ const printFeed = async (sources, directory, header, viewOptions, error) => {
}
}
- feed = feed.sort((a, b) => a.date < b.date)
+ feed = feed.sort((a, b) => a.date > b.date)
// Render
@@ -187,11 +187,12 @@ const printFeed = async (sources, directory, header, viewOptions, error) => {
// Write
+ let lastIndex = getLastIndex()
let promises = []
const writePage = (index, content) =>
promises.push(
- write(Path.join(directory, index == 0 ? 'index' : index.toString() ) + '.html', content)
+ write(Path.join(directory, index == (feed.length - 1) ? 'index' : index.toString() ) + '.html', content)
)
for(let i = 0; i < pages.length; i++) {
diff --git a/lib.js b/lib.js
new file mode 100644
index 0000000..846eeec
--- /dev/null
+++ b/lib.js
@@ -0,0 +1,489 @@
+// Ascii font used is "Shimrod"
+
+import Path from "path"
+import FS from "fs/promises"
+import { JSDOM } from "jsdom"
+
+let cache = await FS.readFile('./cache.json', { encoding: 'utf-8' })
+ .then(json => JSON.parse(json) )
+
+
+// | o |
+// . . |- . | ,-.
+// | | | | | `-.
+// `-` `-' ' ' `-'
+
+export function PromiseBatch() {
+ let promises = []
+
+ this.add = (promise) =>
+ promises.push(promise)
+
+ this.complete = () =>
+ Promise.all(promises)
+}
+
+export const annotate = annotation =>
+ data => {
+ console.log(annotation),
+ data
+ }
+
+export const write = async (path, content) => {
+ let dir = Path.dirname(path)
+
+ try {
+ await FS.access(dir)
+ } catch(e) {
+ await FS.mkdir(dir, { recursive: true })
+ }
+
+ return await FS.writeFile(path, content)
+}
+
+export const download = async (url, path, courtesyWait) => {
+ let response = await delayedFetch(url, {}, courtesyWait)
+ .catch(err => console.error(`Failed download of ${url}:`, err) )
+
+ if(response.ok) {
+ await write(path, response.body)
+ } else {
+ throw createNetworkingError(response)
+ }
+}
+
+export const createNetworkingError = response => {
+ return new Error(`Request failed, ${response.status}: ${response.statusText}`)
+}
+
+export const getLinkExtname = link =>
+ Path.extname(new URL(link).pathname)
+
+export const buildImagePathHandler = (source, id) => (url, i, array) => {
+ let path = `images/${source.name}-${id}`
+
+ if(array.length > 1)
+ path += `-${i}`
+
+ return path + getLinkExtname(url)
+}
+
+export const addStylesheet = (path, { viewDir, batch }) =>
+ batch.add(
+ FS.readFile(path)
+ .then(content => write(Path.join(viewDir, 'style.css'), content))
+ )
+
+export const postIdFromPathname = post => {
+ let { pathname } = new URL(post.link)
+ return pathname.slice(pathname.lastIndexOf('/') + 1)
+}
+
+export const createLock = async renderer => {
+ let lockExists = false
+
+ try {
+ await FS.access(renderer.lockPath)
+ lockExists = true
+ } catch(err) {
+ lockExists = false
+ }
+
+ renderer.lock = {
+ sources: {},
+ lists: {}
+ }
+
+ if(lockExists) {
+ let lock = JSON.parse(await FS.readFile(renderer.lockPath, { encoding: 'utf8' }))
+
+ Object.assign(renderer.lock, lock)
+ }
+}
+
+export const writeLock = renderer =>
+ write(renderer.lockPath, JSON.stringify(renderer.lock) )
+
+
+//
+// ;-. ,-. ,-.
+// | `-. `-.
+// ' `-' `-'
+
+class NoMatchesError extends Error {}
+export const processRss = (source, reducerCallback) => {
+ let { document } = new JSDOM(source.rss, {
+ contentType: 'text/xml'
+ }).window
+ let items = document.querySelectorAll('channel item')
+
+ if(items.length == 0) {
+ throw new NoMatchesError('Got no matches')
+ }
+
+ source.posts = []
+
+ for(let item of items) {
+ let description = new JSDOM(item.querySelector('description').textContent).window.document
+ let dateString = item.querySelector('pubDate').textContent
+ let link = item.querySelector('link').textContent
+ let guid = item.querySelector('guid').textContent
+
+ let post = {
+ source,
+ item,
+ description,
+ dateString,
+ date: new Date(dateString).valueOf() ?? 0,
+ link,
+ guid
+ }
+
+ post = reducerCallback(post)
+
+ if(post) {
+ source.posts.push(post)
+ }
+ }
+
+ return source
+}
+
+let waitingList = new Map()
+export const sleep = delay => new Promise(resolve => setTimeout(() => resolve(), delay) )
+export const delayedFetch = async (url, options, courtesyWait = 5 * 1000) => {
+ let [ domain ] = /[\w-]+.[\w-]+$/.exec(url.hostname)
+ let timeout = waitingList.get(domain) ?? 0
+ let now = Date.now()
+
+ if(timeout == null || timeout <= now) {
+ waitingList.set(domain, timeout + courtesyWait)
+ } else {
+ await sleep(timeout - now)
+ }
+
+ return await fetch(url, options)
+}
+
+export async function fetchRss(source) {
+ let { hostname } = source
+ let error
+ let response
+
+ try {
+ response = await delayedFetch(
+ new URL(source.pathname, 'https://' + hostname),
+ {},
+ source.courtesyWait ?? 5 * 1000
+ )
+ } catch(err) {
+ error = err
+ }
+
+ source.errored = error !== undefined || !response.ok
+ if(source.errored) {
+ source.error = error ?? createNetworkingError(response)
+ source.rss = ''
+ } else {
+ source.rss = await response.text()
+ }
+
+ return source
+}
+
+export const extractImages = (post, cache = true) => {
+ let images = post.description.querySelectorAll('img')
+
+ if(images) {
+ let imageUrls = []
+
+ for(let image of images) {
+ let { src } = image
+
+ if(!src) {
+ let finalSrc = image.srcset.split(', ').pop()
+
+ src = finalSrc.slice(0, finalSrc.indexOf(' ') )
+ }
+
+ // Sending through URL prevents potential XSS
+ imageUrls.push(new URL(src).href)
+ }
+
+ return imageUrls
+ }
+}
+
+
+// o
+// . , . ,-. , , ,
+// |/ | |-' |/|/
+// ' ' `-' ' '
+
+export const createPages = (list, { pageSize, header = '', viewDir, batch, getPageFilename, getPageTitle, lock }) => {
+ let posts = []
+ let lastPageLink = 'about:blank'
+ let pageIndex = 0
+ // let pageIndex = Math.ceil(list.posts.length / pageSize)
+ // let {
+ // index: pageIndex = 0,
+ // lastPostDate
+ // } = lock.lists[list.name]?.lastPage ?? {}
+
+ // let sinceDate = posts[0]?.date ?? 0
+ // posts = list.posts
+ // .filter(post => post.date > sinceDate)
+ // .concat(posts)
+ // .sort((a, b) => b.date - a.date)
+
+ // let firstPageSize =
+
+ list.posts.sort((a, b) => a.date - b.date)
+
+ for(let i = 0; i < list.posts.length; i++) {
+ // for(let i = list.posts.length - 1; i >= 0; i--) {
+ posts.push(list.posts[i])
+
+ if(i % pageSize == 0) {
+ let isLastPage = list.main && i < pageSize
+ let title = getPageTitle(list, pageIndex)
+ let html = renderPage(title, posts.reverse(), header, renderNextPageLink(lastPageLink))
+ let filename = isLastPage ? 'index.html' : getPageFilename(list, pageIndex)
+ let promise = write(Path.join(viewDir, filename), html)
+
+ batch.add(promise.then(annotate(`Created "${title}" (${filename})`)))
+ posts = []
+ lastPageLink = filename
+ pageIndex++
+ }
+ }
+
+ // lock.lists[list.name] = {
+ // pageIndex,
+ // lastPostDate: posts[0]?.date ?? lastPostDate
+ // }
+}
+
+export const renderPage = (title, posts, header, footer) => `\
+
+
+
+${title}
+
+
+
+
+
+
+
+${posts.map(renderPost).join('\n')}
+
+
+
+`
+
+export const renderPost = post => {
+ let date = new Date(post.date)
+
+ return `\
+${post.images.map(renderImage).join('\n')}
+${post.source.displayName} ${renderDate(date)} open
`
+}
+
+export const renderImage = href => {
+ return `\
+
`
+}
+
+export const renderDate = date =>
+ (date.getMonth() + 1) + '.' + date.getDate() + '.' + date.getFullYear()
+
+export const renderNextPageLink = link => `\
+next`
+
+export const renderNav = (feeds, sources) => `\
+
+
+Feeds
+
+
+
+${feeds.map(renderNavEntry).join('\n')}
+
+
+
+
+
+${sources.map(renderNavEntry).join('\n')}
+
+
+
+
+
+
`
+
+export const renderNavEntry = (list) => {
+ let extra = ''
+
+ if(list.error) {
+ extra += ' (errored)'
+ } else if (list.posts.length == 0) {
+ extra += ' (empty)'
+ }
+
+ return `${list.displayName}${extra}`
+}
+
+
+// | | | o
+// ,-. ,-. | | ,-: |- . ,-. ;-.
+// | | | | | | | | | | | | |
+// `-' `-' ' ' `-` `-' ' `-' ' '
+
+export const downloadImages = (images, getImagePath, courtesyWait, { viewDir, batch }) => {
+ let out = []
+
+ for(let i = 0; i < images.length; i ++) {
+ let url = images[i]
+ let relativePath = getImagePath(url, i, images)
+ let fullPath = Path.join(viewDir, relativePath)
+
+ let promise = FS.access(fullPath)
+ .catch(() =>
+ download(url, fullPath, courtesyWait)
+ .then(annotate( `Downloaded ${relativePath}`))
+ )
+
+ out.push(relativePath)
+ batch.add(promise)
+ }
+
+ return out
+}
+
+export const pullImages = (post, renderer, discardPostIfNoImages = false, getPostId = postIdFromPathname) => {
+ let images = extractImages(post)
+
+ if(!discardPostIfNoImages || images.length > 0) {
+ post.images = downloadImages(
+ images,
+ buildImagePathHandler(post.source, getPostId(post)),
+ post.source.courtesyWait,
+ renderer
+ )
+ return post
+ }
+}
+
+export const createFeed = (name, sources) => {
+ return {
+ name,
+ posts: sources.reduce((posts, source) => posts.concat(source.posts), [])
+ }
+}
+
+export const fetchRssFromInstances = async (source, renderer) => {
+ let index = 0
+ let instances = source.instances
+ let lockHostname = renderer.lock.sources[source.name]?.hostname
+
+ if(lockHostname) {
+ instances.unshift(lockHostname)
+ }
+
+ while(!source.rss && index != instances.length) {
+ source.hostname = instances[index]
+ source = await fetchRss(source)
+
+ if(source.errored) {
+ console.error(`Failed to fetch ${source.name} from ${source.hostname}: `, source.error)
+ index++
+ } else {
+ break
+ }
+ }
+
+ (renderer.lock.sources[source.name] ??= {}).hostname = source.hostname
+
+ return source
+}
+
+const addPostsToLock = (source, renderer) => {
+ (renderer.lock.sources[source.name] ??= {}).postData = source.posts.map(post => post.description)
+}
+
+
+// | | ,-
+// ;-. | ,-: |- | ,-. ;-. ;-.-. ,-.
+// | | | | | | |- | | | | | | `-.
+// |-' ' `-` `-' | `-' ' ' ' ' `-'
+// ' -'
+
+export const tumblr = {
+ async createSource(user, courtesyWait, postReducerCallback, renderer) {
+ let source = {
+ hostname: user + '.tumblr.com',
+ pathname: 'rss',
+ courtesyWait,
+ name: `tumblr-${user}`,
+ displayName: user,
+ user
+ }
+
+ source = await fetchRss(source)
+ source = processRss(source, postReducerCallback)
+ addPostsToLock(source, renderer)
+ return source
+ },
+
+ createSources(users, ...args) {
+ return Promise.all(users.map(user => tumblr.createSource(user, ...args)))
+ },
+
+ isRepost(post) {
+ let reblog = post.description.querySelector('p > a.tumblr_blog')
+
+ return reblog && reblog.innerHTML !== post.source.user
+ },
+
+ pullImages
+}
+
+export const nitter = {
+ async createSource(user, instances, courtesyWait, postReducerCallback, renderer) {
+ let source = {
+ instances,
+ pathname: user + '/rss',
+ courtesyWait,
+ name: `nitter-${user}`,
+ displayName: user,
+ user
+ }
+
+ source = await fetchRssFromInstances(source, renderer)
+ source = processRss(source, postReducerCallback)
+ return source
+ },
+
+ createSources(users, ...args) {
+ return Promise.all(users.map(user => nitter.createSource(user, ...args)))
+ },
+
+ isRepost(post) {
+ let creator = post.item.getElementsByTagName('dc:creator')[0]
+
+ return creator.innerHTML.slice(1) === post.source.user
+ },
+
+ pullImages
+}
+
+// TODO: Mastodon support
+//
+// "Turns out Mastodon has built-in RSS; your feed URL is [instance]/@[username].rss, so for example I'm
+// https://mastodon.social/@brownpau.rss (note the "@")"
+// - https://mastodon.social/@brownpau/100523448408374430
\ No newline at end of file
diff --git a/package.json b/package.json
index a9d0b55..49f0e01 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
- "jsdom": "^22.1.0",
- "node-fetch": "^3.3.1"
+ "jsdom": "^22.1.0"
},
"type": "module"
}
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..c6691be
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,13 @@
+{ pkgs ? import {} }:
+
+pkgs.stdenv.mkDerivation {
+ name = "rssssing-dev";
+ buildInputs = [
+ pkgs.nodejs_21
+ pkgs.yarn
+ ];
+ shellHook = ''
+ export PATH="$PWD/node_modules/.bin/:$PATH"
+ export NPM_PACKAGES="$HOME/.npm-packages"
+ '';
+}
\ No newline at end of file
diff --git a/test.html b/test.html
new file mode 100644
index 0000000..a48f92b
--- /dev/null
+++ b/test.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+muses
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 11f9dea..ba8bae8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -38,11 +38,6 @@ cssstyle@^3.0.0:
dependencies:
rrweb-cssom "^0.6.0"
-data-uri-to-buffer@^4.0.0:
- version "4.0.1"
- resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz"
- integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
-
data-urls@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz"
@@ -81,14 +76,6 @@ entities@^4.4.0:
resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
-fetch-blob@^3.1.2, fetch-blob@^3.1.4:
- version "3.2.0"
- resolved "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz"
- integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
- dependencies:
- node-domexception "^1.0.0"
- web-streams-polyfill "^3.0.3"
-
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz"
@@ -98,13 +85,6 @@ form-data@^4.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
-formdata-polyfill@^4.0.10:
- version "4.0.10"
- resolved "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz"
- integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
- dependencies:
- fetch-blob "^3.1.2"
-
html-encoding-sniffer@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz"
@@ -187,20 +167,6 @@ ms@2.1.2:
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
-node-domexception@^1.0.0:
- version "1.0.0"
- resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
- integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
-
-node-fetch@^3.3.1:
- version "3.3.2"
- resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz"
- integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
- dependencies:
- data-uri-to-buffer "^4.0.0"
- fetch-blob "^3.1.4"
- formdata-polyfill "^4.0.10"
-
nwsapi@^2.2.4:
version "2.2.7"
resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz"
@@ -292,11 +258,6 @@ w3c-xmlserializer@^4.0.0:
dependencies:
xml-name-validator "^4.0.0"
-web-streams-polyfill@^3.0.3:
- version "3.2.1"
- resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz"
- integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
-
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz"