├── .npmrc ├── funding.yml ├── screenshot.png ├── .gitignore ├── public ├── manifest.json └── index.css ├── license ├── package.json ├── readme.md ├── lib ├── worker.js ├── browser.js └── render.js └── server.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /funding.yml: -------------------------------------------------------------------------------- 1 | github: wooorm 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wooorm/dictionary/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | node_modules/ 4 | words-db/ 5 | public/index.js 6 | public/worker.js 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Dictionary", 3 | "name": "Dictionary", 4 | "start_url": "/", 5 | "lang": "en", 6 | "theme_color": "#d605d6", 7 | "background_color": "#f7f7f7", 8 | "display": "standalone", 9 | "orientation": "any", 10 | "icons": [ 11 | { 12 | "src": "/static/icon/192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/static/icon/256x256.png", 18 | "sizes": "256x256", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/static/icon/384x384.png", 23 | "sizes": "384x384", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/static/icon/512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2017 Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dictionary", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Dictionary app that can work without JavaScript or internet", 6 | "license": "MIT", 7 | "keywords": [ 8 | "service worker", 9 | "offline", 10 | "dictionary", 11 | "app" 12 | ], 13 | "repository": "https://github.com/wooorm/dictionary", 14 | "bugs": "https://github.com/wooorm/dictionary/issues", 15 | "author": "Titus Wormer (https://wooorm.com)", 16 | "contributors": [ 17 | "Titus Wormer (https://wooorm.com)" 18 | ], 19 | "dependencies": { 20 | "compression": "^1.6.2", 21 | "concat-stream": "^2.0.0", 22 | "debounce": "^1.0.0", 23 | "dotenv": "^8.0.0", 24 | "express": "^4.14.0", 25 | "global": "^4.3.1", 26 | "leveldown": "^5.0.0", 27 | "levelup": "^4.0.0", 28 | "pouchdb": "^7.0.0", 29 | "vdom-to-html": "^2.3.0", 30 | "virtual-dom": "^2.1.1" 31 | }, 32 | "devDependencies": { 33 | "browserify": "^16.0.0", 34 | "prettier": "^1.17.1", 35 | "remark-cli": "^6.0.0", 36 | "remark-preset-wooorm": "^4.0.0", 37 | "stylelint": "^10.0.0", 38 | "stylelint-config-standard": "^18.0.0", 39 | "tinyify": "^2.5.0", 40 | "xo": "^0.24.0" 41 | }, 42 | "scripts": { 43 | "start": "node server", 44 | "format-md": "remark . -qfo", 45 | "format-js": "prettier --write \"**/*.js\" && xo --fix", 46 | "format-css": "stylelint public/index.css --fix", 47 | "format": "npm run format-md && npm run format-js && npm run format-css", 48 | "build-worker": "browserify lib/worker -p tinyify -o public/worker.js", 49 | "build-bundle": "browserify lib/browser -p tinyify -o public/index.js", 50 | "build": "npm run build-worker && npm run build-bundle", 51 | "test": "npm run build && npm run format" 52 | }, 53 | "remarkConfig": { 54 | "plugins": [ 55 | "preset-wooorm" 56 | ] 57 | }, 58 | "stylelint": { 59 | "extends": "stylelint-config-standard" 60 | }, 61 | "prettier": { 62 | "tabWidth": 2, 63 | "useTabs": false, 64 | "singleQuote": true, 65 | "bracketSpacing": false, 66 | "semi": false, 67 | "trailingComma": "none" 68 | }, 69 | "xo": { 70 | "prettier": true, 71 | "esnext": false, 72 | "ignore": [ 73 | "public/worker.js", 74 | "public/index.js" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # dictionary 2 | 3 | Dictionary app that can work without JavaScript or internet. 4 | 5 | * [x] Works without JavaScript 6 | * [x] Works offline: Service worker, [`pouchdb`][pouch] 7 | * [x] Server: [`express`][express] 8 | * [x] Rendering: [`virtual-dom`][vdom] 9 | * [x] 100/100 on [Lighthouse][] when ignoring HTTP/2 support, 88/100 otherwise 10 | * [x] Shared rendering across server and app 11 | * [x] Data from [Words API][wordsapi] 12 | * [x] Caches Words API responses in [`levelup`][level] 13 | 14 | ![screenshot](screenshot.png) 15 | 16 | ## Size 17 | 18 | * CSS: **1.49 kb** GZipped (written for modern browsers w/o prefixed though) 19 | * JS: **46.6 kb** GZipped (mostly [`pouch`][pouch]) written in ES5 20 | * HTML: **2.17 kb** GZipped (small, 1 entry), **4.26 kb** GZipped (large, 21 | 21 entries) 22 | 23 | ## Performance 24 | 25 | Loading `/dictionary` afresh (first load) transfers 114 kb (app itself, and 26 | initialising the service worker cache). 27 | 28 | | Connection | DOMContentLoaded | 29 | | ---------- | ---------------- | 30 | | GRPS | 10.22s | 31 | | Good 2G | 1.39s | 32 | | Good 3G | 447ms | 33 | | Regular 4G | 221ms | 34 | | Wifi | 145ms | 35 | 36 | ## Build 37 | 38 | `git clone`, then configure a [`.env`][env] file with a 39 | [`WORDSAPI_KEY`][wordsapi]. Words API is free up to 2500 request per day, 40 | which is more than enough for trying this out. 41 | 42 | For example, `.env` would looks as follows: 43 | 44 | ```txt 45 | WORDSAPI_KEY=1234567890qwertyuiopasdfghjklzxcvbnm1234567890qwer 46 | ``` 47 | 48 | Then, run `npm install` and `npm build` to build everything. 49 | 50 | Lastly, run `npm start` to start the server on port `2000`. 51 | 52 | ## To do 53 | 54 | * [ ] HTTP/2: I was just trying this out locally, so idc. 55 | * [ ] Manifest icons: I don’t have an Android, so idc. 56 | 57 | ## License 58 | 59 | [MIT][] © [Titus Wormer][author] 60 | 61 | [env]: https://github.com/motdotla/dotenv 62 | 63 | [wordsapi]: https://wordsapi.com 64 | 65 | [lighthouse]: https://github.com/GoogleChrome/lighthouse 66 | 67 | [express]: https://github.com/expressjs/express 68 | 69 | [vdom]: https://github.com/Matt-Esch/virtual-dom 70 | 71 | [pouch]: https://github.com/pouchdb/pouchdb 72 | 73 | [level]: https://github.com/level/levelup 74 | 75 | [mit]: license 76 | 77 | [author]: https://wooorm.com 78 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global self caches URL fetch */ 4 | 5 | var version = '0' 6 | var prefix = 'dictionary' 7 | var staticCacheName = [prefix, 'static', version].join('-') 8 | var staticFiles = [ 9 | '/', 10 | '/static/index.js', 11 | '/static/index.css', 12 | '/static/manifest.json' 13 | ] 14 | 15 | self.addEventListener('install', oninstall) 16 | self.addEventListener('activate', onactivate) 17 | self.addEventListener('fetch', onfetch) 18 | 19 | function oninstall(ev) { 20 | ev.waitUntil(caches.open(staticCacheName).then(oncache)) 21 | 22 | function oncache(cache) { 23 | return cache.addAll(staticFiles) 24 | } 25 | } 26 | 27 | // Clear previous caches. 28 | function onactivate(ev) { 29 | ev.waitUntil(caches.keys().then(onkeys)) 30 | 31 | function onkeys(keys) { 32 | return Promise.all(keys.map(remove)) 33 | } 34 | 35 | function remove(key) { 36 | if (key.indexOf(prefix + '-') === 0 && key !== staticCacheName) { 37 | return caches.delete(key) 38 | } 39 | } 40 | } 41 | 42 | // Clear previous caches. 43 | function onfetch(ev) { 44 | var request = ev.request 45 | var url = new URL(request.url) 46 | 47 | ev.respondWith( 48 | caches 49 | .match(request) 50 | .then(onresponse) 51 | .catch(onuncached) 52 | ) 53 | 54 | function onresponse(response) { 55 | if (response) { 56 | console.log('Returning cached response for %s', url.pathname) 57 | return response 58 | } 59 | 60 | console.log('Fetching uncached response for %s', url.pathname) 61 | 62 | return Promise.race([ 63 | fetch(request), 64 | new Promise(function(resolve, reject) { 65 | setTimeout(reject.bind(null, new Error('Fetch timeout')), 5000) 66 | }) 67 | ]).then(save) 68 | } 69 | 70 | function onuncached(err) { 71 | var pathname = url.pathname 72 | 73 | // Send `/` if this looks like `/:word`. 74 | // `build.js` will kick in and try `pouchdb`. 75 | if ( 76 | staticFiles.indexOf(pathname) === -1 && 77 | pathname.lastIndexOf('/') === 0 && 78 | pathname.indexOf('.') === -1 && 79 | request.method === 'GET' 80 | ) { 81 | return caches.match('/') 82 | } 83 | 84 | throw err 85 | } 86 | 87 | function save(response) { 88 | caches.open(staticCacheName).then(oncache) 89 | 90 | // Return early. 91 | return response.clone() 92 | 93 | function oncache(cache) { 94 | console.log('Cached response for %s', url.pathname) 95 | return cache.put(request, response) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var path = require('path') 4 | var https = require('https') 5 | var express = require('express') 6 | var levelup = require('levelup') 7 | var leveldown = require('leveldown') 8 | var concat = require('concat-stream') 9 | var compression = require('compression') 10 | var toString = require('vdom-to-html') 11 | var render = require('./lib/render') 12 | 13 | var db = levelup(leveldown('words-db')) 14 | 15 | require('dotenv').config() 16 | 17 | var key = process.env.WORDSAPI_KEY 18 | 19 | if (!key) { 20 | throw new Error('Missing `WORDSAPI_KEY` in env.') 21 | } 22 | 23 | var endpoint = 'https://wordsapiv1.p.rapidapi.com' 24 | var headers = {Accept: 'application/json', 'X-RapidAPI-Key': key} 25 | 26 | express() 27 | .use(compression()) 28 | .use('/static', express.static('public', {maxAge: '31d'})) 29 | .use('/worker.js', worker) 30 | .get('/api/:word', word) 31 | .get('/', home) 32 | .get('/:word', entry) 33 | .listen(2000) 34 | 35 | function worker(req, res) { 36 | res.sendFile(path.join(__dirname, 'public', 'worker.js')) 37 | } 38 | 39 | function word(req, res) { 40 | load(req.params.word, callback) 41 | 42 | function callback(err, buf) { 43 | if (err) { 44 | res.emit('error', err) 45 | } else { 46 | res.set('Content-Type', 'application/json') 47 | res.end(String(buf)) 48 | } 49 | } 50 | } 51 | 52 | function entry(req, res) { 53 | var val = decodeURIComponent(req.params.word) 54 | 55 | load(val, callback) 56 | 57 | function callback(err, buf) { 58 | respond(res, err, buf ? JSON.parse(buf) : {word: val, found: false}) 59 | } 60 | } 61 | 62 | function home(req, res) { 63 | if (req.query.word) { 64 | res.redirect('/' + req.query.word) 65 | } else { 66 | respond(res) 67 | } 68 | } 69 | 70 | function respond(res, err, data) { 71 | var doc = toString(render(err, data)) 72 | var source = data ? JSON.stringify(data) : 'null' 73 | 74 | res.set('Content-Type', 'text/html') 75 | res.set('Cache-Control', 'public, max-age=2678400') 76 | res.end( 77 | [ 78 | '', 79 | '', 80 | '', 81 | '', 82 | '', 83 | '', 84 | '', 85 | 'Dictionary', 86 | '', 87 | '', 88 | "", 89 | doc, 90 | '', 93 | '', 94 | '', 95 | '' 96 | ].join('\n') 97 | ) 98 | } 99 | 100 | function load(value, callback) { 101 | var word = String(value).toLowerCase() 102 | 103 | db.get(word, local) 104 | 105 | function local(_, buf) { 106 | if (buf) { 107 | callback(null, buf) 108 | } else { 109 | console.log('Could not find `%s` in database', word) 110 | https.get(endpoint + '/words/' + word, {headers: headers}, onresponse) 111 | } 112 | } 113 | 114 | function onresponse(response) { 115 | response.on('error', callback).pipe(concat(onconcat)) 116 | 117 | function onconcat(buf) { 118 | if (response.statusCode !== 200) { 119 | console.log('Could not find `%s` on remote', word) 120 | buf = JSON.stringify({word: word, found: false}) 121 | } 122 | 123 | db.put(word, buf, ondone) 124 | 125 | function ondone() { 126 | callback(null, buf) 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/browser.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var win = require('global/window') 4 | var doc = require('global/document') 5 | var PouchDB = require('pouchdb') 6 | var createElement = require('virtual-dom/create-element') 7 | var diff = require('virtual-dom/diff') 8 | var patch = require('virtual-dom/patch') 9 | var debounce = require('debounce') 10 | var render = require('./render') 11 | 12 | var db = new PouchDB({name: 'words'}) 13 | 14 | var nav = win.navigator 15 | var XMLHttpRequest = win.XMLHttpRequest 16 | var slot = doc.querySelector('main') 17 | var data = JSON.parse(doc.currentScript.previousElementSibling.textContent) 18 | var evs = {onsubmit: onsubmit, oninput: debounce(oninput, 300)} 19 | var tree = render(null, data, evs) 20 | var dom = createElement(tree) 21 | var tail 22 | 23 | slot.parentNode.replaceChild(dom, slot) 24 | tail = dom.lastChild.lastChild 25 | 26 | win.addEventListener('dblclick', ondoubleclick) 27 | 28 | win.onpopstate = onpopstate 29 | 30 | if (data) { 31 | store(data.word, data) 32 | } 33 | 34 | // Not cached by service worker, so it sent `/`. 35 | // Update. 36 | if (doc.location.pathname !== '/' && !data) { 37 | onpopstate() 38 | } 39 | 40 | // Free memory 41 | slot = null 42 | data = null 43 | 44 | if ('serviceWorker' in nav) { 45 | nav.serviceWorker.register('/worker.js').then(function() { 46 | console.info('Registered service worker') 47 | }, console.error.bind(console)) 48 | } 49 | 50 | function update(next) { 51 | dom = patch(dom, diff(tree, next)) 52 | tail = dom.lastChild.lastChild 53 | tree = next 54 | } 55 | 56 | function onsubmit(ev) { 57 | ev.preventDefault() 58 | word(ev.target.word.value) 59 | } 60 | 61 | function oninput(ev) { 62 | word(ev.target.value) 63 | } 64 | 65 | function word(value) { 66 | win.history.pushState(null, null, encodeURIComponent(value) || '/') 67 | change(value) 68 | } 69 | 70 | function ondoubleclick() { 71 | setTimeout(delayed, 4) 72 | 73 | function delayed() { 74 | var sel = win.getSelection() 75 | var node = sel.anchorNode 76 | var value 77 | 78 | if (node !== sel.focusNode || node.nodeType !== doc.TEXT_NODE) { 79 | return 80 | } 81 | 82 | value = node.data.slice(sel.anchorOffset, sel.focusOffset) 83 | 84 | if (!/[^A-Za-z0-9'’-]/.test(value)) { 85 | dom.firstChild.firstChild.value = value 86 | word(value) 87 | } 88 | } 89 | } 90 | 91 | function onpopstate() { 92 | var value = doc.location.pathname.slice(1) 93 | dom.firstChild.firstChild.value = value 94 | change(value) 95 | } 96 | 97 | function change(value) { 98 | var area = dom.lastChild 99 | var current = area.firstChild 100 | 101 | doc.title = value || 'Dictionary' 102 | 103 | if (!value) { 104 | update(render(null, null, evs)) 105 | return 106 | } 107 | 108 | if (current === tail) { 109 | current = doc.createElement('div') 110 | current.className = 'spinner' 111 | area.replaceChild(current, tail) 112 | } 113 | 114 | load(value, onword) 115 | 116 | function onword(err, data) { 117 | area.replaceChild(tail, current) 118 | update(render(err, data, evs)) 119 | } 120 | } 121 | 122 | function load(value, callback) { 123 | var word = String(value).toLowerCase() 124 | var connection 125 | 126 | db.get(word, local) 127 | 128 | setTimeout(check, 300) 129 | 130 | // There’s a bug in Safari where, when navigating back from somewhere else 131 | // to our app, the database hangs (indefinitely). 132 | // Force reload when that happens. 133 | function check() { 134 | if (!connection) { 135 | console.warn('Forcing reload after unresponsive database.') 136 | win.location.reload() 137 | } 138 | } 139 | 140 | function local(_, doc) { 141 | connection = true 142 | 143 | if (doc) { 144 | callback(null, doc.data) 145 | } else { 146 | remote() 147 | } 148 | } 149 | 150 | function remote() { 151 | var request = new XMLHttpRequest() 152 | var err 153 | 154 | request.open('GET', '/api/' + encodeURIComponent(value)) 155 | request.addEventListener('error', onerror) 156 | request.addEventListener('load', onload) 157 | request.send() 158 | 159 | function onerror() { 160 | err = new Error('Cannot connect to API') 161 | err.code = 'dict:offline' 162 | callback(err) 163 | } 164 | 165 | function onload() { 166 | var result 167 | 168 | if (this.status === 404) { 169 | result = {word: word, found: false} 170 | } else { 171 | try { 172 | result = JSON.parse(this.response) 173 | } catch (error) { 174 | return onerror() 175 | } 176 | } 177 | 178 | callback(null, result) 179 | store(word, result) 180 | } 181 | } 182 | } 183 | 184 | function store(id, data) { 185 | db.put({_id: id, data: data}, onput) 186 | 187 | function onput(err) { 188 | if (err && err.name !== 'conflict') { 189 | console.error('Could not store `%s` in database', id) 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | line-height: calc(1em + 1ex); 5 | box-sizing: border-box; 6 | } 7 | 8 | html { 9 | -webkit-font-smoothing: antialiased; 10 | -ms-text-size-adjust: 100%; 11 | -webkit-text-size-adjust: 100%; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 13 | -webkit-hyphens: auto; 14 | hyphens: auto; 15 | color: rgba(0, 0, 0, 0.97); 16 | background-color: rgba(0, 0, 0, 0.03); 17 | } 18 | 19 | /* Used by WikiPedia for IPAs. */ 20 | .ipa { 21 | font-family: Gentium, GentiumAlt, DejaVu Sans, Segoe UI, Lucida Grande, Charis SIL, Doulos SIL, TITUS Cyberbit Basic, Code2000, Lucida Sans Unicode, sans-serif; 22 | } 23 | 24 | body { 25 | margin: calc(3em + 3ex) auto; 26 | max-width: 50em; 27 | padding: 0 calc(1em + 1ex); 28 | } 29 | 30 | main { 31 | min-height: 80vh; 32 | } 33 | 34 | a { 35 | color: inherit; 36 | text-decoration: underline; 37 | } 38 | 39 | a:focus, 40 | a:hover { 41 | text-decoration: none; 42 | } 43 | 44 | pre { 45 | overflow-x: scroll; 46 | } 47 | 48 | form { 49 | max-width: 100%; 50 | display: flex; 51 | flex: 0 0 2.75em; 52 | } 53 | 54 | input[type=search], 55 | button { 56 | display: block; 57 | font-size: 2em; 58 | font-weight: 200; 59 | margin: 0; 60 | padding: calc(0.25em + 0.25ex) calc(0.5em + 0.5ex); 61 | color: rgba(0, 0, 0, 0.97); 62 | border-radius: 3px; 63 | transition-property: background-color, border-color, box-shadow, opacity; 64 | transition-duration: 300ms; 65 | border: 1px solid rgba(0, 0, 0, 0.5); 66 | background-color: transparent; 67 | opacity: 0.75; 68 | } 69 | 70 | input[type=search] { 71 | flex-grow: 1; 72 | min-width: 0; 73 | padding-left: calc(1em + 1ex); 74 | -webkit-appearance: none; 75 | text-overflow: ellipsis; 76 | } 77 | 78 | /* Safari adds some other padding otherwise… */ 79 | input[type="search"]::-webkit-search-decoration { 80 | -webkit-appearance: none; 81 | } 82 | 83 | button { 84 | margin-left: calc(0.5em + 0.5ex); 85 | border-width: 0; 86 | background-color: #d605d6; 87 | color: white; 88 | } 89 | 90 | .js button { 91 | display: none; 92 | } 93 | 94 | input[type=search]:focus, 95 | button:focus { 96 | border-color: transparent; 97 | opacity: initial; 98 | outline: initial; 99 | } 100 | 101 | button:focus, 102 | input[type=search]:focus, 103 | .dictionary { 104 | box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.2), 0 3px 4px 0 rgba(0, 0, 0, 0.14), 0 3px 3px -2px rgba(0, 0, 0, 0.12); 105 | } 106 | 107 | input[type=search]:focus, 108 | .dictionary { 109 | background-color: rgba(255, 255, 255, 0.97); 110 | } 111 | 112 | button:focus { 113 | background-color: hsla(300, 95%, 43%, 0.5); 114 | } 115 | 116 | .dictionary { 117 | margin: calc(1em + 1ex) auto; 118 | padding: calc(1em + 1ex) calc(2em + 2ex); 119 | } 120 | 121 | .dictionary-placeholder, 122 | .dictionary-empty, 123 | .dictionary-err, 124 | .dictionary-404 { 125 | text-align: center; 126 | color: rgba(0, 0, 0, 0.5); 127 | } 128 | 129 | .dictionary-entry, 130 | .dictionary-definition, 131 | .dictionary-list-label { 132 | font-size: 1em; 133 | font-weight: normal; 134 | } 135 | 136 | .dictionary-definitions, 137 | .dictionary-list { 138 | padding: 0; 139 | margin: 0; 140 | } 141 | 142 | .dictionary-definitions { 143 | list-style-type: upper-roman; 144 | } 145 | 146 | .dictionary-list { 147 | list-style-type: none; 148 | } 149 | 150 | .dictionary-err, 151 | .dictionary-entry, 152 | .dictionary-definition, 153 | .dictionary p, 154 | .dictionary-list-label { 155 | margin-top: calc(0.5em + 0.5ex); 156 | margin-bottom: calc(0.5em + 0.5ex); 157 | } 158 | 159 | .dictionary-entry { 160 | margin-top: 0; 161 | } 162 | 163 | .dictionary-list-label { 164 | margin-bottom: 0; 165 | } 166 | 167 | .dictionary-entry, 168 | .dictionary-definition, 169 | .dictionary-list-label, 170 | .dictionary-pronounciation::before, 171 | .dictionary-pronounciation::after, 172 | .dictionary-list li::before, 173 | footer { 174 | color: rgba(0, 0, 0, 0.75); 175 | } 176 | 177 | .dictionary-list-label, 178 | abbr { 179 | text-transform: lowercase; 180 | font-variant: small-caps; 181 | } 182 | 183 | /* Firefox. */ 184 | abbr { 185 | text-decoration: none; 186 | } 187 | 188 | .dictionary-entry *, 189 | .dictionary-definition * { 190 | color: initial; 191 | } 192 | 193 | .dictionary-entry strong, 194 | .dictionary-definition strong { 195 | font-weight: bold; 196 | } 197 | 198 | .dictionary-pronounciation::before { 199 | content: '| '; 200 | } 201 | 202 | .dictionary-pronounciation::after { 203 | content: ' |'; 204 | } 205 | 206 | .dictionary-list li::before { 207 | content: '○'; 208 | width: 1em; 209 | font-size: 0.625em; 210 | line-height: 1; 211 | position: relative; 212 | top: -0.25em; 213 | margin-left: -1.5em; 214 | margin-right: 0.6em; 215 | } 216 | 217 | footer { 218 | text-align: center; 219 | } 220 | 221 | .spinner { 222 | width: 100px; 223 | height: 100px; 224 | border: 2px solid #d605d6; 225 | border-right-color: transparent; 226 | border-radius: 100%; 227 | margin: 0 auto; 228 | animation: rotate 2s linear infinite; 229 | } 230 | 231 | @keyframes rotate { 232 | 100% { transform: rotate(360deg); } 233 | } 234 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var h = require('virtual-dom/h') 4 | 5 | module.exports = render 6 | 7 | function render(err, data, evs) { 8 | var contents 9 | 10 | if (err) { 11 | if (err.code === 'dict:offline') { 12 | contents = h('.dictionary-err', {key: 'err-offline'}, [ 13 | 'Cannot look up definition. Connect to the internet and try again.' 14 | ]) 15 | } else { 16 | contents = h('.dictionary-err', {key: 'err'}, [ 17 | h('pre', {key: 'err-stack'}, err.stack) 18 | ]) 19 | } 20 | } else if (data && data.found === false) { 21 | contents = h('p.dictionary-404', {key: '404'}, ['Not found in dictionary.']) 22 | } else if (data) { 23 | try { 24 | contents = [syllables(data.syllables), definitions(data.results)] 25 | } catch (error) { 26 | contents = h('.dictionary-err', {key: 'err'}, [ 27 | h('pre', {key: 'err-stack'}, error.stack) 28 | ]) 29 | } 30 | } else { 31 | contents = h('p.dictionary-placeholder', {key: 'placeholder'}, [ 32 | 'Search for something!' 33 | ]) 34 | } 35 | 36 | return h('main', {key: 'main'}, [ 37 | h('form', {key: 'form', action: '/', onsubmit: evs && evs.onsubmit}, [ 38 | h('input', { 39 | type: 'search', 40 | name: 'word', 41 | key: 'input', 42 | autocomplete: 'off', 43 | autocorrect: 'off', 44 | autocapitalize: 'off', 45 | spellcheck: false, 46 | autofocus: true, 47 | placeholder: 'Search…', 48 | attributes: {value: data ? data.word : ''}, 49 | onclick: onclick, 50 | oninput: evs && evs.oninput 51 | }), 52 | h('button', {key: 'submit', type: 'submit'}, ['Define']) 53 | ]), 54 | h('.dictionary', {key: 'dictionary'}, [ 55 | h('div', {key: 'contents'}, [head(data)].concat(contents)) 56 | ]) 57 | ]) 58 | 59 | function onclick(ev) { 60 | var node = ev.target 61 | node.selectionStart = 0 62 | node.selectionEnd = node.value.length 63 | } 64 | } 65 | 66 | function head(data) { 67 | var word = data && data.word 68 | var pronunciation = data && data.pronunciation 69 | var nodes = [] 70 | 71 | if (word) { 72 | nodes.push(h('strong', {key: 'term'}, [word])) 73 | } 74 | 75 | if (pronunciation && typeof pronunciation !== 'string') { 76 | pronunciation = pronunciation.all 77 | } 78 | 79 | if (pronunciation) { 80 | nodes.push( 81 | ' ', 82 | h('span.dictionary-pronounciation.ipa', {key: 'pronounciation'}, [ 83 | pronunciation 84 | ]) 85 | ) 86 | } 87 | 88 | return nodes.length === 0 89 | ? null 90 | : h('h1.dictionary-entry', {key: 'head'}, nodes) 91 | } 92 | 93 | function syllables(data) { 94 | var nodes = [] 95 | 96 | if (!data) { 97 | return 98 | } 99 | 100 | nodes = [ 101 | h('em', {key: 'syllable-label'}, ['Syllables']), 102 | ': ', 103 | h('em', {key: 'syllable-count'}, [String(data.count)]) 104 | ] 105 | 106 | data.list.forEach(addSyllable) 107 | nodes.push('.') 108 | 109 | return h('h2.dictionary-definition', {key: 'syllables'}, nodes) 110 | 111 | function addSyllable(syllable, index) { 112 | nodes.push( 113 | index ? '·' : ' — ', 114 | h('em', {key: 'syllable-' + syllable}, [syllable]) 115 | ) 116 | } 117 | } 118 | 119 | function definitions(data) { 120 | if (data && data.length !== 0) { 121 | return h( 122 | 'ol.dictionary-definitions', 123 | {key: 'definitions'}, 124 | data.map(definition).filter(Boolean) 125 | ) 126 | } 127 | 128 | return h('p.dictionary-empty', {key: 'definitions-empty'}, [ 129 | 'No known definitions.' 130 | ]) 131 | } 132 | 133 | function definition(data, index) { 134 | var id = 'definition-' + index 135 | return h('li', {key: id}, [ 136 | title(data, id), 137 | h('p', {key: id + '-definition'}, pretty(data.definition)), 138 | list('Member of', data.memberOf, id + '-member'), 139 | list('Examples', data.examples, id + '-examples'), 140 | list('Synonyms', data.synonyms, id + '-synonyms'), 141 | list('Antonyms', data.antonyms, id + '-antonyms'), 142 | list('Similar', data.similarTo, id + '-similar') 143 | ]) 144 | } 145 | 146 | function title(data, id) { 147 | var derivation 148 | var nodes = [] 149 | 150 | if (data.partOfSpeech) { 151 | nodes.push(h('strong', {key: id + '-pos'}, [data.partOfSpeech])) 152 | } 153 | 154 | if (data.typeOf) { 155 | nodes.push(' (') 156 | data.typeOf.forEach(add(id + '-type')) 157 | nodes.push(')') 158 | } 159 | 160 | derivation = data.derivation 161 | 162 | if (derivation) { 163 | if (typeof derivation === 'string') { 164 | derivation = [derivation] 165 | } 166 | 167 | if (derivation) { 168 | nodes.push(', derived from: ') 169 | derivation.forEach(add(id + '-derivative')) 170 | } 171 | } 172 | 173 | if (nodes.length === 0) { 174 | return null 175 | } 176 | 177 | return h('h2.dictionary-definition', {key: id + '-title'}, nodes.concat('.')) 178 | 179 | function add(subId) { 180 | return fn 181 | function fn(subvalue, index) { 182 | nodes.push( 183 | index ? ', ' : null, 184 | h('em', {key: subId + '-' + index}, subvalue) 185 | ) 186 | } 187 | } 188 | } 189 | 190 | function list(label, data, id) { 191 | if (!data || data.length === 0) { 192 | return 193 | } 194 | 195 | return h('div', {key: id}, [ 196 | h('h3.dictionary-list-label', {key: id + '-label'}, [label]), 197 | h('ol.dictionary-list', {key: id + '-list'}, data.map(item)) 198 | ]) 199 | 200 | function item(value, index) { 201 | return h('li', {key: id + '-' + index}, [value]) 202 | } 203 | } 204 | 205 | function pretty(value) { 206 | return value.replace(/`(\w+)'/g, '“$1”') 207 | } 208 | --------------------------------------------------------------------------------