├── .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 | 2 | 3 | 4 | 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
elements after h1#Transcript 33 | let currentDomElement = $('#Transcript').parent().next(); 34 | while ( 35 | currentDomElement.length > 0 && 36 | currentDomElement.prop('tagName') === 'DL' 37 | ) { 38 | transcript += 39 | currentDomElement 40 | .text() 41 | .replace(/^|\n\b.*?\b: /g, ' ') // Remove Speaker Names that have the pattern "Speaker: " since it throws off relevancy 42 | .replace(/\s*\[.*?\]\s*/g, '') // Remove explainers within [...] since it throws off relevancy 43 | .trim() + ' '; 44 | currentDomElement = currentDomElement.next(); 45 | } 46 | 47 | let xkcdInfo; 48 | if (id === '404') { 49 | xkcdInfo = { 50 | img: 'https://www.explainxkcd.com/wiki/images/9/92/not_found.png', 51 | month: '4', 52 | year: '2008', 53 | day: '1', 54 | alt: '404 Not Found', 55 | }; 56 | } else { 57 | xkcdInfo = JSON.parse(xkcdInfoContents); 58 | } 59 | 60 | const altTitle = xkcdInfo['alt']; 61 | const publishDateObject = DateTime.local( 62 | parseInt(xkcdInfo['year']), 63 | parseInt(xkcdInfo['month']), 64 | parseInt(xkcdInfo['day']) 65 | ); 66 | const publishDateYear = publishDateObject.year; 67 | const publishDateMonth = publishDateObject.month; 68 | const publishDateDay = publishDateObject.day; 69 | const publishDateTimestamp = publishDateObject.toSeconds(); 70 | const topics = $('#catlinks ul li a') 71 | .toArray() 72 | .map((e) => e.firstChild.nodeValue) 73 | .slice(4); // First 4 are not topics 74 | const normalizedTopics = topics.map((t) => 75 | t.replace(/^Comics featuring /g, '') 76 | ); 77 | 78 | const record = { 79 | id, 80 | title, 81 | transcript, 82 | altTitle, 83 | publishDateYear, 84 | publishDateMonth, 85 | publishDateDay, 86 | publishDateTimestamp, 87 | topics: normalizedTopics, 88 | imageUrl: xkcdInfo['img'], 89 | }; 90 | transformedDataWriteStream.write(JSON.stringify(record) + '\n'); 91 | } 92 | 93 | transformedDataWriteStream.end(); 94 | dir.closeSync(); 95 | -------------------------------------------------------------------------------- /scripts/indexData.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const _ = require('lodash'); 4 | 5 | const BATCH_SIZE = process.env.BATCH_SIZE || 500; 6 | const CHUNK_SIZE = process.env.CHUNK_SIZE || 3; 7 | const MAX_LINES = process.env.MAX_LINES || Infinity; 8 | const DATA_FILE = process.env.DATA_FILE || './data/transformed_dataset.jsonl'; 9 | 10 | const fs = require('fs'); 11 | const readline = require('readline'); 12 | const Typesense = require('typesense'); 13 | 14 | async function addRecordsToTypesense(records, typesense, collectionName) { 15 | try { 16 | const returnDataChunks = await Promise.all( 17 | _.chunk(records, Math.ceil(records.length / CHUNK_SIZE)).map( 18 | (recordsChunk) => { 19 | return typesense 20 | .collections(collectionName) 21 | .documents() 22 | .import(recordsChunk.join('\n')); 23 | } 24 | ) 25 | ); 26 | 27 | const failedItems = returnDataChunks 28 | .map((returnData) => 29 | returnData 30 | .split('\n') 31 | .map((r) => JSON.parse(r)) 32 | .filter((item) => item.success === false) 33 | ) 34 | .flat(); 35 | if (failedItems.length > 0) { 36 | throw new Error( 37 | `Error indexing items ${JSON.stringify(failedItems, null, 2)}` 38 | ); 39 | } 40 | } catch (error) { 41 | console.log(error); 42 | } 43 | } 44 | 45 | module.exports = (async () => { 46 | const typesense = new Typesense.Client({ 47 | nodes: [ 48 | { 49 | host: process.env.TYPESENSE_HOST, 50 | port: process.env.TYPESENSE_PORT, 51 | protocol: process.env.TYPESENSE_PROTOCOL, 52 | }, 53 | ], 54 | apiKey: process.env.TYPESENSE_ADMIN_API_KEY, 55 | connectionTimeoutSeconds: 2 * 60 * 60, 56 | }); 57 | 58 | const collectionName = `xkcd_${Date.now()}`; 59 | const schema = { 60 | name: collectionName, 61 | fields: [ 62 | { name: 'id', type: 'string' }, 63 | { name: 'title', type: 'string' }, 64 | { name: 'transcript', type: 'string' }, 65 | { name: 'altTitle', type: 'string' }, 66 | { name: 'publishDateYear', type: 'int32', facet: true }, 67 | { name: 'publishDateTimestamp', type: 'int64', facet: true }, 68 | { name: 'topics', type: 'string[]', facet: true }, 69 | { 70 | name: 'embedding', 71 | type: 'float[]', 72 | embed: { 73 | from: ['title', 'transcript', 'altTitle', 'topics'], 74 | model_config: { 75 | model_name: 'ts/e5-small-v2', 76 | }, 77 | }, 78 | }, 79 | // { name: 'imageUrl'}, 80 | ], 81 | default_sorting_field: 'publishDateTimestamp', 82 | }; 83 | 84 | console.log(`Populating new collection in Typesense ${collectionName}`); 85 | 86 | console.log('Creating schema: '); 87 | await typesense.collections().create(schema); 88 | 89 | console.log('Adding records: '); 90 | 91 | const fileStream = fs.createReadStream(DATA_FILE); 92 | const rl = readline.createInterface({ 93 | input: fileStream, 94 | crlfDelay: Infinity, 95 | }); 96 | 97 | let records = []; 98 | let currentLine = 0; 99 | for await (const line of rl) { 100 | currentLine += 1; 101 | records.push(line); 102 | if (currentLine % BATCH_SIZE === 0) { 103 | await addRecordsToTypesense(records, typesense, collectionName); 104 | console.log(` Lines upto ${currentLine} ✅`); 105 | records = []; 106 | } 107 | 108 | if (currentLine >= MAX_LINES) { 109 | break; 110 | } 111 | } 112 | 113 | if (records.length > 0) { 114 | await addRecordsToTypesense(records, typesense, collectionName); 115 | console.log(' Last Remaining Lines ✅'); 116 | } 117 | 118 | let oldCollectionName; 119 | try { 120 | oldCollectionName = (await typesense.aliases('xkcd').retrieve())[ 121 | 'collection_name' 122 | ]; 123 | } catch (error) { 124 | console.warn(error); 125 | } 126 | 127 | try { 128 | console.log(`Update alias xkcd -> ${collectionName}`); 129 | await typesense 130 | .aliases() 131 | .upsert('xkcd', { collection_name: collectionName }); 132 | 133 | if (oldCollectionName) { 134 | console.log(`Deleting old collection ${oldCollectionName}`); 135 | await typesense.collections(oldCollectionName).delete(); 136 | } 137 | } catch (error) { 138 | console.error(error); 139 | } 140 | 141 | // Add synonyms 142 | // console.log('Adding synonyms...'); 143 | // const synonyms = [ 144 | // { 145 | // synonyms: ['regex', 'regular expression', 'regular expression'], 146 | // }, 147 | // ]; 148 | // 149 | // for (const synonym of synonyms) { 150 | // await typesense 151 | // .collections('xkcd') 152 | // .synonyms() 153 | // .upsert(synonym.synonyms[0], synonym); 154 | // } 155 | // 156 | // console.log('✅'); 157 | })(); 158 | -------------------------------------------------------------------------------- /images/typesense.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import jQuery from 'jquery'; 2 | 3 | window.$ = jQuery; // workaround for https://github.com/parcel-bundler/parcel/issues/333 4 | 5 | import 'popper.js'; 6 | import 'bootstrap'; 7 | 8 | import instantsearch from 'instantsearch.js/es'; 9 | import { 10 | searchBox, 11 | infiniteHits, 12 | configure, 13 | stats, 14 | analytics, 15 | refinementList, 16 | menu, 17 | sortBy, 18 | } from 'instantsearch.js/es/widgets'; 19 | import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter'; 20 | import { SearchClient as TypesenseSearchClient } from 'typesense'; // To get the total number of docs 21 | import STOP_WORDS from './utils/stop_words.json'; 22 | import copyToClipboard from 'copy-to-clipboard'; 23 | 24 | let TYPESENSE_SERVER_CONFIG = { 25 | apiKey: process.env.TYPESENSE_SEARCH_ONLY_API_KEY, // Be sure to use an API key that only allows searches, in production 26 | nodes: [ 27 | { 28 | host: process.env.TYPESENSE_HOST, 29 | port: process.env.TYPESENSE_PORT, 30 | protocol: process.env.TYPESENSE_PROTOCOL, 31 | }, 32 | ], 33 | numRetries: 8, 34 | useServerSideSearchCache: true, 35 | }; 36 | 37 | // [2, 3].forEach(i => { 38 | // if (process.env[`TYPESENSE_HOST_${i}`]) { 39 | // TYPESENSE_SERVER_CONFIG.nodes.push({ 40 | // host: process.env[`TYPESENSE_HOST_${i}`], 41 | // port: process.env.TYPESENSE_PORT, 42 | // protocol: process.env.TYPESENSE_PROTOCOL, 43 | // }); 44 | // } 45 | // }); 46 | 47 | // Unfortunately, dynamic process.env keys don't work with parcel.js 48 | // So need to enumerate each key one by one 49 | 50 | if (process.env[`TYPESENSE_HOST_2`]) { 51 | TYPESENSE_SERVER_CONFIG.nodes.push({ 52 | host: process.env[`TYPESENSE_HOST_2`], 53 | port: process.env.TYPESENSE_PORT, 54 | protocol: process.env.TYPESENSE_PROTOCOL, 55 | }); 56 | } 57 | 58 | if (process.env[`TYPESENSE_HOST_3`]) { 59 | TYPESENSE_SERVER_CONFIG.nodes.push({ 60 | host: process.env[`TYPESENSE_HOST_3`], 61 | port: process.env.TYPESENSE_PORT, 62 | protocol: process.env.TYPESENSE_PROTOCOL, 63 | }); 64 | } 65 | 66 | if (process.env[`TYPESENSE_HOST_NEAREST`]) { 67 | TYPESENSE_SERVER_CONFIG['nearestNode'] = { 68 | host: process.env[`TYPESENSE_HOST_NEAREST`], 69 | port: process.env.TYPESENSE_PORT, 70 | protocol: process.env.TYPESENSE_PROTOCOL, 71 | }; 72 | } 73 | 74 | const INDEX_NAME = process.env.TYPESENSE_COLLECTION_NAME; 75 | 76 | async function getIndexSize() { 77 | let typesenseSearchClient = new TypesenseSearchClient( 78 | TYPESENSE_SERVER_CONFIG 79 | ); 80 | let results = await typesenseSearchClient 81 | .collections(INDEX_NAME) 82 | .documents() 83 | .search({ q: '*' }); 84 | 85 | return results['found']; 86 | } 87 | 88 | let indexSize; 89 | 90 | (async () => { 91 | indexSize = await getIndexSize(); 92 | })(); 93 | 94 | function queryWithoutStopWords(query) { 95 | const words = query.replace(/[&\/\\#,+()$~%.'":*?<>{}]/g, '').split(' '); 96 | return words 97 | .map((word) => { 98 | if (STOP_WORDS.includes(word.toLowerCase())) { 99 | return null; 100 | } else { 101 | return word; 102 | } 103 | }) 104 | .filter((w) => w) 105 | .join(' ') 106 | .trim(); 107 | } 108 | 109 | const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({ 110 | server: TYPESENSE_SERVER_CONFIG, 111 | // The following parameters are directly passed to Typesense's search API endpoint. 112 | // So you can pass any parameters supported by the search endpoint below. 113 | // queryBy is required. 114 | additionalSearchParameters: { 115 | query_by: 'title,altTitle,transcript,topics,embedding', 116 | query_by_weights: '127,80,80,1,1', 117 | num_typos: 1, 118 | exclude_fields: 'embedding', 119 | vector_query: 'embedding:([], k: 30, distance_threshold: 0.1, alpha: 0.9)', 120 | // prefix: false 121 | }, 122 | }); 123 | const searchClient = typesenseInstantsearchAdapter.searchClient; 124 | 125 | const search = instantsearch({ 126 | searchClient, 127 | indexName: INDEX_NAME, 128 | routing: true, 129 | }); 130 | 131 | search.addWidgets([ 132 | searchBox({ 133 | container: '#searchbox', 134 | showSubmit: false, 135 | showReset: false, 136 | placeholder: 'type in a search term... ', 137 | autofocus: true, 138 | cssClasses: { 139 | input: 'form-control', 140 | loadingIcon: 'stroke-primary', 141 | }, 142 | queryHook(query, search) { 143 | const modifiedQuery = queryWithoutStopWords(query); 144 | search(modifiedQuery); 145 | }, 146 | }), 147 | 148 | analytics({ 149 | pushFunction(formattedParameters, state, results) { 150 | window.ga( 151 | 'set', 152 | 'page', 153 | (window.location.pathname + window.location.search).toLowerCase() 154 | ); 155 | window.ga('send', 'pageView'); 156 | }, 157 | }), 158 | 159 | stats({ 160 | container: '#stats', 161 | templates: { 162 | text: ({ nbHits, hasNoResults, hasOneResult, processingTimeMS }) => { 163 | let statsText = ''; 164 | if (hasNoResults) { 165 | statsText = 'no comics'; 166 | } else if (hasOneResult) { 167 | statsText = '1 comic'; 168 | } else { 169 | statsText = `${nbHits.toLocaleString()} comics`; 170 | } 171 | return `found ${statsText} ${ 172 | indexSize ? ` from ${indexSize.toLocaleString()}` : '' 173 | } in ${processingTimeMS}ms.`; 174 | }, 175 | }, 176 | cssClasses: { 177 | text: 'text-muted', 178 | }, 179 | }), 180 | infiniteHits({ 181 | container: '#hits', 182 | cssClasses: { 183 | list: 'list-unstyled', 184 | item: 'd-flex flex-column search-result-card mb-5', 185 | loadMore: 'btn btn-secondary d-block mt-4', 186 | disabledLoadMore: 'btn btn-light mx-auto d-block mt-4', 187 | }, 188 | templates: { 189 | item: (data) => { 190 | return ` 191 |
192 |
193 |

