├── content ├── posty.txt └── homepage.md ├── theme ├── script.js ├── assets │ ├── favicon.ico │ ├── favicon.png │ ├── custom │ │ ├── style │ │ │ ├── header.css │ │ │ ├── footer.css │ │ │ ├── pagination.css │ │ │ ├── homepage.css │ │ │ ├── tag-page.css │ │ │ ├── skip-link.css │ │ │ ├── tags-page.css │ │ │ ├── base.css │ │ │ ├── debug.css │ │ │ ├── graph.css │ │ │ ├── reset.css │ │ │ ├── layout.css │ │ │ ├── fonts.css │ │ │ ├── content.css │ │ │ ├── search.css │ │ │ ├── overlay-pagination.css │ │ │ ├── links.css │ │ │ ├── logo.css │ │ │ └── tags-list.css │ │ ├── script │ │ │ ├── main.js │ │ │ ├── modes.js │ │ │ ├── views │ │ │ │ ├── index.js │ │ │ │ ├── search.js │ │ │ │ ├── start.js │ │ │ │ ├── tag.js │ │ │ │ └── homepage.js │ │ │ ├── components │ │ │ │ ├── header.js │ │ │ │ ├── intro.js │ │ │ │ ├── activity-chart.js │ │ │ │ ├── content.js │ │ │ │ ├── pagination.js │ │ │ │ ├── search-form.js │ │ │ │ ├── tags-list.js │ │ │ │ ├── canvas.js │ │ │ │ └── chart-pagination.js │ │ │ ├── finder.js │ │ │ ├── links.js │ │ │ ├── intros.js │ │ │ ├── view-resources.js │ │ │ ├── router.js │ │ │ ├── helpers.js │ │ │ ├── controller.js │ │ │ └── vendor │ │ │ │ ├── scrollbooster.min.js │ │ │ │ └── kicss.js │ │ └── fonts │ │ │ └── playfair-display │ │ │ ├── nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2 │ │ │ ├── nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgEM86xRbPQ.woff2 │ │ │ └── OFL.txt │ ├── favicon.svg │ └── logo.svg ├── templates │ ├── components │ │ ├── skip-link.hbs │ │ ├── header.hbs │ │ ├── tags.hbs │ │ ├── footer.hbs │ │ ├── graph.hbs │ │ ├── links.hbs │ │ └── logo.hbs │ ├── layouts │ │ └── grid.hbs │ ├── base │ │ ├── base-script.hbs │ │ ├── features.hbs │ │ ├── custom-head.hbs │ │ ├── custom-script.hbs │ │ ├── index.hbs │ │ └── custom-style.hbs │ ├── pages │ │ ├── homepage │ │ │ └── basic │ │ │ │ ├── content.hbs │ │ │ │ └── index.hbs │ │ └── tags │ │ │ ├── index.hbs │ │ │ └── tag.hbs │ ├── features │ │ └── rss │ │ │ └── index.hbs │ └── helpers.js ├── style.css └── theme-settings.css ├── .gitignore ├── src ├── scripts │ ├── build.js │ ├── start.js │ └── publish-latest-link.js ├── addLink.js ├── publish.js ├── git.js ├── writ │ ├── index.js │ ├── prepare.js │ └── minifyPostsJSON.js ├── channels │ ├── tumblr.js │ └── twitter.js ├── lib │ └── Channel.js └── helpers.js ├── .env.example ├── settings.json ├── package.json ├── .github └── workflows │ ├── deploy.yml │ └── publish.yml ├── misc ├── commit-timestamp-for-date-published.js ├── stats-03-07-2020.md ├── router.js └── setup.md ├── readme.md └── add /content/posty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /theme/script.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .DS_Store 4 | site 5 | _notes 6 | -------------------------------------------------------------------------------- /src/scripts/build.js: -------------------------------------------------------------------------------- 1 | require('../writ').build({ rootDirectory: '.' }) 2 | -------------------------------------------------------------------------------- /src/scripts/start.js: -------------------------------------------------------------------------------- 1 | require('../writ').start({ rootDirectory: '.' }) 2 | -------------------------------------------------------------------------------- /theme/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriptype/feed/HEAD/theme/assets/favicon.ico -------------------------------------------------------------------------------- /theme/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriptype/feed/HEAD/theme/assets/favicon.png -------------------------------------------------------------------------------- /theme/templates/components/skip-link.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /theme/assets/custom/style/header.css: -------------------------------------------------------------------------------- 1 | .header { 2 | perspective: var(--header-perspective); 3 | } 4 | -------------------------------------------------------------------------------- /theme/assets/custom/script/main.js: -------------------------------------------------------------------------------- 1 | import Controller from './controller.js' 2 | 3 | Controller().start() 4 | -------------------------------------------------------------------------------- /theme/templates/layouts/grid.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{>@partial-block}} 3 |
4 | -------------------------------------------------------------------------------- /theme/templates/components/header.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{>components/logo}} 3 |
4 |
5 | -------------------------------------------------------------------------------- /theme/templates/base/base-script.hbs: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /theme/assets/custom/style/footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | font-size: 1em; 3 | padding: 2em 0 4em; 4 | color: var(--footer-fg); 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /theme/templates/base/features.hbs: -------------------------------------------------------------------------------- 1 | {{#if (isEnabled "rss")}} 2 | 3 | {{/if}} 4 | -------------------------------------------------------------------------------- /theme/assets/custom/script/modes.js: -------------------------------------------------------------------------------- 1 | export const hydration = 'enhance statically rendered elements' 2 | export const full = 'fully re-render existing elements' 3 | 4 | export default { 5 | hydration, 6 | full 7 | } 8 | -------------------------------------------------------------------------------- /theme/assets/custom/fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriptype/feed/HEAD/theme/assets/custom/fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2 -------------------------------------------------------------------------------- /theme/assets/custom/fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgEM86xRbPQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriptype/feed/HEAD/theme/assets/custom/fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgEM86xRbPQ.woff2 -------------------------------------------------------------------------------- /theme/assets/custom/script/views/index.js: -------------------------------------------------------------------------------- 1 | export { default as Start } from './start.js' 2 | export { default as Homepage } from './homepage.js' 3 | export { default as Tag } from './tag.js' 4 | export { default as Search } from './search.js' 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TUMBLR_CONSUMER_KEY=xxx 2 | TUMBLR_CONSUMER_SECRET=xxx 3 | TUMBLR_REQUEST_TOKEN=xxx 4 | TUMBLR_REQUEST_TOKEN_SECRET=xxx 5 | TWITTER_CONSUMER_KEY=xxx 6 | TWITTER_CONSUMER_SECRET=xxx 7 | TWITTER_ACCESS_TOKEN_KEY=xxx 8 | TWITTER_ACCESS_TOKEN_SECRET=xxx 9 | -------------------------------------------------------------------------------- /src/addLink.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises') 2 | const links = require('../links') 3 | 4 | module.exports = async ({ outputPath, link }) => { 5 | const content = JSON.stringify(links.concat(link), null, 2) 6 | return fs.writeFile(outputPath, content) 7 | } 8 | -------------------------------------------------------------------------------- /theme/templates/base/custom-head.hbs: -------------------------------------------------------------------------------- 1 | {{#if (isTagsPage)}} 2 | 3 | 4 | 5 | {{/if}} 6 | -------------------------------------------------------------------------------- /theme/assets/custom/style/pagination.css: -------------------------------------------------------------------------------- 1 | .pagination { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } 6 | 7 | .pagination-buttons { 8 | display: flex; 9 | gap: 2em; 10 | justify-content: center; 11 | margin: 2em 0 1em; 12 | } 13 | -------------------------------------------------------------------------------- /theme/templates/pages/homepage/basic/content.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{> @partial-block }} 5 |
6 |
7 | {{>components/graph}} 8 | {{>components/links}} 9 |
10 | -------------------------------------------------------------------------------- /theme/templates/pages/homepage/basic/index.hbs: -------------------------------------------------------------------------------- 1 | {{#>base page="homepage"}} 2 | {{#>layouts/grid}} 3 | {{>components/skip-link}} 4 | {{>components/header}} 5 | {{>components/tags tags=(featuredTags)}} 6 | {{>pages/homepage/basic/content}} 7 | {{>components/footer}} 8 | {{/layouts/grid}} 9 | {{/base}} 10 | -------------------------------------------------------------------------------- /src/publish.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const twitter = require('./channels/twitter') 3 | const tumblr = require('./channels/tumblr') 4 | 5 | module.exports = (entry) => { 6 | return Promise.all([ 7 | twitter.publish(entry), 8 | tumblr.publish(entry) 9 | ]).catch(e => { 10 | console.log('error publishing', e) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /theme/templates/base/custom-script.hbs: -------------------------------------------------------------------------------- 1 | {{#if (not (isTagsPage))}} 2 | 3 | 4 | {{/if}} 5 | 6 | {{#if (hasCustomScript)}} 7 | 8 | {{/if}} 9 | -------------------------------------------------------------------------------- /content/homepage.md: -------------------------------------------------------------------------------- 1 |

Welcome to my feed!

2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/header.js: -------------------------------------------------------------------------------- 1 | import { query } from '../helpers.js' 2 | 3 | const findLogo = () => query('.logo-container') 4 | 5 | const render = ({ onClickLogo }) => { 6 | findLogo().addEventListener('click', event => { 7 | event.preventDefault() 8 | onClickLogo() 9 | }) 10 | } 11 | 12 | const blurLogo = () => { 13 | findLogo().blur() 14 | } 15 | 16 | export default { 17 | render, 18 | blur: blurLogo 19 | } 20 | -------------------------------------------------------------------------------- /theme/assets/custom/style/homepage.css: -------------------------------------------------------------------------------- 1 | @import url("skip-link.css"); 2 | 3 | .homepage-content { 4 | text-align: center; 5 | } 6 | 7 | .homepage-title { 8 | font-size: 2em; 9 | font-weight: 700; 10 | letter-spacing: -0.04em; 11 | word-spacing: 0.05em; 12 | } 13 | 14 | .homepage-social-links { 15 | margin-bottom: 1em; 16 | } 17 | 18 | .homepage-links-count { 19 | font-size: 1.25em; 20 | color: var(--link-count-color); 21 | } 22 | -------------------------------------------------------------------------------- /theme/assets/custom/style/tag-page.css: -------------------------------------------------------------------------------- 1 | @import url("skip-link.css"); 2 | 3 | .tag-header { 4 | font-size: 2.5em; 5 | font-weight: 700; 6 | letter-spacing: -0.04em; 7 | word-spacing: 0.05em; 8 | text-align: center; 9 | } 10 | 11 | .tag-header-post-count { 12 | display: block; 13 | color: var(--link-count-color); 14 | font-size: .5em; 15 | font-weight: initial; 16 | word-spacing: initial; 17 | letter-spacing: initial; 18 | } 19 | -------------------------------------------------------------------------------- /theme/style.css: -------------------------------------------------------------------------------- 1 | @import url("style/reset.css"); 2 | @import url("style/fonts.css"); 3 | @import url("style/base.css"); 4 | @import url("style/layout.css"); 5 | @import url("style/header.css"); 6 | @import url("style/logo.css"); 7 | @import url("style/tags-list.css"); 8 | @import url("style/content.css"); 9 | @import url("style/graph.css"); 10 | @import url("style/overlay-pagination.css"); 11 | @import url("style/links.css"); 12 | @import url("style/footer.css"); 13 | @import url("style/pagination.css"); 14 | -------------------------------------------------------------------------------- /theme/templates/pages/tags/index.hbs: -------------------------------------------------------------------------------- 1 | {{#>base page="tags"}} 2 |
3 | {{>components/logo}} 4 | {{#if tags.length}} 5 | {{#each tags as |tag|}} 6 | #{{tag.tag}} 7 | {{/each}} 8 | {{else}} 9 |

{{lookup 'tagsNotFoundHeader'}}

10 | {{/if}} 11 |
12 |

💛

13 | {{/base}} 14 | -------------------------------------------------------------------------------- /theme/assets/custom/style/skip-link.css: -------------------------------------------------------------------------------- 1 | .skip-link { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | transform: translateY(-125%); 6 | transform-origin: center top; 7 | opacity: .5; 8 | padding: 0.3em 1.6em 0.5em 0.8em; 9 | color: white; 10 | background: royalblue; 11 | font-size: 1.5em; 12 | z-index: 1; 13 | text-decoration: none; 14 | border-radius: 0 0 4em 0; 15 | transition: all .2s; 16 | } 17 | 18 | .skip-link:focus { 19 | opacity: 1; 20 | transform: translateY(0); 21 | } 22 | -------------------------------------------------------------------------------- /src/git.js: -------------------------------------------------------------------------------- 1 | const { execute } = require('./helpers') 2 | 3 | const Git = { 4 | commit({ message, paths }) { 5 | return execute({ 6 | cmd: `\\ 7 | git pull && \\ 8 | git add ${paths.join(' ')} && \\ 9 | git commit -m "${message}" 10 | `, 11 | outStream: process.stdout, 12 | errStream: process.stderr 13 | }) 14 | }, 15 | 16 | push() { 17 | return execute({ 18 | cmd: 'git push', 19 | outStream: process.stdout, 20 | errStream: process.stderr 21 | }) 22 | } 23 | } 24 | 25 | module.exports = Git 26 | -------------------------------------------------------------------------------- /theme/templates/pages/tags/tag.hbs: -------------------------------------------------------------------------------- 1 | {{#>base page="tag"}} 2 | {{#>layouts/grid}} 3 | {{>components/skip-link}} 4 | {{>components/header}} 5 | {{>components/tags tags=(featuredTags)}} 6 |
7 |
8 |

9 | #{{tag.tag}} 10 | {{tag.posts.length}} links 11 |

12 |
13 | {{>components/graph}} 14 | {{>components/links}} 15 |
16 | {{>components/footer}} 17 | {{/layouts/grid}} 18 | {{/base}} 19 | -------------------------------------------------------------------------------- /src/scripts/publish-latest-link.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs/promises') 2 | const path = require('path') 3 | const { last } = require('../helpers') 4 | const publish = require('../publish') 5 | 6 | const publishLatestLink = async () => { 7 | const linksPath = path.join(__dirname, '..', '..', 'links.json') 8 | let linksJSON 9 | try { 10 | linksJSON = await readFile(linksPath, { encoding: 'utf-8' }) 11 | } catch (e) { 12 | return console.log('Error reading links.json', e) 13 | } 14 | const lastLink = last(JSON.parse(linksJSON)) 15 | return publish(lastLink) 16 | } 17 | 18 | publishLatestLink() 19 | -------------------------------------------------------------------------------- /src/writ/index.js: -------------------------------------------------------------------------------- 1 | const prepare = require('./prepare') 2 | const minifyPostsJSON = require('./minifyPostsJSON') 3 | 4 | const run = async ({ links, rootDirectory, mode, debug }) => { 5 | const writ = prepare(links) 6 | 7 | await writ[mode]({ 8 | rootDirectory: rootDirectory, 9 | cli: true, 10 | debug, 11 | onFinish({ settings }) { 12 | return minifyPostsJSON(settings) 13 | } 14 | }) 15 | } 16 | 17 | module.exports = { 18 | build: (options) => run({ 19 | ...options, 20 | mode: 'build' 21 | }), 22 | 23 | start: (options) => run({ 24 | ...options, 25 | mode: 'start' 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /theme/templates/components/tags.hbs: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /theme/assets/custom/style/tags-page.css: -------------------------------------------------------------------------------- 1 | .all-tags-listing { 2 | background: #222; 3 | display: flex; 4 | flex-wrap: wrap; 5 | gap: 0.5em; 6 | justify-content: center; 7 | align-items: center; 8 | padding: 0 3.6rem; 9 | } 10 | 11 | .all-tags-listing-item { 12 | font-size: calc(1em + 10em * var(--importance)); 13 | color: hsl( 14 | var(--i), 15 | 80%, 16 | calc(70% - 20% * var(--importance)) 17 | ); 18 | } 19 | 20 | .all-tags-listing-end-message { 21 | padding: 3em; 22 | font-size: 2em; 23 | background: #222; 24 | color: #eee; 25 | text-align: center; 26 | } 27 | -------------------------------------------------------------------------------- /theme/templates/components/footer.hbs: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/channels/tumblr.js: -------------------------------------------------------------------------------- 1 | const tumblr = require('tumblr.js') 2 | const Channel = require('../lib/Channel') 3 | 4 | const client = tumblr.createClient({ 5 | consumer_key: process.env.TUMBLR_CONSUMER_KEY, 6 | consumer_secret: process.env.TUMBLR_CONSUMER_SECRET, 7 | token: process.env.TUMBLR_REQUEST_TOKEN, 8 | token_secret: process.env.TUMBLR_REQUEST_TOKEN_SECRET 9 | }) 10 | 11 | client.returnPromises() 12 | 13 | const postLink = item => 14 | client.createLinkPost('readsfeed', { 15 | tags: item.tags.join(','), 16 | title: item.title, 17 | url: item.url 18 | }) 19 | 20 | module.exports = new Channel({ 21 | name: 'tumblr', 22 | method: postLink 23 | }) 24 | -------------------------------------------------------------------------------- /theme/assets/custom/style/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | scroll-behavior: smooth; 7 | } 8 | 9 | body { 10 | font: 100 normal 16px/1.5 "Playfair Display", times, serif; 11 | background: var(--bg); 12 | color: var(--fg); 13 | } 14 | 15 | img { 16 | vertical-align: middle; 17 | } 18 | 19 | a { 20 | color: var(--primary-color); 21 | } 22 | 23 | strong { 24 | font-weight: bold; 25 | } 26 | 27 | button { 28 | padding: 0; 29 | margin: 0; 30 | border: none; 31 | background: none; 32 | vertical-align: middle; 33 | appearance: none; 34 | -webkit-appearance: none; 35 | font: inherit; 36 | } 37 | -------------------------------------------------------------------------------- /src/writ/prepare.js: -------------------------------------------------------------------------------- 1 | const writ = require('writ-cms') 2 | 3 | const extendPostAsLink = (post, link) => { 4 | return { 5 | ...post, 6 | publishDatePrototype: { 7 | value: new Date(link.datePublished), 8 | checkCache: false 9 | }, 10 | type: 'link', 11 | permalink: link.url, 12 | ...link 13 | } 14 | } 15 | 16 | module.exports = (_links) => { 17 | const links = _links || require('../../links.json') 18 | 19 | writ.useContentModel((value) => { 20 | const samplePost = value.posts[0] 21 | return { 22 | ...value, 23 | posts: links.map(link => extendPostAsLink(samplePost, link)) 24 | } 25 | }) 26 | 27 | return writ 28 | } 29 | -------------------------------------------------------------------------------- /theme/assets/custom/style/debug.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --debug-v-rythm: 0; 3 | --debug-v-rythm-offset: 3.8px; 4 | --debug-v-rythm-height: 0.7em; 5 | } 6 | 7 | body::after { 8 | content: ""; 9 | display: block; 10 | width: calc(var(--debug-v-rythm) * 100%); 11 | height: calc(var(--debug-v-rythm) * 100%); 12 | position: fixed; 13 | top: var(--debug-v-rythm-offset); 14 | left: 0; 15 | z-index: 9999; 16 | background: 17 | repeating-linear-gradient( 18 | to bottom, 19 | transparent, 20 | transparent var(--debug-v-rythm-height), 21 | black calc(var(--debug-v-rythm-height) + 1px) 22 | ); 23 | pointer-events: none; 24 | } 25 | -------------------------------------------------------------------------------- /theme/templates/components/graph.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Pages 5 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/channels/twitter.js: -------------------------------------------------------------------------------- 1 | const { TwitterApi } = require('twitter-api-v2') 2 | const Channel = require('../lib/Channel') 3 | 4 | const client = new TwitterApi({ 5 | appKey: process.env.TWITTER_CONSUMER_KEY, 6 | appSecret: process.env.TWITTER_CONSUMER_SECRET, 7 | accessToken: process.env.TWITTER_ACCESS_TOKEN_KEY, 8 | accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET 9 | }) 10 | 11 | const statusTemplate = link => { 12 | const title = link.isTweet ? link.retweetQuote : link.title 13 | return [title, link.url].filter(Boolean).join(': ') 14 | } 15 | 16 | const tweet = item => { 17 | return client.v2.tweet(statusTemplate(item)) 18 | } 19 | 20 | module.exports = new Channel({ 21 | name: 'twitter', 22 | method: tweet 23 | }) 24 | -------------------------------------------------------------------------------- /theme/templates/base/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{pageTitle}} 10 | {{>base/custom-head}} 11 | {{>base/features}} 12 | {{>base/custom-style}} 13 | 14 | 15 | 16 | {{>@partial-block}} 17 | {{>base/base-script}} 18 | {{>base/custom-script}} 19 | 20 | 21 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": "zen", 3 | "url": "https://enes.in/feed", 4 | "title": "Reads Feed", 5 | "description": "A collection of links to blog posts, talks and opinions on art, design and technology", 6 | "permalinkPrefix": "/feed", 7 | "revisionHistory": "off", 8 | "rss": "on", 9 | "exportDirectory": "site", 10 | "featuredTags": [ 11 | "web", 12 | "software", 13 | "design", 14 | "accessibility", 15 | "ux", 16 | "ai", 17 | "art", 18 | "science", 19 | "environment", 20 | "nature", 21 | "philosophy", 22 | "life", 23 | "privacy", 24 | "video", 25 | "git", 26 | "colors", 27 | "geography", 28 | "javascript", 29 | "css", 30 | "security", 31 | "podcast", 32 | "animation" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/intro.js: -------------------------------------------------------------------------------- 1 | import { debounce, region, transition } from '../helpers.js' 2 | 3 | const render = debounce(async (template, data) => { 4 | const $el = region('intro') 5 | const { html, transitionSettings } = template(data) 6 | const $newEl = $el.cloneNode(true) 7 | $newEl.innerHTML = html 8 | await transition({ 9 | target: $el, 10 | className: 'exiting', 11 | duration: (transitionSettings || {}).exit || 500, 12 | crossFade: 2/3 13 | }) 14 | $el.parentElement.prepend($newEl) 15 | await transition({ 16 | target: $newEl, 17 | className: 'entering', 18 | duration: (transitionSettings || {}).enter || 500, 19 | crossFade: 2/3 20 | }) 21 | $el.remove() 22 | }, 500) 23 | 24 | export default { 25 | render 26 | } 27 | -------------------------------------------------------------------------------- /src/writ/minifyPostsJSON.js: -------------------------------------------------------------------------------- 1 | const { readFile, writeFile } = require('fs/promises') 2 | const { join } = require('path') 3 | 4 | const minify = async ({ exportDirectory }) => { 5 | const postsJSONFile = await readFile( 6 | join(exportDirectory, 'posts.json'), 7 | { encoding: 'utf-8' } 8 | ) 9 | 10 | const postsJSON = JSON.parse(postsJSONFile) 11 | 12 | const minifiedPostsJSON = postsJSON.map(post => ({ 13 | title: post.title, 14 | datePublished: post.datePublished, 15 | tags: post.tags, 16 | url: post.url 17 | })) 18 | 19 | const minifiedPostsJSONFileContent = JSON.stringify(minifiedPostsJSON) 20 | 21 | await writeFile( 22 | join(exportDirectory, 'posts.min.json'), 23 | minifiedPostsJSONFileContent 24 | ) 25 | } 26 | 27 | module.exports = minify 28 | -------------------------------------------------------------------------------- /theme/templates/base/custom-style.hbs: -------------------------------------------------------------------------------- 1 | {{#if (isHomePage)}} 2 | 3 | {{/if}} 4 | 5 | {{#if (isTagPage)}} 6 | 7 | {{/if}} 8 | 9 | {{#if (isTagsPage)}} 10 | 11 | {{/if}} 12 | 13 | {{#if (isStartMode)}} 14 | 15 | {{/if}} 16 | 17 | {{#if (hasThemeSettings)}} 18 | 19 | {{/if}} 20 | 21 | {{#if (hasCustomStyle)}} 22 | 23 | {{/if}} 24 | -------------------------------------------------------------------------------- /theme/assets/custom/script/finder.js: -------------------------------------------------------------------------------- 1 | export default class Finder { 2 | constructor({ entries, searchIn }) { 3 | this.entries = entries 4 | this.itemIndexFn = searchIn 5 | this.index = this.makeIndex() 6 | } 7 | 8 | makeIndex() { 9 | return this.entries.map((entry, index) => ({ 10 | content: this.itemIndexFn.call(entry).join('\n'), 11 | index 12 | })) 13 | } 14 | 15 | find(query) { 16 | const escapedQuery = query.replace(/\[/g, '\\[') 17 | const matches = this.index.filter(({ content }) => { 18 | return content.match(new RegExp(escapedQuery, 'gsi')) 19 | }) 20 | 21 | return this.entries 22 | .map((entry, i) => { 23 | const isMatching = matches.find(({ index }) => index === i) 24 | return isMatching && entry 25 | }) 26 | .filter(Boolean) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /theme/assets/custom/script/views/search.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import Intros from '../intros.js' 3 | import Intro from '../components/intro.js' 4 | import SearchForm from '../components/search-form.js' 5 | import ActivityChart from '../components/activity-chart.js' 6 | import Pagination from '../components/pagination.js' 7 | import Content from '../components/content.js' 8 | 9 | const render = ({ links, searchQuery, }) => { 10 | Intro.render(Intros.search, { 11 | searchQuery, 12 | links 13 | }) 14 | 15 | SearchForm.setInputValue(searchQuery) 16 | 17 | Content.render({ 18 | links 19 | }) 20 | 21 | ActivityChart.render({ 22 | readonly: true, 23 | mode: Modes.full, 24 | links, 25 | pages: [links] 26 | }) 27 | 28 | Pagination.remove() 29 | } 30 | 31 | export default { 32 | render 33 | } 34 | -------------------------------------------------------------------------------- /theme/assets/custom/style/graph.css: -------------------------------------------------------------------------------- 1 | .graph-container { 2 | margin: 3em 0 4em; 3 | overflow: auto; 4 | } 5 | 6 | .graph-container.has-scroll { 7 | cursor: grab; 8 | } 9 | 10 | .graph-container.has-scroll.faked-active-state { 11 | cursor: grabbing; 12 | } 13 | 14 | .graph-container.exiting { 15 | animation: disappear var(--transition-duration) forwards; 16 | } 17 | 18 | @keyframes disappear { 19 | 99% { 20 | transform: scaleY(1); 21 | } 22 | to { 23 | opacity: 0; 24 | transform: scaleY(0); 25 | } 26 | } 27 | 28 | .graph-container:has([open]) { 29 | margin: 3em 0 0; 30 | } 31 | 32 | .js-enhanced .graph-container { 33 | margin: 1em 0; 34 | } 35 | 36 | .graph { 37 | height: 100px; 38 | } 39 | 40 | .canvas-container { 41 | width: min-content; 42 | position: relative; 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feed", 3 | "version": "3.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "axios": "^1.6.8", 8 | "cheerio": "^1.0.0-rc.3", 9 | "dotenv": "^8.2.0", 10 | "inquirer": "^7.0.0", 11 | "lodash": "^4.17.11", 12 | "tumblr.js": "^2.0.2", 13 | "twitter-api-v2": "^1.14.3", 14 | "writ-cms": "github:scriptype/writ-cms#v0.29.2" 15 | }, 16 | "scripts": { 17 | "start": "node ./src/scripts/start", 18 | "build": "node ./src/scripts/build", 19 | "publish-latest-link": "node src/scripts/publish-latest-link.js" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/scriptype/feed.git" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "ISC", 28 | "bugs": { 29 | "url": "https://github.com/scriptype/feed/issues" 30 | }, 31 | "homepage": "https://github.com/scriptype/feed#readme" 32 | } 33 | -------------------------------------------------------------------------------- /theme/assets/custom/script/links.js: -------------------------------------------------------------------------------- 1 | import Finder from './finder.js' 2 | import { fetchPrefix } from './helpers.js' 3 | 4 | let links = [] 5 | let finderInstance 6 | 7 | const fetchLinks = (fileName) => { 8 | links = fetch(`${fetchPrefix}/${fileName}`).then(r => r.json()) 9 | return links 10 | } 11 | 12 | const all = async () => { 13 | return await links 14 | } 15 | 16 | const findByTag = async (tag) => { 17 | return (await links).filter((link) => { 18 | return !!link.tags.find(t => t.tag === tag) 19 | }) 20 | } 21 | 22 | const search = async (query) => { 23 | finderInstance = finderInstance || new Finder({ 24 | entries: await links, 25 | searchIn() { 26 | return [ 27 | this.title, 28 | this.url, 29 | this.tags.map(({ tag }) => tag).join(' ') 30 | ] 31 | } 32 | }) 33 | 34 | return finderInstance.find(query) 35 | } 36 | 37 | export default { 38 | fetch: fetchLinks, 39 | all, 40 | findByTag, 41 | search 42 | } 43 | -------------------------------------------------------------------------------- /theme/templates/components/links.hbs: -------------------------------------------------------------------------------- 1 | 18 | {{#with pagination}} 19 | 29 | {{/with}} 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy site to github pages 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | deploy: 18 | environment: 19 | name: github-pages 20 | url: ${{ steps.deployment.outputs.page_url }} 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Pages 27 | uses: actions/configure-pages@v4 28 | 29 | - name: Install Node.js 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: '16' 33 | 34 | - name: Install dependencies 35 | run: npm i 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: 'site' 44 | 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /theme/assets/custom/script/intros.js: -------------------------------------------------------------------------------- 1 | const homepage = ({ links }) => ({ 2 | transitionSettings: { 3 | enter: 800 4 | }, 5 | html: ` 6 |
7 |

Welcome to my feed!

8 | 9 | 12 | 13 | 14 |
15 | ` 16 | }) 17 | 18 | const tag = ({ tag, links }) => ({ 19 | html: ` 20 |

21 | #${tag} 22 | ${links.length} links 23 |

24 | ` 25 | }) 26 | 27 | const search = ({ searchQuery, links }) => ({ 28 | html: ` 29 |

30 | “${searchQuery}” 31 | ${links.length} links 32 |

33 | ` 34 | }) 35 | 36 | export default { 37 | homepage, 38 | tag, 39 | search 40 | } 41 | -------------------------------------------------------------------------------- /theme/assets/custom/script/view-resources.js: -------------------------------------------------------------------------------- 1 | import { fetchPrefix } from './helpers.js' 2 | 3 | export default class ViewResources { 4 | constructor({ pathPrefix = fetchPrefix + '/assets/custom/', stylesheets, scripts }) { 5 | this.pathPrefix = pathPrefix 6 | this.stylesheets = stylesheets 7 | this.scripts = scripts 8 | } 9 | 10 | load() { 11 | for (let id in this.stylesheets) { 12 | if (!this.stylesheets.hasOwnProperty(id)) { 13 | continue 14 | } 15 | if (document.getElementById(id)) { 16 | continue 17 | } 18 | const link = document.createElement('link') 19 | link.rel = 'stylesheet' 20 | link.id = id 21 | link.href = this.pathPrefix + this.stylesheets[id] 22 | document.head.appendChild(link) 23 | } 24 | 25 | for (let id in this.scripts) { 26 | if (!this.scripts.hasOwnProperty(id)) { 27 | continue 28 | } 29 | if (document.getElementById(id)) { 30 | continue 31 | } 32 | const script = document.createElement('script') 33 | script.id = id 34 | script.src = this.pathPrefix + this.scripts[id] 35 | document.body.appendChild(script) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/Channel.js: -------------------------------------------------------------------------------- 1 | module.exports = class Channel { 2 | constructor({ name, method, waitBetween = 1000 }) { 3 | this.name = name 4 | this.method = method 5 | this.waitBetween = waitBetween 6 | return this 7 | } 8 | 9 | log(message, ...args) { 10 | console.log(`log ${this.name}: ${message}`, ...args) 11 | } 12 | 13 | error(message, ...args) { 14 | console.error(`error ${this.name}: ${message}`, ...args) 15 | } 16 | 17 | logNotStarting(...args) { 18 | this.log(...['nothing to publish', ...args]) 19 | } 20 | 21 | logStart(...args) { 22 | this.log(...['publishing', ...args]) 23 | } 24 | 25 | logSuccess(...args) { 26 | this.log(...['published', ...args]) 27 | } 28 | 29 | logError(...args) { 30 | this.error(...['couldnt publish', ...args]) 31 | } 32 | 33 | wait(timeout) { 34 | return new Promise(resolve => { 35 | setTimeout(() => resolve(), timeout) 36 | }) 37 | } 38 | 39 | async publish(item) { 40 | if (!item) { 41 | return this.logNotStarting() 42 | } 43 | try { 44 | this.logStart() 45 | await this.method(item) 46 | this.logSuccess() 47 | } catch (e) { 48 | this.logError(e) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /theme/assets/custom/script/router.js: -------------------------------------------------------------------------------- 1 | import { fetchPrefix } from './helpers.js' 2 | 3 | const getPaginationPart = (pageNumber) => { 4 | return pageNumber > 1 ? `/page/${pageNumber}` : '/' 5 | } 6 | 7 | export default { 8 | debug: false, 9 | 10 | logState(...msg) { 11 | if (this.debug) { 12 | console.info(...msg) 13 | } 14 | }, 15 | 16 | start(state, onGoBack, options) { 17 | window.history.replaceState(state, '', document.location.href) 18 | window.addEventListener("popstate", onGoBack) 19 | this.debug = options && options.debug 20 | this.logState('Router.start', state) 21 | }, 22 | 23 | homepage(state) { 24 | const path = getPaginationPart(state.pageNumber) 25 | window.history.pushState(state, '', `${fetchPrefix}${path}`) 26 | this.logState('Router.homepage', state) 27 | }, 28 | 29 | tag(state) { 30 | const paginationPart = getPaginationPart(state.pageNumber) 31 | window.history.pushState(state, '', `${fetchPrefix}/tags/${state.tag}${paginationPart}`) 32 | this.logState('Router.tag', state) 33 | }, 34 | 35 | search(state) { 36 | window.history.pushState(state, '', `?search=${state.searchQuery}`) 37 | this.logState('Router.search', state) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish latest link 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | if: contains(github.event.head_commit.message, '[publish]') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Install Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '16' 19 | 20 | - name: Install dependencies 21 | run: npm i 22 | 23 | - name: Run publish script 24 | env: 25 | TUMBLR_CONSUMER_KEY: ${{ secrets.TUMBLR_CONSUMER_KEY }} 26 | TUMBLR_CONSUMER_SECRET: ${{ secrets.TUMBLR_CONSUMER_SECRET }} 27 | TUMBLR_REQUEST_TOKEN: ${{ secrets.TUMBLR_REQUEST_TOKEN }} 28 | TUMBLR_REQUEST_TOKEN_SECRET: ${{ secrets.TUMBLR_REQUEST_TOKEN_SECRET }} 29 | TWITTER_API_VERSION: ${{ secrets.TWITTER_API_VERSION }} 30 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 31 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 32 | TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} 33 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 34 | run: npm run publish-latest-link 35 | -------------------------------------------------------------------------------- /theme/assets/custom/style/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /theme/assets/custom/style/layout.css: -------------------------------------------------------------------------------- 1 | .app { 2 | display: grid; 3 | grid-template-columns: 190px min-content; 4 | justify-content: center; 5 | } 6 | 7 | .header { 8 | place-self: end; 9 | grid-row: 1; 10 | } 11 | 12 | #search-form-container { 13 | height: 2em; 14 | margin-bottom: 2em; 15 | } 16 | 17 | .js-enabled #search-form-container { 18 | height: auto; 19 | margin-bottom: 0; 20 | } 21 | 22 | .tags { 23 | width: min-content; 24 | place-self: end; 25 | grid-column: 1; 26 | margin-right: 3em; 27 | } 28 | 29 | .content { 30 | width: var(--content-width); 31 | grid-row: 1 / span 3; 32 | } 33 | 34 | .footer { 35 | width: var(--content-width); 36 | grid-column: 2; 37 | } 38 | 39 | @media (max-width: 640px) { 40 | :root { 41 | --content-width: 100vw; 42 | } 43 | } 44 | 45 | @media (max-width: 900px) { 46 | .app { 47 | grid-template-columns: 1fr; 48 | justify-items: center; 49 | } 50 | 51 | .header, 52 | .tags { 53 | place-self: center; 54 | } 55 | 56 | .tags { 57 | position: fixed; 58 | bottom: 0; 59 | right: 0; 60 | z-index: 1; 61 | width: 100%; 62 | margin-right: 0; 63 | } 64 | 65 | .content, .footer { 66 | grid-column: 1; 67 | } 68 | 69 | .content { 70 | grid-row: 3; 71 | } 72 | 73 | .footer { 74 | grid-row: 4; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /theme/templates/features/rss/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ feed.title }} 5 | {{ feed.link }} 6 | {{ feed.description }} 7 | {{ settings.language }} 8 | 9 | {{ settings.site.title }} 10 | {{ feed.iconUrl }} 11 | {{ settings.site.url }} 12 | 13 | {{#each categories as |category|}} 14 | {{ category.name }} 15 | {{/each}} 16 | {{ feed.lastBuildDate }} 17 | All rights reserved 2024, {{ settings.site.title }} 18 | https://www.rssboard.org/rss-specification 19 | writ-cms 20 | 21 | {{#each posts as |post|}} 22 | 23 | <![CDATA[{{ title }}]]> 24 | {{ permalink }} 25 | {{ permalink }} 26 | {{ publishDateUTC }} 27 | 28 | 29 | {{/each}} 30 | 31 | 32 | -------------------------------------------------------------------------------- /theme/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /theme/assets/custom/script/views/start.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import ViewResources from '../view-resources.js' 3 | import Intros from '../intros.js' 4 | import Intro from '../components/intro.js' 5 | import Header from '../components/header.js' 6 | import TagsList from '../components/tags-list.js' 7 | import SearchForm from '../components/search-form.js' 8 | import ActivityChart from '../components/activity-chart.js' 9 | import Pagination from '../components/pagination.js' 10 | import Content from '../components/content.js' 11 | import { scrollToTop, getPages, getPageLinks } from '../helpers.js' 12 | 13 | const render = async ({ 14 | onClickLogo, 15 | onClickTag, 16 | onSearch, 17 | onResetSearch, 18 | onPaginate, 19 | tag, 20 | links, 21 | pageNumber 22 | }) => { 23 | const pages = getPages({ links }) 24 | const pageLinks = getPageLinks({ pages, pageNumber }) 25 | 26 | Header.render({ onClickLogo }) 27 | 28 | TagsList.render({ onClickTag }) 29 | 30 | ActivityChart.render({ 31 | mode: Modes.hydration, 32 | tag, 33 | links, 34 | pages, 35 | pageNumber, 36 | onPaginate: (payload) => onPaginate({ 37 | ...payload, 38 | tag, 39 | paginationType: 'ActivityChart' 40 | }) 41 | }) 42 | 43 | Content.render({ 44 | mode: Modes.hydration, 45 | links: pageLinks 46 | }) 47 | 48 | Pagination.render({ 49 | mode: Modes.hydration, 50 | tag, 51 | onPaginate: onPaginate 52 | }) 53 | 54 | await SearchForm.render({ 55 | onSearch, 56 | onReset: onResetSearch 57 | }) 58 | } 59 | 60 | export default { 61 | render 62 | } 63 | -------------------------------------------------------------------------------- /misc/commit-timestamp-for-date-published.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a script that was run just once to add datePublished field 3 | * to links for the first time. 4 | * 5 | * Extract UNIX timestamp of the commit that adds the link. 6 | * 7 | * Before running, install the dependency: 8 | * npm i -S isomorphic-git 9 | * 10 | * The file is kept for reference and also why not. 11 | * */ 12 | const fs = require('fs') 13 | const { writeFile } = require('fs/promises') 14 | const git = require('isomorphic-git') 15 | 16 | const run = async () => { 17 | const links = require('./links.json') 18 | 19 | const logs = await git.log({ 20 | fs, 21 | dir: '.' 22 | }) 23 | 24 | const findMatchingLog = (link) => { 25 | const urlForRegex = link.url 26 | .replace(/\?/, '\\?') 27 | .replace(/\(/, '\\(') 28 | .replace(/\)/, '\\)') 29 | const regex = new RegExp(`Add: ${urlForRegex}(?:\n)$`) 30 | const regex2 = new RegExp(`- ${urlForRegex}`) 31 | return logs.find(log => ( 32 | log.commit.message.match(regex) || 33 | log.commit.message.match(regex2) 34 | )) 35 | } 36 | 37 | const findExceptionsLog = (links) => { 38 | return logs.find(log => ( 39 | log.commit.message.match('alwaysjudgeabookbyitscover') 40 | )) 41 | } 42 | 43 | const linksWithDates = links.map(link => { 44 | const matchingLog = findMatchingLog(link) || findExceptionsLog(link) 45 | return { 46 | ...link, 47 | datePublished: matchingLog.commit.committer.timestamp * 1000 48 | } 49 | }) 50 | 51 | await writeFile('./links.json', JSON.stringify(linksWithDates, null, 2)) 52 | } 53 | 54 | run() 55 | -------------------------------------------------------------------------------- /theme/assets/custom/style/fonts.css: -------------------------------------------------------------------------------- 1 | /* latin-ext */ 2 | @font-face { 3 | font-family: 'Playfair Display'; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: url(../fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgEM86xRbPQ.woff2) format('woff2'); 8 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; 9 | } 10 | /* latin */ 11 | @font-face { 12 | font-family: 'Playfair Display'; 13 | font-style: normal; 14 | font-weight: 400; 15 | font-display: swap; 16 | src: url(../fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2) format('woff2'); 17 | 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; 18 | } 19 | /* latin-ext */ 20 | @font-face { 21 | font-family: 'Playfair Display'; 22 | font-style: normal; 23 | font-weight: 700; 24 | font-display: swap; 25 | src: url(../fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgEM86xRbPQ.woff2) format('woff2'); 26 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; 27 | } 28 | /* latin */ 29 | @font-face { 30 | font-family: 'Playfair Display'; 31 | font-style: normal; 32 | font-weight: 700; 33 | font-display: swap; 34 | src: url(../fonts/playfair-display/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2) format('woff2'); 35 | 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; 36 | } 37 | -------------------------------------------------------------------------------- /theme/assets/custom/script/views/tag.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import ViewResources from '../view-resources.js' 3 | import Intros from '../intros.js' 4 | import Intro from '../components/intro.js' 5 | import SearchForm from '../components/search-form.js' 6 | import ActivityChart from '../components/activity-chart.js' 7 | import Pagination from '../components/pagination.js' 8 | import Content from '../components/content.js' 9 | import { scrollToTop, getPages, getPageLinks } from '../helpers.js' 10 | 11 | const resources = new ViewResources({ 12 | stylesheets: { 13 | 'tag-stylesheet': 'style/tag-page.css' 14 | } 15 | }) 16 | 17 | const render = ({ 18 | tag, 19 | links, 20 | pageNumber, 21 | navigatedFrom, 22 | onPaginate 23 | }) => { 24 | const pages = getPages({ links }) 25 | const pageLinks = getPageLinks({ pages, pageNumber }) 26 | 27 | resources.load() 28 | 29 | Intro.render(Intros.tag, { 30 | links, 31 | tag 32 | }) 33 | 34 | SearchForm.setInputValue('') 35 | 36 | if (navigatedFrom.pagination === 'ActivityChart') { 37 | ActivityChart.updateCurrentPage({ 38 | pageNumber 39 | }) 40 | } else { 41 | ActivityChart.render({ 42 | mode: Modes.full, 43 | tag, 44 | links, 45 | pages, 46 | pageNumber, 47 | onPaginate: (payload) => onPaginate({ 48 | ...payload, 49 | tag, 50 | paginationType: 'ActivityChart' 51 | }) 52 | }) 53 | } 54 | 55 | Content.render({ 56 | links: pageLinks 57 | }) 58 | 59 | Pagination.render({ 60 | mode: Modes.full, 61 | tag, 62 | pageNumber, 63 | totalPages: pages.length, 64 | onPaginate 65 | }) 66 | 67 | if (navigatedFrom.pagination !== 'ActivityChart') { 68 | scrollToTop() 69 | } 70 | } 71 | 72 | export default { 73 | render 74 | } 75 | -------------------------------------------------------------------------------- /theme/assets/custom/style/content.css: -------------------------------------------------------------------------------- 1 | .content { 2 | position: relative; 3 | padding: 150px 0 1em; 4 | background: var(--content-bg-color); 5 | border-radius: 3px; 6 | border-bottom: 1px solid var(--content-border-color); 7 | box-shadow: -0.6em 0 2em #00000009; 8 | } 9 | 10 | @media (max-width: 900px) { 11 | .content { 12 | box-shadow: none; 13 | } 14 | } 15 | 16 | [data-region-id="intro"] { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | padding-top: 2em; 22 | } 23 | 24 | [data-region-id="intro"].entering { 25 | animation: enter-intro var(--transition-duration) forwards; 26 | } 27 | 28 | [data-region-id="intro"].exiting { 29 | animation: exit-intro var(--transition-duration) forwards; 30 | animation-timing-function: cubic-bezier(.66,-0.97,.27,1.55); 31 | } 32 | 33 | @keyframes enter-intro { 34 | from { 35 | opacity: 0; 36 | transform: scale(1.025); 37 | } 38 | to { 39 | opacity: 1; 40 | transform: translateY(1); 41 | } 42 | } 43 | 44 | @keyframes exit-intro { 45 | to { 46 | opacity: 0; 47 | transform: scale(1.05); 48 | } 49 | } 50 | 51 | @media (prefers-color-scheme: dark) { 52 | .content::after { 53 | content: ""; 54 | position: absolute; 55 | top: 0; 56 | left: 0; 57 | width: 2dvmin; 58 | height: 50dvmin; 59 | translate: 0 0; 60 | background: radial-gradient( 61 | ellipse 10% 50% at left center, 62 | #fdd3, 63 | transparent 64 | ); 65 | opacity: 0; 66 | transition: all .4s var(--logo-bezier); 67 | pointer-events: none; 68 | } 69 | 70 | .header:has(.logo-container:hover, .logo-container:focus-within) ~ .content::after { 71 | opacity: 1; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /theme/assets/custom/script/views/homepage.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import ViewResources from '../view-resources.js' 3 | import Intros from '../intros.js' 4 | import Intro from '../components/intro.js' 5 | import Header from '../components/header.js' 6 | import SearchForm from '../components/search-form.js' 7 | import ActivityChart from '../components/activity-chart.js' 8 | import Pagination from '../components/pagination.js' 9 | import Content from '../components/content.js' 10 | import { scrollToTop, getPages, getPageLinks } from '../helpers.js' 11 | 12 | const resources = new ViewResources({ 13 | stylesheets: { 14 | 'homepage-stylesheet': 'style/homepage.css' 15 | } 16 | }) 17 | 18 | const render = ({ 19 | links, 20 | pageNumber, 21 | navigatedFrom, 22 | onPaginate 23 | }) => { 24 | const pages = getPages({ links }) 25 | const pageLinks = getPageLinks({ pages, pageNumber }) 26 | 27 | resources.load() 28 | 29 | SearchForm.setInputValue('') 30 | 31 | Intro.render(Intros.homepage, { 32 | links 33 | }) 34 | 35 | if (navigatedFrom.pagination === 'ActivityChart') { 36 | ActivityChart.updateCurrentPage({ 37 | pageNumber 38 | }) 39 | } else { 40 | ActivityChart.render({ 41 | mode: Modes.full, 42 | links, 43 | pages, 44 | pageNumber, 45 | onPaginate: (payload) => onPaginate({ 46 | ...payload, 47 | paginationType: 'ActivityChart' 48 | }) 49 | }) 50 | } 51 | 52 | Content.render({ 53 | links: pageLinks 54 | }) 55 | 56 | Pagination.render({ 57 | mode: Modes.full, 58 | pageNumber, 59 | totalPages: pages.length, 60 | onPaginate 61 | }) 62 | 63 | if (navigatedFrom.pagination !== 'ActivityChart') { 64 | scrollToTop() 65 | } 66 | 67 | Header.blur() 68 | } 69 | 70 | export default { 71 | render 72 | } 73 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |   2 | 3 |

Reads Feed

4 | 5 |

My collection of links to blog posts, talks and opinions on art, design and technology.

6 |

Website | RSS | Twitter | Tumblr

7 |

Set up your own POSSE feed

8 | 9 |   10 | 11 | ## The website 12 | 13 | The website is built with my static site generator [Writ-CMS](https://github.com/scriptype/writ-cms). 14 | 15 |   16 | 17 | ### Features 18 | 19 | - Pogressively enhanced SPA (pure CSS + JS) based on statically generated html 20 | - RSS feed 21 | - JSON Search with RegExp support 22 | - Paginated activity graph 23 | - Tag pages 24 | - Check the lighthouse scores below 25 | 26 | image 27 | 28 |   29 | 30 | ### Lighthouse scores 31 | 32 | #### Desktop 33 | 34 | Lighthouse scores on desktop. Performance: 99, Accessibility: 100, Best Practices: 100, SEO: 100 35 | 36 |   37 | 38 | #### Mobile 39 | 40 | Lighthouse scores on mobile. Performance: 97, Accessibility: 100, Best Practices: 100, SEO: 93 41 | 42 | 43 | ## Licence 44 | 45 | ``` 46 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 47 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 48 | 49 | 0. You just DO WHAT THE FUCK YOU WANT TO. 50 | ``` 51 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | const { rm, mkdir } = require('fs/promises') 2 | const cp = require('child_process') 3 | const axios = require('axios') 4 | const cheerio = require('cheerio') 5 | 6 | // Get the last item of an array 7 | const last = array => array[array.length - 1] 8 | 9 | // Returns a reversed copy array, without mutating it. 10 | const reverseNew = array => Object.assign([], array).reverse() 11 | 12 | // Split an array into parts using a limit. 13 | const splitByLimit = (array, limit) => 14 | array.reduce((result, item, index) => { 15 | if (index % limit === 0) { 16 | return result.concat([[item]]) 17 | } 18 | return Object.assign([], result, { 19 | [result.length - 1]: last(result).concat(item) 20 | }) 21 | }, []) 22 | 23 | const flatten = arr => [].concat(...arr) 24 | 25 | // Remove and re-create a folder. 26 | const cleanFolder = async (folderPath) => { 27 | await rm(folderPath, { recursive: true }) 28 | await mkdir(folderPath) 29 | return Promise.resolve() 30 | } 31 | 32 | // Execute a command and supply output stream, error stream and accept env. 33 | const execute = options => new Promise((resolve, reject) => { 34 | const { cmd, outStream, errStream, env } = options 35 | const ps = cp.exec(cmd, { env }) 36 | let errData = '' 37 | ps.stdout.pipe(outStream) 38 | ps.stderr.pipe(errStream) 39 | ps.stderr.on('data', data => { 40 | errData += data 41 | }) 42 | ps.on('close', code => { 43 | if (code === 0) { 44 | resolve() 45 | } else { 46 | reject(new Error(`Error executing ${cmd}. Code: ${code}, Error: ${errData}.`)) 47 | } 48 | }) 49 | }) 50 | 51 | const loadPage = async (url) => { 52 | const { data: html } = await axios.get(url) 53 | return cheerio.load(html) 54 | } 55 | 56 | const scrapePageTitle = async (url) => { 57 | const $ = await loadPage(url) 58 | return $('title').text() 59 | } 60 | 61 | const isTweet = url => { 62 | return /twitter.com\/.+\/status\/\d+$/.test(url) 63 | } 64 | 65 | const parseTags = (str) => { 66 | return str 67 | .split(',') 68 | .map(tag => tag.trim()) 69 | .filter(tag => tag.length) 70 | } 71 | 72 | module.exports = { 73 | last, 74 | reverseNew, 75 | flatten, 76 | splitByLimit, 77 | cleanFolder, 78 | execute, 79 | loadPage, 80 | scrapePageTitle, 81 | isTweet, 82 | parseTags 83 | } 84 | -------------------------------------------------------------------------------- /theme/assets/custom/style/search.css: -------------------------------------------------------------------------------- 1 | .feat-search { 2 | --transition-duration: .25s; 3 | position: relative; 4 | height: 2em; 5 | width: 160px; 6 | margin: 0 3em 2em auto; 7 | } 8 | 9 | .feat-search-input { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | width: 100%; 14 | height: 100%; 15 | padding-right: 30px; 16 | border-radius: 5px; 17 | border: 1px solid var(--search-input-border-color); 18 | background: var(--search-input-bg); 19 | color: var(--search-input-color); 20 | box-shadow: inset 0 0px 2em #0001 21 | } 22 | 23 | .feat-search-button { 24 | position: absolute; 25 | top: 50%; 26 | right: 50%; 27 | transform: translate(50%, -50%); 28 | line-height: 1; 29 | color: var(--search-input-label-color); 30 | cursor: pointer; 31 | pointer-events: none; 32 | transition: all var(--transition-duration); 33 | } 34 | 35 | .feat-search-button-text { 36 | display: inline-block; 37 | font-family: helvetica, arial, sans-serif; 38 | font-size: .9em; 39 | transition: all calc(var(--transition-duration)); 40 | } 41 | 42 | .feat-search-icon { 43 | width: 18px; 44 | aspect-ratio: 1; 45 | vertical-align: middle; 46 | fill: currentColor; 47 | } 48 | 49 | .feat-search.has-query .feat-search-input, 50 | .feat-search:focus-within .feat-search-input { 51 | box-shadow: none; 52 | } 53 | 54 | .feat-search.has-query .feat-search-button, 55 | .feat-search:focus-within .feat-search-button { 56 | transform: translate(0, -50%) scale(1.1); 57 | right: 2px; 58 | pointer-events: initial; 59 | } 60 | 61 | .feat-search.has-query .feat-search-button-text, 62 | .feat-search:focus-within .feat-search-button-text { 63 | overflow: hidden; 64 | width: 0; 65 | opacity: 0; 66 | } 67 | 68 | .feat-search-results-message { 69 | font-size: 2.5em; 70 | font-weight: 700; 71 | letter-spacing: -0.04em; 72 | word-spacing: 0.05em; 73 | text-align: center; 74 | } 75 | 76 | .feat-search-results-count { 77 | display: block; 78 | margin-bottom: 1em; 79 | color: var(--link-count-color); 80 | font-size: .5em; 81 | font-weight: initial; 82 | word-spacing: initial; 83 | letter-spacing: initial; 84 | } 85 | 86 | @media (prefers-color-scheme: dark) { 87 | .feat-search-input { 88 | transition: all .4s var(--logo-bezier); 89 | } 90 | 91 | :is(.logo-container:hover, .logo-container:focus-within) + #search-form-container .feat-search-input { 92 | border-bottom-color: #685852; 93 | border-top-color: #383832; 94 | box-shadow: inset 0 .75dvmin 2dvmin #0009; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /theme/assets/custom/style/overlay-pagination.css: -------------------------------------------------------------------------------- 1 | #overlay-pagination-toggle { 2 | position: sticky; 3 | width: var(--content-width); 4 | text-align: center; 5 | color: royalblue; 6 | opacity: 0; 7 | animation: appear .2s .4s forwards; 8 | } 9 | 10 | #overlay-pagination-container:not([open]) #overlay-pagination-toggle:focus { 11 | box-shadow: inset 0 0 1px 2px royalblue; 12 | } 13 | 14 | @keyframes appear { 15 | to { 16 | opacity: 1; 17 | } 18 | } 19 | 20 | .js-enhanced #overlay-pagination-toggle { 21 | position: absolute; 22 | bottom: 200vh; 23 | z-index: 1; 24 | font-size: 0; 25 | text-align: initial; 26 | width: auto; 27 | } 28 | 29 | .js-enhanced #overlay-pagination-toggle:focus { 30 | font-size: .9em; 31 | height: 1.5em; 32 | top: 0; 33 | bottom: auto; 34 | box-shadow: none; 35 | left: var(--graph-left); 36 | } 37 | 38 | .overlay-pagination { 39 | display: flex; 40 | padding: .5em 0 .5em; 41 | } 42 | 43 | .js-enhanced .overlay-pagination { 44 | position: absolute; 45 | inset: 0; 46 | flex-direction: row-reverse; 47 | padding: 0; 48 | } 49 | 50 | .overlay-pagination-item { 51 | --width: max-content; 52 | width: var(--width); 53 | padding: .75em .5em; 54 | } 55 | 56 | .js-enhanced .overlay-pagination-item { 57 | padding: 0; 58 | } 59 | 60 | .overlay-pagination-item:has(.is-current) { 61 | background: var(--tag-badge-color); 62 | border-radius: .8em; 63 | } 64 | 65 | .js-enhanced .overlay-pagination-item:has(.is-current) { 66 | border-radius: 0; 67 | background: none; 68 | } 69 | 70 | .overlay-pagination-link { 71 | display: block; 72 | width: 100%; 73 | height: 100%; 74 | } 75 | 76 | .js-enhanced .overlay-pagination-link { 77 | /* The quickest way to get mix-blend-mode working in safari */ 78 | -webkit-backdrop-filter: blur(); 79 | } 80 | 81 | .js-enhanced .overlay-pagination-link { 82 | text-indent: -2000vw; 83 | } 84 | 85 | .js-enhanced .overlay-pagination-link:hover, 86 | .js-enhanced .overlay-pagination-link:focus-visible { 87 | background: var(--chart-hover-page-bg); 88 | mix-blend-mode: var(--chart-hover-blend); 89 | } 90 | 91 | .overlay-pagination-link.faked-active-state { 92 | cursor: grabbing; 93 | } 94 | 95 | .overlay-pagination-link.is-current { 96 | background: var(--tag-badge-color); 97 | } 98 | 99 | .js-enhanced .overlay-pagination-link.is-current { 100 | background: linear-gradient( 101 | to top, 102 | transparent, 103 | var(--chart-active-page) 30%, 104 | var(--chart-active-page) 70%, 105 | transparent 106 | ); 107 | mix-blend-mode: multiply; 108 | box-shadow: 0 0 0 1px #ca33; 109 | } 110 | -------------------------------------------------------------------------------- /misc/stats-03-07-2020.md: -------------------------------------------------------------------------------- 1 | # 3 July 2020 Statistics 2 | 3 | ## Number of links 4 | 5 | ```sh 6 | cat public/data/all.json | jq "." | grep { | wc -l 7 | ``` 8 | 9 | 400 10 | 11 | ## Twitter followers 12 | 13 | 84 14 | 15 | ## How many unique tags 16 | 17 | ```sh 18 | cat public/data/all.json | jq ".[].tags" | tr -d [],# | grep "\w" | sort | uniq -c | sort | wc -l 19 | 20 | 491 21 | ``` 22 | 23 | ## Top 20 most common tags 24 | 25 | ```sh 26 | cat public/data/all.json | jq ".[].tags" | tr -d [],# | grep "\w" | sort | uniq -c | sort -r | head -n 20 27 | 28 | # And manually: 29 | - Add number of #türkçe (1) to türkçe (35) 30 | - Add number of JavaScript (1) to javascript (26) 31 | - Add number of front-end (3) to frontend (8) 32 | 33 | 36 "türkçe" 34 | 28 "web" 35 | 27 "javascript" 36 | 27 "css" 37 | 25 "programming" 38 | 22 "accessibility" 39 | 20 "design" 40 | 19 "security" 41 | 17 "software" 42 | 13 "science" 43 | 12 "react" 44 | 12 "art" 45 | 11 "frontend" 46 | 11 "environment" 47 | 10 "climate" 48 | 9 "development" 49 | 8 "privacy" 50 | 8 "life" 51 | 8 "learning" 52 | 8 "ai" 53 | ``` 54 | 55 | ## Top 20 most common non-programming tags 56 | 57 | ```sh 58 | cat public/data/all.json | jq ".[].tags" | tr -d [],# | grep "\w" | egrep -v '(web|css|javascript|programming|software|react|frontend|ai|development|algorithms|html|vue|unix)' |sort | uniq -c | sort -r | head -n 20 59 | 60 | 36 "türkçe" 61 | 22 "accessibility" 62 | 20 "design" 63 | 19 "security" 64 | 13 "science" 65 | 12 "art" 66 | 11 "environment" 67 | 10 "climate" 68 | 8 "privacy" 69 | 8 "life" 70 | 8 "learning" 71 | 7 "finland" 72 | 7 "astronomy" 73 | 7 "animation" 74 | 6 "tutorial" 75 | 6 "photography" 76 | 6 "google" 77 | 6 "change" 78 | 5 "video" 79 | 5 "story" 80 | ``` 81 | 82 | ## Number of tags that are used only once 83 | 84 | ```sh 85 | cat public/data/all.json | jq ".[].tags" | tr -d [],# | grep "\w" | sort | uniq -c | sort | grep " 1 " | wc -l 86 | 87 | 356 88 | ``` 89 | 90 | ## Interesting tags that are used only once 91 | 92 | - butthole 93 | - y 94 | - fake 95 | - PCG, CTS, SDLC 96 | - Modules 97 | - Upernavik 98 | 99 | ## Most common tags that are used along with #türkçe (Turkish tags, basically) 100 | 101 | ```sh 102 | cat public/data/all.json | jq -c ".[].tags" | tr -d []# | grep "\w" | grep türkçe | tr ',' '\n' | egrep -v türkçe | sort | uniq -c | sort -r | head -n 10 103 | 104 | 2 "yazılım" (Turkish for software) 105 | 2 "video" 106 | 2 "tasarım" (Turkish for design) 107 | 2 "http" 108 | 2 "deprem" (Turkish for earthquake) 109 | 110 | # After these, there are 88 more tags used only once 111 | ``` 112 | 113 | ## Interesting tags used along with #türkçe 114 | 115 | - çürüme (Turkish for corruption) 116 | - yozlaşma (Turkish for degeneration, retrogression) 117 | - nah (A popular hand gesture that has a similar meaning to 🖕) 118 | - adamasmaca (Turkish for Hangman [game]) 119 | - dil (Turkish for tongue, language) 120 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/activity-chart.js: -------------------------------------------------------------------------------- 1 | import Color from "../vendor/Color.js" 2 | import Modes from '../modes.js' 3 | import { query, transition } from '../helpers.js' 4 | import Canvas from './canvas.js' 5 | import ChartPagination from './chart-pagination.js' 6 | const { min, max } = Math 7 | 8 | const DAY = 1000 * 60 * 60 * 24 9 | 10 | const findScrollContainer = () => query('.graph-container') 11 | const findCanvasContainer = () => query('.canvas-container') 12 | const findCanvas = (container) => query('canvas', container) 13 | 14 | const darkMode = window.matchMedia("(prefers-color-scheme:dark)").matches 15 | 16 | const colors = darkMode ? [ 17 | new Color('#a6a6e0'), 18 | new Color('#ffeaac') 19 | ] : [ 20 | new Color('#ffeaac'), 21 | new Color('#a6a6e0') 22 | ] 23 | 24 | const renderCanvas = ({ data, dayScale, startDate }) => { 25 | const canvas = Canvas({ 26 | height: 260, 27 | data: data.map(p => p.datePublished), 28 | dayScale, 29 | lineWidth: 6, 30 | resolution: 1/12, 31 | startDate, 32 | yearMarkPosition: 'bottom', 33 | yearMarkFont: '30px sans-serif', 34 | padding: { 35 | top: 20, 36 | right: 0, 37 | bottom: 80, 38 | left: 0 39 | }, 40 | colors 41 | }) 42 | 43 | const canvasContainer = findCanvasContainer() 44 | const existingCanvas = findCanvas(canvasContainer) 45 | if (existingCanvas) { 46 | existingCanvas.remove() 47 | } 48 | if (data.length) { 49 | const $canvas = canvas.draw() 50 | canvasContainer.prepend($canvas) 51 | } 52 | } 53 | 54 | const rerender = () => { 55 | return ` 56 |
57 |
58 |
59 | Pages 60 |
    61 |
    62 |
    63 |
    64 | ` 65 | } 66 | 67 | const render = async ({ readonly, mode, tag, links, pages, pageNumber, startDate = new Date(1532221014000), onPaginate }) => { 68 | if (mode === Modes.full) { 69 | findScrollContainer().outerHTML = rerender() 70 | } 71 | 72 | const totalDaysElapsed = (Date.now() - startDate) / DAY 73 | const minDayScale = 1665 / totalDaysElapsed 74 | const maxDayScale = 2 75 | const dayScale = min(maxDayScale, max(minDayScale, links.length / 200)) 76 | 77 | renderCanvas({ 78 | data: links, 79 | dayScale, 80 | startDate 81 | }) 82 | 83 | ChartPagination.render({ 84 | readonly, 85 | mode, 86 | tag, 87 | pages, 88 | pageNumber, 89 | dayScale, 90 | onPaginate 91 | }) 92 | } 93 | 94 | const updateCurrentPage = ({ pageNumber }) => { 95 | return ChartPagination.updateCurrentPage({ pageNumber }) 96 | } 97 | 98 | const remove = async () => { 99 | const container = findScrollContainer() 100 | await transition({ 101 | target: container, 102 | className: 'exiting', 103 | duration: 300 104 | }) 105 | } 106 | 107 | export default { 108 | render, 109 | updateCurrentPage, 110 | remove 111 | } 112 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/content.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import { query, queryAll, truncate, transition } from '../helpers.js' 3 | 4 | const TITLE_CHARS_MAX = 70 5 | const URL_CHARS_MAX = 50 6 | const ENTER_CLASS = 'entering' 7 | const EXIT_CLASS = 'exiting' 8 | const findContainer = () => query('.links') 9 | const findLinkElements = () => queryAll('.link') 10 | const findLinkTitle = (linkElement) => query('.link-title', linkElement) 11 | const findLinkUrl = (linkElement) => query('.link-url', linkElement) 12 | const findLinkTags = (linkElement) => query('.link-tags', linkElement) 13 | 14 | let sampleLinkContainer = null 15 | 16 | const linkTitleInnerHTMLTemplate = (link) => { 17 | return `

    ${truncate(link.title, TITLE_CHARS_MAX, true)}

    ` 18 | } 19 | 20 | const linkTitleTemplate = (link) => { 21 | return ` 22 | 23 | ${linkTitleInnerHTMLTemplate(link)} 24 | 25 | ` 26 | } 27 | 28 | const linkTagTemplate = ({ tag, permalink }) => { 29 | return ` 30 | 33 | ` 34 | } 35 | 36 | const exitCurrentLinks = () => { 37 | return Promise.all( 38 | Array.from(findLinkElements()).map(linkElement => { 39 | if (linkElement.classList.contains(ENTER_CLASS)) { 40 | linkElement.classList.remove(ENTER_CLASS) 41 | } 42 | return transition({ 43 | target: linkElement, 44 | className: EXIT_CLASS, 45 | duration: 250 46 | }) 47 | }) 48 | ) 49 | } 50 | 51 | const enterNewLinks = (links) => { 52 | const container = findContainer() 53 | const fragment = document.createDocumentFragment() 54 | links.forEach((link, i) => { 55 | const linkElement = sampleLinkContainer.cloneNode(true) 56 | linkElement.classList.remove(EXIT_CLASS) 57 | transition({ 58 | target: linkElement, 59 | className: ENTER_CLASS, 60 | duration: 300 61 | }) 62 | linkElement.style.setProperty('--i', i) 63 | const linkTitle = findLinkTitle(linkElement) 64 | const linkUrl = findLinkUrl(linkElement) 65 | const linkTags = findLinkTags(linkElement) 66 | linkTitle.outerHTML = linkTitleTemplate(link) 67 | linkUrl.textContent = truncate(link.url, URL_CHARS_MAX, true) 68 | linkTags.innerHTML = link.tags.map(linkTagTemplate).join('') 69 | fragment.appendChild(linkElement) 70 | }) 71 | container.innerHTML = '' 72 | container.appendChild(fragment) 73 | } 74 | 75 | const hydrate = (links) => { 76 | findLinkElements().forEach((linkElement, i) => { 77 | const link = links[i] 78 | const linkTitle = findLinkTitle(linkElement) 79 | linkTitle.outerHTML = linkTitleTemplate(link) 80 | 81 | const linkUrl = findLinkUrl(linkElement) 82 | linkUrl.textContent = truncate(link.url, URL_CHARS_MAX, true) 83 | }) 84 | } 85 | 86 | const render = async ({ mode, links }) => { 87 | if (mode === Modes.hydration) { 88 | return hydrate(links) 89 | } 90 | sampleLinkContainer = (sampleLinkContainer || findLinkElements()[0]).cloneNode(true) 91 | await exitCurrentLinks() 92 | enterNewLinks(links) 93 | } 94 | 95 | export default { 96 | render 97 | } 98 | -------------------------------------------------------------------------------- /add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('dotenv').config() 3 | const path = require('path') 4 | const inquirer = require('inquirer') 5 | const links = require('./links') 6 | const { parseTags, scrapePageTitle, isTweet } = require('./src/helpers') 7 | const addLink = require('./src/addLink') 8 | const writ = require('./src/writ') 9 | const git = require('./src/git') 10 | const publish = require('./src/publish') 11 | 12 | let linkIsTweet 13 | 14 | const questions = [ 15 | { 16 | type: 'input', 17 | name: 'url', 18 | message: 'URL', 19 | async validate(value) { 20 | if (!value) { 21 | return 'Url is required' 22 | } 23 | 24 | const url = value 25 | linkIsTweet = isTweet(url) 26 | 27 | const alreadyAdded = links.find(link => 28 | link.url === url || ( 29 | link.url.match(/\/$/) && link.url.slice(0, -1) === url 30 | ) 31 | ) 32 | if (alreadyAdded) { 33 | return 'This link already exists.' 34 | } 35 | 36 | return true 37 | }, 38 | filter: String 39 | }, 40 | { 41 | type: 'input', 42 | name: 'title', 43 | message: 'Title', 44 | async default({ url }) { 45 | try { 46 | return await scrapePageTitle(url) 47 | } catch (e) { 48 | console.log("Couldn't scrape title, enter manually", e) 49 | return undefined 50 | } 51 | }, 52 | validate(value) { 53 | return !!value || 'Title is required' 54 | }, 55 | filter: String 56 | }, 57 | { 58 | type: 'input', 59 | name: 'RTQuote', 60 | message: 'Retweet quote', 61 | filter: String, 62 | when: () => linkIsTweet 63 | }, 64 | { 65 | type: 'input', 66 | name: 'tags', 67 | message: 'Tags (comma separated)', 68 | filter: String 69 | }, 70 | { 71 | type: 'confirm', 72 | name: 'isConfirmed', 73 | message: 'Sure?', 74 | default: true 75 | }, 76 | { 77 | type: 'confirm', 78 | name: 'shouldPublish', 79 | message: 'Publish now?', 80 | default: true 81 | } 82 | ] 83 | 84 | console.log('Add a new link') 85 | 86 | inquirer.prompt(questions).then(async answers => { 87 | const tags = parseTags(answers.tags) 88 | 89 | const twitterOptions = linkIsTweet ? { 90 | isTweet: true, 91 | retweetQuote: answers.RTQuote 92 | } : {} 93 | 94 | const link = { 95 | url: answers.url, 96 | title: answers.title, 97 | tags, 98 | datePublished: Date.now(), 99 | ...twitterOptions 100 | } 101 | 102 | if (!answers.isConfirmed) { 103 | return console.log('OK, link is not added.') 104 | } 105 | 106 | await addLink({ 107 | outputPath: './links.json', 108 | link 109 | }) 110 | 111 | await writ.build({ 112 | links: links.concat(link), 113 | rootDirectory: '.' 114 | }) 115 | 116 | console.log('Added link:', JSON.stringify(link, null, 2)) 117 | 118 | // Commit msg with this mark will trigger gh-action publish workflow 119 | const messagePrefix = answers.shouldPublish ? '[publish] ' : '' 120 | await git.commit({ 121 | message: `${messagePrefix}Add: ${link.url}`, 122 | paths: ['links.json'] 123 | }) 124 | 125 | if (answers.shouldPublish) { 126 | await git.push() 127 | } 128 | }) 129 | -------------------------------------------------------------------------------- /misc/router.js: -------------------------------------------------------------------------------- 1 | /* 2 | * During SPA transition, I first thought I would be using a full-fledged router 3 | * and reactively render the app. Not that this router is robust at all. But it 4 | * would do the job. 5 | * 6 | * Soon I realized that's not what I want. I didn't want to run cycles 7 | * unnecessarily re-rendering everything on every route. Maybe I could still 8 | * use this to imperatively manage things, but, at the time, it didn't seem like 9 | * it would have obvious advantages compared to a simpler solution. 10 | * 11 | * Simpler solution being: controller injects callbacks into components 12 | * 13 | * To keep things simpler, I decided to abandon this for now. And implemented 14 | * a dead-simple history wrapper. 15 | * 16 | * If I hadn't, here's how I would be using this router: 17 | * 18 | * const Router = createRouter() 19 | * 20 | * Router 21 | * .on('/', Controller.homepage) 22 | * .on('/page/:pageNumber', Controller.homepage) 23 | * .on('/tags/:tag', Controller.tag) 24 | * .on('/tags/:tag/:pageNumber', Controller.tag) 25 | * .on('#search?q=:query', Controller.search) // This wouldn't work 26 | * .start() 27 | */ 28 | 29 | import { fetchPrefix } from './helpers.js' 30 | 31 | const createRouter = () => { 32 | const routes = {} 33 | 34 | const getRouteParts = (route) => { 35 | return route 36 | .replace(/^\//, '') 37 | .replace(/\/$/, '') 38 | .split('/') 39 | } 40 | 41 | const checkMatch = (actualRoute) => { 42 | let routeParams 43 | let matchingRoute 44 | for (const route in routes) { 45 | if (!routes.hasOwnProperty(route)) { 46 | continue 47 | } 48 | const parts = [ 49 | getRouteParts(route), 50 | getRouteParts(actualRoute) 51 | ] 52 | if (parts[0].length !== parts[1].length) { 53 | continue 54 | } 55 | routeParams = parts[1].reduce((acc, part, i) => { 56 | if (!part.startsWith(':') && parts[0][i] === part) { 57 | return acc 58 | } 59 | if (parts[0][i].startsWith(':')) { 60 | return { 61 | ...acc, 62 | [parts[0][i].replace(/^:/, '')]: part 63 | } 64 | } 65 | return false 66 | }, {}) 67 | if (routeParams) { 68 | matchingRoute = route 69 | break 70 | } 71 | } 72 | return { 73 | route: matchingRoute, 74 | params: routeParams 75 | } 76 | } 77 | 78 | return { 79 | start(initialState = {}) { 80 | window.history.replaceState(initialState, '', document.location.href) 81 | window.addEventListener("popstate", (event) => { 82 | console.log('router popstate event', event) 83 | const route = document.location.pathname.replace(new RegExp('^' + fetchPrefix), '') 84 | const match = checkMatch(route) 85 | if (match) { 86 | console.log('routing to', match.route) 87 | this.navigate(route, event.state) 88 | } 89 | }) 90 | console.log('router start') 91 | return this 92 | }, 93 | 94 | on(route, handler) { 95 | routes[route] = handler 96 | return this 97 | }, 98 | 99 | navigate(route, state) { 100 | window.history.pushState(state, '', route) 101 | const match = checkMatch(route) 102 | if (!match) { 103 | console.info('Unknown route', route) 104 | } 105 | const handler = routes[match.route] 106 | handler(state || match.params) 107 | } 108 | } 109 | } 110 | 111 | export default createRouter 112 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/pagination.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import { query, fixLeadingSlashes } from '../helpers.js' 3 | 4 | const NEWER_PAGE_LINK_ID = 'newer-page-link' 5 | const OLDER_PAGE_LINK_ID = 'older-page-link' 6 | 7 | const findContainer = () => query('.pagination-buttons') 8 | const findNewerPageLink = (container) => query(`#${NEWER_PAGE_LINK_ID}`, container) 9 | const findOlderPageLink = (container) => query(`#${OLDER_PAGE_LINK_ID}`, container) 10 | 11 | const getBaseUrl = (tag) => { 12 | const urlParts = [window.permalinkPrefix] 13 | if (tag) { 14 | urlParts.push('tags', tag) 15 | } 16 | return fixLeadingSlashes(urlParts.filter(Boolean).join('/')) 17 | } 18 | 19 | const getPageNumbers = (pageNumber, totalPages) => { 20 | return { 21 | newer: pageNumber ? (pageNumber >= 2 && pageNumber - 1) : undefined, 22 | older: (pageNumber < totalPages) && pageNumber + (pageNumber === 0 ? 2 : 1) 23 | } 24 | } 25 | 26 | const getPageNumberFromLink = (link) => { 27 | const match = link.href.match(/page\/(\d+)/) 28 | if (match) { 29 | return parseInt(match[1], 10) 30 | } 31 | return 0 32 | } 33 | 34 | const containerTemplate = ({ baseUrl, pageNumbers }) => { 35 | const newerPageUrl = pageNumbers.newer === 1 ? 36 | baseUrl : 37 | fixLeadingSlashes(`${baseUrl}/page/${pageNumbers.newer}`) 38 | 39 | const olderPageUrl = fixLeadingSlashes(`${baseUrl}/page/${pageNumbers.older}`) 40 | 41 | return ` 42 |
    43 | ${pageNumbers.newer ? `« Newer` : ''} 44 | ${pageNumbers.older ? `Older »` : ''} 45 |
    46 | ` 47 | } 48 | 49 | const renderLinks = ({ tag, pageNumbers }) => { 50 | const baseUrl = getBaseUrl(tag) 51 | 52 | const container = findContainer() 53 | container.innerHTML = containerTemplate({ 54 | baseUrl, 55 | pageNumbers 56 | }) 57 | return container 58 | } 59 | 60 | const attachClickHandlers = ({ container, onClick }) => { 61 | const links = [ 62 | findNewerPageLink(container), 63 | findOlderPageLink(container) 64 | ] 65 | 66 | links.forEach((link, i) => { 67 | if (!link) { 68 | return 69 | } 70 | link.addEventListener('click', event => { 71 | event.preventDefault() 72 | const pageNumber = getPageNumberFromLink(link) 73 | onClick({ 74 | pageNumber: getPageNumberFromLink(link) 75 | }) 76 | }) 77 | }) 78 | } 79 | 80 | const softRender = ({ tag, onPaginate }) => { 81 | const linksContainer = findContainer() 82 | 83 | attachClickHandlers({ 84 | container: linksContainer, 85 | onClick: (payload) => onPaginate({ 86 | ...payload, 87 | tag 88 | }) 89 | }) 90 | } 91 | 92 | const fullRender = ({ tag, pageNumber, totalPages, onPaginate }) => { 93 | const pageNumbers = getPageNumbers(pageNumber || 1, totalPages) 94 | 95 | const linksContainer = renderLinks({ 96 | tag, 97 | pageNumbers 98 | }) 99 | 100 | attachClickHandlers({ 101 | container: linksContainer, 102 | onClick: (payload) => onPaginate({ 103 | ...payload, 104 | tag 105 | }) 106 | }) 107 | } 108 | 109 | const render = ({ mode, ...rest }) => { 110 | if (mode === Modes.hydration) { 111 | return softRender(rest) 112 | } 113 | return fullRender(rest) 114 | } 115 | 116 | const remove = () => { 117 | findContainer().innerHTML = '' 118 | } 119 | 120 | export default { 121 | render, 122 | remove 123 | } 124 | -------------------------------------------------------------------------------- /theme/assets/custom/script/helpers.js: -------------------------------------------------------------------------------- 1 | export const truncate = (text, limit, ellipsis) => { 2 | return text.length > limit ? 3 | `${ text.slice(0, limit).trim() }${ ellipsis ? '...' : '' }` : 4 | text 5 | } 6 | 7 | export const region = (id, parent = document) => { 8 | return parent.querySelector(`[data-region-id="${id}"]`) 9 | } 10 | 11 | export const transition = ({ target, className, duration, crossFade }) => { 12 | target.classList.add(className) 13 | target.style.setProperty('--transition-duration', duration + 'ms') 14 | if (crossFade) { 15 | setTimeout(() => { 16 | target.classList.remove(className) 17 | }, duration) 18 | return new Promise(r => { 19 | setTimeout(r, duration - duration * crossFade) 20 | }) 21 | } 22 | return new Promise(r => { 23 | setTimeout(() => { 24 | target.classList.remove(className) 25 | r() 26 | }, duration) 27 | }) 28 | } 29 | 30 | export const debounce = (fn, timeout) => { 31 | let waiting 32 | return (...args) => { 33 | if (waiting) { 34 | return 35 | } 36 | waiting = true 37 | setTimeout(() => waiting = false, timeout) 38 | return fn(...args) 39 | } 40 | } 41 | 42 | export const fetchPrefix = window.permalinkPrefix === '/' ? 43 | '' : 44 | window.permalinkPrefix 45 | 46 | export const fetchHTML = (path) => { 47 | const url = [fetchPrefix, path.replace(/^\//, '')].join('/') 48 | return fetch(url).then(r => r.text()) 49 | } 50 | 51 | export const HTMLToDOM = (html) => { 52 | const el = document.createElement('div') 53 | el.innerHTML = html 54 | return el.firstElementChild 55 | } 56 | 57 | export const last = (arr) => arr[arr.length - 1] 58 | 59 | export const chunk = (array, size) => { 60 | return array.reduce((result, item, index) => { 61 | if (index % size === 0) { 62 | return result.concat([[item]]) 63 | } 64 | return Object.assign([], result, { 65 | [result.length - 1]: last(result).concat(item) 66 | }) 67 | }, []) 68 | } 69 | 70 | export const query = (selector, parent = document) => { 71 | return parent.querySelector(selector) 72 | } 73 | 74 | export const queryAll = (selector, parent = document) => { 75 | return parent.querySelectorAll(selector) 76 | } 77 | 78 | export const fixLeadingSlashes = (str) => str.replace(/^\/\//, '/') 79 | 80 | export const route = (url, state) => { 81 | window.history.pushState(state, '', `${fetchPrefix}${url}`) 82 | } 83 | 84 | export const getTagFromUrl = () => { 85 | const urlParts = window.location.pathname 86 | .replace(window.permalinkPrefix, '') 87 | .split('/') 88 | .filter(Boolean) 89 | let tag 90 | if (urlParts.includes('tags')) { 91 | tag = urlParts[1] 92 | } 93 | return tag 94 | } 95 | 96 | export const getPageNumberFromUrl = () => { 97 | const match = document.location.pathname.match(/page\/(\d+)/) 98 | return match && Number(match[1]) 99 | } 100 | 101 | export const getSearchQueryFromUrl = () => { 102 | const match = document.location.search.match(/\?search=(.+)/) 103 | return match && decodeURI(match[1]) 104 | } 105 | 106 | export const scrollToTop = () => { 107 | document.documentElement.scrollTop = 0 108 | } 109 | 110 | export const getPages = ({ links, linksPerPage = 15 }) => { 111 | return chunk(links, linksPerPage) 112 | } 113 | 114 | export const getPageLinks = ({ links, pages, pageNumber, linksPerPage = 15 }) => { 115 | const _pages = pages || getPages({ links, linksPerPage }) 116 | 117 | return pageNumber > 1 ? 118 | _pages[pageNumber - 1] : 119 | _pages[0] 120 | } 121 | -------------------------------------------------------------------------------- /theme/templates/helpers.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pageTitle() { 3 | if (this.page === 'post') { 4 | return `${this.post.title} / ${this.settings.site.title}` 5 | } 6 | if (this.page === 'subpage') { 7 | return `${this.subpage.title} / ${this.settings.site.title}` 8 | } 9 | if (this.page === 'category') { 10 | return `${this.category.name} / ${this.settings.site.title}` 11 | } 12 | if (this.page === 'tag') { 13 | return `#${this.tag.tag} / ${this.settings.site.title}` 14 | } 15 | if (this.page === 'homepage' && this.homepage.title) { 16 | return `${this.homepage.title} / ${this.settings.site.title}` 17 | } 18 | return `${this.settings.site.title}` 19 | }, 20 | 21 | pageDescription() { 22 | if (this.page === 'tags') { 23 | return 'What have I been up to since 2018?' 24 | } 25 | return this.settings.site.description 26 | }, 27 | 28 | assetsPath() { 29 | const { permalinkPrefix, assetsDirectory } = this.settings 30 | const prefix = permalinkPrefix === '/' ? '' : permalinkPrefix 31 | return prefix + '/' + assetsDirectory 32 | }, 33 | 34 | featuredTags() { 35 | const { featuredTags } = this.settings 36 | return this.tags.filter((tag) => featuredTags.includes(tag.tag)) 37 | }, 38 | 39 | tagImportance(allTags, tag) { 40 | return tag.posts.length / allTags[0].posts.length 41 | }, 42 | 43 | truncate (text, limit, ellipsis) { 44 | return text.length > limit ? 45 | `${ text.slice(0, limit).trim() }${ ellipsis ? '...' : '' }` : 46 | text 47 | }, 48 | 49 | region(name) { 50 | return ` data-region-id="${name}" ` 51 | }, 52 | 53 | serialize(obj) { 54 | return JSON.stringify(obj) 55 | }, 56 | 57 | pluck(arr, fieldName) { 58 | return arr.map((obj) => obj[fieldName]) 59 | }, 60 | 61 | seeMore() { 62 | return '' 63 | }, 64 | 65 | map(...keyValues) { 66 | return keyValues.reduce((result, keyOrValue, index) => { 67 | if (index % 2 > 0) { 68 | return result 69 | } 70 | return { 71 | ...result, 72 | [keyOrValue]: keyValues[index + 1] 73 | } 74 | }, {}) 75 | }, 76 | 77 | mention(permalink, options) { 78 | const pattern = new RegExp('^(|\/)' + permalink) 79 | const entry = [ 80 | this.homepage, 81 | ...this.posts, 82 | ...this.categories, 83 | ...this.subpages 84 | ].find(e => pattern.test(e.permalink)) 85 | if (options.fn) { 86 | return options.fn(entry) 87 | } 88 | return `${entry.title}` 89 | }, 90 | 91 | filterPostsByType(type) { 92 | return this.posts.filter(p => p.type === type) 93 | }, 94 | 95 | is(value1, value2) { 96 | return value1 === value2 97 | }, 98 | 99 | isNot(value1, value2) { 100 | return value1 !== value2 101 | }, 102 | 103 | not(value) { 104 | return !value 105 | }, 106 | 107 | isEnabled(featureName) { 108 | return this.settings[featureName] !== 'off' 109 | }, 110 | 111 | isStartMode() { 112 | return this.settings.mode === 'start' 113 | }, 114 | 115 | isBuildMode() { 116 | return this.settings.mode === 'build' 117 | }, 118 | 119 | isPostPage() { 120 | return this.page === 'post' 121 | }, 122 | 123 | isSubPage() { 124 | return this.page === 'subpage' 125 | }, 126 | 127 | isHomePage() { 128 | return this.page === 'homepage' 129 | }, 130 | 131 | isCategoryPage() { 132 | return this.page === 'category' 133 | }, 134 | 135 | isTagPage() { 136 | return this.page === 'tag' 137 | }, 138 | 139 | isTagsPage() { 140 | return this.page === 'tags' 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/search-form.js: -------------------------------------------------------------------------------- 1 | import Dictionary from '../../../common/dictionary.js' 2 | import { query } from '../helpers.js' 3 | 4 | const findContainer = () => query('#search-form-container') 5 | const findForm = () => query('.feat-search') 6 | const findInput = () => query('#feat-search') 7 | 8 | const loadResources = () => { 9 | return new Promise(resolve => { 10 | const stylesheet = document.createElement('link') 11 | stylesheet.rel = 'stylesheet' 12 | stylesheet.href = `${window.assetsPath}/custom/style/search.css` 13 | stylesheet.onload = resolve 14 | document.head.appendChild(stylesheet) 15 | }) 16 | } 17 | 18 | const searchIconTemplate = () => ` 19 | 20 | 21 | 22 | 23 | 24 | ` 25 | 26 | const searchFormTemplate = () => ` 27 | 44 | ` 45 | 46 | const renderSearchForm = () => { 47 | const container = findContainer() 48 | container.innerHTML = ` 49 | ${searchFormTemplate()} 50 | ${searchIconTemplate()} 51 | ` 52 | return container.querySelector('form') 53 | } 54 | 55 | const addFormStateClassNames = () => { 56 | const form = findForm() 57 | const input = findInput() 58 | form.classList.toggle('has-query', input.value.trim()) 59 | } 60 | 61 | const attachEventListeners = ({ searchForm, onSearch, onReset }) => { 62 | const searchInput = searchForm.querySelector('input[type="search"]') 63 | addFormStateClassNames() 64 | searchInput.addEventListener('change', () => { 65 | addFormStateClassNames() 66 | }) 67 | searchForm.addEventListener('submit', event => { 68 | event.preventDefault() 69 | const formData = new FormData(event.target) 70 | const query = formData.get('search') 71 | if (!query.trim()) { 72 | return onReset() 73 | } 74 | onSearch({ query }) 75 | }) 76 | } 77 | 78 | const render = async ({ onSearch, onReset }) => { 79 | try { 80 | await loadResources() 81 | } catch (e) { 82 | console.log('failed loading resources for search-form', e) 83 | } 84 | return new Promise(resolve => { 85 | Dictionary.ready(async () => { 86 | const searchForm = renderSearchForm() 87 | attachEventListeners({ 88 | searchForm, 89 | onSearch, 90 | onReset 91 | }) 92 | resolve(searchForm) 93 | }) 94 | }) 95 | } 96 | 97 | const empty = () => { 98 | findInput().value = '' 99 | addFormStateClassNames() 100 | } 101 | 102 | const setInputValue = (value) => { 103 | findInput().value = value 104 | addFormStateClassNames() 105 | } 106 | 107 | export default { 108 | render, 109 | empty, 110 | setInputValue 111 | } 112 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/tags-list.js: -------------------------------------------------------------------------------- 1 | import { query, queryAll } from '../helpers.js' 2 | 3 | import { reportScroll } from "../vendor/kicss.js" 4 | 5 | const TAG_LINK_CLASS = 'tags-list-item-link' 6 | const findOutsideRegions = () => queryAll('header.header, main.content, footer.footer') 7 | const findContainer = () => query('.tags-list-container') 8 | const findToggle = (container = findContainer()) => query('.tags-list-toggle-label', container) 9 | const findList = (container) => query('.tags-list', container) 10 | const findTagLinks = (list) => queryAll('.' + TAG_LINK_CLASS, list) 11 | const isScreenSmall = () => window.innerWidth <= 900 12 | 13 | const findClosestTagLink = (element, recursion) => { 14 | if (element.classList.contains(TAG_LINK_CLASS)) { 15 | return element 16 | } 17 | if (recursion < 1) { 18 | return undefined 19 | } 20 | return findClosestTagLink(element.parentElement, recursion - 1) 21 | } 22 | 23 | const toggleLabels = { 24 | clickToOpen: 'Show topics', 25 | clickToClose: 'Hide topics' 26 | } 27 | 28 | const autoExpand = () => { 29 | if (isScreenSmall() && findContainer().hasAttribute('open')) { 30 | collapse() 31 | } 32 | if (!isScreenSmall()) { 33 | expand() 34 | } 35 | } 36 | 37 | const collapse = () => { 38 | const container = findContainer() 39 | const outsideRegions = findOutsideRegions() 40 | const toggleBtn = findToggle(container) 41 | container.removeAttribute('open') 42 | toggleBtn.textContent = toggleLabels.clickToOpen 43 | document.body.classList.remove('tags-list-is-open') 44 | outsideRegions.forEach(region => { 45 | region.removeAttribute('inert') 46 | }) 47 | } 48 | 49 | const expand = () => { 50 | const container = findContainer() 51 | const outsideRegions = findOutsideRegions() 52 | const toggleBtn = findToggle(container) 53 | container.setAttribute('open', true) 54 | toggleBtn.textContent = toggleLabels.clickToClose 55 | toggleBtn.textContent = toggleLabels.clickToClose 56 | document.body.classList.add('tags-list-is-open') 57 | if (isScreenSmall()) { 58 | outsideRegions.forEach(region => region.inert = true) 59 | } 60 | } 61 | 62 | const attachToggleClickHandler = () => { 63 | const toggleBtn = findToggle() 64 | 65 | toggleBtn.addEventListener('click', () => { 66 | // Immediately changing toggle text causes mobile safari not to expand the 67 | // details element at all. 68 | setTimeout(() => { 69 | if (toggleBtn.textContent === toggleLabels.clickToClose) { 70 | collapse() 71 | } else { 72 | expand() 73 | } 74 | }, 200) 75 | }) 76 | } 77 | 78 | const attachTagClickHandler = (cb) => { 79 | const container = findContainer() 80 | const list = findList(container) 81 | list.addEventListener('click', event => { 82 | const closestLink = findClosestTagLink(event.target, 2) 83 | if (closestLink) { 84 | event.preventDefault() 85 | cb({ 86 | tag: closestLink.dataset.tag 87 | }) 88 | if (isScreenSmall()) { 89 | collapse() 90 | } 91 | } 92 | }) 93 | } 94 | 95 | const attachResizeHandler = () => { 96 | window.addEventListener('resize', autoExpand) 97 | window.addEventListener('orientationchange', autoExpand) 98 | } 99 | 100 | const attachScrollHandler = () => { 101 | const container = findContainer() 102 | const list = findList(container) 103 | list.addEventListener('scroll', reportScroll('--tags-list-scroll')) 104 | } 105 | 106 | 107 | const render = ({ onClickTag }) => { 108 | attachToggleClickHandler() 109 | attachTagClickHandler(onClickTag) 110 | attachResizeHandler() 111 | attachScrollHandler() 112 | autoExpand() 113 | } 114 | 115 | export default { 116 | render 117 | } 118 | -------------------------------------------------------------------------------- /theme/assets/custom/style/links.css: -------------------------------------------------------------------------------- 1 | .links { 2 | perspective: 500vh; 3 | } 4 | 5 | .link { 6 | --enter-rotate-x: 30deg; 7 | --enter-rotate-y: 0deg; 8 | --enter-shift-x: 0%; 9 | --enter-shift-y: 4%; 10 | --enter-scale: .95, .95; 11 | --enter-duration: .2s; 12 | --enter-stagger: 50ms; 13 | --exit-shift-x: 0%; 14 | --exit-shift-y: 2%; 15 | --exit-scale: .97, .97; 16 | --exit-duration: .15s; 17 | --exit-stagger: 30ms; 18 | position: relative; 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | padding: .8em 2em 1.1em; 23 | font-size: 1.8em; 24 | font-weight: 100; 25 | transition: all .3s; 26 | } 27 | 28 | @media (max-width: 640px) { 29 | .link { 30 | padding: .7em 1em 1em; 31 | } 32 | } 33 | 34 | .link:hover, 35 | .link:focus-within { 36 | background: var(--link-hover-bg); 37 | } 38 | 39 | .link:active { 40 | background: var(--link-active-bg); 41 | box-shadow: none; 42 | } 43 | 44 | .link.entering, 45 | .link.exiting { 46 | transform-origin: top center; 47 | transform-style: preserve-3d; 48 | animation-fill-mode: forwards; 49 | } 50 | 51 | .link.entering { 52 | opacity: 0; 53 | transform: 54 | translate( 55 | calc(var(--enter-shift-x) * -1), 56 | calc(var(--enter-shift-y) * -1) 57 | ) 58 | scale(var(--enter-scale)) 59 | rotateX(calc(var(--enter-rotate-x) * -1)) 60 | rotateY(calc(var(--enter-rotate-y) * -1)); 61 | animation-name: link-enter; 62 | animation-duration: var(--enter-duration); 63 | animation-delay: calc(var(--i) * var(--enter-stagger)); 64 | } 65 | 66 | @keyframes link-enter { 67 | to { 68 | transform: 69 | translate(0, 0) 70 | scale(1, 1) 71 | rotateX(0) 72 | rotateY(0); 73 | opacity: 1; 74 | } 75 | } 76 | 77 | .link.exiting { 78 | transform-origin: bottom center; 79 | animation-name: link-exit; 80 | animation-duration: var(--exit-duration); 81 | animation-delay: calc(var(--i) * var(--exit-stagger)); 82 | } 83 | 84 | @keyframes link-exit { 85 | to { 86 | transform: 87 | translate( 88 | var(--exit-shift-x), 89 | var(--exit-shift-y) 90 | ) 91 | scale(var(--exit-scale)); 92 | opacity: 0; 93 | } 94 | } 95 | 96 | .link-title { 97 | color: var(--link-color); 98 | text-decoration: none; 99 | } 100 | 101 | .link-title::after { 102 | content: ""; 103 | display: block; 104 | position: absolute; 105 | inset: 0; 106 | } 107 | 108 | .link-title { 109 | line-height: 1.4; 110 | } 111 | 112 | .link-url { 113 | margin-top: .25rem; 114 | font-size: .6em; 115 | max-width: 100%; 116 | overflow: hidden; 117 | text-overflow: ellipsis; 118 | color: var(--primary-color); 119 | } 120 | 121 | .link-tags { 122 | display: flex; 123 | flex-wrap: wrap; 124 | margin-top: .35rem; 125 | margin-bottom: -.2em; 126 | } 127 | 128 | .link-tag-container { 129 | font-size: 0.45em; 130 | margin-top: 0.3em; 131 | margin-bottom: 0.25em; 132 | } 133 | 134 | .link-tag { 135 | position: relative; 136 | padding: 0.3em .5em .25em 0; 137 | letter-spacing: .07em; 138 | text-transform: uppercase; 139 | text-decoration: none; 140 | color: var(--link-tag-color); 141 | box-shadow: 1px 20px 0 -15px #aaaa; 142 | transition: all .3s; 143 | } 144 | 145 | .link-tag:hover, 146 | .link-tag:focus { 147 | color: var(--link-tag-hover-color); 148 | box-shadow: 1px 11.5px 0 -10px currentColor; 149 | text-shadow: 0 0 1px var(--link-tag-hover-stroke-color); 150 | } 151 | -------------------------------------------------------------------------------- /theme/assets/custom/style/logo.css: -------------------------------------------------------------------------------- 1 | .logo-container { 2 | display: block; 3 | width: 256px; 4 | height: 256px; 5 | background: var(--logo-bg); 6 | border-radius: 100%; 7 | border-bottom: 1px solid #ddd; 8 | overflow: hidden; 9 | transform: scale(.75, .75); 10 | transition: all .4s var(--logo-bezier); 11 | box-shadow: var(--logo-box-shadow); 12 | } 13 | 14 | .shine { 15 | content: ""; 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | width: 200%; 20 | height: 200%; 21 | border-radius: 100%; 22 | background: rgba(255, 255, 255, .5); 23 | opacity: 0; 24 | transform: 25 | rotate(-45deg) 26 | translateY(-110%); 27 | transition: inherit; 28 | } 29 | 30 | .logo-container:hover, 31 | .logo-container:focus { 32 | background: var(--logo-hover-bg); 33 | transform: var(--logo-hover-transform); 34 | box-shadow: var(--logo-hover-box-shadow); 35 | transform-style: preserve-3d; 36 | } 37 | 38 | .logo-container:hover .shine, 39 | .logo-container:focus .shine { 40 | opacity: var(--logo-hover-shine-opacity); 41 | transform: 42 | rotate(-45deg) 43 | translateY(-85%) 44 | } 45 | 46 | .logo-container:focus { 47 | outline: none; 48 | } 49 | 50 | .logo-container:active { 51 | background: #fff; 52 | transform: scale(.75, .75); 53 | } 54 | 55 | .logo-container:active .shine { 56 | opacity: 0; 57 | transform: 58 | rotate(-45deg) 59 | translateY(-110%) 60 | } 61 | 62 | .logo { 63 | --color: var(--logo-fg); 64 | position: absolute; 65 | max-width: 100%; 66 | border-radius: inherit; 67 | transform: 68 | scale(0.756) 69 | translate(.5%, -58.125%); 70 | height: 2200px; 71 | overflow: visible; 72 | stroke: var(--color); 73 | stroke-width: 1.5px; 74 | } 75 | 76 | @media (prefers-color-scheme: dark) { 77 | .logo-container { 78 | perspective: 500px; 79 | } 80 | 81 | .shine { 82 | top: auto; 83 | bottom: 0; 84 | left: 50%; 85 | width: 105%; 86 | height: 100%; 87 | transform-style: preserve-3d; 88 | transform-origin: center 60%; 89 | translate: -50% 5%; 90 | clip-path: polygon( 0 50%, 100% 50%, 100% 100%, 0 100%); 91 | background: linear-gradient( 92 | to bottom, 93 | transparent 40%, 94 | #fdee 95 | ); 96 | background-size: 100% 300%; 97 | background-position: top center; 98 | } 99 | 100 | .logo-container:hover, 101 | .logo-container:focus { 102 | background: var(--logo-hover-bg); 103 | transform: scale(.75, .75); 104 | box-shadow: var(--logo-box-shadow); 105 | transform-style: preserve-3d; 106 | border-bottom: 30px solid #fed; 107 | } 108 | 109 | .logo-container:hover .shine, 110 | .logo-container:focus .shine { 111 | transform: none; 112 | opacity: 1; 113 | rotate: x 45deg; 114 | background-position: center 30%; 115 | } 116 | 117 | .logo-container:active .shine { 118 | transform: none; 119 | } 120 | 121 | .logo-bottom-part { 122 | transform-style: preserve-3d; 123 | transform-origin: 0% center; 124 | transition: all .4s cubic-bezier(0.68, -0.55, 0.27, 1.55); 125 | } 126 | 127 | :nth-child(1 of .logo) .logo-bottom-part { 128 | opacity: 0; 129 | } 130 | 131 | :nth-child(2 of .logo) .logo-top-part { 132 | opacity: 0; 133 | } 134 | 135 | .logo-container:hover :nth-child(2 of .logo) .logo-bottom-part, 136 | .logo-container:focus :nth-child(2 of .logo) .logo-bottom-part { 137 | background: var(--logo-hover-bg); 138 | rotate: x 45deg; 139 | border-bottom: 30px solid #fed; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/canvas.js: -------------------------------------------------------------------------------- 1 | const { floor, max } = Math 2 | 3 | const DAY = 1000 * 60 * 60 * 24 4 | 5 | const createCanvas = () => { 6 | const canvas = document.createElement('canvas') 7 | canvas.classList.add('graph') 8 | canvas.id = 'graph' 9 | return canvas 10 | } 11 | 12 | export default (options) => { 13 | const { 14 | data, 15 | height, 16 | dayScale, 17 | resolution, 18 | colors, 19 | lineWidth, 20 | padding, 21 | startDate, 22 | yearMarkPosition, 23 | yearMarkFont 24 | } = options 25 | 26 | const totalDaysElapsed = (Date.now() - startDate) / DAY 27 | const width = totalDaysElapsed * dayScale 28 | const outlierScale = 0.8 29 | const innerWidth = width - (padding.left + padding.right) 30 | const innerHeight = height - (padding.top + padding.bottom) 31 | 32 | const getHeightMap = ({ timeResolution }) => { 33 | const heightMap = [] 34 | let tallestPoint = 0 35 | for (let i = 0; i < data.length - 1; i ++) { 36 | const timeElapsed = data[i] - data[data.length - 1] 37 | let dataPoint = floor(timeElapsed / timeResolution) 38 | heightMap[dataPoint] = (heightMap[dataPoint] || 0) + 1 39 | tallestPoint = max(tallestPoint, heightMap[dataPoint]) 40 | } 41 | const timeElapsedUntilNow = Date.now() - data[data.length - 1] 42 | const imaginaryDataPoint = timeElapsedUntilNow * Math.max(1.15, Math.min(50, 20 / data.length)) 43 | const lastDataPoint = floor(imaginaryDataPoint / timeResolution) 44 | heightMap[lastDataPoint] = heightMap[lastDataPoint] || 0 45 | for (let i = 0; i < heightMap.length; i++) { 46 | heightMap[i] = heightMap[i] || 0 47 | if (heightMap[i] === tallestPoint) { 48 | heightMap[i] *= outlierScale 49 | tallestPoint *= outlierScale 50 | } 51 | } 52 | return { 53 | heightMap, 54 | tallestPoint 55 | } 56 | } 57 | 58 | const drawLine = ({ ctx, heightMap, tallestPoint }) => { 59 | const gradient = colors[0].range(colors[1], { 60 | space: "lch", 61 | outputSpace: "srgb" 62 | }) 63 | ctx.beginPath() 64 | for (let i = 0; i < heightMap.length - 1; i++) { 65 | const progress = i / (heightMap.length - 1) 66 | const x = padding.left + i / resolution 67 | const y = padding.top + innerHeight - (heightMap[i] / tallestPoint) * innerHeight 68 | ctx.lineCap = 'round' 69 | ctx.lineWidth = lineWidth 70 | ctx.lineTo(x, y) 71 | ctx.strokeStyle = gradient(progress) 72 | ctx.stroke() 73 | ctx.closePath() 74 | ctx.beginPath() 75 | ctx.moveTo(x, y) 76 | } 77 | ctx.closePath() 78 | } 79 | 80 | const drawYearMarks = ({ ctx }) => { 81 | let firstVisibleYear = startDate.getFullYear() + 1 82 | const firstVisibleYearDate = new Date(0) 83 | firstVisibleYearDate.setYear(firstVisibleYear) 84 | const timeToFirstYear = firstVisibleYearDate - startDate 85 | const startOffset = timeToFirstYear / DAY * dayScale 86 | const yearLength = floor(365 * dayScale) 87 | ctx.font = yearMarkFont 88 | ctx.fillStyle = '#666' 89 | ctx.textAlign = 'center' 90 | ctx.textBaseline = 'middle' 91 | for (let i = 0; i < innerWidth + startOffset; i++) { 92 | if (i > startOffset && i % yearLength === 0) { 93 | const x = padding.left + i - startOffset 94 | const y = yearMarkPosition === 'bottom' ? height - 40 : 20 95 | ctx.fillText(firstVisibleYear, x, y) 96 | firstVisibleYear++ 97 | } 98 | } 99 | } 100 | 101 | const draw = () => { 102 | const $el = createCanvas() 103 | $el.width = width 104 | $el.height = height 105 | const ctx = $el.getContext('2d') 106 | const timeResolution = DAY * (totalDaysElapsed / innerWidth / resolution) 107 | const { heightMap, tallestPoint } = getHeightMap({ timeResolution }) 108 | drawLine({ 109 | ctx, 110 | heightMap, 111 | tallestPoint 112 | }) 113 | drawYearMarks({ ctx }) 114 | return $el 115 | } 116 | 117 | return { 118 | draw 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /misc/setup.md: -------------------------------------------------------------------------------- 1 | # Set up your own [POSSE](https://indieweb.org/POSSE) feed 2 | 3 | Reads Feed helps you save and share your links/bookmarks without relying on external services. 4 | 5 | Your links are indexed and stored locally in your computer until you decide to push to GitHub. 6 | 7 | Once you push, 8 | 9 | - A static site, built with [Writ-CMS](https://github.com/scriptype/writ-cms), will be deployed to 10 | GitHub pages 11 | - The static site will enable subscription via RSS 12 | - Links will be shared on your Twitter and Tumblr accounts 13 | 14 | ## Usage overview 15 | 16 | - Add some links to a json file, by using `./add` CLI helper 17 | - To preview your feed, run `npm start` 18 | - To finalize, run `npm run build` 19 | - Make it public by pushing to GitHub (`./add` will ask doing it for you) 20 | 21 | **Note: Publishing multiple/batch links at once is not tested.** 22 | 23 | ## Install 24 | 25 | Fork this repository to your account, and then: 26 | 27 | ```sh 28 | # Clone 29 | git clone git@github.com:/feed.git 30 | cd feed 31 | 32 | # You may optionally want to clean up git history at this point 33 | 34 | # Install dependencies 35 | npm i 36 | 37 | # Reset the links 38 | rm links.json 39 | echo "[]" > links.json 40 | 41 | # Add a link 42 | ./add 43 | ``` 44 | 45 | ## Social media APIs 46 | 47 | You will need to register OAUTH applications on 48 | [Twitter](https://developer.twitter.com/en/docs/basics/getting-started#get-started-app) 49 | and [Tumblr](https://www.tumblr.com/docs/en/api/v2) for social sharing to work. 50 | 51 | Then go to your repository settings and add the secrets as repository secrets: 52 | 53 | Preview of my repository variables 54 | 55 | **The secrets are _secrets_, so make sure never to make them public** 56 | 57 | ## The look & feel 58 | 59 | Adding a link looks like this: 60 | 61 | https://user-images.githubusercontent.com/5516876/207561991-00259d33-9ee2-424a-9e3c-26262a3dcb4d.mov 62 | 63 | > Update: Now it will also confirm whether to push changes (and trigger publish) or not. 64 | 65 | Run `./add` in the root of the project. 66 | 67 | This will ask: 68 | - Url 69 | - Title 70 | - Retweet quote (if link is a tweet) 71 | - Tags 72 | - Whether to publish now 73 | 74 | If you choose to publish now, changes will be pushed to GitHub, triggering the workflows for social sharing and deploying to GitHub Pages. 75 | 76 | And let's see the results. 77 | 78 | ### Twitter 79 | 80 | Hashtags are omitted on Twitter to avoid the trashy/spammy look. 81 | 82 | link-on-twitter 83 | 84 | ### Tumblr 85 | 86 | link-on-tumblr 87 | 88 | ### Static site 89 | 90 | link-on-site 91 | 92 | ## Automagical retweeting 93 | 94 | If you add a tweet link, you will be asked for an optional retweet quote. 95 | 96 | https://user-images.githubusercontent.com/5516876/207567328-d1f103a6-a38d-45a4-91e8-cd42fd19945a.mov 97 | 98 | It will result in a retweet on Twitter, with the quote you entered. Elsewhere the link itself will be shared as usual. 99 | 100 | retweetable link on twitter 101 | 102 | Previously, feed was able to fetch the link in a tweet and share _that_ link on Tumblr and static site. 103 | But, with the Twitter's current free API it's not possible any more. Similarly, the title can't be extracted from a tweet any more. 104 | 105 | *** 106 | 107 | I hope my instructions are clear and helpful. If you get stuck, you can always open an issue or contact me and I'll be happily helping you ✌️ 108 | 109 | If you find this project helpful, please consider [supporting me](https://github.com/sponsors/scriptype) 💛 110 | -------------------------------------------------------------------------------- /theme/assets/custom/fonts/playfair-display/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2005–2023 The Playfair Project Authors (https://github.com/clauseggers/Playfair) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /theme/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | -------------------------------------------------------------------------------- /theme/theme-settings.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: repeating-linear-gradient( 3 | 60deg, 4 | #fcfcfc, 5 | #f4f2f0 1px, 6 | #fcfcfc 2px, 7 | #fcfcfc 3px 8 | ); 9 | --fg: black; 10 | --primary-color-r: 207; 11 | --primary-color-g: 49; 12 | --primary-color-b: 33; 13 | --primary-color: rgb( 14 | var(--primary-color-r), 15 | var(--primary-color-g), 16 | var(--primary-color-b) 17 | ); 18 | --footer-fg: #555; 19 | 20 | --logo-bezier: cubic-bezier(0.68, -0.55, 0.27, 1.55); 21 | 22 | --link-color: #333; 23 | --link-hover-bg: #f4f4f4; 24 | --link-active-bg: #e8e8e8; 25 | --link-count-color: #333; 26 | --link-tag-color: #555; 27 | --link-tag-hover-color: #000; 28 | --link-tag-hover-stroke-color: #0005; 29 | 30 | --tag-link-color: inherit; 31 | --tag-badge-color: #ffe633; 32 | --tag-badge-number-color: #96433f; 33 | --header-perspective: auto; 34 | 35 | --logo-bg: #fff; 36 | --logo-fg: var(--primary-color); 37 | --logo-box-shadow: auto; 38 | 39 | --logo-hover-bg: #fafafa; 40 | --logo-hover-transform: scale(.85, .85); 41 | --logo-hover-box-shadow: auto; 42 | 43 | --search-input-border-color: #c8c8c8; 44 | --search-input-bg: auto; 45 | --search-input-color: auto; 46 | --search-input-label-color: #444; 47 | 48 | --chart-hover-page-bg: linear-gradient( 49 | to top, 50 | #000, 51 | #018 30%, 52 | #018 70%, 53 | #000 54 | ); 55 | --chart-hover-blend: difference; 56 | --chart-active-page: lemonchiffon; 57 | --content-bg-color: #fff; 58 | --content-border-color: #eaeaea; 59 | --content-width: 640px; 60 | 61 | --tags-list-sphere-1-bg: 62 | radial-gradient(circle at 80% 90%, #a93700 -150%, transparent 75%), 63 | radial-gradient(circle at 30% 30%, #fafae5, #ffea41 30%); 64 | --tags-list-sphere-1-glow: none; 65 | --tags-list-sphere-2-bg: 66 | radial-gradient(circle at 80% 90%, #1e0071 -60%, transparent 80%), 67 | radial-gradient(circle at 30% 20%, #7e7aff, #4b07e2 40%); 68 | --tags-list-sphere-2-glow: none; 69 | --tags-list-sphere-3-bg: 70 | radial-gradient(circle at 80% 90%, #710000 -60%, transparent 80%), 71 | radial-gradient(circle at 30% 20%, #ff7a7a, #e21207 40%); 72 | --tags-list-sphere-3-glow: none; 73 | 74 | --bounce-easing: linear( 75 | 0, 0.004, 0.016, 0.035, 0.063 9.1%, 0.141, 0.25, 0.391, 0.563, 0.765, 1, 76 | 0.891, 0.813 45.5%, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785, 0.813 63.6%, 0.891, 1 72.7%, 77 | 0.973, 0.953, 0.941, 0.938, 0.941, 0.953, 0.973, 1, 78 | 0.988, 0.984, 0.988, 1 79 | ); 80 | } 81 | 82 | @media (prefers-color-scheme: dark) { 83 | :root { 84 | --bg: #181a1f; 85 | --fg: #cccac8; 86 | --primary-color-r: 191; 87 | --primary-color-g: 115; 88 | --primary-color-b: 168; 89 | --primary-color: rgb( 90 | var(--primary-color-r), 91 | var(--primary-color-g), 92 | var(--primary-color-b) 93 | ); 94 | --footer-fg: inherit; 95 | 96 | --link-color: inherit; 97 | --link-hover-bg: #fff1; 98 | --link-active-bg: #aaa1; 99 | --link-count-color: #bbb; 100 | --link-tag-color: inherit; 101 | --link-tag-hover-color: #fff; 102 | --link-tag-hover-stroke-color: #fff5; 103 | 104 | --tag-link-color: inherit; 105 | --tag-badge-color: #7b2f56; 106 | --tag-badge-number-color: #edfd; 107 | --header-perspective: 400px; 108 | 109 | --logo-bg: transparent; 110 | --logo-fg: #ffe5dd; 111 | --logo-box-shadow: 112 | 0 0 4vmin #ffe5dd44, 113 | 0 .2vmin 2vmin .5vmin #ffe5dd77, 114 | 0 -.5vmin 2vmin #a6f7; 115 | --logo-hover-shine-opacity: 0; 116 | 117 | --logo-hover-bg: transparent; 118 | --logo-hover-transform: 119 | scale(.75, .75) 120 | rotateY(10deg) 121 | rotateX(-10deg); 122 | --logo-hover-box-shadow: 123 | 0 0 4vmin #ffe5dd99, 124 | 0 .2vmin 2vmin .5vmin #ffe5dd99, 125 | 0 -.5vmin 3vmin #a6f8; 126 | --logo-hover-shine-opacity: .15; 127 | 128 | --search-input-border-color: #383828; 129 | --search-input-bg: #0002; 130 | --search-input-color: white; 131 | --search-input-label-color: #aaa; 132 | 133 | --chart-hover-page-bg: linear-gradient( 134 | to top, 135 | transparent, 136 | #4a234f 30%, 137 | #4a234f 70%, 138 | transparent 139 | ); 140 | --chart-hover-blend: lighten; 141 | --chart-active-page: violet; 142 | --content-bg-color: rgb(29, 30, 35); 143 | --content-border-color: #111; 144 | 145 | --tags-list-sphere-1-bg: #b59a33; 146 | --tags-list-sphere-1-glow: 147 | inset 0 0 1.5em 0em palegoldenrod, 0 0 1em 0em #fff; 148 | --tags-list-sphere-2-bg: darkslategray; 149 | --tags-list-sphere-2-glow: 150 | inset -.15em -.08em .2em 0em #87ebb7, 0 0 1em 0em #fffa; 151 | --tags-list-sphere-3-bg: darkslateblue; 152 | --tags-list-sphere-3-glow: 153 | inset -.3em 0 .35em 0em #db7070, 0 0 1em 0em #fffa 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /theme/assets/custom/script/controller.js: -------------------------------------------------------------------------------- 1 | import * as Views from './views/index.js' 2 | import Links from './links.js' 3 | import Router from './router.js' 4 | import { getTagFromUrl, getPageNumberFromUrl, getSearchQueryFromUrl } from './helpers.js' 5 | 6 | const debug = false 7 | 8 | const createController = () => { 9 | let state = {} 10 | 11 | const start = async () => { 12 | document.body.classList.add('js-enhanced') 13 | 14 | Links.fetch('posts.min.json') 15 | 16 | const searchQuery = getSearchQueryFromUrl() 17 | const tag = getTagFromUrl() 18 | const pageNumber = getPageNumberFromUrl() 19 | const links = tag ? await Links.findByTag(tag) : await Links.all() 20 | 21 | state = { 22 | tag, 23 | links, 24 | pageNumber, 25 | searchQuery 26 | } 27 | 28 | await Views.Start.render({ 29 | onClickLogo: navigateHomepage, 30 | onClickTag: navigateTagPage, 31 | onPaginate: navigatePage, 32 | onSearch: (payload) => { 33 | onSearch(payload) 34 | state.searchQuery = payload.query 35 | Router.search(state) 36 | }, 37 | onResetSearch, 38 | tag, 39 | links, 40 | pageNumber 41 | }) 42 | 43 | if (searchQuery) { 44 | onSearch({ query: searchQuery }) 45 | } 46 | 47 | Router.start(state, navigateBack, { debug }) 48 | } 49 | 50 | const navigateBack = async (event) => { 51 | if (!event.state) { 52 | console.log('no') 53 | return 54 | } 55 | const { links, pageNumber, tag, searchQuery } = event.state 56 | const navigatedFrom = { 57 | differentPage: state.searchQuery || state.tag !== tag 58 | } 59 | state = { 60 | tag, 61 | links, 62 | pageNumber, 63 | searchQuery 64 | } 65 | 66 | if (searchQuery) { 67 | return onSearch({ query: searchQuery }) 68 | } 69 | 70 | if (tag) { 71 | return Views.Tag.render({ 72 | tag, 73 | links, 74 | pageNumber, 75 | navigatedFrom, 76 | onPaginate: navigatePage 77 | }) 78 | } 79 | 80 | return Views.Homepage.render({ 81 | links, 82 | pageNumber, 83 | navigatedFrom, 84 | onPaginate: navigatePage 85 | }) 86 | } 87 | 88 | const navigatePage = async ({ tag, pageNumber, paginationType }) => { 89 | const links = tag ? await Links.findByTag(tag) : await Links.all() 90 | const navigatedFrom = { 91 | pagination: paginationType || true 92 | } 93 | 94 | state = { 95 | tag, 96 | links, 97 | pageNumber, 98 | searchQuery: '' 99 | } 100 | 101 | if (tag) { 102 | Views.Tag.render({ 103 | tag, 104 | links, 105 | pageNumber, 106 | navigatedFrom, 107 | onPaginate: navigatePage 108 | }) 109 | 110 | return Router.tag(state) 111 | } 112 | 113 | Views.Homepage.render({ 114 | links, 115 | pageNumber, 116 | navigatedFrom, 117 | onPaginate: navigatePage 118 | }) 119 | 120 | Router.homepage(state) 121 | } 122 | 123 | const navigateHomepage = async () => { 124 | const links = await Links.all() 125 | const navigatedFrom = { 126 | differentPage: state.searchQuery || state.tag, 127 | pagination: state.pageNumber > 0 128 | } 129 | 130 | state = { 131 | links, 132 | pageNumber: 0, 133 | searchQuery: '' 134 | } 135 | 136 | Views.Homepage.render({ 137 | links, 138 | pageNumber: 0, 139 | navigatedFrom, 140 | onPaginate: navigatePage 141 | }) 142 | 143 | Router.homepage(state) 144 | } 145 | 146 | const navigateTagPage = async ({ tag } = {}) => { 147 | const links = await Links.findByTag(tag) 148 | const navigatedFrom = { 149 | differentPage: state.searchQuery || state.tag !== tag, 150 | pagination: state.pageNumber > 0 151 | } 152 | state = { 153 | tag, 154 | links, 155 | pageNumber: 0, 156 | searchQuery: '' 157 | } 158 | 159 | Views.Tag.render({ 160 | tag, 161 | links, 162 | pageNumber: 0, 163 | navigatedFrom, 164 | onPaginate: navigatePage 165 | }) 166 | 167 | Router.tag(state) 168 | } 169 | 170 | const onSearch = async ({ query }) => { 171 | const links = await Links.search(query) 172 | 173 | Views.Search.render({ 174 | links, 175 | searchQuery: query 176 | }) 177 | } 178 | 179 | const onResetSearch = async () => { 180 | const tag = getTagFromUrl() 181 | const pageNumber = getPageNumberFromUrl() 182 | const links = tag ? await Links.findByTag(tag) : await Links.all() 183 | const navigatedFrom = { 184 | differentPage: false, 185 | pagination: false 186 | } 187 | state.searchQuery = '' 188 | 189 | if (tag) { 190 | Views.Tag.render({ 191 | tag, 192 | links, 193 | pageNumber, 194 | navigatedFrom, 195 | onPaginate: navigatePage 196 | }) 197 | 198 | return Router.tag(state) 199 | } 200 | 201 | Views.Homepage.render({ 202 | links, 203 | pageNumber, 204 | navigatedFrom, 205 | onPaginate: navigatePage 206 | }) 207 | 208 | return Router.homepage(state) 209 | } 210 | 211 | return { 212 | start, 213 | onSearch, 214 | onResetSearch, 215 | navigatePage, 216 | navigateHomepage, 217 | navigateTagPage 218 | } 219 | } 220 | 221 | export default createController 222 | -------------------------------------------------------------------------------- /theme/assets/custom/style/tags-list.css: -------------------------------------------------------------------------------- 1 | .tags-list-toggle-label { 2 | position: relative; 3 | display: list-item; 4 | min-width: 190px; 5 | width: 100%; 6 | margin-bottom: 2em; 7 | padding-left: 30px; 8 | text-align: center; 9 | color: royalblue; 10 | opacity: 0; 11 | animation: appear .2s .4s forwards; 12 | } 13 | 14 | @keyframes appear { 15 | to { 16 | opacity: 1; 17 | } 18 | } 19 | 20 | @media (min-width: 901px) { 21 | .js-enhanced .tags-list-toggle-label { 22 | bottom: 200vh; 23 | font-size: 0; 24 | } 25 | 26 | .tags-list-toggle-label:focus { 27 | bottom: 0; 28 | font-size: 1em; 29 | } 30 | } 31 | 32 | .tags-list { 33 | --number-of-items: 22; 34 | display: flex; 35 | flex-direction: column; 36 | align-items: flex-end; 37 | gap: 2.3em; 38 | } 39 | 40 | .tags-list-item { 41 | --i: 0; 42 | opacity: 0; 43 | animation: tags-list-item .3s forwards; 44 | animation-delay: calc(var(--i) * 25ms); 45 | padding: 0; 46 | margin-left: 1em; 47 | white-space: nowrap; 48 | letter-spacing: 0.1em; 49 | text-transform: uppercase; 50 | } 51 | 52 | @keyframes tags-list-item { 53 | from { 54 | transform: translateX(-10%); 55 | } 56 | to { 57 | transform: translateX(0); 58 | opacity: 1; 59 | } 60 | } 61 | 62 | .tags-list-item-link { 63 | display: inline-block; 64 | position: relative; 65 | padding-right: 1.7em; 66 | text-decoration-thickness: 0.04em; 67 | text-underline-offset: 0.1em; 68 | text-decoration-color: #999; 69 | color: var(--tag-link-color); 70 | } 71 | 72 | .tag-hash { 73 | margin-right: -0.3em; 74 | opacity: 0.7; 75 | } 76 | 77 | .tag-count-badge { 78 | position: absolute; 79 | top: 50%; 80 | right: 0; 81 | transform: translateY(-45%); 82 | display: inline-flex; 83 | justify-content: center; 84 | align-items: center; 85 | width: 2em; 86 | aspect-ratio: 1; 87 | font-size: 0.7em; 88 | font-family: monospace; 89 | font-weight: 100; 90 | letter-spacing: -.04em; 91 | border-radius: 0.8em; 92 | background: var(--tag-badge-color); 93 | color: var(--tag-badge-number-color); 94 | } 95 | 96 | @media (max-width: 900px) { 97 | ::-webkit-details-marker { 98 | display: none; 99 | } 100 | 101 | body:has(.tags-list-container[open]) { 102 | overflow: hidden; 103 | } 104 | 105 | body.tags-list-is-open { 106 | overflow: hidden; 107 | } 108 | 109 | .tags-list-toggle-label { 110 | --animation-duration: 1s; 111 | display: inline-block; 112 | position: absolute; 113 | right: 1em; 114 | bottom: 1em; 115 | width: 3.5em; 116 | min-width: auto; 117 | margin: 0; 118 | text-indent: -2000vw; 119 | aspect-ratio: 1; 120 | border-radius: 50%; 121 | cursor: pointer; 122 | opacity: 1; 123 | background: var(--tags-list-sphere-1-bg); 124 | box-shadow: var(--tags-list-sphere-1-glow); 125 | animation: bounce var(--animation-duration) var(--bounce-easing) forwards; 126 | } 127 | 128 | .tags-list-toggle-label, 129 | .tags-list-toggle-label::before, 130 | .tags-list-toggle-label::after { 131 | z-index: 2; 132 | } 133 | 134 | @keyframes bounce { 135 | from { 136 | transform: translateY(-200%); 137 | } 138 | to { 139 | transform: translateY(0); 140 | } 141 | } 142 | 143 | .tags-list-toggle-label::before, 144 | .tags-list-toggle-label::after { 145 | content: ""; 146 | display: inline-block; 147 | aspect-ratio: 1; 148 | border-radius: 50%; 149 | position: absolute; 150 | } 151 | 152 | .tags-list-toggle-label::after { 153 | right: 3.8em; 154 | bottom: 1.05em; 155 | width: 1.33em; 156 | background: var(--tags-list-sphere-3-bg); 157 | box-shadow: var(--tags-list-sphere-3-glow); 158 | animation: bounce var(--animation-duration) -.1s var(--bounce-easing) forwards; 159 | } 160 | 161 | .tags-list-toggle-label::before { 162 | right: 3.3em; 163 | bottom: 2.75em; 164 | width: 0.73em; 165 | background: var(--tags-list-sphere-2-bg); 166 | box-shadow: var(--tags-list-sphere-2-glow); 167 | animation: bounce var(--animation-duration) -.2s var(--bounce-easing) forwards; 168 | } 169 | 170 | .tags-list-toggle[open] .tags-list-toggle-label {} 171 | .tags-list-toggle[open] .tags-list-toggle-label::after {} 172 | .tags-list-toggle[open] .tags-list-toggle-label::before {} 173 | 174 | /* 175 | * For some reason, mobile safari eats up a chunk from top padding. 176 | * Increase padding-top, and move top fader-gradient lower 177 | * It looks slightly agressive on responsive emulator, but fine on phone. 178 | * */ 179 | .tags-list { 180 | background: var(--content-bg-color); 181 | justify-content: flex-start; 182 | align-items: center; 183 | gap: 2em; 184 | height: 100dvh; 185 | padding: 10em 0 8em; 186 | overflow: auto; 187 | flex-wrap: nowrap; 188 | } 189 | 190 | .tags-list::after, 191 | .tags-list::before { 192 | content: ""; 193 | position: absolute; 194 | width: 100%; 195 | height: 30%; 196 | pointer-events: none; 197 | } 198 | 199 | .tags-list::before { 200 | --scroll: var(--tags-list-scroll-1, 0); 201 | content: "Topics"; 202 | top: 0; 203 | z-index: 1; 204 | display: block; 205 | padding-top: 5dvh; 206 | font-size: calc(2em - var(--scroll) * 3em); 207 | font-weight: bold; 208 | letter-spacing: -.03em; 209 | text-align: center; 210 | text-shadow: 211 | 0 calc(1em + var(--scroll) * -20em) 212 | calc(var(--scroll) * 3em) 213 | rgba( 214 | var(--primary-color-r), 215 | var(--primary-color-g), 216 | var(--primary-color-b), 217 | calc(1 - var(--scroll) * 10) 218 | ); 219 | color: transparent; 220 | background: radial-gradient( 221 | ellipse 100% 100% at center -20%, 222 | var(--content-bg-color) 20%, 223 | transparent 224 | ); 225 | transform: translateZ(0); 226 | } 227 | 228 | .tags-list::after { 229 | bottom: 0; 230 | background: radial-gradient( 231 | ellipse 100% 100% at center 120%, 232 | var(--content-bg-color) 20%, 233 | transparent 234 | ); 235 | } 236 | 237 | .tags-list-item { 238 | font-size: 1.6em; 239 | } 240 | } 241 | 242 | @media (prefers-color-scheme: dark) { 243 | .tags-list-item, 244 | .tag-count-badge { 245 | transition: all .4s; 246 | } 247 | 248 | .header:has(.logo-container:hover, .logo-container:focus-within) ~ .tags .tags-list-item { 249 | --proximity: calc(1 - var(--i) / var(--number-of-items)); 250 | text-shadow: 251 | .25dvmin calc(2.5dvmin - var(--proximity) * 1.2dvmin) 252 | calc(1.75dvmin - var(--proximity) * 1dvmin) 253 | rgba(0, 0, 0, var(--proximity)), 254 | -.1dvmin -.1dvmin .2dvmin 255 | hsla(30, 100%, 60%, 256 | calc(var(--proximity) * var(--proximity) - .25) 257 | ); 258 | } 259 | 260 | .header:has(.logo-container:hover, .logo-container:focus-within) ~ .tags .tag-count-badge { 261 | box-shadow: 262 | inset .1dvmin .3dvmin .3dvmin #fed3, 263 | inset -.1dvmin -.3dvmin .3dvmin #3213; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /theme/assets/custom/script/components/chart-pagination.js: -------------------------------------------------------------------------------- 1 | import Modes from '../modes.js' 2 | import { last, query, queryAll, fixLeadingSlashes } from '../helpers.js' 3 | 4 | const findScrollContainer = () => { 5 | return query('.graph-container') 6 | } 7 | 8 | const findContentContainer = () => { 9 | return query('.links') 10 | } 11 | 12 | const findPaginationContainer = (scrollContainer = findScrollContainer()) => { 13 | return query('#overlay-pagination-container', scrollContainer) 14 | } 15 | 16 | const findToggle = (scrollContainer = findScrollContainer()) => { 17 | return query('#overlay-pagination-toggle', scrollContainer) 18 | } 19 | 20 | const findList = (scrollContainer = findScrollContainer()) => { 21 | return query('#overlay-pagination', scrollContainer) 22 | } 23 | 24 | const findListItems = (list = findList()) => { 25 | return queryAll('.overlay-pagination-item', list) 26 | } 27 | 28 | const findLinks = () => { 29 | return queryAll('.overlay-pagination-link') 30 | } 31 | 32 | const findPageLink = (pageNumber, list = findList()) => { 33 | return query(`[data-page-number="${pageNumber}"]`, list) 34 | } 35 | 36 | const findCurrentLink = (list = findList()) => { 37 | return query('.overlay-pagination-link.is-current', list) 38 | } 39 | 40 | const LINK_CLASS = 'overlay-pagination-link' 41 | const CURRENT_LINK_CLASS = 'is-current' 42 | const FAKE_ACTIVE_STATE_CLASS = 'faked-active-state' 43 | 44 | const DAY = 1000 * 60 * 60 * 24 45 | 46 | const eventListeners = { 47 | global: null 48 | } 49 | 50 | const setContainerProperties = () => { 51 | const container = findScrollContainer() 52 | container.classList.toggle('has-scroll', container.scrollWidth > container.clientWidth) 53 | } 54 | 55 | const setPageLinkProperties = (dayScale) => (page, i) => { 56 | const pageStartDate = i === 0 ? Date.now() : page[0] 57 | const pageEndDate = last(page) 58 | const daysElapsedInPage = (pageStartDate - pageEndDate) / DAY 59 | const width = daysElapsedInPage * dayScale 60 | const list = findList() 61 | const link = findListItems(list)[i] 62 | if (link) { 63 | link.style.setProperty('--i', i) 64 | link.style.setProperty('--width', width + 'px') 65 | } 66 | } 67 | 68 | const setListProperties = () => { 69 | const container = findPaginationContainer() 70 | const toggle = findToggle(container) 71 | const toggleLabels = { 72 | clickToOpen: 'Show graph pagination links', 73 | clickToClose: 'Hide graph pagination links' 74 | } 75 | toggle.addEventListener('click', () => { 76 | if (toggle.textContent === toggleLabels.clickToClose) { 77 | toggle.textContent = toggleLabels.clickToOpen 78 | } else { 79 | toggle.textContent = toggleLabels.clickToClose 80 | } 81 | }) 82 | container.setAttribute('open', true) 83 | toggle.textContent = toggleLabels.clickToClose 84 | } 85 | 86 | const setCurrentPageLink = (pageNumber) => { 87 | const list = findList() 88 | const currentPageLink = findCurrentLink(list) || findLinks(list)[0] 89 | currentPageLink.classList.remove(CURRENT_LINK_CLASS) 90 | 91 | if (pageNumber) { 92 | const pageLink = findPageLink(pageNumber, list) 93 | pageLink.classList.add(CURRENT_LINK_CLASS) 94 | } else { 95 | const pageLink = findLinks(list)[0] 96 | pageLink.classList.add(CURRENT_LINK_CLASS) 97 | } 98 | } 99 | 100 | const scrollToCurrentPageLink = ({ behavior = 'auto' }) => { 101 | const link = findCurrentLink() 102 | if (!link) { 103 | return 104 | } 105 | const container = findScrollContainer() 106 | const { clientWidth: containerWidth } = container 107 | const { offsetLeft: linkLeft, clientWidth: linkWidth } = link 108 | requestAnimationFrame(() => { 109 | container.scrollTo({ 110 | top: 0, 111 | left: linkLeft - (containerWidth / 2) + linkWidth / 2, 112 | behavior 113 | }) 114 | }) 115 | } 116 | 117 | const scrollBoost = ({ readonly }) => { 118 | return new window.ScrollBooster({ 119 | viewport: findScrollContainer(), 120 | scrollMode: 'native', 121 | 122 | onPointerDown(state, event) { 123 | let element = event.target 124 | if (readonly) { 125 | element = findScrollContainer() 126 | } 127 | element.classList.add(FAKE_ACTIVE_STATE_CLASS) 128 | }, 129 | 130 | onPointerUp(state, event) { 131 | if (readonly) { 132 | findScrollContainer().classList.remove(FAKE_ACTIVE_STATE_CLASS) 133 | } else { 134 | findLinks().forEach(link => { 135 | link.classList.remove(FAKE_ACTIVE_STATE_CLASS) 136 | }) 137 | } 138 | }, 139 | 140 | shouldScroll(state, event) { 141 | return readonly || event.target.classList.contains(LINK_CLASS) 142 | }, 143 | 144 | // Later event handlers will know that drag is in progress 145 | onClick(state, event) { 146 | if (state.isMoving) { 147 | event.isDragScrolling = true 148 | } 149 | } 150 | }) 151 | } 152 | 153 | const attachClickLinkListener = ({ pages, onClickLink }) => { 154 | const clickLinkListener = (event) => { 155 | if (!event.target.classList.contains(LINK_CLASS)) { 156 | return 157 | } 158 | if (event.isDragScrolling) { 159 | return 160 | } 161 | event.preventDefault() 162 | 163 | const pageNumberMatch = event.target.href.match(/\/page\/(\d+)/) 164 | const pageNumber = pageNumberMatch && Number(pageNumberMatch[1]) 165 | 166 | onClickLink({ 167 | pageNumber 168 | }) 169 | } 170 | 171 | findScrollContainer().addEventListener('click', clickLinkListener) 172 | return clickLinkListener 173 | } 174 | 175 | const attachListenersToAdjustTogglePosition = () => { 176 | const scrollContainer = findScrollContainer() 177 | window.addEventListener('resize', setTogglePosition) 178 | window.addEventListener('orientationchange', setTogglePosition) 179 | scrollContainer.addEventListener('scroll', setTogglePosition) 180 | return setTogglePosition 181 | } 182 | 183 | const setTogglePosition = () => { 184 | const scrollContainer = findScrollContainer() 185 | scrollContainer.style.setProperty( 186 | '--graph-left', scrollContainer.scrollLeft + 'px' 187 | ) 188 | } 189 | 190 | const getBaseUrl = (tag) => { 191 | const urlParts = [window.permalinkPrefix] 192 | if (tag) { 193 | urlParts.push('tags', tag) 194 | } 195 | return fixLeadingSlashes(urlParts.filter(Boolean).join('/')) 196 | } 197 | 198 | const getPageUrl = (baseUrl, pageNumber) => { 199 | if (baseUrl === '/') { 200 | return baseUrl + ['page', String(pageNumber)].join('/') 201 | } 202 | return [baseUrl, 'page', String(pageNumber)].join('/') 203 | } 204 | 205 | const template = ({ tag, pages, pageNumber }) => { 206 | const baseUrl = getBaseUrl(tag) 207 | return ` 208 | 224 | ` 225 | } 226 | 227 | const rerenderList = ({ tag, pages, pageNumber }) => { 228 | const list = findList() 229 | list.outerHTML = template({ 230 | tag, 231 | pages, 232 | pageNumber 233 | }) 234 | return list 235 | } 236 | 237 | const render = ({ readonly, mode, tag, pages, pageNumber = 0, dayScale, onPaginate }) => { 238 | if (!readonly && mode === Modes.full) { 239 | rerenderList({ 240 | tag, 241 | pages, 242 | pageNumber 243 | }) 244 | } 245 | 246 | setContainerProperties() 247 | setListProperties() 248 | 249 | pages 250 | .map(p => p.map(l => l.datePublished)) 251 | .forEach(setPageLinkProperties(dayScale)) 252 | 253 | scrollBoost({ readonly }) 254 | 255 | if (!readonly) { 256 | attachClickLinkListener({ 257 | pages, 258 | onClickLink: onPaginate 259 | }) 260 | 261 | updateCurrentPage({ 262 | pageNumber, 263 | scrollBehavior: 'auto' 264 | }) 265 | } 266 | 267 | if (!eventListeners.global) { 268 | eventListeners.global = attachListenersToAdjustTogglePosition() 269 | } 270 | setTogglePosition() 271 | } 272 | 273 | const updateCurrentPage = ({ pageNumber, scrollBehavior = 'smooth' }) => { 274 | setCurrentPageLink(pageNumber) 275 | scrollToCurrentPageLink({ 276 | behavior: scrollBehavior 277 | }) 278 | } 279 | 280 | export default { 281 | render, 282 | updateCurrentPage 283 | } 284 | -------------------------------------------------------------------------------- /theme/templates/components/logo.hbs: -------------------------------------------------------------------------------- 1 | 2 | 21 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /theme/assets/custom/script/vendor/scrollbooster.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("ScrollBooster",[],e):"object"==typeof exports?exports.ScrollBooster=e():t.ScrollBooster=e()}(this,(function(){return function(t){var e={};function i(o){if(e[o])return e[o].exports;var n=e[o]={i:o,l:!1,exports:{}};return t[o].call(n.exports,n,n.exports,i),n.l=!0,n.exports}return i.m=t,i.c=e,i.d=function(t,e,o){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:o})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var o=Object.create(null);if(i.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var n in t)i.d(o,n,function(e){return t[e]}.bind(null,n));return o},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=0)}([function(t,e,i){"use strict";function o(t,e){var i=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),i.push.apply(i,o)}return i}function n(t){for(var e=1;e0&&void 0!==arguments[0]?arguments[0]:{};s(this,t);var i={content:e.viewport.children[0],direction:"all",pointerMode:"all",scrollMode:void 0,bounce:!0,bounceForce:.1,friction:.05,textSelection:!1,inputsFocus:!0,emulateScroll:!1,preventDefaultOnEmulateScroll:!1,preventPointerMoveDefault:!0,lockScrollOnDragDirection:!1,pointerDownPreventDefault:!0,dragDirectionTolerance:40,onPointerDown:function(){},onPointerUp:function(){},onPointerMove:function(){},onClick:function(){},onUpdate:function(){},onWheel:function(){},shouldScroll:function(){return!0}};if(this.props=n(n({},i),e),this.props.viewport&&this.props.viewport instanceof Element)if(this.props.content){this.isDragging=!1,this.isTargetScroll=!1,this.isScrolling=!1,this.isRunning=!1;var o={x:0,y:0};this.position=n({},o),this.velocity=n({},o),this.dragStartPosition=n({},o),this.dragOffset=n({},o),this.clientOffset=n({},o),this.dragPosition=n({},o),this.targetPosition=n({},o),this.scrollOffset=n({},o),this.rafID=null,this.events={},this.updateMetrics(),this.handleEvents()}else console.error("ScrollBooster init error: Viewport does not have any content");else console.error('ScrollBooster init error: "viewport" config property must be present and must be Element')}var e,i,o;return e=t,(i=[{key:"updateOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.props=n(n({},this.props),t),this.props.onUpdate(this.getState()),this.startAnimationLoop()}},{key:"updateMetrics",value:function(){var t;this.viewport={width:this.props.viewport.clientWidth,height:this.props.viewport.clientHeight},this.content={width:(t=this.props.content,Math.max(t.offsetWidth,t.scrollWidth)),height:l(this.props.content)},this.edgeX={from:Math.min(-this.content.width+this.viewport.width,0),to:0},this.edgeY={from:Math.min(-this.content.height+this.viewport.height,0),to:0},this.props.onUpdate(this.getState()),this.startAnimationLoop()}},{key:"startAnimationLoop",value:function(){var t=this;this.isRunning=!0,cancelAnimationFrame(this.rafID),this.rafID=requestAnimationFrame((function(){return t.animate()}))}},{key:"animate",value:function(){var t=this;if(this.isRunning){this.updateScrollPosition(),this.isMoving()||(this.isRunning=!1,this.isTargetScroll=!1);var e=this.getState();this.setContentPosition(e),this.props.onUpdate(e),this.rafID=requestAnimationFrame((function(){return t.animate()}))}}},{key:"updateScrollPosition",value:function(){this.applyEdgeForce(),this.applyDragForce(),this.applyScrollForce(),this.applyTargetForce();var t=1-this.props.friction;this.velocity.x*=t,this.velocity.y*=t,"vertical"!==this.props.direction&&(this.position.x+=this.velocity.x),"horizontal"!==this.props.direction&&(this.position.y+=this.velocity.y),this.props.bounce&&!this.isScrolling||this.isTargetScroll||(this.position.x=Math.max(Math.min(this.position.x,this.edgeX.to),this.edgeX.from),this.position.y=Math.max(Math.min(this.position.y,this.edgeY.to),this.edgeY.from))}},{key:"applyForce",value:function(t){this.velocity.x+=t.x,this.velocity.y+=t.y}},{key:"applyEdgeForce",value:function(){if(this.props.bounce&&!this.isDragging){var t=this.position.xthis.edgeX.to,i=this.position.ythis.edgeY.to,n=t||e,r=i||o;if(n||r){var s=t?this.edgeX.from:this.edgeX.to,a=i?this.edgeY.from:this.edgeY.to,l=s-this.position.x,p=a-this.position.y,c={x:l*this.props.bounceForce,y:p*this.props.bounceForce},h=this.position.x+(this.velocity.x+c.x)/this.props.friction,u=this.position.y+(this.velocity.y+c.y)/this.props.friction;(t&&h>=this.edgeX.from||e&&h<=this.edgeX.to)&&(c.x=l*this.props.bounceForce-this.velocity.x),(i&&u>=this.edgeY.from||o&&u<=this.edgeY.to)&&(c.y=p*this.props.bounceForce-this.velocity.y),this.applyForce({x:n?c.x:0,y:r?c.y:0})}}}},{key:"applyDragForce",value:function(){if(this.isDragging){var t=this.dragPosition.x-this.position.x,e=this.dragPosition.y-this.position.y;this.applyForce({x:t-this.velocity.x,y:e-this.velocity.y})}}},{key:"applyScrollForce",value:function(){this.isScrolling&&(this.applyForce({x:this.scrollOffset.x-this.velocity.x,y:this.scrollOffset.y-this.velocity.y}),this.scrollOffset.x=0,this.scrollOffset.y=0)}},{key:"applyTargetForce",value:function(){this.isTargetScroll&&this.applyForce({x:.08*(this.targetPosition.x-this.position.x)-this.velocity.x,y:.08*(this.targetPosition.y-this.position.y)-this.velocity.y})}},{key:"isMoving",value:function(){return this.isDragging||this.isScrolling||Math.abs(this.velocity.x)>=.01||Math.abs(this.velocity.y)>=.01}},{key:"scrollTo",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.isTargetScroll=!0,this.targetPosition.x=-t.x||0,this.targetPosition.y=-t.y||0,this.startAnimationLoop()}},{key:"setPosition",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.velocity.x=0,this.velocity.y=0,this.position.x=-t.x||0,this.position.y=-t.y||0,this.startAnimationLoop()}},{key:"getState",value:function(){return{isMoving:this.isMoving(),isDragging:!(!this.dragOffset.x&&!this.dragOffset.y),position:{x:-this.position.x,y:-this.position.y},dragOffset:this.dragOffset,dragAngle:this.getDragAngle(this.clientOffset.x,this.clientOffset.y),borderCollision:{left:this.position.x>=this.edgeX.to,right:this.position.x<=this.edgeX.from,top:this.position.y>=this.edgeY.to,bottom:this.position.y<=this.edgeY.from}}}},{key:"getDragAngle",value:function(t,e){return Math.round(Math.atan2(t,e)*(180/Math.PI))}},{key:"getDragDirection",value:function(t,e){return Math.abs(90-Math.abs(t))<=90-e?"horizontal":"vertical"}},{key:"setContentPosition",value:function(t){"transform"===this.props.scrollMode&&(this.props.content.style.transform="translate(".concat(-t.position.x,"px, ").concat(-t.position.y,"px)")),"native"===this.props.scrollMode&&(this.props.viewport.scrollTop=t.position.y,this.props.viewport.scrollLeft=t.position.x)}},{key:"handleEvents",value:function(){var t=this,e={x:0,y:0},i={x:0,y:0},o=null,n=null,r=!1,s=function(n){if(t.isDragging){var s=r?n.touches[0]:n,a=s.pageX,l=s.pageY,p=s.clientX,c=s.clientY;t.dragOffset.x=a-e.x,t.dragOffset.y=l-e.y,t.clientOffset.x=p-i.x,t.clientOffset.y=c-i.y,(Math.abs(t.clientOffset.x)>5&&!o||Math.abs(t.clientOffset.y)>5&&!o)&&(o=t.getDragDirection(t.getDragAngle(t.clientOffset.x,t.clientOffset.y),t.props.dragDirectionTolerance)),t.props.lockScrollOnDragDirection&&"all"!==t.props.lockScrollOnDragDirection?o===t.props.lockScrollOnDragDirection&&r?(t.dragPosition.x=t.dragStartPosition.x+t.dragOffset.x,t.dragPosition.y=t.dragStartPosition.y+t.dragOffset.y):r?(t.dragPosition.x=t.dragStartPosition.x,t.dragPosition.y=t.dragStartPosition.y):(t.dragPosition.x=t.dragStartPosition.x+t.dragOffset.x,t.dragPosition.y=t.dragStartPosition.y+t.dragOffset.y):(t.dragPosition.x=t.dragStartPosition.x+t.dragOffset.x,t.dragPosition.y=t.dragStartPosition.y+t.dragOffset.y)}};this.events.pointerdown=function(o){r=!(!o.touches||!o.touches[0]),t.props.onPointerDown(t.getState(),o,r);var n=r?o.touches[0]:o,a=n.pageX,l=n.pageY,p=n.clientX,c=n.clientY,h=t.props.viewport,u=h.getBoundingClientRect();if(!(p-u.left>=h.clientLeft+h.clientWidth)&&!(c-u.top>=h.clientTop+h.clientHeight)&&t.props.shouldScroll(t.getState(),o)&&2!==o.button&&("mouse"!==t.props.pointerMode||!r)&&("touch"!==t.props.pointerMode||r)&&!(t.props.inputsFocus&&["input","textarea","button","select","label"].indexOf(o.target.nodeName.toLowerCase())>-1)){if(t.props.textSelection){if(function(t,e,i){for(var o=t.childNodes,n=document.createRange(),r=0;r=a.left&&i>=a.top&&e<=a.right&&i<=a.bottom)return s}}return!1}(o.target,p,c))return;(f=window.getSelection?window.getSelection():document.selection)&&(f.removeAllRanges?f.removeAllRanges():f.empty&&f.empty())}var f;t.isDragging=!0,e.x=a,e.y=l,i.x=p,i.y=c,t.dragStartPosition.x=t.position.x,t.dragStartPosition.y=t.position.y,s(o),t.startAnimationLoop(),!r&&t.props.pointerDownPreventDefault&&o.preventDefault()}},this.events.pointermove=function(e){!e.cancelable||"all"!==t.props.lockScrollOnDragDirection&&t.props.lockScrollOnDragDirection!==o||e.preventDefault(),s(e),t.props.onPointerMove(t.getState(),e,r)},this.events.pointerup=function(e){t.isDragging=!1,o=null,t.props.onPointerUp(t.getState(),e,r)},this.events.wheel=function(e){var i=t.getState();t.props.emulateScroll&&(t.velocity.x=0,t.velocity.y=0,t.isScrolling=!0,t.scrollOffset.x=-e.deltaX,t.scrollOffset.y=-e.deltaY,t.props.onWheel(i,e),t.startAnimationLoop(),clearTimeout(n),n=setTimeout((function(){return t.isScrolling=!1}),80),t.props.preventDefaultOnEmulateScroll&&t.getDragDirection(t.getDragAngle(-e.deltaX,-e.deltaY),t.props.dragDirectionTolerance)===t.props.preventDefaultOnEmulateScroll&&e.preventDefault())},this.events.scroll=function(){var e=t.props.viewport,i=e.scrollLeft,o=e.scrollTop;Math.abs(t.position.x+i)>3&&(t.position.x=-i,t.velocity.x=0),Math.abs(t.position.y+o)>3&&(t.position.y=-o,t.velocity.y=0)},this.events.click=function(e){var i=t.getState(),o="vertical"!==t.props.direction?i.dragOffset.x:0,n="horizontal"!==t.props.direction?i.dragOffset.y:0;Math.max(Math.abs(o),Math.abs(n))>5&&(e.preventDefault(),e.stopPropagation()),t.props.onClick(i,e,r)},this.events.contentLoad=function(){return t.updateMetrics()},this.events.resize=function(){return t.updateMetrics()},this.props.viewport.addEventListener("mousedown",this.events.pointerdown),this.props.viewport.addEventListener("touchstart",this.events.pointerdown,{passive:!1}),this.props.viewport.addEventListener("click",this.events.click),this.props.viewport.addEventListener("wheel",this.events.wheel,{passive:!1}),this.props.viewport.addEventListener("scroll",this.events.scroll),this.props.content.addEventListener("load",this.events.contentLoad,!0),window.addEventListener("mousemove",this.events.pointermove),window.addEventListener("touchmove",this.events.pointermove,{passive:!1}),window.addEventListener("mouseup",this.events.pointerup),window.addEventListener("touchend",this.events.pointerup),window.addEventListener("resize",this.events.resize)}},{key:"destroy",value:function(){this.props.viewport.removeEventListener("mousedown",this.events.pointerdown),this.props.viewport.removeEventListener("touchstart",this.events.pointerdown),this.props.viewport.removeEventListener("click",this.events.click),this.props.viewport.removeEventListener("wheel",this.events.wheel),this.props.viewport.removeEventListener("scroll",this.events.scroll),this.props.content.removeEventListener("load",this.events.contentLoad),window.removeEventListener("mousemove",this.events.pointermove),window.removeEventListener("touchmove",this.events.pointermove),window.removeEventListener("mouseup",this.events.pointerup),window.removeEventListener("touchend",this.events.pointerup),window.removeEventListener("resize",this.events.resize)}}])&&a(e.prototype,i),o&&a(e,o),t}()}]).default})); 2 | -------------------------------------------------------------------------------- /theme/assets/custom/script/vendor/kicss.js: -------------------------------------------------------------------------------- 1 | const { performInterpolation, purgeRangeCache } = (() => { 2 | function findRange(input, inputRange) { 3 | let i 4 | for (i = 1; i < inputRange.length - 1; ++i) { 5 | if (inputRange[i] >= input) { 6 | break 7 | } 8 | } 9 | return i - 1 10 | } 11 | 12 | const interpolate = ({ value, inputRange, outputRange}) => { 13 | const range = findRange(value, inputRange) 14 | const inputMin = inputRange[range] 15 | const inputMax = inputRange[range + 1] 16 | const outputMin = outputRange[range] 17 | const outputMax = outputRange[range + 1] 18 | let interpolated = value 19 | interpolated = (interpolated - inputMin) / (inputMax - inputMin) 20 | interpolated = interpolated * (outputMax - outputMin) + outputMin 21 | return interpolated 22 | } 23 | 24 | const performInterpolation = ({ interpolation, id, value }) => { 25 | const { 26 | name: interpolationName, 27 | scope, 28 | inputRange, 29 | outputRange, 30 | cache = true, 31 | cacheDuration = 300 32 | } = interpolation 33 | const [cachedInputRange, cachedOutputRane] = cache ? 34 | cacheRanges(`${interpolationName}-${id}`, [inputRange, outputRange], cacheDuration) : 35 | [ 36 | typeof inputRange === 'function' ? inputRange() : inputRange, 37 | typeof outputRange === 'function' ? outputRange() : outputRange, 38 | ] 39 | const interpolated = interpolate({ 40 | value, 41 | inputRange: cachedInputRange, 42 | outputRange: cachedOutputRane 43 | }) 44 | return { 45 | interpolationName, 46 | interpolated, 47 | scope 48 | } 49 | } 50 | 51 | let rangeCache = {} 52 | const cacheRanges = (interpolationName, ranges, cacheDuration) => { 53 | const cachedRanges = rangeCache[interpolationName] 54 | if (cachedRanges && (Date.now() - cachedRanges.timestamp < cacheDuration)) { 55 | return cachedRanges.ranges 56 | } 57 | rangeCache[interpolationName] = { 58 | ranges: ranges.map(r => typeof r === 'function' ? r() : r), 59 | timestamp: Date.now() 60 | } 61 | return rangeCache[interpolationName].ranges 62 | } 63 | 64 | const purgeRangeCache = () => { 65 | rangeCache = {} 66 | } 67 | 68 | return { 69 | performInterpolation, 70 | purgeRangeCache 71 | } 72 | })() 73 | 74 | const { getCurrentScript, getScriptParameters } = (() => { 75 | const toFlagMap = (string) => { 76 | return string 77 | .split(',') 78 | .filter(Boolean) 79 | .reduce((flags, key) => ({ 80 | ...flags, 81 | [key]: true 82 | }), {}) 83 | } 84 | 85 | const getCurrentScript = (scriptName) => { 86 | const scripts = Array.from(document.getElementsByTagName('script')) 87 | const currentScript = scripts.find( 88 | script => script.src.includes(`/${scriptName}`) 89 | ) 90 | return currentScript 91 | } 92 | 93 | const getScriptParameters = (currentScript) => { 94 | const query = currentScript.src.split('?').pop().split('&') 95 | const parameters = query.reduce((params, part) => { 96 | const [key, value] = part.split('=') 97 | return { 98 | ...params, 99 | [key]: value 100 | } 101 | }, {}) 102 | 103 | if (!parameters) { 104 | return undefined 105 | } 106 | 107 | if (parameters.report) { 108 | return { 109 | ...parameters, 110 | report: toFlagMap(parameters.report) 111 | } 112 | } 113 | 114 | return parameters 115 | } 116 | 117 | return { 118 | getCurrentScript, 119 | getScriptParameters 120 | } 121 | })() 122 | 123 | const validations = (() => { 124 | const reportVariable = (...args) => { 125 | if (!args) { 126 | throw new Error('options are mandatory.') 127 | } 128 | if (typeof args[0] === 'string') { 129 | if (typeof args[1] === 'undefined') { 130 | throw new Error('2nd argument is necessary when only the variable name is present') 131 | } 132 | if (typeof args[1] === 'object' && typeof args[1].value === 'undefined') { 133 | throw new Error('`value` is mandatory') 134 | } 135 | } else if (typeof args[0] === 'object') { 136 | if (!args[0].name || !args[0].name.trim()) { 137 | throw new Error('`name` is mandatory') 138 | } 139 | if (typeof args[0].value === 'undefined') { 140 | throw new Error('`value` is mandatory') 141 | } 142 | } else { 143 | throw new Error('First argument must be a string or an object.') 144 | } 145 | } 146 | 147 | const reportPageScroll = (...args) => { 148 | const { direction, interpolations } = args[0] 149 | if (interpolations) { 150 | if (!direction) { 151 | throw new Error('"direction" must be provided for interpolations') 152 | } 153 | if (direction !== 'horizontal' && direction !== 'vertical') { 154 | throw new Error('"direction" can be only "horizontal" or "vertical".') 155 | } 156 | } 157 | } 158 | 159 | const reportScroll = (...args) => { 160 | if (!args[0]) { 161 | throw new Error('First argument must be name or options') 162 | } 163 | let direction 164 | if (typeof args[1] === 'string') { 165 | direction = args[1] 166 | } else if (typeof args[1] === 'object' && args[1].direction) { 167 | direction = args[1].direction 168 | } 169 | if (direction && direction !== 'horizontal' && direction !== 'vertical') { 170 | throw new Error('"direction" can be only "horizontal" or "vertical".') 171 | } 172 | } 173 | 174 | return { 175 | reportVariable, 176 | reportPageScroll, 177 | reportScroll 178 | } 179 | })() 180 | 181 | const setCSSProperty = (key, value, element = window.document.documentElement) => { 182 | element.style.setProperty(key, value) 183 | } 184 | 185 | const reportResponsiveVariable = (name, valueFn, scope) => { 186 | window.addEventListener('resize', () => setCSSProperty(name, valueFn(), scope)) 187 | window.addEventListener('orientationchange', () => setCSSProperty(name, valueFn(), scope)) 188 | setCSSProperty(name, valueFn(), scope) 189 | } 190 | 191 | const reportPageCursor = (event) => { 192 | let x, y 193 | if (event.touches) { 194 | x = event.touches.item(0).clientX 195 | y = event.touches.item(0).clientY 196 | } else { 197 | x = event.x 198 | y = event.y 199 | } 200 | setCSSProperty('--cursor-x', `${x}px`) 201 | setCSSProperty('--cursor-y',`${y}px`) 202 | 203 | const { innerWidth, innerHeight } = window 204 | 205 | setCSSProperty('--cursor-x-1', x / innerWidth) 206 | setCSSProperty('--cursor-y-1', y / innerHeight) 207 | } 208 | 209 | const reportPageScroll = ({ direction, interpolations }) => () => { 210 | validations.reportPageScroll({ direction, interpolations }) 211 | 212 | const { scrollTop, scrollLeft } = document.documentElement 213 | setCSSProperty('--scroll-x', scrollLeft) 214 | setCSSProperty('--scroll-y', scrollTop) 215 | 216 | const { scrollWidth, scrollHeight } = document.documentElement 217 | const { innerWidth, innerHeight } = window 218 | setCSSProperty('--scroll-x-1', scrollLeft / (scrollWidth - innerWidth)) 219 | setCSSProperty('--scroll-y-1', scrollTop / (scrollHeight - innerHeight)) 220 | 221 | if (interpolations) { 222 | interpolations.forEach((interpolation, index) => { 223 | const { interpolationName, interpolated, scope } = performInterpolation({ 224 | interpolation, 225 | id: index, 226 | value: direction === 'horizontal' ? scrollLeft : scrollTop 227 | }) 228 | setCSSProperty(interpolationName, interpolated, scope) 229 | }) 230 | } 231 | } 232 | 233 | const reportScroll = (...args) => (event) => { 234 | validations.reportScroll(...args) 235 | 236 | let name 237 | let direction = 'vertical' 238 | let interpolations 239 | if (typeof args[0] === 'string') { 240 | name = args[0] 241 | 242 | if (typeof args[1] === 'string') { 243 | direction = args[1] 244 | } else if (typeof args[1] === 'object') { 245 | direction = args[1].direction || direction 246 | interpolations = args[1].interpolations 247 | } 248 | 249 | } else if (typeof args[0] === 'object') { 250 | name = args[0].name 251 | direction = args[0].direction 252 | interpolations = args[0].interpolations 253 | } 254 | 255 | const { target } = event 256 | let absoluteScroll 257 | let targetScrollSize 258 | let targetSize 259 | if (direction === 'horizontal') { 260 | absoluteScroll = target.scrollLeft 261 | targetScrollSize = target.scrollWidth 262 | targetSize = target.clientWidth 263 | } else if (direction === 'vertical') { 264 | absoluteScroll = target.scrollTop 265 | targetScrollSize = target.scrollHeight 266 | targetSize = target.clientHeight 267 | } 268 | setCSSProperty(name, absoluteScroll) 269 | setCSSProperty(`${name}-1`, absoluteScroll / (targetScrollSize - targetSize)) 270 | 271 | if (interpolations) { 272 | interpolations.forEach((interpolation, index) => { 273 | const { interpolationName, interpolated, scope } = performInterpolation({ 274 | interpolation, 275 | id: index, 276 | value: absoluteScroll 277 | }) 278 | setCSSProperty(interpolationName, interpolated, scope) 279 | }) 280 | } 281 | } 282 | 283 | const reportVariable = (...args) => { 284 | validations.reportVariable(...args) 285 | let name 286 | let value 287 | let scope 288 | if (typeof args[0] === 'string') { 289 | name = args[0] 290 | if (typeof args[1] === 'function') { 291 | return reportResponsiveVariable(name, args[1]) 292 | } 293 | if (typeof args[1] === 'object') { 294 | scope = args[1].scope 295 | value = args[1].value 296 | if (typeof args[1].value === 'function') { 297 | return reportResponsiveVariable(name, args[1].value, scope) 298 | } 299 | } else { 300 | value = args[1] 301 | } 302 | setCSSProperty(name, value, scope) 303 | 304 | } else if (typeof args[0] === 'object') { 305 | name = args[0].name 306 | value = args[0].value 307 | scope = args[0].scope 308 | if (typeof value === 'function') { 309 | return reportResponsiveVariable(name, value, scope) 310 | } 311 | setCSSProperty(name, value, scope) 312 | } 313 | } 314 | 315 | const reportIndex = (selector, { 316 | indexVariableName = '--index', 317 | rowIndexVariableName = '--row-index', 318 | rowIndexBy 319 | } = { 320 | indexVariableName: '--index', 321 | rowIndexVariableName: '--row-index' 322 | }) => { 323 | const elements = Array.from(document.querySelectorAll(selector)) 324 | elements.forEach((element, index) => { 325 | setCSSProperty(indexVariableName, index, element) 326 | if (typeof rowIndexBy === 'number') { 327 | const rowIndex = Math.floor(index / rowIndexBy) 328 | setCSSProperty(rowIndexVariableName, rowIndex, element) 329 | } 330 | }) 331 | } 332 | 333 | const cursor = () => { 334 | window.addEventListener('mousemove', reportPageCursor) 335 | window.addEventListener('touchmove', reportPageCursor) 336 | reportPageCursor({ x: 0, y: 0 }) 337 | } 338 | 339 | const time = () => { 340 | const reportSeconds = () => { 341 | const seconds = (Date.now() - start) / 1000 342 | reportVariable('--seconds', seconds) 343 | } 344 | 345 | const reportMilliseconds = () => { 346 | const milliseconds = (Date.now() - start) 347 | reportVariable('--milliseconds', milliseconds) 348 | millisecondsLoop = requestAnimationFrame(reportMilliseconds) 349 | } 350 | 351 | let start = Date.now() 352 | let secondsLoop = window.setInterval(reportSeconds, 1000) 353 | let millisecondsLoop = requestAnimationFrame(reportMilliseconds) 354 | 355 | return { 356 | clear() { 357 | window.clearInterval(secondsLoop) 358 | window.cancelAnimationFrame(millisecondsLoop) 359 | } 360 | } 361 | } 362 | 363 | const reportGlobals = ({ scroll, cursor } = { scroll: true, cursor: true }) => { 364 | if (cursor) { 365 | window.addEventListener('mousemove', reportPageCursor) 366 | window.addEventListener('touchmove', reportPageCursor) 367 | reportPageCursor({ x: 0, y: 0 }) 368 | } 369 | if (scroll) { 370 | let interpolations = scroll.interpolations 371 | let direction = scroll.direction 372 | window.addEventListener('scroll', reportPageScroll({ 373 | direction, 374 | interpolations 375 | })) 376 | window.addEventListener('resize', (e) => { 377 | purgeRangeCache() 378 | reportPageScroll({ 379 | direction, 380 | interpolations 381 | })(e) 382 | }) 383 | reportPageScroll({ 384 | direction, 385 | interpolations 386 | })() 387 | } 388 | } 389 | 390 | const currentScript = getCurrentScript('kicss.js') 391 | if (currentScript) { 392 | const queryParameters = getScriptParameters(currentScript) 393 | if (queryParameters && queryParameters.report) { 394 | const globalsToReport = queryParameters.report 395 | reportGlobals(globalsToReport) 396 | } 397 | window.kicss = { 398 | reportScroll, 399 | reportVariable, 400 | reportIndex, 401 | reportGlobals, 402 | cursor, 403 | time 404 | } 405 | } 406 | 407 | export { 408 | reportScroll, 409 | reportVariable, 410 | reportIndex, 411 | reportGlobals, 412 | cursor, 413 | time 414 | } 415 | 416 | export default { 417 | reportScroll, 418 | reportVariable, 419 | reportIndex, 420 | reportGlobals, 421 | cursor, 422 | time 423 | } 424 | --------------------------------------------------------------------------------