├── .gitmodules ├── .nvmrc ├── data ├── .keep └── raw │ └── .keep ├── .eslintignore ├── favicon.png ├── .sassrc.json ├── .prettierrc ├── images ├── opengraph_banner.png ├── refine_icon.svg └── typesense.svg ├── .env.example ├── .editorconfig ├── scripts ├── utils │ ├── path.mjs │ └── network.mjs ├── fetchExplainXkcdData.mjs ├── fetchXkcdData.mjs ├── transformData.mjs └── indexData.js ├── manifest.webmanifest ├── .gitignore ├── .eslintrc.js ├── src ├── utils │ └── stop_words.json ├── app.scss ├── index.scss ├── bootstrap.scss └── app.js ├── package.json ├── .github └── workflows │ └── refreshData.yml ├── README.md ├── index.html └── LICENSE /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/raw/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /.cache 4 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typesense/showcase-xkcd-search/HEAD/favicon.png -------------------------------------------------------------------------------- /.sassrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "quietDeps": true, 3 | "silenceDeprecations": ["import"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "proseWrap": "never", 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /images/opengraph_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typesense/showcase-xkcd-search/HEAD/images/opengraph_banner.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TYPESENSE_HOST=localhost 2 | TYPESENSE_PORT=8108 3 | TYPESENSE_PROTOCOL=http 4 | TYPESENSE_ADMIN_API_KEY=xyz 5 | TYPESENSE_SEARCH_ONLY_API_KEY=xyz 6 | TYPESENSE_COLLECTION_NAME=commits 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /scripts/utils/path.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import path from 'path'; 3 | 4 | const __filename = fileURLToPath(import.meta.url); 5 | const __dirname = path.dirname(__filename); 6 | 7 | export const DATA_DIR = path.resolve(__dirname, '../../data/raw'); 8 | -------------------------------------------------------------------------------- /images/refine_icon.svg: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "xkcd-search", 3 | "name": "xkcd-search", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | /.cache 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | /data/* 26 | !/data/.keep 27 | !/data/raw/.keep 28 | 29 | /.parcel-cache -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'eslint:recommended', 3 | plugins: ['prettier'], 4 | rules: { 5 | //https://stackoverflow.com/a/53769213 6 | 'prettier/prettier': [ 7 | 'error', 8 | { 9 | endOfLine: 'auto', 10 | }, 11 | ], 12 | }, 13 | parser: 'babel-eslint', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | modules: true, 18 | }, 19 | ecmaVersion: 2020, 20 | sourceType: 'module', 21 | useJSXTextNode: true, 22 | }, 23 | root: true, 24 | env: { 25 | browser: true, 26 | es6: true, 27 | node: true, 28 | commonjs: true, 29 | }, 30 | globals: { 31 | $: true, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/stop_words.json: -------------------------------------------------------------------------------- 1 | [ 2 | "a", 3 | "am", 4 | "an", 5 | "and", 6 | "as", 7 | "at", 8 | "by", 9 | "c's", 10 | "co", 11 | "do", 12 | "eg", 13 | "et", 14 | "for", 15 | "he", 16 | "hi", 17 | "i", 18 | "i'd", 19 | "i'm", 20 | "ie", 21 | "if", 22 | "in", 23 | "inc", 24 | "is", 25 | "it", 26 | "its", 27 | "me", 28 | "my", 29 | "nd", 30 | "no", 31 | "non", 32 | "nor", 33 | "not", 34 | "of", 35 | "off", 36 | "oh", 37 | "ok", 38 | "on", 39 | "or", 40 | "per", 41 | "que", 42 | "qv", 43 | "rd", 44 | "re", 45 | "so", 46 | "sub", 47 | "t's", 48 | "th", 49 | "the", 50 | "to", 51 | "too", 52 | "two", 53 | "un", 54 | "up", 55 | "us", 56 | "vs", 57 | "we" 58 | ] 59 | -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | @use 'bootstrap' as bs; 2 | html { 3 | scroll-behavior: smooth; 4 | } 5 | 6 | // Flush footer 7 | html { 8 | position: relative; 9 | min-height: 100%; 10 | } 11 | 12 | body { 13 | margin-bottom: 50px; // Controls spacing from content to footer 14 | } 15 | 16 | footer.navbar { 17 | position: absolute; 18 | bottom: 0; 19 | margin-top: 20px; 20 | 21 | background-color: bs.$gray-300; 22 | color: bs.$navbar-light-color; 23 | 24 | .navbar-text a { 25 | color: bs.$navbar-light-color; 26 | 27 | &:hover { 28 | color: bs.$link-color; 29 | } 30 | } 31 | } 32 | 33 | // End flush footer 34 | 35 | body { 36 | letter-spacing: 0.02rem; 37 | } 38 | 39 | input[type='search']::-webkit-search-cancel-button { 40 | display: none; 41 | } 42 | 43 | input[type='search']:focus::-webkit-search-cancel-button { 44 | display: none; 45 | } 46 | 47 | .ais-CurrentRefinements-item { 48 | .ais-CurrentRefinements-label, 49 | .ais-CurrentRefinements-category { 50 | display: block; 51 | text-align: left; 52 | } 53 | 54 | .ais-CurrentRefinements-label { 55 | padding-bottom: 0.5rem; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | 3 | @use 'bootstrap'; 4 | @use 'app'; 5 | 6 | body * { 7 | //border: 1px solid red; 8 | } 9 | .search-result-card { 10 | min-height: 100px; 11 | 12 | @media (min-width: 768px) { 13 | min-height: 175px; 14 | } 15 | } 16 | 17 | @media (min-width: 768px) { 18 | #filters-section.d-md-block { 19 | display: block !important; 20 | } 21 | } 22 | 23 | a.clickable-search-term { 24 | color: unset; 25 | text-decoration-line: underline; 26 | text-decoration-color: bootstrap.$gray-500; 27 | -webkit-text-decoration-line: underline; 28 | -webkit-text-decoration-color: bootstrap.$gray-500; 29 | cursor: pointer; 30 | 31 | &:hover { 32 | color: color.adjust(bootstrap.$link-color, $lightness: -50%, $space: hsl); 33 | } 34 | } 35 | 36 | mark { 37 | color: unset; 38 | background-color: rgba(bootstrap.$gray-200, 0.7); 39 | padding: 0 0.08em; 40 | } 41 | 42 | .ais-SearchBox-loadingIndicator { 43 | position: absolute; 44 | top: 21px; 45 | right: 60px; 46 | } 47 | 48 | @media (max-width: 1200px) { 49 | h1, 50 | .h1 { 51 | font-size: calc(1.6rem + 1.2vw); 52 | } 53 | } 54 | 55 | #sort-by { 56 | max-width: 185px; 57 | } 58 | 59 | .ais-RefinementList-searchBox { 60 | max-width: 250px; 61 | } 62 | -------------------------------------------------------------------------------- /scripts/utils/network.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | const SCRAPE_REQUEST_BATCH_SIZE = parseInt( 4 | process.env.SCRAPE_REQUEST_BATCH_SIZE || '52' 5 | ); 6 | 7 | // https://www.codewithyou.com/blog/how-to-implement-retry-with-exponential-backoff-in-nodejs 8 | export function exponentialBackoffRetry( 9 | fn, 10 | { maxAttempts = 5, baseDelayMs = 1000, callback = () => {} } 11 | ) { 12 | let attempt = 1; 13 | 14 | const execute = async () => { 15 | try { 16 | return await fn(); 17 | } catch (error) { 18 | if (attempt >= maxAttempts) { 19 | throw error; 20 | } 21 | 22 | const delayMs = baseDelayMs * 2 ** attempt; 23 | callback({ attempt, delayMs }); 24 | await new Promise((resolve) => setTimeout(resolve, delayMs)); 25 | 26 | attempt++; 27 | return execute(); 28 | } 29 | }; 30 | 31 | return execute(); 32 | } 33 | 34 | export class BatchAPICall { 35 | constructor(batchSize = SCRAPE_REQUEST_BATCH_SIZE) { 36 | this.batchSize = batchSize; 37 | } 38 | requestList = []; 39 | 40 | async makeRequests() { 41 | if (this.requestList.length === 0) return console.log('No requests!'); 42 | 43 | for (let i = 0; i <= this.requestList.length / this.batchSize + 1; i++) { 44 | const result = await Promise.all( 45 | this.requestList 46 | .slice((i === 0 ? 0 : i - 1) * this.batchSize, i * this.batchSize) 47 | .map((fn) => fn()) 48 | ); 49 | console.log(result); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "showcase-xkcd-search", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "parcel index.html --port 3000", 7 | "lint": "eslint .", 8 | "lint:fix": "npm run lint -- --fix", 9 | "typesenseServer": "docker run -i -p 8108:8108 -v/tmp/typesense-server-data/:/data typesense/typesense:0.20.0 --data-dir /data --api-key=xyz --listen-port 8108 --enable-cors", 10 | "fetchData": "node scripts/fetchExplainXkcdData.mjs && node scripts/fetchXkcdData.mjs", 11 | "transformData": "node scripts/transformData.mjs", 12 | "indexData": "node scripts/indexData.js", 13 | "refreshData": "yarn transformData && yarn indexData", 14 | "build": "parcel build index.html --public-url https://findxkcd.com" 15 | }, 16 | "engines": { 17 | "node": ">=20.0" 18 | }, 19 | "devDependencies": { 20 | "@parcel/packager-raw-url": "2.13.2", 21 | "@parcel/transformer-sass": "2.13.2", 22 | "@parcel/transformer-webmanifest": "2.13.2", 23 | "babel-eslint": "10.1.0", 24 | "buffer": "^5.5.0||^6.0.0", 25 | "eslint": "9.16.0", 26 | "eslint-config-prettier": "9.1.0", 27 | "eslint-plugin-import": "2.31.0", 28 | "eslint-plugin-prettier": "5.2.1", 29 | "parcel": "^2.13.2", 30 | "prettier": "3.4.2", 31 | "process": "^0.11.10", 32 | "sass": "^1.83.0", 33 | "svgo": "^3" 34 | }, 35 | "dependencies": { 36 | "@babel/runtime": "^7.14.0", 37 | "@popperjs/core": "^2.9.2", 38 | "bootstrap": "4.5.2", 39 | "cheerio": "^1.0.0-rc.10", 40 | "copy-to-clipboard": "^3.3.1", 41 | "dotenv": "^16.4.7", 42 | "instantsearch.js": "^4.75.6", 43 | "jquery": "^3.6.0", 44 | "lodash": "^4.17.21", 45 | "luxon": "^3.5.0", 46 | "node-fetch": "^3.0.0", 47 | "popper.js": "^1.16.1", 48 | "typesense": "^1.8.2", 49 | "typesense-instantsearch-adapter": "^2.8.0" 50 | }, 51 | "browserslist": [ 52 | "last 2 versions", 53 | "not dead" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /scripts/fetchExplainXkcdData.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import fetch from 'node-fetch'; 3 | import fs from 'fs'; 4 | import { DATA_DIR } from './utils/path.mjs'; 5 | import { exponentialBackoffRetry, BatchAPICall } from './utils/network.mjs'; 6 | 7 | let response; 8 | 9 | response = await fetch('https://xkcd.com/info.0.json'); 10 | const latestComicId = (await response.json())['num']; 11 | const comicIds = [...Array(latestComicId + 1).keys()].slice(1); 12 | const batchAPICall = new BatchAPICall(); 13 | 14 | for await (const comicId of comicIds) { 15 | const filePath = `${DATA_DIR}/${comicId}.html`; 16 | if (fs.existsSync(filePath)) { 17 | console.log(`Explanation for comic ${comicId} already exists. Skipping.`); 18 | } else { 19 | const request = async () => { 20 | console.log(`Fetching explanation for comic ${comicId}.`); 21 | 22 | const fetchExplanation = async () => { 23 | const res = await fetch( 24 | `https://www.explainxkcd.com/wiki/index.php/${comicId}` 25 | ); 26 | if (!res.ok) throw new Error('Request failed!'); 27 | return res; 28 | }; 29 | 30 | try { 31 | const response = await exponentialBackoffRetry(fetchExplanation, { 32 | callback: ({ attempt, delayMs }) => 33 | console.log( 34 | `Retry fetching explanation for comic ${comicId}: attempt ${attempt} after ${delayMs}ms` 35 | ), 36 | }); 37 | // 🙏 https://stackoverflow.com/a/51302466/123545 38 | const fileStream = fs.createWriteStream(filePath); 39 | await new Promise((resolve, reject) => { 40 | response.body.pipe(fileStream); 41 | response.body.on('error', reject); 42 | fileStream.on('finish', resolve); 43 | }); 44 | return `Explanation ${comicId} success`; 45 | } catch (error) { 46 | console.warn(`Error fetching explanation for comic ${comicId}`); 47 | return `Explanation ${comicId} failed`; 48 | } 49 | }; 50 | batchAPICall.requestList.push(request); 51 | } 52 | } 53 | 54 | batchAPICall.makeRequests(); 55 | -------------------------------------------------------------------------------- /scripts/fetchXkcdData.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import fetch from 'node-fetch'; 3 | import fs from 'fs'; 4 | import { DATA_DIR } from './utils/path.mjs'; 5 | import { exponentialBackoffRetry, BatchAPICall } from './utils/network.mjs'; 6 | 7 | let response; 8 | 9 | response = await fetch('https://xkcd.com/info.0.json'); 10 | const latestComicId = (await response.json())['num']; 11 | const comicIds = [...Array(latestComicId + 1).keys()].slice(1); 12 | 13 | const batchAPICall = new BatchAPICall(); 14 | 15 | for await (const comicId of comicIds) { 16 | const filePath = `${DATA_DIR}/${comicId}.json`; 17 | if (fs.existsSync(filePath)) { 18 | console.log(`Comic ${comicId} already exists. Skipping.`); 19 | continue; 20 | } 21 | if (comicId === 404) { 22 | // id 404 is an April fools joke 23 | const writer = fs.createWriteStream(filePath); 24 | writer.write('{}'); 25 | continue; 26 | } 27 | const request = async () => { 28 | const fetchInfo = async () => { 29 | console.log(`Fetching info for comic ${comicId}.`); 30 | const res = await fetch(`https://xkcd.com/${comicId}/info.0.json`); 31 | 32 | if (!res.ok) throw new Error('Request failed!'); 33 | return res; 34 | }; 35 | 36 | try { 37 | const response = await exponentialBackoffRetry(fetchInfo, { 38 | callback: ({ attempt, delayMs }) => 39 | console.log( 40 | `Retry fetching info for comic ${comicId}: attempt ${attempt} after ${delayMs}ms` 41 | ), 42 | }); 43 | // 🙏 https://stackoverflow.com/a/51302466/123545 44 | const fileStream = fs.createWriteStream(filePath); 45 | await new Promise((resolve, reject) => { 46 | response.body.pipe(fileStream); 47 | response.body.on('error', reject); 48 | fileStream.on('finish', resolve); 49 | }); 50 | return `Comic info ${comicId} success`; 51 | } catch (error) { 52 | console.warn(`Error fetching explanation for comic ${comicId}`); 53 | return `Comic info ${comicId} failed`; 54 | } 55 | }; 56 | 57 | batchAPICall.requestList.push(request); 58 | } 59 | 60 | batchAPICall.makeRequests(); 61 | -------------------------------------------------------------------------------- /.github/workflows/refreshData.yml: -------------------------------------------------------------------------------- 1 | name: Refresh xkcd data 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version-file: '.nvmrc' 20 | cache: yarn 21 | cache-dependency-path: yarn.lock 22 | 23 | - name: Install dependencies 24 | run: yarn install --frozen-lockfile 25 | 26 | - name: Download xkcd cache 27 | uses: dawidd6/action-download-artifact@v2 28 | with: 29 | name: xkcd-cache-v2 30 | search_artifacts: true 31 | workflow_conclusion: "" 32 | if_no_artifact_found: warn 33 | 34 | - name: Uncompress xkcd cache 35 | run: | 36 | tar_file="xkcd-cache.tar.gz" && \ 37 | [ -f "$tar_file" ] && \ 38 | tar -xzvf "$tar_file" -C data/raw && \ 39 | rm xkcd-cache.tar.gz 40 | exit 0 41 | 42 | - name: Run script 43 | env: 44 | TYPESENSE_HOST: ${{ vars.TYPESENSE_HOST }} 45 | TYPESENSE_PORT: ${{ vars.TYPESENSE_PORT }} 46 | TYPESENSE_PROTOCOL: ${{ vars.TYPESENSE_PROTOCOL }} 47 | TYPESENSE_ADMIN_API_KEY: ${{ secrets.TYPESENSE_ADMIN_API_KEY }} 48 | run: | 49 | find data/raw -name "*.html" -type f -exec grep -l "explanation may be incomplete" {} + | xargs -r rm 50 | yarn fetchData 51 | find data/raw -name "*.html" -type f -exec grep -l "server is temporarily unable" {} + | xargs -r rm 52 | find data/raw -name "*.json" -type f -exec grep -l "" {} + | xargs -r rm 53 | yarn refreshData 54 | 55 | - name: Compress xkcd cache 56 | if: success() || failure() 57 | run: | 58 | tar -czvf xkcd-cache.tar.gz -C data/raw . 59 | 60 | - name: Save xkcd cache 61 | uses: actions/upload-artifact@v4 62 | if: always() 63 | with: 64 | name: xkcd-cache-v2 65 | path: xkcd-cache.tar.gz 66 | if-no-files-found: warn 67 | retention-days: 7 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xkcd search 2 | 3 | This is a demo that showcases some of Typesense's features using [xkcd](https://xkcd.com/) comics and metadata from [explain xkcd](https://www.explainxkcd.com/). 4 | 5 | View it live here: https://findxkcd.com/ 6 | 7 | # Tech Stack 8 | 9 | This search experience is powered by Typesense which is a fast, open source typo-tolerant search-engine. It is an open source alternative to Algolia and an easier-to-use alternative to ElasticSearch. 10 | 11 | The app was built using the [Typesense Adapter for InstantSearch.js](https://github.com/typesense/typesense-instantsearch-adapter) and is hosted on Cloudflare Pages. 12 | 13 | The search/browsing backend is powered by a geo-distributed 3-node Typesense cluster running on [Typesense Cloud](https://cloud.typesense.org), with nodes in Oregon, Frankfurt and Mumbai. 14 | 15 | 16 | ## Repo structure 17 | 18 | - `src/` and `index.html` - contain the frontend UI components, built with Typesense Adapter for InstantSearch.js 19 | - `scripts/` - contains the scripts to extract, transform and index the data into Typesense. 20 | 21 | ## Development 22 | 23 | 1. Create a `.env` file using `.env.example` as reference. 24 | 25 | 2. Fetch Data 26 | 27 | ```shell 28 | mkdir -p data/raw 29 | yarn fetchData 30 | ``` 31 | 32 | 3. Transform and index the data 33 | ```shell 34 | yarn transformData 35 | yarn indexData 36 | ``` 37 | 38 | 4. Install dependencies and run the local server: 39 | 40 | ```shell 41 | yarn 42 | yarn start 43 | ``` 44 | 45 | Open http://localhost:3000 to see the app. 46 | 47 | ## Update data 48 | 49 | ```shell 50 | # Delete cached files that might not have had explanations during previous run 51 | find data/raw -name "*.html" -type f -exec grep -l "explanation may be incomplete" {} + | xargs -r rm 52 | yarn fetchData 53 | 54 | # Handle 503s 55 | find data/raw -name "*.html" -type f -exec grep -l "server is temporarily unable" {} + | xargs -r rm 56 | find data/raw -name "*.json" -type f -exec grep -l "" {} + | xargs -r rm 57 | 58 | # Refresh and index 59 | yarn refreshData 60 | ``` 61 | 62 | ## Deployment 63 | 64 | The app is hosted on Cloudflare Pages and is set to auto-deploy on git push 65 | -------------------------------------------------------------------------------- /src/bootstrap.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @import url('https://fonts.googleapis.com/css2?family=Arimo:ital,wght@0,400;0,500;1,400;1,500&family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); 3 | $font-family-sans-serif: 'Barlow', sans-serif; 4 | 5 | $primary: #6e7b91; 6 | $secondary: #330c2f; 7 | $gray-100: #e2e5e9; 8 | $gray-200: #c5cad3; 9 | $gray-300: #a8b0bd; 10 | $gray-400: #8b95a7; 11 | $gray-500: #6e7b91; 12 | $gray-600: #586274; 13 | $gray-700: #424a57; 14 | $gray-800: #2c313a; 15 | $gray-900: #16191d; 16 | $black: #0b0c0e; 17 | 18 | $text-muted: $gray-500; 19 | 20 | $link-decoration: underline; 21 | $link-color: $gray-500; 22 | 23 | $input-font-weight: 400; 24 | $input-border-width: 2px; 25 | $input-border-color: color.adjust($black, $lightness: 10%, $space: hsl); 26 | $input-focus-border-color: $black; 27 | $input-btn-focus-box-shadow: 0 0 15px 0.2em rgba($input-focus-border-color, 0.1); 28 | 29 | $component-active-bg: $secondary; 30 | $custom-select-color: $gray-800; 31 | $custom-select-bg: $gray-100; 32 | $custom-select-border-width: 0; 33 | $input-btn-padding-x-sm: 0.9rem; 34 | $input-btn-padding-y-sm: 0.4rem; 35 | 36 | $input-padding-x: 1.3rem; 37 | $input-padding-y: 0.8rem; 38 | 39 | $font-size-base: 0.95rem; 40 | $input-font-size: 1.4rem; 41 | 42 | $input-font-weight: 400; 43 | $headings-font-weight: 700; 44 | $display1-weight: 400; 45 | $display2-weight: 400; 46 | $display3-weight: 400; 47 | $display4-weight: 400; 48 | 49 | $border-radius: 0px; 50 | 51 | $badge-font-size: 75%; 52 | $badge-font-weight: 300; 53 | $badge-pill-padding-x: 1.5em; 54 | $badge-padding-y: 0.75em; 55 | 56 | $enable-rounded: false; 57 | $enable-responsive-font-sizes: true; 58 | 59 | $link-hover-color: color.adjust($link-color, $lightness: -50%, $space: hsl); 60 | 61 | @import '~bootstrap/scss/bootstrap'; 62 | 63 | .font-letter-spacing-tight { 64 | letter-spacing: -0.05rem; 65 | } 66 | 67 | input[type='search'] { 68 | letter-spacing: -0.05em; 69 | } 70 | 71 | a { 72 | text-decoration: underline !important; 73 | } 74 | 75 | .text-dark-2 { 76 | color: $gray-600; 77 | } 78 | 79 | .navbar-toggler-icon { 80 | background-size: 75% 75%; 81 | } 82 | 83 | // For loading indicator svg 84 | .stroke-primary { 85 | stroke: $primary; 86 | } 87 | 88 | .border-width-2 { 89 | border-width: 2px; 90 | } 91 | 92 | .navbar-light .navbar-toggler-icon { 93 | background-image: url('~images/refine_icon.svg'); 94 | } 95 | -------------------------------------------------------------------------------- /scripts/transformData.mjs: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import fs from 'fs'; 3 | import { fileURLToPath } from 'url'; 4 | import path from 'path'; 5 | import { DateTime } from 'luxon'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const DATA_DIR = path.resolve(__dirname, '../data/raw'); 10 | const transformedDataWriteStream = fs.createWriteStream( 11 | path.resolve(DATA_DIR, '..', 'transformed_dataset.jsonl') 12 | ); 13 | 14 | const dir = fs.opendirSync(DATA_DIR); 15 | let dirent; 16 | while ((dirent = dir.readSync()) !== null) { 17 | if (!dirent.name.endsWith('.html')) { 18 | continue; 19 | } 20 | console.log(`Transforming ${dirent.name}`); 21 | const explainXkcdFileContents = fs 22 | .readFileSync(path.resolve(DATA_DIR, dirent.name)) 23 | .toString(); 24 | const $ = cheerio.load(explainXkcdFileContents); 25 | const [id, title] = $('#firstHeading').text().split(': '); 26 | 27 | const xkcdInfoContents = fs 28 | .readFileSync(path.resolve(DATA_DIR, `${id}.json`)) 29 | .toString(); 30 | let transcript = ''; 31 | 32 | // Read all text in
{{ query }}. Try another search term.', 228 | showMoreText: 'Show more comics', 229 | }, 230 | }), 231 | menu({ 232 | container: '#comic-publication-year', 233 | attribute: 'publishDateYear', 234 | sortBy: ['name:desc'], 235 | cssClasses: { 236 | list: 'list-unstyled', 237 | label: 'text-dark', 238 | link: 'text-decoration-none', 239 | count: 'badge text-dark-2 ml-2', 240 | selectedItem: 'bg-light pl-2', 241 | }, 242 | }), 243 | refinementList({ 244 | container: '#comic-topic', 245 | attribute: 'topics', 246 | searchable: true, 247 | searchablePlaceholder: 'search topics', 248 | showMore: true, 249 | limit: 10, 250 | showMoreLimit: 100, 251 | operator: 'and', 252 | cssClasses: { 253 | searchableInput: 'form-control form-control-sm mb-2', 254 | searchableSubmit: 'd-none', 255 | searchableReset: 'd-none', 256 | showMore: 'btn btn-secondary btn-sm', 257 | list: 'list-unstyled', 258 | count: 'badge text-dark-2 ml-2', 259 | label: 'd-flex align-items-center mb-1', 260 | checkbox: 'mr-2', 261 | }, 262 | }), 263 | configure({ 264 | hitsPerPage: 5, 265 | }), 266 | sortBy({ 267 | container: '#sort-by', 268 | items: [ 269 | { 270 | label: 'relevancy', 271 | value: `${INDEX_NAME}/sort/_text_match(buckets: 10):desc,publishDateTimestamp:desc`, 272 | }, 273 | { 274 | label: 'recent first', 275 | value: `${INDEX_NAME}/sort/publishDateTimestamp:desc`, 276 | }, 277 | { 278 | label: 'oldest first', 279 | value: `${INDEX_NAME}/sort/publishDateTimestamp:asc`, 280 | }, 281 | ], 282 | cssClasses: { 283 | select: 'custom-select custom-select-sm', 284 | }, 285 | }), 286 | ]); 287 | 288 | search.start(); 289 | 290 | search.on('render', function () { 291 | // Copy-to-Clipboard event handler 292 | $('.btn-copy-to-clipboard').on('click', handleCopyToClipboard); 293 | $('.topic').on('click', handleTopicClick); 294 | }); 295 | 296 | function handleSearchTermClick(event) { 297 | const $searchBox = $('#searchbox input[type=search]'); 298 | search.helper.clearRefinements(); 299 | $searchBox.val(event.currentTarget.textContent); 300 | $searchBox.trigger('change'); 301 | search.helper.setQuery($searchBox.val()).search(); 302 | return false; 303 | } 304 | 305 | function handleTopicClick(event) { 306 | search.helper.clearRefinements(); 307 | search.renderState[INDEX_NAME].refinementList.topics.refine( 308 | event.currentTarget.textContent 309 | ); 310 | setTimeout(() => { 311 | $('html, body').animate( 312 | { 313 | scrollTop: $('#searchbox-container').offset().top, 314 | }, 315 | 200 316 | ); 317 | }, 200); 318 | return false; 319 | } 320 | 321 | function handleCopyToClipboard() { 322 | copyToClipboard($(this).data('link'), { 323 | debug: true, 324 | message: 'Press #{key} to copy', 325 | }); 326 | 327 | $(this).text('Done'); 328 | 329 | setTimeout(() => { 330 | $(this).text('Copy to clipboard'); 331 | }, 2000); 332 | 333 | return false; 334 | } 335 | 336 | $(async function () { 337 | const $searchBox = $('#searchbox input[type=search]'); 338 | 339 | // Handle example search terms 340 | $('.clickable-search-term').on('click', handleSearchTermClick); 341 | }); 342 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
89 | 104 | about 107 | • 108 | source code 113 |
114 |