mirror of
https://github.com/osmarks/website
synced 2025-09-01 02:07:57 +00:00
Apparently I changed everything and forgot to commit it.
- I just added sidenotes (blog being rewritten slightly to incorporate them; WIP) - Microblog added, compiler caching mechanism reworked - Image compression
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"If you can't stand the heat, get out of the server room."
|
||||
],
|
||||
"feeds": [
|
||||
"https://blogs.sciencemag.org/pipeline/feed",
|
||||
"https://www.science.org/blogs/pipeline/feed",
|
||||
"https://www.rtl-sdr.com/feed/",
|
||||
"https://astralcodexten.substack.com/feed",
|
||||
"https://www.rifters.com/crawl/?feed=rss2",
|
||||
@@ -27,5 +27,6 @@
|
||||
"https://aphyr.com/posts.atom",
|
||||
"https://os.phil-opp.com/rss.xml"
|
||||
],
|
||||
"dateFormat": "YYYY-MM-DD"
|
||||
"dateFormat": "YYYY-MM-DD",
|
||||
"microblogSource": "https://b.osmarks.net/outbox"
|
||||
}
|
130
src/index.js
130
src/index.js
@@ -18,6 +18,10 @@ const childProcess = require("child_process")
|
||||
const chalk = require("chalk")
|
||||
const crypto = require("crypto")
|
||||
const uuid = require("uuid")
|
||||
const sqlite = require("better-sqlite3")
|
||||
const axios = require("axios")
|
||||
const msgpack = require("@msgpack/msgpack")
|
||||
const esbuild = require("esbuild")
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
@@ -28,6 +32,7 @@ const blogDir = path.join(root, "blog")
|
||||
const errorPagesDir = path.join(root, "error")
|
||||
const assetsDir = path.join(root, "assets")
|
||||
const outDir = path.join(root, "out")
|
||||
const srcDir = path.join(root, "src")
|
||||
|
||||
const buildID = nanoid()
|
||||
globalData.buildID = buildID
|
||||
@@ -189,7 +194,7 @@ const processBlog = async () => {
|
||||
}, processContent: renderMarkdown })
|
||||
})
|
||||
console.log(chalk.yellow(`${Object.keys(blog).length} blog entries`))
|
||||
globalData.blog = addGuids(R.sortBy(x => x.updated ? -x.updated.valueOf() : 0, R.values(blog)))
|
||||
globalData.blog = addGuids(R.filter(x => !x.draft, R.sortBy(x => x.updated ? -x.updated.valueOf() : 0, R.values(blog))))
|
||||
}
|
||||
|
||||
const processErrorPages = () => {
|
||||
@@ -214,51 +219,76 @@ const applyMetricPrefix = (x, unit) => {
|
||||
globalData.metricPrefix = applyMetricPrefix
|
||||
|
||||
const writeBuildID = () => fsp.writeFile(path.join(outDir, "buildID.txt"), buildID)
|
||||
|
||||
const index = async () => {
|
||||
const index = globalData.templates.index({ ...globalData, title: "Index", posts: globalData.blog, description: globalData.siteDescription })
|
||||
await fsp.writeFile(path.join(outDir, "index.html"), index)
|
||||
}
|
||||
const compileCSS = async () => {
|
||||
const css = sass.renderSync({
|
||||
data: await readFile(path.join(root, "style.sass")),
|
||||
outputStyle: "compressed",
|
||||
indentedSyntax: true
|
||||
}).css
|
||||
globalData.css = css
|
||||
|
||||
const cache = sqlite("cache.sqlite3")
|
||||
cache.exec("CREATE TABLE IF NOT EXISTS cache (k TEXT NOT NULL PRIMARY KEY, v BLOB NOT NULL, ts INTEGER NOT NULL)")
|
||||
const writeCacheStmt = cache.prepare("INSERT OR REPLACE INTO cache VALUES (?, ?, ?)")
|
||||
const readCacheStmt = cache.prepare("SELECT * FROM cache WHERE k = ?")
|
||||
const readCache = (k, maxAge=null, ts=null) => {
|
||||
const row = readCacheStmt.get(k)
|
||||
if (!row) return
|
||||
if ((maxAge && row.ts < (Date.now() - maxAge) || (ts && row.ts != ts))) return
|
||||
return msgpack.decode(row.v)
|
||||
}
|
||||
const loadTemplates = async () => {
|
||||
globalData.templates = await loadDir(templateDir, async fullPath => pug.compile(await readFile(fullPath), { filename: fullPath }))
|
||||
const writeCache = (k, v, ts=Date.now()) => {
|
||||
const enc = msgpack.encode(v)
|
||||
writeCacheStmt.run(k, Buffer.from(enc.buffer, enc.byteOffset, enc.byteLength), ts)
|
||||
}
|
||||
|
||||
const fetchMicroblog = async () => {
|
||||
const cached = readCache("microblog", 60*60*1000)
|
||||
if (cached) { globalData.microblog = cached; return }
|
||||
const posts = (await axios({ url: globalData.microblogSource, headers: { "Accept": 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } })).data.orderedItems
|
||||
globalData.microblog = posts.slice(0, 6).map(post => minifyHTML(globalData.templates.activitypub({
|
||||
...globalData,
|
||||
permalink: post.object.id,
|
||||
date: dayjs(post.object.published),
|
||||
content: post.object.content,
|
||||
bgcol: hashColor(post.object.id, 1, 0.9)
|
||||
})))
|
||||
writeCache("microblog", globalData.microblog)
|
||||
}
|
||||
|
||||
const runOpenring = async () => {
|
||||
try {
|
||||
var cached = JSON.parse(await fsp.readFile("cache.json", {encoding: "utf8"}))
|
||||
} catch(e) {
|
||||
console.log(chalk.keyword("orange")("Failed to load cache:"), e)
|
||||
}
|
||||
if (cached && (Date.now() - cached.time) < (60 * 60 * 1000)) {
|
||||
console.log(chalk.keyword("orange")("Loading Openring data from cache"))
|
||||
return cached.data
|
||||
}
|
||||
globalData.openring = "bee"
|
||||
const cached = readCache("openring", 60*60*1000)
|
||||
if (cached) { globalData.openring = cached; return }
|
||||
// wildly unsafe but only runs on input from me anyway
|
||||
const arg = `./openring -n6 ${globalData.feeds.map(x => '-s "' + x + '"').join(" ")} < openring.html`
|
||||
console.log(chalk.keyword("orange")("Openring:") + " " + arg)
|
||||
const out = await util.promisify(childProcess.exec)(arg)
|
||||
console.log(chalk.keyword("orange")("Openring:") + "\n" + out.stderr.trim())
|
||||
globalData.openring = minifyHTML(out.stdout)
|
||||
await fsp.writeFile("cache.json", JSON.stringify({
|
||||
time: Date.now(),
|
||||
data: globalData.openring
|
||||
}))
|
||||
writeCache("openring", globalData.openring)
|
||||
}
|
||||
|
||||
const compileCSS = async () => {
|
||||
const css = sass.renderSync({
|
||||
data: await readFile(path.join(srcDir, "style.sass")),
|
||||
outputStyle: "compressed",
|
||||
indentedSyntax: true
|
||||
}).css
|
||||
globalData.css = css
|
||||
}
|
||||
|
||||
const loadTemplates = async () => {
|
||||
globalData.templates = await loadDir(templateDir, async fullPath => pug.compile(await readFile(fullPath), { filename: fullPath }))
|
||||
}
|
||||
|
||||
const genRSS = async () => {
|
||||
const rssFeed = globalData.templates.rss({ ...globalData, items: globalData.blog, lastUpdate: new Date() })
|
||||
await fsp.writeFile(path.join(outDir, "rss.xml"), rssFeed)
|
||||
}
|
||||
|
||||
const genManifest = async () => {
|
||||
const m = mustache.render(await readFile(path.join(assetsDir, "manifest.webmanifest")), globalData)
|
||||
fsp.writeFile(path.join(outAssets, "manifest.webmanifest"), m)
|
||||
}
|
||||
|
||||
const minifyJSTask = async () => {
|
||||
const jsDir = path.join(assetsDir, "js")
|
||||
const jsOutDir = path.join(outAssets, "js")
|
||||
@@ -267,10 +297,22 @@ const minifyJSTask = async () => {
|
||||
await minifyJSFile(await readFile(fullpath), file, path.join(jsOutDir, file))
|
||||
}))
|
||||
}
|
||||
|
||||
const compilePageJSTask = async () => {
|
||||
await esbuild.build({
|
||||
entryPoints: [ path.join(srcDir, "page.js") ],
|
||||
bundle: true,
|
||||
outfile: path.join(outAssets, "js/page.js"),
|
||||
minify: true,
|
||||
sourcemap: true
|
||||
})
|
||||
}
|
||||
|
||||
const genServiceWorker = async () => {
|
||||
const serviceWorker = mustache.render(await readFile(path.join(assetsDir, "sw.js")), globalData)
|
||||
await minifyJSFile(serviceWorker, "sw.js", path.join(outDir, "sw.js"))
|
||||
}
|
||||
|
||||
const copyAsset = subpath => fse.copy(path.join(assetsDir, subpath), path.join(outAssets, subpath))
|
||||
|
||||
const doImages = async () => {
|
||||
@@ -279,9 +321,37 @@ const doImages = async () => {
|
||||
copyAsset("titillium-web-semibold.woff2")
|
||||
copyAsset("share-tech-mono.woff2")
|
||||
globalData.images = {}
|
||||
for (const image of await fse.readdir(path.join(assetsDir, "images"), { encoding: "utf-8" })) {
|
||||
globalData.images[image.split(".").slice(0, -1).join(".")] = "/assets/images/" + image
|
||||
}
|
||||
await Promise.all(
|
||||
(await fse.readdir(path.join(assetsDir, "images"), { encoding: "utf-8" })).map(async image => {
|
||||
if (image.endsWith(".original")) { // generate alternative formats
|
||||
const stripped = image.replace(/\.original$/).split(".").slice(0, -1).join(".")
|
||||
globalData.images[stripped] = {}
|
||||
const fullPath = path.join(assetsDir, "images", image)
|
||||
const stat = await fse.stat(fullPath)
|
||||
const writeFormat = async (name, ext, mime, cmd, supplementaryArgs) => {
|
||||
let bytes = readCache(`images/${stripped}/${name}`, null, stat.mtimeMs)
|
||||
const destFilename = stripped + ext
|
||||
const destPath = path.join(outAssets, "images", destFilename)
|
||||
if (!bytes) {
|
||||
console.log(chalk.keyword("orange")(`Compressing image ${stripped} (${name})`))
|
||||
await util.promisify(childProcess.execFile)(cmd, supplementaryArgs.concat([
|
||||
fullPath,
|
||||
destPath
|
||||
]))
|
||||
writeCache(`images/${stripped}/${name}`, await fsp.readFile(destPath), stat.mtimeMs)
|
||||
} else {
|
||||
await fsp.writeFile(destPath, bytes)
|
||||
}
|
||||
|
||||
globalData.images[stripped][mime] = "/assets/images/" + destFilename
|
||||
}
|
||||
await writeFormat("avif", ".avif", "image/avif", "avifenc", ["-s", "0", "-q", "20"])
|
||||
await writeFormat("jpeg-scaled", ".jpg", "_fallback", "convert", ["-resize", "25%", "-format", "jpeg"])
|
||||
} else {
|
||||
globalData.images[image.split(".").slice(0, -1).join(".")] = "/assets/images/" + image
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tasks = {
|
||||
@@ -290,18 +360,20 @@ const tasks = {
|
||||
pagedeps: { deps: ["templates", "css"] },
|
||||
css: { deps: [], fn: compileCSS },
|
||||
writeBuildID: { deps: [], fn: writeBuildID },
|
||||
index: { deps: ["openring", "pagedeps", "blog", "experiments", "images"], fn: index },
|
||||
index: { deps: ["openring", "pagedeps", "blog", "experiments", "images", "fetchMicroblog"], fn: index },
|
||||
openring: { deps: [], fn: runOpenring },
|
||||
rss: { deps: ["blog"], fn: genRSS },
|
||||
blog: { deps: ["pagedeps"], fn: processBlog },
|
||||
fetchMicroblog: { deps: [], fn: fetchMicroblog },
|
||||
experiments: { deps: ["pagedeps"], fn: processExperiments },
|
||||
assetsDir: { deps: [], fn: () => fse.ensureDir(outAssets) },
|
||||
manifest: { deps: ["assetsDir"], fn: genManifest },
|
||||
minifyJS: { deps: ["assetsDir"], fn: minifyJSTask },
|
||||
compilePageJS: { deps: ["assetsDir"], fn: compilePageJSTask },
|
||||
serviceWorker: { deps: [], fn: genServiceWorker },
|
||||
images: { deps: ["assetsDir"], fn: doImages },
|
||||
offlinePage: { deps: ["assetsDir", "pagedeps"], fn: () => applyTemplate(globalData.templates.experiment, path.join(assetsDir, "offline.html"), () => path.join(outAssets, "offline.html"), {}) },
|
||||
assets: { deps: ["manifest", "minifyJS", "serviceWorker", "images"] },
|
||||
assets: { deps: ["manifest", "minifyJS", "serviceWorker", "images", "compilePageJS"] },
|
||||
main: { deps: ["writeBuildID", "index", "errorPages", "assets", "experiments", "blog", "rss"] }
|
||||
}
|
||||
|
||||
|
519
src/page.js
Normal file
519
src/page.js
Normal file
@@ -0,0 +1,519 @@
|
||||
const idb = require("idb")
|
||||
const { solve } = require("yalps")
|
||||
|
||||
// attempt to register service worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).then(reg => {
|
||||
if (reg.installing) {
|
||||
console.log("Service worker installing");
|
||||
} else if (reg.waiting) {
|
||||
console.log("Service worker installed");
|
||||
} else if (reg.active) {
|
||||
console.log("Service worker active");
|
||||
}
|
||||
}).catch(error => {
|
||||
// registration failed
|
||||
console.log("Registration failed with " + error);
|
||||
});
|
||||
} else {
|
||||
console.log("Service workers are not supported.");
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
||||
const hashString = function(str, seed = 0) {
|
||||
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0, ch; i < str.length; i++) {
|
||||
ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = Math.imul(h1 ^ h1>>>16, 2246822507) ^ Math.imul(h2 ^ h2>>>13, 3266489909);
|
||||
h2 = Math.imul(h2 ^ h2>>>16, 2246822507) ^ Math.imul(h1 ^ h1>>>13, 3266489909)
|
||||
return 4294967296 * (2097151 & h2) + (h1>>>0)
|
||||
}
|
||||
|
||||
const colHash = (str, saturation = 100, lightness = 70) => `hsl(${hashString(str) % 360}, ${saturation}%, ${lightness}%)`
|
||||
window.colHash = colHash
|
||||
|
||||
// Arbitrary Points code, wrapped in an IIFE to not pollute the global environment much more than it already is
|
||||
window.points = (async () => {
|
||||
const achievementInfo = {
|
||||
test: {
|
||||
title: "Test",
|
||||
conditions: "testing",
|
||||
description: "This achievement is for testing purposes.",
|
||||
points: 10
|
||||
},
|
||||
firstAchievement: {
|
||||
title: "Achievement-Achieving Achievement™",
|
||||
conditions: "unlocking another achievement",
|
||||
description: "You achieved your first achievement, so here's an achievement to commemorate your achievement of an achievement! Enjoy the sense of achievement you get from this achievement!",
|
||||
points: 5.5
|
||||
},
|
||||
timeSpent1Hour: {
|
||||
title: "Causal Mondays",
|
||||
conditions: "using the site for a total of 1 hour",
|
||||
description: "Apparently you've spent an hour on this site. Weird. You get an achievement for it, though.",
|
||||
points: 9.3
|
||||
},
|
||||
visitArbitraryPoints: {
|
||||
title: "Arbitrary Arbitration",
|
||||
conditions: "visiting the Arbitrary Points management page",
|
||||
description: "You've now visited the Arbitrary Points page, from which you can see your achievements, point count and tracked metrics.",
|
||||
points: 15
|
||||
},
|
||||
reset: {
|
||||
title: "Burn It Down",
|
||||
conditions: "resetting",
|
||||
description: "So you wiped your Arbitrary Points data for whatever reason. Now you get this exclusive achievement!",
|
||||
points: 11.4
|
||||
},
|
||||
pagesVisited64: {
|
||||
title: "Real Dedication",
|
||||
conditions: "visiting 64 pages",
|
||||
points: 15.01,
|
||||
description: "You've visited something between 64 pages or 1 page 64 times and are thus being rewarded for your frequent use of the site."
|
||||
},
|
||||
blindLuck: {
|
||||
title: "Ridiculous Blind Luck",
|
||||
conditions: "0.001% chance of getting this every second",
|
||||
points: 66.6,
|
||||
description: "Through sheer chance you have obtained this achievement, which provides more points than all the other ones. This is probably a metaphor for life."
|
||||
},
|
||||
offline: {
|
||||
title: "Not The Dinosaur Game",
|
||||
conditions: "seeing the offline page",
|
||||
points: 10.1,
|
||||
description: "Something broke somewhere and you're seeing this. Sadly this no longer has the Chrome dinosaur game, but you can use other stuff."
|
||||
},
|
||||
attemptedXSS: {
|
||||
title: "1337 h4xx0r",
|
||||
conditions: "attempting an XSS attack",
|
||||
points: 43.01,
|
||||
description: "You appear to have attempted a cross-site-scripting attack. This probably hasn't worked. If it has, please tell me as this is a problem."
|
||||
},
|
||||
emuwar10: {
|
||||
title: "Emu Warrior",
|
||||
conditions: "vanquishing 10 or more foes in Emu War",
|
||||
points: 28.5,
|
||||
description: "You have become a mighty Emu Warrior by defeating 10 or more monsters and/or probably things which live in Australia."
|
||||
},
|
||||
lorem400: {
|
||||
title: "quare?",
|
||||
conditions: "seeing 400 paragraphs of Lorem Ipsum",
|
||||
points: 42.3,
|
||||
description: "Apparently you viewed 400 paragraphs of randomly generated Lorem Ipsum. I don't know why."
|
||||
},
|
||||
firstComment: {
|
||||
title: "That's just, like, your opinion, man",
|
||||
conditions: "posting a comment",
|
||||
points: 30.5,
|
||||
description: "You (probably, the detection isn't 100% accurate) posted a comment! Enjoy expressing your opinion (or random meaningless message) to random internet people!"
|
||||
},
|
||||
aprilFools: {
|
||||
title: "April Folly",
|
||||
conditions: "visiting on April Fools' Day",
|
||||
description: "Enjoy being... April Fooled? Good luck getting this, speedrunners.",
|
||||
points: 16.0303
|
||||
},
|
||||
heavgame1: {
|
||||
title: "Pastaphysical Futility",
|
||||
conditions: "Annoying the narrator of Heavpoot's Game",
|
||||
description: "Irritating the narrator with control of your reality is rarely a good choice. In this case it definitely wasn't.",
|
||||
points: 34.04
|
||||
},
|
||||
heavgame2: {
|
||||
title: "(insert meaningful secret O5 councily thing here)",
|
||||
conditions: "Solving the puzzle in Heavpoot's Game",
|
||||
description: `You... did something or other and somehow found the "black moon" thing and guessed/found the answer to the puzzle there, so enjoy this achievement, O6-3234234!`,
|
||||
points: 41.5824
|
||||
},
|
||||
tttWinai1:{
|
||||
title: "Beginner's Luck",
|
||||
conditions: `Beat "AI" 1 on Tic-Tac-Toe`,
|
||||
description: "Congratulations on beating a slightly aware random number generator.",
|
||||
points: 23
|
||||
},
|
||||
tttWinai2:{
|
||||
title: "Superior Algorithms",
|
||||
conditions: `Beat "AI" 2 on Tic-Tac-Toe`,
|
||||
description: "Nonsarcastic congratulations on beating the near-optimal minimax agent.",
|
||||
points: 46
|
||||
},
|
||||
apioformGame: {
|
||||
title: "Relativistic apiohydromagnetoplasmodynamic cryomemetics",
|
||||
conditions: "Finish the Apioform Game tutorial",
|
||||
description: "You have braved the complete lack of guidance or a description in the Apioform Game and apioformed many apioforms.",
|
||||
points: 40
|
||||
},
|
||||
rpnv4recursion: {
|
||||
title: "You are doing it right",
|
||||
conditions: "Recurse to a stack depth of 100 or more on RPNCalc v4",
|
||||
description: "For using RPNCalcV4 as it is meant to be used - highly, highly recursively.",
|
||||
points: 18.324
|
||||
}
|
||||
}
|
||||
|
||||
let [metrics, pointsData] = [{}, {}]
|
||||
try {
|
||||
const [oldMetrics, oldPoints] = await Promise.all([idb.openDB("arbitrary-metrics"), idb.openDB("arbitrary-points")])
|
||||
const getMetrics = async () => {
|
||||
const metrics = {}
|
||||
const tx = oldMetrics.transaction("metrics", "readonly")
|
||||
for (const key of await tx.store.getAllKeys()) {
|
||||
metrics[key] = await tx.store.get(key)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
const getPointsData = async () => {
|
||||
const data = {}
|
||||
const tx = oldPoints.transaction("data", "readonly")
|
||||
for (const key of await tx.store.getAllKeys()) {
|
||||
data[key] = await tx.store.get(key)
|
||||
}
|
||||
return data
|
||||
}
|
||||
[metrics, pointsData] = await Promise.all([getMetrics(), getPointsData()])
|
||||
await Promise.all([oldMetrics.close(), oldPoints.close()])
|
||||
} catch(e) {
|
||||
console.warn("old achievements not loaded due to", e)
|
||||
}
|
||||
const db = await idb.openDB("arbitrary-data", 1, {
|
||||
async upgrade(db, oldVersion, newVersion, tx) {
|
||||
console.log("migrating", oldVersion, newVersion)
|
||||
if (!oldVersion || oldVersion < 1) {
|
||||
// create metrics, KV, achievements stores
|
||||
db.createObjectStore("kv")
|
||||
db.createObjectStore("metrics")
|
||||
db.createObjectStore("achievements", {
|
||||
keyPath: "id"
|
||||
})
|
||||
for (const [key, value] of Object.entries(metrics)) {
|
||||
await tx.objectStore("metrics").put(value, key)
|
||||
}
|
||||
for (const achievement of (pointsData["achievements"] || [])) {
|
||||
await tx.objectStore("achievements").put(achievement)
|
||||
}
|
||||
}
|
||||
await tx.done
|
||||
},
|
||||
blocked() {
|
||||
console.warn("Database error (older version open)")
|
||||
},
|
||||
blocking() {
|
||||
window.location.reload()
|
||||
},
|
||||
terminated() {
|
||||
console.warn("Database error (unexpectedly closed)")
|
||||
},
|
||||
});
|
||||
|
||||
const e = (cls, parent, content) => {
|
||||
const element = document.createElement("div")
|
||||
element.classList.add(cls)
|
||||
if (content) { element.appendChild(document.createTextNode(content)) }
|
||||
if (parent) { parent.appendChild(element) }
|
||||
return element
|
||||
}
|
||||
|
||||
const achievementsContainer = e("achievements", document.body)
|
||||
const displayAchievement = (title, description, conditions, points) => {
|
||||
const elem = e("achievement", achievementsContainer)
|
||||
elem.title = "click to dismiss"
|
||||
e("title", elem, "Achievement achieved!")
|
||||
e("title", elem, title)
|
||||
elem.style.backgroundColor = colHash(title)
|
||||
e("description", elem, description)
|
||||
e("conditions", elem, `Unlocked by: ${conditions}`)
|
||||
e("points", elem, `${points} points`)
|
||||
// disappear on click
|
||||
elem.addEventListener("click", () => {
|
||||
achievementsContainer.removeChild(elem)
|
||||
})
|
||||
}
|
||||
|
||||
const fireUpdatedEvent = () => document.dispatchEvent(new Event("points-update"))
|
||||
|
||||
let achievementsList
|
||||
|
||||
const getAchievements = async tx => {
|
||||
if (achievementsList) { return achievementsList }
|
||||
if (!tx) { tx = db.transaction("achievements", "readonly") }
|
||||
achievementsList = await tx.objectStore("achievements").getAll()
|
||||
return achievementsList
|
||||
}
|
||||
|
||||
const getPoints = async () => (await getAchievements()).map(a => a.points).reduce((x, y) => x + y, 0)
|
||||
|
||||
const updateMetric = async (name, fn, def) => {
|
||||
const tx = db.transaction("metrics", "readwrite")
|
||||
const init = await tx.store.get(name) || def
|
||||
const newValue = fn(init)
|
||||
await tx.store.put(newValue, name)
|
||||
switch (name) {
|
||||
case "achievements":
|
||||
if (newValue === 1) {
|
||||
await unlockAchievement("firstAchievement")
|
||||
}
|
||||
break
|
||||
}
|
||||
return newValue
|
||||
}
|
||||
// increment pages visited count, since this should be run when a page is visited
|
||||
updateMetric("pagesVisited", x => x + 1, 0)
|
||||
|
||||
const visitStart = Date.now()
|
||||
window.onbeforeunload = () => {
|
||||
const elapsedMs = Date.now() - visitStart
|
||||
updateMetric("timeSpent", x => x + (elapsedMs / 1000), 0)
|
||||
}
|
||||
|
||||
const readAllMetrics = async () => {
|
||||
const out = new Map()
|
||||
const tx = db.transaction("metrics", "readonly")
|
||||
for (const key of await tx.store.getAllKeys()) {
|
||||
out.set(key, await tx.store.get(key))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
const tx = db.transaction(["achievements", "metrics"], "readwrite")
|
||||
for (const achievement of await tx.objectStore("achievements").getAllKeys()) {
|
||||
await tx.objectStore("achievements").delete(achievement)
|
||||
}
|
||||
for (const metric of await tx.objectStore("metrics").getAllKeys()) {
|
||||
await tx.objectStore("metrics").delete(metric)
|
||||
}
|
||||
achievementsList = []
|
||||
// fireUpdatedEvent()
|
||||
// called when achievement is unlocked there anyway
|
||||
await unlockAchievement("reset")
|
||||
}
|
||||
|
||||
const unlockAchievement = async id => {
|
||||
const tx = db.transaction("achievements", "readwrite")
|
||||
const achievementsUnlocked = await getAchievements(tx)
|
||||
if (achievementsUnlocked.filter(a => a.id === id).length > 0) { return "already unlocked" }
|
||||
const info = achievementInfo[id]
|
||||
if (!info) { throw new Error("Achievement not recognized") }
|
||||
info.points = info.points || 10
|
||||
displayAchievement(info.title, info.description, info.conditions, info.points)
|
||||
const item = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
page: window.location.pathname,
|
||||
points: info.points
|
||||
}
|
||||
achievementsList.push(item)
|
||||
await Promise.all([
|
||||
updateMetric("achievements", x => x + 1, 0),
|
||||
tx.objectStore("achievements").put(item),
|
||||
])
|
||||
|
||||
fireUpdatedEvent()
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const metrics = await readAllMetrics()
|
||||
if (metrics.get("timeSpent") > 3600) { // one hour in seconds
|
||||
unlockAchievement("timeSpent1Hour")
|
||||
}
|
||||
if (metrics.get("pagesVisited") > 64) {
|
||||
unlockAchievement("pagesVisited64")
|
||||
}
|
||||
const now = new Date()
|
||||
if (now.getUTCMonth() === 3 && now.getUTCDate() === 1) {
|
||||
//if (now.getUTCMonth() === 2 && now.getUTCDate() === 22) {
|
||||
unlockAchievement("aprilFools")
|
||||
}
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
if (Math.random() < 0.00001) {
|
||||
unlockAchievement("blindLuck")
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
window.addEventListener("input", e => {
|
||||
if (e.target) {
|
||||
const text = e.target.value || e.target.textContent
|
||||
// extremely advanced XSS detection algorithm
|
||||
if (text && (text.includes("<script") || text.includes("onload="))) {
|
||||
unlockAchievement("attemptedXSS")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("click", e => {
|
||||
// detect clicking of comment "submit" button
|
||||
if (e.target &&
|
||||
e.target.value === "Submit" &&
|
||||
e.target.parentElement &&
|
||||
e.target.parentElement.parentElement &&
|
||||
e.target.parentElement.parentElement.className === "auth-section") {
|
||||
unlockAchievement("firstComment")
|
||||
points.updateMetric("commentsPosted", function(x) { return x + 1 }, 0)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
reset,
|
||||
updateMetric,
|
||||
readAllMetrics,
|
||||
getPoints,
|
||||
unlockAchievement,
|
||||
getAchievements,
|
||||
achievementInfo
|
||||
}
|
||||
})()
|
||||
|
||||
const footnotes = document.querySelector(".footnotes")
|
||||
const sidenotes = document.querySelector(".sidenotes")
|
||||
if (sidenotes) {
|
||||
const codeblocks = document.querySelectorAll("pre.hljs")
|
||||
const article = document.querySelector("main.blog-post")
|
||||
while (footnotes.firstChild) {
|
||||
sidenotes.appendChild(footnotes.firstChild)
|
||||
}
|
||||
const footnoteItems = sidenotes.querySelectorAll(".footnote-item")
|
||||
|
||||
const sum = xs => xs.reduce((a, b) => a + b, 0)
|
||||
const arrayOf = (n, x) => new Array(n).fill(x)
|
||||
const BORDER = 16
|
||||
const sidenotesAtSide = () => getComputedStyle(sidenotes).paddingLeft !== "0px"
|
||||
let rendered = false
|
||||
const relayout = forceRedraw => {
|
||||
// sidenote column width is static: no need to redo positioning on resize unless no positions applied
|
||||
if (sidenotesAtSide()) {
|
||||
if (rendered && !forceRedraw) return
|
||||
// sidenote vertical placement algorithm
|
||||
const snRect = sidenotes.getBoundingClientRect()
|
||||
const articleRect = article.getBoundingClientRect()
|
||||
const exclusions = [[-Infinity, Math.max(articleRect.top, snRect.top)]]
|
||||
for (const codeblock of codeblocks) {
|
||||
const codeblockRect = codeblock.getBoundingClientRect()
|
||||
if (codeblockRect.width !== 0) { // collapsed
|
||||
exclusions.push([codeblockRect.top - BORDER, codeblockRect.top + codeblockRect.height + BORDER])
|
||||
}
|
||||
}
|
||||
// convert unusable regions into list of usable regions
|
||||
const inclusions = []
|
||||
for (const [start, end] of exclusions) {
|
||||
if (inclusions.length) inclusions[inclusions.length - 1].end = start - snRect.top
|
||||
inclusions.push({ start: end - snRect.top, contents: [] })
|
||||
}
|
||||
inclusions[inclusions.length - 1].end = Infinity
|
||||
const notes = []
|
||||
// read off sidenotes to place
|
||||
for (const item of footnoteItems) {
|
||||
const itemRect = item.getBoundingClientRect()
|
||||
const link = article.querySelector(`#${item.id.replace(/^fn/, "fnref")}`)
|
||||
const linkRect = link.getBoundingClientRect()
|
||||
item.style.position = "absolute"
|
||||
item.style.left = getComputedStyle(sidenotes).paddingLeft
|
||||
item.style.marginBottom = item.style.marginTop = `${BORDER / 2}px`
|
||||
notes.push({
|
||||
item,
|
||||
height: itemRect.height + BORDER,
|
||||
target: linkRect.top - snRect.top
|
||||
})
|
||||
}
|
||||
// preliminary placement: place in valid regions going down
|
||||
for (const note of notes) {
|
||||
const index = inclusions.findLastIndex(inc => (inc.start + note.height) < note.target)
|
||||
const next = inclusions.slice(index)
|
||||
.findIndex(inc => (sum(inc.contents.map(x => x.height)) + note.height) < (inc.end - inc.start))
|
||||
inclusions[index + next].contents.push(note)
|
||||
}
|
||||
// TODO: try simple moves between regions? might be useful sometimes
|
||||
// place within region and apply styles
|
||||
for (const inc of inclusions) {
|
||||
const regionNotes = inc.contents
|
||||
if (regionNotes.length > 0) {
|
||||
const variables = {}
|
||||
const constraints = {}
|
||||
if (inc.end !== Infinity) {
|
||||
const heights = regionNotes.map(note => note.height)
|
||||
constraints["sum_gaps"] = { max: inc.end - inc.start - sum(heights) }
|
||||
}
|
||||
regionNotes.forEach((note, i) => {
|
||||
variables[`distbound_${i}`] = {
|
||||
"distsum": 1,
|
||||
[`distbound_${i}_offset`]: 1,
|
||||
[`distbound_${i}_offset_neg`]: 1
|
||||
}
|
||||
|
||||
const heightsum = sum(regionNotes.slice(0, i).map(x => x.height))
|
||||
const baseoffset = heightsum - note.target
|
||||
|
||||
// WANT: distbound_i >= placement_i - target_i AND distbound_i <= target_i - placement_i
|
||||
// distbound_i >= gapsum_i + heightsum_i - target_i
|
||||
|
||||
// distbound_i_offset = distbound_i - gapsum_i
|
||||
// so distbound_i_offset >= heightsum_i - target_i
|
||||
// implies distbound_i - gapsum_i >= heightsum_i - target_i
|
||||
// (as required)
|
||||
|
||||
// distbound_i + gapsum_i >= heightsum_i - target_i
|
||||
|
||||
constraints[`distbound_${i}_offset`] = { min: baseoffset }
|
||||
constraints[`distbound_${i}_offset_neg`] = { min: -baseoffset }
|
||||
|
||||
constraints[`gap_${i}`] = { min: 0 }
|
||||
const G_i_var = { "sum_gaps": 1 }
|
||||
for (let j = i; j <= regionNotes.length; j++) G_i_var[`distbound_${j}_offset`] = -1
|
||||
for (let j = i; j < regionNotes.length; j++) G_i_var[`distbound_${j}_offset_neg`] = 1
|
||||
variables[`gap_${i}`] = G_i_var
|
||||
})
|
||||
const model = {
|
||||
direction: "minimize",
|
||||
objective: "distsum",
|
||||
constraints,
|
||||
variables
|
||||
}
|
||||
const solution = solve(model, { includeZeroVariables: true })
|
||||
if (solution.status !== "optimal") {
|
||||
// implode
|
||||
solution.variables = []
|
||||
console.warn("Sidenote layout failed", solution.status)
|
||||
}
|
||||
const solutionVars = new Map(solution.variables)
|
||||
let position = 0
|
||||
regionNotes.forEach((note, i) => {
|
||||
position += solutionVars.get(`gap_${i}`) || 0
|
||||
note.item.style.top = position + "px"
|
||||
position += note.height
|
||||
})
|
||||
}
|
||||
}
|
||||
rendered = true
|
||||
} else {
|
||||
for (const item of sidenotes.querySelectorAll(".footnote-item")) {
|
||||
item.style.position = "static"
|
||||
}
|
||||
rendered = false
|
||||
}
|
||||
}
|
||||
|
||||
window.onresize = relayout
|
||||
window.onload = relayout
|
||||
document.querySelectorAll("summary").forEach(x => {
|
||||
x.addEventListener("click", () => {
|
||||
setTimeout(() => relayout(true), 0)
|
||||
})
|
||||
})
|
||||
window.relayout = relayout
|
||||
}
|
||||
|
||||
const customStyle = localStorage.getItem("user-stylesheet")
|
||||
let customStyleEl = null
|
||||
if (customStyle) {
|
||||
customStyleEl = document.createElement("style")
|
||||
customStyleEl.appendChild(document.createTextNode(customStyle))
|
||||
customStyleEl.onload = () => console.log("Loaded custom styles")
|
||||
customStyleEl.id = "custom-style"
|
||||
document.head.appendChild(customStyleEl)
|
||||
}
|
||||
window.customStyleEl = customStyleEl
|
||||
window.customStyle = customStyle
|
204
src/style.sass
Normal file
204
src/style.sass
Normal file
@@ -0,0 +1,204 @@
|
||||
$sidenotes-width: 20rem
|
||||
$content-margin: 1rem
|
||||
$content-width: 40rem
|
||||
|
||||
@font-face
|
||||
font-family: 'Titillium Web'
|
||||
font-style: normal
|
||||
font-weight: 400
|
||||
font-display: swap
|
||||
src: url(/assets/titillium-web.woff2) format('woff2')
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
|
||||
|
||||
@font-face
|
||||
font-family: 'Titillium Web'
|
||||
font-style: normal
|
||||
font-weight: 600
|
||||
font-display: swap
|
||||
src: url(/assets/titillium-web-semibold.woff2) format('woff2')
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
|
||||
|
||||
@font-face
|
||||
font-family: 'Share Tech Mono'
|
||||
font-style: normal
|
||||
font-weight: 400
|
||||
font-display: swap
|
||||
src: url(/assets/share-tech-mono.woff2) format('woff2')
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD
|
||||
|
||||
body
|
||||
margin: 0
|
||||
font-family: 'Titillium Web', 'Fira Sans', sans-serif
|
||||
line-height: 1.3
|
||||
|
||||
pre, code, .deemph
|
||||
font-family: 'Share Tech Mono', monospace
|
||||
|
||||
a
|
||||
text-decoration: none
|
||||
|
||||
nav
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
padding: 1em
|
||||
margin-bottom: 0.5em
|
||||
background: black
|
||||
overflow-x: scroll
|
||||
font-size: 1.1em
|
||||
|
||||
.logo
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
image-rendering: crisp-edges
|
||||
image-rendering: pixelated
|
||||
|
||||
.logocont
|
||||
display: flex
|
||||
align-items: center
|
||||
font-style: italic
|
||||
color: white
|
||||
font-size: 1.25em
|
||||
|
||||
a, img, picture
|
||||
margin-right: 0.5em
|
||||
|
||||
@for $i from 1 through 6
|
||||
a:nth-child(#{$i + 1})
|
||||
color: hsl(120 + ($i * 30), 100%, 80%)
|
||||
|
||||
h1, h2, h3, h4, h5, h6
|
||||
margin: 0
|
||||
font-weight: 600
|
||||
a
|
||||
text-decoration: none !important
|
||||
color: inherit
|
||||
|
||||
main, .header
|
||||
margin-left: $content-margin
|
||||
margin-right: $content-margin
|
||||
|
||||
// for easier viewing on big screen devices, narrow the width of text
|
||||
// also make links a bit more distinct
|
||||
main.blog-post
|
||||
max-width: $content-width
|
||||
text-align: justify
|
||||
a
|
||||
text-decoration: underline
|
||||
|
||||
.blog, .experiments, .atl, .microblog
|
||||
margin: -0.5em
|
||||
margin-bottom: 0
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
> div
|
||||
min-width: 20em
|
||||
background: #eee
|
||||
margin: 0.5em
|
||||
padding: 1em
|
||||
flex: 1 1 20%
|
||||
|
||||
.microblog > div
|
||||
flex: 1 1 30%
|
||||
|
||||
main
|
||||
margin-top: 1em
|
||||
|
||||
ul
|
||||
list-style-type: square
|
||||
padding-left: 1em
|
||||
|
||||
.isso
|
||||
padding: 1em
|
||||
overflow-x: clip
|
||||
|
||||
button, select, input, textarea, .textarea
|
||||
border-radius: 0 !important
|
||||
border: 1px solid gray
|
||||
box-shadow: none !important
|
||||
|
||||
.achievements
|
||||
position: fixed
|
||||
bottom: 0
|
||||
left: 0
|
||||
.achievement
|
||||
cursor: pointer
|
||||
|
||||
.achievement
|
||||
border: 1px solid black
|
||||
margin: 0.5em
|
||||
padding: 0.5em
|
||||
max-width: 40em
|
||||
|
||||
.title
|
||||
font-weight: 500
|
||||
.conditions
|
||||
font-style: italic
|
||||
|
||||
.smallinfo
|
||||
font-size: 0.8em
|
||||
margin-top: 0.5em
|
||||
margin-bottom: 0.5em
|
||||
|
||||
.ring
|
||||
.art
|
||||
flex-direction: column
|
||||
flex: 1 1 25%
|
||||
.sum
|
||||
font-size: 0.8rem
|
||||
flex: 1 1 0
|
||||
.atr
|
||||
text-align: right
|
||||
font-size: 0.8rem
|
||||
color: #555
|
||||
|
||||
.imbox
|
||||
display: flex
|
||||
img, picture
|
||||
padding-right: 1em
|
||||
height: 8em
|
||||
width: 8em
|
||||
|
||||
.title
|
||||
font-size: 1.1em
|
||||
font-weight: 600
|
||||
|
||||
.caption
|
||||
width: calc(100% - 2em)
|
||||
background: lightgray
|
||||
border: 1px solid black
|
||||
padding: 1em
|
||||
margin: -1px
|
||||
img, picture
|
||||
width: 100%
|
||||
|
||||
blockquote
|
||||
padding-left: 0.4rem
|
||||
border-left: 0.4rem solid black
|
||||
margin-left: 0.2rem
|
||||
|
||||
.microblog p
|
||||
margin: 0
|
||||
|
||||
.sidenotes-container
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
.sidenotes
|
||||
width: $sidenotes-width
|
||||
min-width: $sidenotes-width
|
||||
padding-left: 1.5rem
|
||||
position: relative
|
||||
.footnotes-sep
|
||||
display: none
|
||||
.footnotes-list
|
||||
text-align: justify
|
||||
@media (max-width: calc(2 * $content-margin + $content-width + $sidenotes-width))
|
||||
.sidenotes
|
||||
min-width: auto
|
||||
width: auto
|
||||
max-width: $content-width
|
||||
padding: 0
|
||||
margin-left: $content-margin
|
||||
margin-right: $content-margin
|
||||
.footnotes-sep
|
||||
display: block
|
Reference in New Issue
Block a user