194 | ${data.title} 195 |

196 | 209 |
210 |
211 |
212 | 213 |
214 |
215 | ${data.altTitle} 216 |
217 |
218 | ${data.topics 219 | .map( 220 | (t) => 221 | `${t}` 222 | ) 223 | .join(' • ')} 224 |
225 | `; 226 | }, 227 | empty: 'No comics found for {{ 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 | findxkcd: find that perfect xkcd comic by topic 17 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | 49 | 60 | 61 | 62 | 63 |
64 |
65 |
66 |
67 |
68 |

69 | findxkcd 74 |

75 |
76 | Browse and search xkcd comics by topics, keywords, characters, 77 | transcript, date and more. 78 |
79 |
80 |
83 | xkcd 89 |
90 |
91 | 92 |
93 | powered by 94 | 99 | 100 | 101 |
102 | 103 |

104 | about 107 | • 108 | source code 113 |

114 |
115 |
116 | 117 |
118 |
119 | 120 |
121 |
122 | Try: 123 | 124 | eclipse, 125 | online, 126 | mars, 127 | captcha, 128 | noise, 129 | cheese, 130 | floppy, 131 | stupid, 132 | urgent, 133 | bug, 134 | gravity 135 | 136 |
137 |
138 |
139 |
140 | 141 |
142 |
143 |
144 |
145 |
146 |
150 |
151 |
152 |
153 |
154 | 155 |
156 |
157 | 190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 | 198 | 199 | 281 | 282 | 283 | 284 | 285 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------