├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .mailmap ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── .zed └── settings.json ├── AUTHORS.txt ├── LICENSE.txt ├── README.md ├── client.js ├── client ├── ReadMe.md ├── dialog │ ├── index.html │ └── style.css ├── images │ ├── crosses.png │ ├── email_sign_in_blue.png │ ├── external-link-ltr-icon.png │ ├── noise.png │ └── oops.jpg ├── js │ ├── jquery-3.7.1.min.js │ ├── jquery-migrate-3.5.0.js │ ├── jquery-migrate-3.5.0.min.js │ ├── jquery-ui │ │ └── 1.14.1 │ │ │ ├── images │ │ │ ├── ui-icons_444444_256x240.png │ │ │ ├── ui-icons_555555_256x240.png │ │ │ ├── ui-icons_777620_256x240.png │ │ │ ├── ui-icons_777777_256x240.png │ │ │ ├── ui-icons_cc0000_256x240.png │ │ │ └── ui-icons_ffffff_256x240.png │ │ │ ├── jquery-ui.min.css │ │ │ └── jquery-ui.min.js │ ├── jquery.ui.touch-punch.min.js │ ├── underscore-min.js │ └── underscore-min.map ├── runtests.html ├── style │ ├── print.css │ └── style.css ├── test │ ├── mocha.css │ └── mocha.js └── twitter-maintainance.jpg ├── eslint.config.mjs ├── lib ├── actionSymbols.js ├── active.js ├── addToJournal.js ├── bind.js ├── dialog.js ├── drop.js ├── editor.js ├── factory.js ├── future.js ├── importer.js ├── itemz.js ├── legacy.js ├── license.js ├── lineup.js ├── link.js ├── neighborhood.js ├── neighbors.js ├── page.js ├── pageHandler.js ├── paragraph.js ├── plugin.js ├── plugins.js ├── random.js ├── reference.js ├── refresh.js ├── resolve.js ├── revision.js ├── search.js ├── searchbox.js ├── security.js ├── siteAdapter.js ├── state.js ├── synopsis.js ├── target.js ├── util.js └── wiki.js ├── package-lock.json ├── package.json ├── scripts ├── build-client.mjs ├── build-testclient.mjs ├── call-graph.dot ├── call-sites.dot ├── call-sites.pl ├── requires-graph.dot ├── requires-graph.pl └── update-authors.js ├── test ├── active.js ├── drop.js ├── lineup.js ├── mockServer.js ├── neighborhood.js ├── page.js ├── pageHandler.js ├── plugin.js ├── random.js ├── refresh.js ├── resolve.js ├── revision.js ├── search.js ├── util.js └── wiki.js ├── testclient.js └── views ├── oops.html └── static.html /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | # Releases https://github.com/nodejs/release#release-schedule 16 | node-version: 17 | - 18.x # LTS 18 | - 20.x # LTS 19 | - 22.x # LTS 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *.iml 4 | .idea/ 5 | .sass-cache 6 | .rvmrc 7 | 8 | node_modules 9 | npm-debug.log 10 | 11 | 12 | # ignore generated javascript - recreated by running build 13 | client/client.js 14 | client/client.js.map 15 | client/test/testclient.js 16 | client/test/testclient.js.map 17 | # esbuild metadata 18 | meta-client.json 19 | # c8 output 20 | coverage 21 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Nick Niemeir 2 | Joshua Benuck 3 | Eric Dobbs 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /client.js 2 | /testclient.js 3 | coverage 4 | scripts 5 | test 6 | meta-client.json 7 | eslint.config.mjs 8 | .github 9 | .mailmap 10 | .prettier* 11 | .vscode 12 | .zed 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | client/js 2 | client/test 3 | client/client.* 4 | coverage 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "bracketSameLine": true, 6 | "arrowParens": "avoid", 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true 7 | } 8 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 | { 6 | "format_on_save": "on" 7 | } 8 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Authors ordered by first contribution 2 | 3 | Ward Cunningham 4 | Stephen Judkins 5 | Sam Goldstein 6 | Steven Black 7 | Don Park 8 | Sven Dowideit 9 | Adam Solove 10 | Nick Niemeir 11 | Erkan Yilmaz 12 | Matt Niemeir 13 | Daan van Berkel 14 | Nicholas Hallahan 15 | Ola Bini 16 | Danilo Sato 17 | Henning Schumann 18 | Michael Deardeuff 19 | Pete Hodgson 20 | Marcin Cieslak 21 | M. Kelley Harris (http://www.kelleyharris.com) 22 | Ryan Bennett 23 | Paul Rodwell 24 | David Turnbull 25 | Austin King 26 | enyst 27 | Enrico Spinielli 28 | judell 29 | Santiago Ferreira 30 | i2p-lbt 31 | Andrew Ettinger 32 | Joshua Benuck 33 | Eric Dobbs 34 | Andrew Shell 35 | Matthew B. Gray 36 | decaffeinate 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2015 Ward Cunningham and other contributors 2 | 3 | This software consists of voluntary contributions made by many 4 | individuals. For exact contribution history, see the revision history 5 | available at https://github.com/fedwiki/wiki-client 6 | 7 | The following license applies to all parts of this software except as 8 | documented below: 9 | 10 | ==== 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | 30 | ==== 31 | 32 | All files located in the pages directory are licensed under a 33 | Creative Commons Attribution-ShareAlike 4.0 International License. 34 | 35 | CC BY-SA 4.0 : http://creativecommons.org/licenses/by-sa/4.0/ 36 | 37 | ==== 38 | 39 | All files located in the node_modules and client/js are 40 | externally maintained libraries used by this software which have their 41 | own licenses; we recommend you read them, as their terms may differ from 42 | the terms above. 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wiki-client 2 | =========== 3 | 4 | Federated wiki client-side javascript as a npm module. 5 | 6 | Goals 7 | ===== 8 | 9 | Over its first two years the Smallest Federated Wiki (SFW) project explored 10 | many ways that a wiki could embrace HTML5 and related technologies. Here 11 | we will cautiously reorganize this work as small independent modules that 12 | favor ongoing innovation. 13 | 14 | We proceed by dividing SFW first into large pieces and then these into 15 | smaller pieces as we simplify and regularize the communications between them. 16 | We now favor the node.js module and event conventions, dependency injection, 17 | and increased separation between the DOM and the logic that manages it. 18 | 19 | Federated wiki's single-page application reads page content from many sources 20 | and writes updates to a few. Read-write server backends are maintained in 21 | ruby (sinatra) and node (express). Read-only servers have been realized 22 | with static files and cgi scripts. Encouraging experiments have exploited 23 | exotic service architectures such as CCNx content-addressable networks. 24 | 25 | Participation 26 | ============= 27 | 28 | We're happy to take issues or pull requests regarding the goals and 29 | their implementation within this code. 30 | 31 | A wider-ranging conversation is documented in the GitHub ReadMe of the 32 | founding project, [SFW](https://github.com/WardCunningham/Smallest-Federated-Wiki/blob/master/ReadMe.md). 33 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | window.name = window.location.host 2 | 3 | window.wiki = require('./lib/wiki') 4 | require('./lib/legacy') 5 | require('./lib/bind') 6 | require('./lib/plugins') 7 | -------------------------------------------------------------------------------- /client/ReadMe.md: -------------------------------------------------------------------------------- 1 | # Client Goals 2 | 3 | A server offers direct restful read/write access to pages it owns and proxy access to pages held elsewhere in federated space. 4 | A page is owned if it was created with the server or has been cloned and edited such that it is believed to be the most authoritative copy of a page previously owned elsewhere. 5 | A server operates as a proxy to the rest of the federated wiki. 6 | In this role it reformats data and metadata providing a unified experience. 7 | It is welcome to collect behavioral statistics in order to improve this experience by anticipating permitted peer-to-peer server operations. 8 | 9 | In summary, the server's client side exists to: 10 | 11 | - Offer to a user a browsing experience that is independent of any specific server. 12 | - Support writing, editing and curating of one server in a way that offers suitable influence over others. 13 | 14 | # Testing 15 | 16 | All the client tests can be run by visiting /runtests.html on your server 17 | or by running `npm run runtests`. Information about the libraries we 18 | are using for testing can be found at: 19 | 20 | - http://visionmedia.github.com/mocha/ 21 | - https://github.com/LearnBoost/expect.js 22 | - http://sinonjs.org/ 23 | -------------------------------------------------------------------------------- /client/dialog/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /client/dialog/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | inset: 0; 3 | position: absolute; 4 | background: #eee url('/images/crosses.png'); 5 | padding: 0; 6 | margin: 0; 7 | line-height: 1.3; 8 | color: #333; 9 | } 10 | .page { 11 | margin: 8px; 12 | outline: none; 13 | box-shadow: 2px 1px 4px rgba(0, 0, 0, 0.2); 14 | background: #eee; 15 | padding: 8px; 16 | position: absolute; 17 | inset: 0px; 18 | overflow: auto; 19 | scrollbar-gutter: stable; 20 | scrollbar-width: thin; 21 | scrollbar-color: #ccc #eee; 22 | } 23 | p { 24 | margin: 8px; 25 | } 26 | .remote { 27 | vertical-align: text-top; 28 | width: 16px; 29 | height: 16px; 30 | } 31 | a { 32 | text-decoration: none; 33 | } 34 | 35 | :is(#authors, #provenance) a { 36 | margin-left: 16px; 37 | } 38 | 39 | .page:has(> img, svg) { 40 | overflow: hidden; 41 | scrollbar-gutter: auto; 42 | } 43 | 44 | .page > img, 45 | svg { 46 | width: auto !important; 47 | max-width: 100%; 48 | max-height: 100%; 49 | } 50 | -------------------------------------------------------------------------------- /client/images/crosses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/images/crosses.png -------------------------------------------------------------------------------- /client/images/email_sign_in_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/images/email_sign_in_blue.png -------------------------------------------------------------------------------- /client/images/external-link-ltr-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/images/external-link-ltr-icon.png -------------------------------------------------------------------------------- /client/images/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/images/noise.png -------------------------------------------------------------------------------- /client/images/oops.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/images/oops.jpg -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_444444_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_444444_256x240.png -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_555555_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_555555_256x240.png -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_777620_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_777620_256x240.png -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_777777_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_777777_256x240.png -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_cc0000_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_cc0000_256x240.png -------------------------------------------------------------------------------- /client/js/jquery-ui/1.14.1/images/ui-icons_ffffff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/js/jquery-ui/1.14.1/images/ui-icons_ffffff_256x240.png -------------------------------------------------------------------------------- /client/js/jquery.ui.touch-punch.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery UI Touch Punch 0.2.3 3 | * 4 | * Copyright 2011–2014, Dave Furfero 5 | * Dual licensed under the MIT or GPL Version 2 licenses. 6 | * 7 | * Depends: 8 | * jquery.ui.widget.js 9 | * jquery.ui.mouse.js 10 | */ 11 | !function(a){function f(a,b){if(!(a.originalEvent.touches.length>1)){a.preventDefault();var c=a.originalEvent.changedTouches[0],d=document.createEvent("MouseEvents");d.initMouseEvent(b,!0,!0,window,1,c.screenX,c.screenY,c.clientX,c.clientY,!1,!1,!1,!1,0,null),a.target.dispatchEvent(d)}}if(a.support.touch="ontouchend"in document,a.support.touch){var e,b=a.ui.mouse.prototype,c=b._mouseInit,d=b._mouseDestroy;b._touchStart=function(a){var b=this;!e&&b._mouseCapture(a.originalEvent.changedTouches[0])&&(e=!0,b._touchMoved=!1,f(a,"mouseover"),f(a,"mousemove"),f(a,"mousedown"))},b._touchMove=function(a){e&&(this._touchMoved=!0,f(a,"mousemove"))},b._touchEnd=function(a){e&&(f(a,"mouseup"),f(a,"mouseout"),this._touchMoved||f(a,"click"),e=!1)},b._mouseInit=function(){var b=this;b.element.bind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),c.call(b)},b._mouseDestroy=function(){var b=this;b.element.unbind({touchstart:a.proxy(b,"_touchStart"),touchmove:a.proxy(b,"_touchMove"),touchend:a.proxy(b,"_touchEnd")}),d.call(b)}}}(jQuery); 12 | -------------------------------------------------------------------------------- /client/runtests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SFW Mocha Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /client/style/print.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 3 | ‘Times New Roman’, 4 | Georgia, 5 | serif; 6 | font-size: 12pt; 7 | line-height: 1.2; 8 | background: white; 9 | color: black; 10 | } 11 | 12 | .main { 13 | width: auto; 14 | border: 0; 15 | margin: 10% 20%; 16 | padding: 0; 17 | float: none !important; 18 | } 19 | 20 | .page { 21 | page-break-after: always; 22 | } 23 | 24 | .story { 25 | text-align: left; 26 | } 27 | 28 | p { 29 | orphans: 3; 30 | widows: 2; 31 | } 32 | 33 | img.remote { 34 | width: 16px; 35 | height: 16px; 36 | } 37 | 38 | A:link, 39 | A:visited { 40 | background: transparent; 41 | color: #111; 42 | text-decoration: none; 43 | font-weight: 600; 44 | } 45 | 46 | .story a:link:after, 47 | .story a:visited:after { 48 | content: ' (' attr(href) ') '; 49 | font-size: 90%; 50 | font-weight: 500; 51 | } 52 | 53 | .backlinks { 54 | display: none; 55 | } 56 | 57 | .journal { 58 | display: none; 59 | } 60 | 61 | .footer { 62 | font-size: 10pt; 63 | padding-top: 24pt; 64 | float: bottom; 65 | string-set: Footer self; 66 | } 67 | 68 | .footer a { 69 | text-decoration: none; 70 | } 71 | 72 | footer { 73 | display: none; 74 | } 75 | -------------------------------------------------------------------------------- /client/test/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | :root { 4 | --mocha-color: #000; 5 | --mocha-bg-color: #fff; 6 | --mocha-pass-icon-color: #00d6b2; 7 | --mocha-pass-color: #fff; 8 | --mocha-pass-shadow-color: rgba(0,0,0,.2); 9 | --mocha-pass-mediump-color: #c09853; 10 | --mocha-pass-slow-color: #b94a48; 11 | --mocha-test-pending-color: #0b97c4; 12 | --mocha-test-pending-icon-color: #0b97c4; 13 | --mocha-test-fail-color: #c00; 14 | --mocha-test-fail-icon-color: #c00; 15 | --mocha-test-fail-pre-color: #000; 16 | --mocha-test-fail-pre-error-color: #c00; 17 | --mocha-test-html-error-color: #000; 18 | --mocha-box-shadow-color: #eee; 19 | --mocha-box-bottom-color: #ddd; 20 | --mocha-test-replay-color: #000; 21 | --mocha-test-replay-bg-color: #eee; 22 | --mocha-stats-color: #888; 23 | --mocha-stats-em-color: #000; 24 | --mocha-stats-hover-color: #eee; 25 | --mocha-progress-ring-color: #eee; 26 | --mocha-progress-ring-highlight-color: #9f9f9f; 27 | --mocha-progress-text-color: #000; 28 | --mocha-error-color: #c00; 29 | 30 | --mocha-code-comment: #ddd; 31 | --mocha-code-init: #2f6fad; 32 | --mocha-code-string: #5890ad; 33 | --mocha-code-keyword: #8a6343; 34 | --mocha-code-number: #2f6fad; 35 | } 36 | 37 | @media (prefers-color-scheme: dark) { 38 | :root { 39 | --mocha-color: #fff; 40 | --mocha-bg-color: #222; 41 | --mocha-pass-icon-color: #00d6b2; 42 | --mocha-pass-color: #222; 43 | --mocha-pass-shadow-color: rgba(255,255,255,.2); 44 | --mocha-pass-mediump-color: #f1be67; 45 | --mocha-pass-slow-color: #f49896; 46 | --mocha-test-pending-color: #0b97c4; 47 | --mocha-test-pending-icon-color: #0b97c4; 48 | --mocha-test-fail-color: #f44; 49 | --mocha-test-fail-icon-color: #f44; 50 | --mocha-test-fail-pre-color: #fff; 51 | --mocha-test-fail-pre-error-color: #f44; 52 | --mocha-test-html-error-color: #fff; 53 | --mocha-box-shadow-color: #444; 54 | --mocha-box-bottom-color: #555; 55 | --mocha-test-replay-color: #fff; 56 | --mocha-test-replay-bg-color: #444; 57 | --mocha-stats-color: #aaa; 58 | --mocha-stats-em-color: #fff; 59 | --mocha-stats-hover-color: #444; 60 | --mocha-progress-ring-color: #444; 61 | --mocha-progress-ring-highlight-color: #888; 62 | --mocha-progress-text-color: #fff; 63 | --mocha-error-color: #f44; 64 | 65 | --mocha-code-comment: #ddd; 66 | --mocha-code-init: #9cc7f1; 67 | --mocha-code-string: #80d4ff; 68 | --mocha-code-keyword: #e3a470; 69 | --mocha-code-number: #4ca7ff; 70 | } 71 | } 72 | 73 | body { 74 | margin:0; 75 | background-color: var(--mocha-bg-color); 76 | color: var(--mocha-color); 77 | } 78 | 79 | #mocha { 80 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 81 | margin: 60px 50px; 82 | } 83 | 84 | #mocha ul, 85 | #mocha li { 86 | margin: 0; 87 | padding: 0; 88 | } 89 | 90 | #mocha ul { 91 | list-style: none; 92 | } 93 | 94 | #mocha h1, 95 | #mocha h2 { 96 | margin: 0; 97 | } 98 | 99 | #mocha h1 { 100 | margin-top: 15px; 101 | font-size: 1em; 102 | font-weight: 200; 103 | } 104 | 105 | #mocha h1 a { 106 | text-decoration: none; 107 | color: inherit; 108 | } 109 | 110 | #mocha h1 a:hover { 111 | text-decoration: underline; 112 | } 113 | 114 | #mocha .suite .suite h1 { 115 | margin-top: 0; 116 | font-size: .8em; 117 | } 118 | 119 | #mocha .hidden { 120 | display: none; 121 | } 122 | 123 | #mocha h2 { 124 | font-size: 12px; 125 | font-weight: normal; 126 | cursor: pointer; 127 | } 128 | 129 | #mocha .suite { 130 | margin-left: 15px; 131 | } 132 | 133 | #mocha .test { 134 | margin-left: 15px; 135 | overflow: hidden; 136 | } 137 | 138 | #mocha .test.pending:hover h2::after { 139 | content: '(pending)'; 140 | font-family: arial, sans-serif; 141 | } 142 | 143 | #mocha .test.pass.medium .duration { 144 | background: var(--mocha-pass-mediump-color); 145 | } 146 | 147 | #mocha .test.pass.slow .duration { 148 | background: var(--mocha-pass-slow-color); 149 | } 150 | 151 | #mocha .test.pass::before { 152 | content: '✓'; 153 | font-size: 12px; 154 | display: block; 155 | float: left; 156 | margin-right: 5px; 157 | color: var(--mocha-pass-icon-color); 158 | } 159 | 160 | #mocha .test.pass .duration { 161 | font-size: 9px; 162 | margin-left: 5px; 163 | padding: 2px 5px; 164 | color: var(--mocha-pass-color); 165 | -webkit-box-shadow: inset 0 1px 1px var(--mocha-pass-shadow-color); 166 | -moz-box-shadow: inset 0 1px 1px var(--mocha-pass-shadow-color); 167 | box-shadow: inset 0 1px 1px var(--mocha-pass-shadow-color); 168 | -webkit-border-radius: 5px; 169 | -moz-border-radius: 5px; 170 | -ms-border-radius: 5px; 171 | -o-border-radius: 5px; 172 | border-radius: 5px; 173 | } 174 | 175 | #mocha .test.pass.fast .duration { 176 | display: none; 177 | } 178 | 179 | #mocha .test.pending { 180 | color: var(--mocha-test-pending-color); 181 | } 182 | 183 | #mocha .test.pending::before { 184 | content: '◦'; 185 | color: var(--mocha-test-pending-icon-color); 186 | } 187 | 188 | #mocha .test.fail { 189 | color: var(--mocha-test-fail-color); 190 | } 191 | 192 | #mocha .test.fail pre { 193 | color: var(--mocha-test-fail-pre-color); 194 | } 195 | 196 | #mocha .test.fail::before { 197 | content: '✖'; 198 | font-size: 12px; 199 | display: block; 200 | float: left; 201 | margin-right: 5px; 202 | color: var(--mocha-test-fail-icon-color); 203 | } 204 | 205 | #mocha .test pre.error { 206 | color: var(--mocha-test-fail-pre-error-color); 207 | max-height: 300px; 208 | overflow: auto; 209 | } 210 | 211 | #mocha .test .html-error { 212 | overflow: auto; 213 | color: var(--mocha-test-html-error-color); 214 | display: block; 215 | float: left; 216 | clear: left; 217 | font: 12px/1.5 monaco, monospace; 218 | margin: 5px; 219 | padding: 15px; 220 | border: 1px solid var(--mocha-box-shadow-color); 221 | max-width: 85%; /*(1)*/ 222 | max-width: -webkit-calc(100% - 42px); 223 | max-width: -moz-calc(100% - 42px); 224 | max-width: calc(100% - 42px); /*(2)*/ 225 | max-height: 300px; 226 | word-wrap: break-word; 227 | border-bottom-color: var(--mocha-box-bottom-color); 228 | -webkit-box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 229 | -moz-box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 230 | box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 231 | -webkit-border-radius: 3px; 232 | -moz-border-radius: 3px; 233 | border-radius: 3px; 234 | } 235 | 236 | #mocha .test .html-error pre.error { 237 | border: none; 238 | -webkit-border-radius: 0; 239 | -moz-border-radius: 0; 240 | border-radius: 0; 241 | -webkit-box-shadow: 0; 242 | -moz-box-shadow: 0; 243 | box-shadow: 0; 244 | padding: 0; 245 | margin: 0; 246 | margin-top: 18px; 247 | max-height: none; 248 | } 249 | 250 | /** 251 | * (1): approximate for browsers not supporting calc 252 | * (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border) 253 | * ^^ seriously 254 | */ 255 | #mocha .test pre { 256 | display: block; 257 | float: left; 258 | clear: left; 259 | font: 12px/1.5 monaco, monospace; 260 | margin: 5px; 261 | padding: 15px; 262 | border: 1px solid var(--mocha-box-shadow-color); 263 | max-width: 85%; /*(1)*/ 264 | max-width: -webkit-calc(100% - 42px); 265 | max-width: -moz-calc(100% - 42px); 266 | max-width: calc(100% - 42px); /*(2)*/ 267 | word-wrap: break-word; 268 | border-bottom-color: var(--mocha-box-bottom-color); 269 | -webkit-box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 270 | -moz-box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 271 | box-shadow: 0 1px 3px var(--mocha-box-shadow-color); 272 | -webkit-border-radius: 3px; 273 | -moz-border-radius: 3px; 274 | border-radius: 3px; 275 | } 276 | 277 | #mocha .test h2 { 278 | position: relative; 279 | } 280 | 281 | #mocha .test a.replay { 282 | position: absolute; 283 | top: 3px; 284 | right: 0; 285 | text-decoration: none; 286 | vertical-align: middle; 287 | display: block; 288 | width: 15px; 289 | height: 15px; 290 | line-height: 15px; 291 | text-align: center; 292 | background: var(--mocha-test-replay-bg-color); 293 | font-size: 15px; 294 | -webkit-border-radius: 15px; 295 | -moz-border-radius: 15px; 296 | border-radius: 15px; 297 | -webkit-transition:opacity 200ms; 298 | -moz-transition:opacity 200ms; 299 | -o-transition:opacity 200ms; 300 | transition: opacity 200ms; 301 | opacity: 0.7; 302 | color: var(--mocha-test-replay-color); 303 | } 304 | 305 | #mocha .test:hover a.replay { 306 | box-shadow: 0 0 1px inset var(--mocha-test-replay-color); 307 | opacity: 1; 308 | } 309 | 310 | #mocha-report.pass .test.fail { 311 | display: none; 312 | } 313 | 314 | #mocha-report.fail .test.pass { 315 | display: none; 316 | } 317 | 318 | #mocha-report.pending .test.pass, 319 | #mocha-report.pending .test.fail { 320 | display: none; 321 | } 322 | #mocha-report.pending .test.pass.pending { 323 | display: block; 324 | } 325 | 326 | #mocha-error { 327 | color: var(--mocha-error-color); 328 | font-size: 1.5em; 329 | font-weight: 100; 330 | letter-spacing: 1px; 331 | } 332 | 333 | #mocha-stats { 334 | --ring-container-size: 40px; 335 | --ring-size: 39px; 336 | --ring-radius: calc(var(--ring-size) / 2); 337 | 338 | position: fixed; 339 | top: 15px; 340 | right: 10px; 341 | font-size: 12px; 342 | margin: 0; 343 | color: var(--mocha-stats-color); 344 | z-index: 1; 345 | } 346 | 347 | #mocha-stats .progress-contain { 348 | float: right; 349 | padding: 0; 350 | } 351 | 352 | #mocha-stats :is(.progress-element, .progress-text) { 353 | width: var(--ring-container-size); 354 | display: block; 355 | top: 12px; 356 | position: absolute; 357 | } 358 | 359 | #mocha-stats .progress-element { 360 | visibility: hidden; 361 | height: calc(var(--ring-container-size) / 2); 362 | } 363 | 364 | #mocha-stats .progress-text { 365 | text-align: center; 366 | text-overflow: clip; 367 | overflow: hidden; 368 | color: var(--mocha-stats-em-color); 369 | font-size: 11px; 370 | } 371 | 372 | #mocha-stats .progress-ring { 373 | width: var(--ring-container-size); 374 | height: var(--ring-container-size); 375 | } 376 | 377 | #mocha-stats :is(.ring-flatlight, .ring-highlight) { 378 | --stroke-thickness: 1.65px; 379 | --center: calc(var(--ring-container-size) / 2); 380 | cx: var(--center); 381 | cy: var(--center); 382 | r: calc(var(--ring-radius) - calc(var(--stroke-thickness) / 2)); 383 | fill: hsla(0, 0%, 0%, 0); 384 | stroke-width: var(--stroke-thickness); 385 | } 386 | 387 | #mocha-stats .ring-flatlight { 388 | stroke: var(--mocha-progress-ring-color); 389 | } 390 | 391 | #mocha-stats .ring-highlight { 392 | stroke: var(--mocha-progress-ring-highlight-color); 393 | } 394 | 395 | #mocha-stats em { 396 | color: var(--mocha-stats-em-color); 397 | } 398 | 399 | #mocha-stats a { 400 | text-decoration: none; 401 | color: inherit; 402 | } 403 | 404 | #mocha-stats a:hover { 405 | border-bottom: 1px solid var(--mocha-stats-hover-color); 406 | } 407 | 408 | #mocha-stats li { 409 | display: inline-block; 410 | margin: 0 5px; 411 | list-style: none; 412 | padding-top: 11px; 413 | } 414 | 415 | #mocha code .comment { color: var(--mocha-code-comment); } 416 | #mocha code .init { color: var(--mocha-code-init); } 417 | #mocha code .string { color: var(--mocha-code-string); } 418 | #mocha code .keyword { color: var(--mocha-code-keyword); } 419 | #mocha code .number { color: var(--mocha-code-number); } 420 | 421 | @media screen and (max-device-width: 480px) { 422 | #mocha { 423 | margin: 60px 0px; 424 | } 425 | 426 | #mocha #stats { 427 | position: absolute; 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /client/twitter-maintainance.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedwiki/wiki-client/b9cd6c76c060bfa2d378d58a0883a1e45a2c581a/client/twitter-maintainance.jpg -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import pluginJs from '@eslint/js' 3 | 4 | /** @type {import('eslint').Linter.Config[]} */ 5 | export default [ 6 | { ignores: ['client/**'] }, 7 | pluginJs.configs.recommended, 8 | { 9 | files: ['client.js', 'lib/*.js'], 10 | languageOptions: { 11 | sourceType: 'commonjs', 12 | globals: { 13 | wiki: 'writable', 14 | ...globals.browser, 15 | ...globals.jquery, 16 | }, 17 | }, 18 | }, 19 | { 20 | files: ['testclient.js', 'test/*.js'], 21 | languageOptions: { 22 | sourceType: 'commonjs', 23 | globals: { 24 | expect: 'readonly', 25 | sinon: 'readonly', 26 | ...globals.browser, 27 | ...globals.jquery, 28 | ...globals.mocha, 29 | }, 30 | }, 31 | }, 32 | { 33 | files: ['scripts/*.js'], 34 | languageOptions: { 35 | sourceType: 'commonjs', 36 | globals: { 37 | ...globals.node, 38 | }, 39 | }, 40 | }, 41 | { 42 | files: ['scripts/*.mjs'], 43 | languageOptions: { 44 | sourceType: 'module', 45 | globals: { 46 | ...globals.node, 47 | }, 48 | }, 49 | }, 50 | ] 51 | -------------------------------------------------------------------------------- /lib/actionSymbols.js: -------------------------------------------------------------------------------- 1 | // We use unicode characters as icons for actions 2 | // in the journal. Fork and add are also button 3 | // labels used for user actions leading to forks 4 | // and adds. How poetic. 5 | 6 | // Page keeps its own list of symbols used as journal 7 | // action separators. 8 | 9 | const symbols = { 10 | create: '☼', 11 | add: '+', 12 | edit: '✎', 13 | fork: '⚑', 14 | move: '↕', 15 | remove: '✕', 16 | copyIn: '⨭', 17 | copyOut: '⨵', 18 | } 19 | 20 | const fork = symbols['fork'] 21 | const add = symbols['add'] 22 | 23 | module.exports = { symbols, fork, add } 24 | -------------------------------------------------------------------------------- /lib/active.js: -------------------------------------------------------------------------------- 1 | // Wiki considers one page to be active. Use active.set to change which 2 | // page this is. A page need not be active to be edited. 3 | 4 | let active 5 | module.exports = active = {} 6 | 7 | // active.scrollContainer = undefined; 8 | 9 | // const findScrollContainer = function() { 10 | // const scrolled = $(".main").filter(function() { return $(this).scrollLeft() > 0; }); 11 | // if (scrolled.length > 0) { 12 | // return scrolled; 13 | // } else { 14 | // return $(".main").scrollLeft(12).filter(function() { return $(this).scrollLeft() > 0; }).scrollTop(0); 15 | // } 16 | // }; 17 | 18 | // const scrollTo = function($page) { 19 | // let scrollTarget; 20 | // if ($page.position() == null) { return; } 21 | // if (active.scrollContainer == null) { active.scrollContainer = findScrollContainer(); } 22 | // const bodyWidth = $("body").width(); 23 | // const minX = active.scrollContainer.scrollLeft(); 24 | // const maxX = minX + bodyWidth; 25 | // const target = $page.position().left; 26 | // const width = $page.outerWidth(true); 27 | // const contentWidth = $(".page").outerWidth(true) * $(".page").length; 28 | 29 | // // determine target position to scroll to... 30 | // if (target < minX) { 31 | // scrollTarget = target; 32 | // } else if ((target + width) > maxX) { 33 | // scrollTarget = target - (bodyWidth - width); 34 | // } else if (maxX > $(".pages").outerWidth()) { 35 | // scrollTarget = Math.min(target, contentWidth - bodyWidth); 36 | // } 37 | // // scroll to target and set focus once animation is complete 38 | // active.scrollContainer.animate({ 39 | // scrollLeft: scrollTarget 40 | // }, function() { 41 | // // only set focus if focus is not already within the page to get focus 42 | // if (!$.contains($page[0], document.activeElement)) { $page.trigger('focus'); } 43 | // } ); 44 | // }; 45 | 46 | function scrollTo($page) { 47 | const element = $page.get(0) 48 | element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) 49 | } 50 | 51 | active.set = function ($page, noScroll) { 52 | if ($page == null) return 53 | $('.incremental-search').remove() 54 | $page = $($page) 55 | $('.active').removeClass('active') 56 | $page.addClass('active') 57 | if (!noScroll) { 58 | scrollTo($page) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/addToJournal.js: -------------------------------------------------------------------------------- 1 | // A wiki page has a journal of actions that have been completed. 2 | // The addToJournal function is called when the origin server 3 | // response that the network operation is complete. 4 | 5 | const util = require('./util') 6 | const actionSymbols = require('./actionSymbols') 7 | 8 | module.exports = ($journal, action) => { 9 | const $page = $journal.parents('.page:first') 10 | const $action = $(' ') 11 | .addClass('action') 12 | .addClass(action.type || 'separator') 13 | .text(action.symbol || actionSymbols.symbols[action.type]) 14 | .attr('title', util.formatActionTitle(action)) 15 | .attr('data-id', action.id || '0') 16 | .attr('data-date', action.date || '0') 17 | .data('action', action) 18 | if (action.type === 'add' && action.attribution) { 19 | $action.text(actionSymbols.symbols['copyIn']) 20 | if (action.attribution.site) { 21 | $action.css('background-image', `url(${wiki.site(action.attribution.site).flag()})`) 22 | } 23 | } 24 | if (action.type === 'remove' && action.removedTo) { 25 | $action.text(actionSymbols.symbols['copyOut']) 26 | } 27 | const controls = $journal.children('.control-buttons') 28 | if (controls.length > 0) { 29 | $action.insertBefore(controls) 30 | } else { 31 | $action.appendTo($journal) 32 | } 33 | if (action.type === 'fork' && action.site) { 34 | $action 35 | .css('background-image', `url(${wiki.site(action.site).flag()}`) 36 | .attr('href', `${wiki.site(action.site).getDirectURL($page.attr('id'))}.html`) 37 | .attr('target', `${action.site}`) 38 | .data('site', action.site) 39 | .data('slug', $page.attr('id')) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/bind.js: -------------------------------------------------------------------------------- 1 | // Bind connects the searchbox and the neighbors, both views, 2 | // to the neighborhood, the model that they use. This breaks 3 | // a dependency loop that will probably dissapear when views 4 | // are more event oriented. 5 | 6 | // Similarly state depends on injection rather than requiring 7 | // link and thereby breaks another dependency loop. 8 | 9 | const neighborhood = require('./neighborhood') 10 | const neighbors = require('./neighbors') 11 | const searchbox = require('./searchbox') 12 | 13 | const state = require('./state') 14 | const link = require('./link') 15 | 16 | $(function () { 17 | searchbox.inject(neighborhood) 18 | searchbox.bind() 19 | 20 | neighbors.inject(neighborhood) 21 | neighbors.bind() 22 | 23 | if (window.seedNeighbors) { 24 | window.seedNeighbors.split(',').forEach(site => neighborhood.registerNeighbor(site.trim())) 25 | } 26 | 27 | state.inject(link) 28 | }) 29 | -------------------------------------------------------------------------------- /lib/dialog.js: -------------------------------------------------------------------------------- 1 | // Dialog manages a single popup window that is used to present a 2 | // dialog used for detail display, usually on double click. 3 | 4 | const open = (title, html) => { 5 | let body = html 6 | if (typeof html === 'object') { 7 | body = html[0].outerHTML 8 | } 9 | 10 | const dialogWindow = window.open('/dialog/#', 'dialog', 'popup,height=600,width=800') 11 | 12 | if (dialogWindow.location.pathname !== '/dialog/') { 13 | // this will only happen when popup is first opened. 14 | dialogWindow.addEventListener('load', () => dialogWindow.postMessage({ title, body }, window.origin)) 15 | } else { 16 | dialogWindow.postMessage({ title, body }, window.origin) 17 | } 18 | } 19 | 20 | module.exports = { open } 21 | -------------------------------------------------------------------------------- /lib/drop.js: -------------------------------------------------------------------------------- 1 | // handle drops of wiki pages or thing that go on wiki pages 2 | // (we'll move decoding logic out of factory) 3 | 4 | const isFile = function (event) { 5 | let dt 6 | if ((dt = event.originalEvent.dataTransfer) != null) { 7 | if (dt.types.includes('Files')) { 8 | return dt.files[0] 9 | } 10 | } 11 | return null 12 | } 13 | 14 | const isUrl = function (event) { 15 | let dt 16 | if ((dt = event.originalEvent.dataTransfer) != null) { 17 | if (dt.types != null && (dt.types.includes('text/uri-list') || dt.types.includes('text/x-moz-url'))) { 18 | const url = dt.getData('URL') 19 | if (url != null ? url.length : undefined) { 20 | return url 21 | } 22 | } 23 | } 24 | return null 25 | } 26 | 27 | const isPage = function (url) { 28 | let found 29 | if ((found = url.match(/^https?:\/\/([a-zA-Z0-9:.-]+)(\/([a-zA-Z0-9:.-]+)\/([a-z0-9-]+(_rev\d+)?))+$/))) { 30 | let origin 31 | const item = {} 32 | ;[, origin, , item.site, item.slug] = found 33 | if (['view', 'local', 'origin'].includes(item.site)) { 34 | item.site = origin 35 | } 36 | return item 37 | } 38 | return null 39 | } 40 | 41 | const isImage = function (url) { 42 | const parsedURL = new URL(url) 43 | if (parsedURL.pathname.match(/\.(jpg|jpeg|png)$/i)) { 44 | return url 45 | } 46 | return null 47 | } 48 | 49 | const isSvg = function (url) { 50 | const parsedURL = new URL(url) 51 | if (parsedURL.pathname.match(/\.(svg)$/i)) { 52 | return url 53 | } 54 | return null 55 | } 56 | 57 | const isVideo = function (url) { 58 | let parsedURL = new URL(url) 59 | // check if video dragged from search (Google) 60 | try { 61 | if (parsedURL.searchParams.get('source') === 'video') { 62 | parsedURL = new URL(parsedURL.searchParams.get('url')) 63 | } 64 | } catch { 65 | // continue regardless of error 66 | } 67 | 68 | switch (parsedURL.hostname) { 69 | case 'www.youtube.com': 70 | if (parsedURL.searchParams.get('list') != null) { 71 | return { text: `YOUTUBE PLAYLIST ${parsedURL.searchParams.get('list')}` } 72 | } else { 73 | return { text: `YOUTUBE ${parsedURL.searchParams.get('v')}` } 74 | } 75 | case 'youtu.be': // should redirect to www.youtube.com, but... 76 | if (parsedURL.searchParams.get('list') != null) { 77 | return { text: `YOUTUBE PLAYLIST ${parsedURL.searchParams.get('list')}` } 78 | } else { 79 | return { text: `YOUTUBE ${parsedURL.pathname.substring(1)}` } 80 | } 81 | case 'vimeo.com': 82 | return { text: `VIMEO ${parsedURL.pathname.substring(1)}` } 83 | case 'archive.org': 84 | return { text: `ARCHIVE ${parsedURL.pathname.substring(parsedURL.pathname.lastIndexOf('/') + 1)}` } 85 | case 'tedxtalks.ted.com': 86 | return { text: `TEDX ${parsedURL.pathname.substring(parsedURL.pathname.lastIndexOf('/') + 1)}` } 87 | case 'www.ted.com': 88 | return { text: `TED ${parsedURL.pathname.substring(parsedURL.pathname.lastIndexOf('/') + 1)}` } 89 | default: 90 | return null 91 | } 92 | } 93 | 94 | const dispatch = handlers => event => { 95 | let file, handle, punt, url 96 | const stop = () => { 97 | event.preventDefault() 98 | event.stopPropagation() 99 | } 100 | if ((url = isUrl(event))) { 101 | let image, page, svg, video 102 | if ((page = isPage(url))) { 103 | if ((handle = handlers.page)) { 104 | return stop(handle(page)) 105 | } 106 | } 107 | if ((video = isVideo(url))) { 108 | if ((handle = handlers.video)) { 109 | return stop(handle(video)) 110 | } 111 | } 112 | if ((image = isImage(url))) { 113 | if ((handle = handlers.image)) { 114 | return stop(handle(image)) 115 | } 116 | } 117 | if ((svg = isSvg(url))) { 118 | if ((handle = handlers.svg)) { 119 | return stop(handle(svg)) 120 | } 121 | } 122 | punt = { url } 123 | } 124 | if ((file = isFile(event))) { 125 | if ((handle = handlers.file)) { 126 | return stop(handle(file)) 127 | } 128 | punt = { file } 129 | } 130 | if ((handle = handlers.punt)) { 131 | if (!punt) { 132 | punt = { dt: event.dataTransfer, types: event.dataTransfer != null ? event.dataTransfer.types : undefined } 133 | } 134 | return stop(handle(punt)) 135 | } 136 | } 137 | 138 | module.exports = { dispatch } 139 | -------------------------------------------------------------------------------- /lib/editor.js: -------------------------------------------------------------------------------- 1 | // Editor provides a small textarea for editing wiki markup. 2 | // It can split and join paragraphs markup but leaves other 3 | // types alone assuming they will interpret multiple lines. 4 | 5 | const plugin = require('./plugin') 6 | const itemz = require('./itemz') 7 | const pageHandler = require('./pageHandler') 8 | const link = require('./link') 9 | const random = require('./random') 10 | 11 | // Editor takes a div and an item that goes in it. 12 | // Options manage state during splits and joins. 13 | // Options are available to plugins but rarely used. 14 | // 15 | // caret: position -- sets the cursor at the point of join 16 | // append: true -- sets the cursor to end and scrolls there 17 | // after: id -- new item to be added after id 18 | // sufix: text -- editor opens with unsaved suffix appended 19 | // field: 'text' -- editor operates on this field of the item 20 | 21 | const escape = string => string.replace(/&/g, '&').replace(//g, '>') 22 | 23 | var textEditor = function ($item, item, option) { 24 | // console.log 'textEditor', item.id, option 25 | let enterCount 26 | if (option == null) { 27 | option = {} 28 | } 29 | if (item.type === 'markdown') { 30 | enterCount = 0 31 | } 32 | if (!$('.editEnable').is(':visible')) { 33 | return 34 | } 35 | 36 | const keydownHandler = function (e) { 37 | if (e.which === 27) { 38 | //esc for save 39 | e.preventDefault() 40 | $textarea.trigger('focusout') 41 | return false 42 | } 43 | 44 | if ((e.ctrlKey || e.metaKey) && e.which === 83) { 45 | //ctrl-s for save 46 | e.preventDefault() 47 | $textarea.trigger('focusout') 48 | return false 49 | } 50 | 51 | if ((e.ctrlKey || e.metaKey) && e.which === 73) { 52 | //ctrl-i for information 53 | let page 54 | e.preventDefault() 55 | if (!e.shiftKey) { 56 | page = $(e.target).parents('.page') 57 | } 58 | link.doInternalLink(`about ${item.type} plugin`, page) 59 | return false 60 | } 61 | 62 | if ((e.ctrlKey || e.metaKey) && e.which === 77) { 63 | //ctrl-m for menu 64 | e.preventDefault() 65 | $item.data('originalType', item.type) 66 | $item.removeClass(item.type).addClass((item.type = 'factory')) 67 | $textarea.trigger('focusout') 68 | return false 69 | } 70 | 71 | // provides automatic new paragraphs on enter and concatenation on backspace 72 | if (item.type === 'paragraph' || item.type === 'markdown') { 73 | let suffix 74 | const sel = getSelectionPos($textarea) // position of caret or selected text coords 75 | 76 | if (e.which === $.ui.keyCode.BACKSPACE && sel.start === 0 && sel.start === sel.end) { 77 | const $previous = $item.prev() 78 | const previous = itemz.getItem($previous) 79 | if (previous.type !== item.type) { 80 | return false 81 | } 82 | const caret = previous[option.field || 'text'].length 83 | suffix = $textarea.val() 84 | $textarea.val('') // Need current text area to be empty. Item then gets deleted. 85 | textEditor($previous, previous, { caret, suffix }) 86 | return false 87 | } 88 | 89 | if (e.which === $.ui.keyCode.ENTER) { 90 | // console.log "Type: #{item.type}, enterCount: #{enterCount}" 91 | if (!sel) { 92 | return false 93 | } 94 | if (item.type === 'markdown') { 95 | enterCount++ 96 | } 97 | // console.log "Type: #{item.type}, enterCount: #{enterCount}" 98 | if (item.type === 'paragraph' || (item.type === 'markdown' && enterCount === 2)) { 99 | const $page = $item.parents('.page') 100 | const text = $textarea.val() 101 | const prefix = text.substring(0, sel.start).trim() 102 | suffix = text.substring(sel.end).trim() 103 | if (prefix === '') { 104 | $textarea.val(suffix) 105 | $textarea.trigger('focusout') 106 | spawnEditor($page, $item.prev(), item.type, prefix) 107 | } else { 108 | $textarea.val(prefix) 109 | $textarea.trigger('focusout') 110 | spawnEditor($page, $item, item.type, suffix) 111 | } 112 | return false 113 | } 114 | } else { 115 | if (item.type === 'markdown') { 116 | enterCount = 0 117 | } 118 | } 119 | } 120 | } 121 | 122 | const focusoutHandler = function () { 123 | $item.removeClass('textEditing') 124 | $textarea.off() 125 | const $page = $item.parents('.page:first') 126 | if ((item[option.field || 'text'] = $textarea.val())) { 127 | // Remove output and source styling as type may have changed. 128 | $item.removeClass('output-item') 129 | $item.removeClass((_index, className) => (className.match(/\S+-source/) || []).join(' ')) 130 | plugin.do($item.empty(), item) 131 | if (option.after) { 132 | if (item[option.field || 'text'] === '') { 133 | return 134 | } 135 | pageHandler.put($page, { type: 'add', id: item.id, item, after: option.after }) 136 | } else { 137 | if ( 138 | item[option.field || 'text'] !== original || 139 | (item.type != 'factory' && $item.data('originalType') && $item.data('originalType') != item.type) 140 | ) { 141 | $item.removeData('originalType') 142 | pageHandler.put($page, { type: 'edit', id: item.id, item }) 143 | } 144 | } 145 | } else { 146 | if (!option.after) { 147 | pageHandler.put($page, { type: 'remove', id: item.id }) 148 | } 149 | const index = $('.item').index($item) 150 | $item.remove() 151 | plugin.renderFrom(index) 152 | } 153 | } 154 | 155 | if ($item.hasClass('textEditing')) { 156 | return 157 | } 158 | $item.addClass('textEditing') 159 | $item.off() 160 | var original = item[option.field || 'text'] || '' 161 | var $textarea = $(``) 162 | .on('focusout', focusoutHandler) 163 | .on('keydown', keydownHandler) 164 | $item.html($textarea) 165 | if (option.caret) { 166 | setCaretPosition($textarea, option.caret) 167 | } else if (option.append) { 168 | // we want the caret to be at the end 169 | setCaretPosition($textarea, $textarea.val().length) 170 | //scrolls to bottom of text area 171 | $textarea.scrollTop($textarea[0].scrollHeight - $textarea.height()) 172 | } else { 173 | $textarea.trigger('focus') 174 | } 175 | } 176 | 177 | var spawnEditor = function ($page, $before, type, text) { 178 | const item = { 179 | type, 180 | id: random.itemId(), 181 | text, 182 | } 183 | const $item = $(`
`) 184 | $item.data('item', item).data('pageElement', $page) 185 | $before.after($item) 186 | const before = itemz.getItem($before) 187 | textEditor($item, item, { after: before?.id }) 188 | } 189 | 190 | // If the selection start and selection end are both the same, 191 | // then you have the caret position. If there is selected text, 192 | // the browser will not tell you where the caret is, but it will 193 | // either be at the beginning or the end of the selection 194 | // (depending on the direction of the selection). 195 | 196 | var getSelectionPos = function ($textarea) { 197 | const el = $textarea.get(0) // gets DOM Node from from jQuery wrapper 198 | if (document.selection) { 199 | // IE 200 | el.focus() 201 | const sel = document.selection.createRange() 202 | sel.moveStart('character', -el.value.length) 203 | const iePos = sel.text.length 204 | return { start: iePos, end: iePos } 205 | } else { 206 | return { start: el.selectionStart, end: el.selectionEnd } 207 | } 208 | } 209 | 210 | var setCaretPosition = function ($textarea, caretPos) { 211 | const el = $textarea.get(0) 212 | if (el) { 213 | if (el.createTextRange) { 214 | // IE 215 | const range = el.createTextRange() 216 | range.move('character', caretPos) 217 | range.select() 218 | } else { 219 | // rest of the world 220 | el.setSelectionRange(caretPos, caretPos) 221 | } 222 | el.focus() 223 | } 224 | } 225 | 226 | // # may want special processing on paste eventually 227 | // textarea.bind 'paste', (e) -> 228 | // console.log 'textedit paste', e 229 | // console.log e.originalEvent.clipboardData.getData('text') 230 | 231 | module.exports = { textEditor } 232 | -------------------------------------------------------------------------------- /lib/factory.js: -------------------------------------------------------------------------------- 1 | // A Factory plugin provides a drop zone for desktop content 2 | // destined to be one or another kind of item. Double click 3 | // will turn it into a normal paragraph. 4 | 5 | // const neighborhood = require('./neighborhood') 6 | const plugin = require('./plugin') 7 | const resolve = require('./resolve') 8 | const pageHandler = require('./pageHandler') 9 | const editor = require('./editor') 10 | const synopsis = require('./synopsis') 11 | const drop = require('./drop') 12 | const active = require('./active') 13 | 14 | const escape = line => line.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') 15 | 16 | const emit = function ($item, item) { 17 | $item.append('

Double-Click to Edit
Drop Text or Image to Insert

') 18 | 19 | const showMenu = function () { 20 | const menu = $item.find('p').append(`\ 21 |
Or Choose a Plugin 22 |
23 | 24 |
        \ 25 | `) 26 | for (var info of window.catalog) { 27 | if (info && info.category) { 28 | var column = info.category 29 | if (!['format', 'data'].includes(column)) { 30 | column = 'other' 31 | } 32 | menu.find('#' + column).append(`\ 33 |
      • ${info.name}
      • \ 34 | `) 35 | } 36 | } 37 | menu.find('a.menu').on('click', function (evt) { 38 | const pluginName = evt.target.text 39 | const pluginType = pluginName.toLowerCase() 40 | $item.removeClass('factory').addClass((item.type = pluginType)) 41 | $item.off() 42 | evt.preventDefault() 43 | active.set($item.parents('.page')) 44 | const catalogEntry = window.catalog.find(entry => pluginName === entry.name) 45 | if (catalogEntry.editor) { 46 | try { 47 | window.plugins[pluginType].editor($item, item) 48 | } catch (error) { 49 | console.log(`${pluginName} Plugin editor failed: ${error}. Falling back to textEditor`) 50 | editor.textEditor($item, item) 51 | } 52 | } else { 53 | editor.textEditor($item, item) 54 | } 55 | }) 56 | } 57 | 58 | const showPrompt = () => $item.append(`

        ${resolve.resolveLinks(item.prompt, escape)}`) 59 | 60 | if (item.prompt) { 61 | showPrompt() 62 | } else if (window.catalog) { 63 | showMenu() 64 | } else { 65 | wiki.origin.get('system/factories.json', function (error, data) { 66 | // console.log 'factory', data 67 | window.catalog = data 68 | showMenu() 69 | }) 70 | } 71 | } 72 | 73 | const bind = function ($item, item) { 74 | const syncEditAction = function () { 75 | $item.empty().off() 76 | $item.removeClass('factory').addClass(item.type) 77 | const $page = $item.parents('.page:first') 78 | try { 79 | $item.data('pageElement', $page) 80 | $item.data('item', item) 81 | plugin.getPlugin(item.type, function (plugin) { 82 | plugin.emit($item, item) 83 | plugin.bind($item, item) 84 | }) 85 | } catch (err) { 86 | $item.append(`

        ${err}

        `) 87 | } 88 | pageHandler.put($page, { type: 'edit', id: item.id, item }) 89 | } 90 | 91 | const punt = function (data) { 92 | item.prompt = 93 | "Unexpected Item\nWe can't make sense of the drop.\nTry something else or see [[About Factory Plugin]]." 94 | data.userAgent = navigator.userAgent 95 | item.punt = data 96 | syncEditAction() 97 | } 98 | 99 | const addReference = data => 100 | wiki.site(data.site).get(`${data.slug}.json`, function (err, remote) { 101 | if (!err) { 102 | item.type = 'reference' 103 | item.site = data.site 104 | item.slug = data.slug 105 | item.title = remote.title || data.slug 106 | item.text = synopsis(remote) 107 | syncEditAction() 108 | // don't extend neighborhood on reference creation 109 | // if (item.site) { 110 | // neighborhood.registerNeighbor(item.site) 111 | // } 112 | } 113 | }) 114 | 115 | const addVideo = function (video) { 116 | item.type = 'video' 117 | item.text = `${video.text}\n(double-click to edit caption)\n` 118 | syncEditAction() 119 | } 120 | 121 | const addRemoteImage = function (url) { 122 | // give some feedback, in case this is going to take a while... 123 | document.documentElement.style.cursor = 'wait' 124 | 125 | fetch(url) 126 | .then(function (response) { 127 | if (response.ok) { 128 | return response.blob() 129 | } 130 | throw new Error('Unable to fetch image') 131 | }) 132 | .then(function (imageBlob) { 133 | const imageFileName = url.split('/').pop().split('#')[0].split('?')[0] 134 | // not sure if converting to file gives anything! 135 | // imageFile = new File([imageBlob], imageFileName, { type: imageBlob.type }) 136 | const reader = new FileReader() 137 | reader.readAsDataURL(imageBlob) 138 | reader.onload = function (loadEvent) { 139 | const imageDataURL = loadEvent.target.result 140 | window.plugins['image'].editor({ 141 | imageDataURL, 142 | filename: imageFileName, 143 | imageSourceURL: url, 144 | imageCaption: `Remote image [${url} source]`, 145 | $item, 146 | item, 147 | }) 148 | } 149 | }) 150 | } 151 | 152 | const addRemoteSvg = function (url) { 153 | document.documentElement.style.cursor = 'wait' 154 | fetch(url) 155 | .then(function (response) { 156 | if (response.ok) { 157 | return response 158 | } 159 | throw new Error('Unable to fetch svg') 160 | }) 161 | .then(response => response.text()) 162 | .then(function (svgText) { 163 | document.documentElement.style.cursor = 'default' 164 | item.type = 'html' 165 | item.source = url 166 | item.text = svgText + `

        [${url} Source]

        ` 167 | syncEditAction() 168 | }) 169 | } 170 | 171 | const readFile = function (file) { 172 | if (file != null) { 173 | const [majorType, minorType] = file.type.split('/') 174 | const reader = new FileReader() 175 | if (majorType === 'image') { 176 | // svg -> html plugin 177 | if (minorType.startsWith('svg')) { 178 | reader.onload = function (loadEvent) { 179 | const { result } = loadEvent.target 180 | item.type = 'html' 181 | item.text = result 182 | syncEditAction() 183 | } 184 | reader.readAsText(file) 185 | } else { 186 | reader.onload = function (loadEvent) { 187 | // console.log('upload file', file) 188 | const imageDataURL = loadEvent.target.result 189 | window.plugins['image'].editor({ 190 | imageDataURL, 191 | filename: file.name, 192 | imageCaption: 'Uploaded image', 193 | $item, 194 | item, 195 | }) 196 | } 197 | reader.readAsDataURL(file) 198 | } 199 | } else if (majorType === 'text') { 200 | reader.onload = function (loadEvent) { 201 | const { result } = loadEvent.target 202 | if (minorType === 'csv') { 203 | let array 204 | item.type = 'data' 205 | item.columns = (array = csvToArray(result))[0] 206 | item.data = arrayToJson(array) 207 | item.text = file.fileName 208 | } else { 209 | item.type = 'paragraph' 210 | item.text = result 211 | } 212 | syncEditAction() 213 | } 214 | reader.readAsText(file) 215 | } else { 216 | punt({ 217 | name: file.name, 218 | type: file.type, 219 | size: file.size, 220 | fileName: file.fileName, 221 | lastModified: file.lastModified, 222 | }) 223 | } 224 | } 225 | } 226 | 227 | $item.on('dblclick', function (e) { 228 | if (!$('.editEnable').is(':visible')) { 229 | return 230 | } 231 | 232 | if (e.shiftKey) { 233 | return editor.textEditor($item, item, { field: 'prompt' }) 234 | } else { 235 | $item.removeClass('factory').addClass((item.type = 'paragraph')) 236 | $item.off() 237 | return editor.textEditor($item, item) 238 | } 239 | }) 240 | 241 | $item.on('dragenter', evt => evt.preventDefault()) 242 | $item.on('dragover', evt => evt.preventDefault()) 243 | $item.on( 244 | 'drop', 245 | drop.dispatch({ 246 | page: addReference, 247 | file: readFile, 248 | video: addVideo, 249 | image: addRemoteImage, 250 | svg: addRemoteSvg, 251 | punt, 252 | }), 253 | ) 254 | } 255 | 256 | // from http://www.bennadel.com/blog/1504-Ask-Ben-Parsing-CSV-Strings-With-Javascript-Exec-Regular-Expression-Command.htm 257 | // via http://stackoverflow.com/questions/1293147/javascript-code-to-parse-csv-data 258 | 259 | var csvToArray = function (strData, strDelimiter) { 260 | strDelimiter = strDelimiter || ',' 261 | const objPattern = new RegExp( 262 | '(\\' + strDelimiter + '|\\r?\\n|\\r|^)' + '(?:"([^"]*(?:""[^"]*)*)"|' + '([^"\\' + strDelimiter + '\\r\\n]*))', 263 | 'gi', 264 | ) 265 | const arrData = [[]] 266 | let arrMatches = null 267 | while ((arrMatches = objPattern.exec(strData))) { 268 | var strMatchedValue 269 | var strMatchedDelimiter = arrMatches[1] 270 | if (strMatchedDelimiter.length && strMatchedDelimiter !== strDelimiter) { 271 | arrData.push([]) 272 | } 273 | if (arrMatches[2]) { 274 | strMatchedValue = arrMatches[2].replace(new RegExp('""', 'g'), '"') 275 | } else { 276 | strMatchedValue = arrMatches[3] 277 | } 278 | arrData[arrData.length - 1].push(strMatchedValue) 279 | } 280 | return arrData 281 | } 282 | 283 | var arrayToJson = function (array) { 284 | const cols = array.shift() 285 | const rowToObject = function (row) { 286 | const obj = {} 287 | row.forEach(function (v, idx) { 288 | let k = cols[idx] 289 | if (v != null && v.match(/\S/) && v !== 'NULL') { 290 | obj[k] = v 291 | } 292 | }) 293 | return obj 294 | } 295 | const result = [] 296 | for (var row of array) { 297 | result.push(rowToObject(row)) 298 | } 299 | return result 300 | } 301 | 302 | module.exports = { emit, bind } 303 | -------------------------------------------------------------------------------- /lib/future.js: -------------------------------------------------------------------------------- 1 | // A Future plugin represents a page that hasn't been written 2 | // or wasn't found where expected. It recognizes template pages 3 | // and offers to clone them or make a blank page. 4 | 5 | const resolve = require('./resolve') 6 | const neighborhood = require('./neighborhood') 7 | 8 | const lineup = require('./lineup') 9 | const refresh = require('./refresh') 10 | 11 | const emit = function ($item, item) { 12 | let info, transport 13 | $item.append(`${item.text}`) 14 | const proposedSlug = $item.parents('.page:first')[0].id 15 | if (wiki.asSlug(item.title) !== proposedSlug) { 16 | $item.append( 17 | "

        Page titles with leading/trailing spaces cannot be used to create a new page.

        ", 18 | ) 19 | } else { 20 | $item.append('

        new blank page') 21 | } 22 | 23 | if ((transport = item.create?.source?.transport)) { 24 | $item.append(`
        transport from ${transport}`) 25 | $item.append('

        unavailable

        ') 26 | $.get('//localhost:4020', () => $item.find('.caption').text('ready')) 27 | } 28 | 29 | if ((info = neighborhood.sites[location.host]) && info.sitemap) { 30 | for (var localPage of info.sitemap) { 31 | if (localPage.slug.match(/-template$/)) { 32 | $item.append( 33 | `
        from ${resolve.resolveLinks(`[[${localPage.title}]]`)}`, 34 | ) 35 | } 36 | } 37 | } 38 | 39 | if (item.context?.length > 0 || (isSecureContext && !location.hostname.endsWith('localhost'))) { 40 | $item.append(`\ 41 |

        Some possible places to look for this page, if it exists:

        \ 42 | `) 43 | } 44 | 45 | let offerAltLineup = true 46 | 47 | if (item.context?.length > 0) { 48 | const offerPages = [] 49 | item.context.forEach(c => { 50 | if (wiki.neighborhood[c].lastModified === 0) { 51 | const slug = wiki.asSlug(item.title) 52 | offerPages.push(`\ 53 |

        54 | 57 | ${c} 60 |

        \ 61 | `) 62 | } 63 | }) 64 | if (offerPages.length > 0) { 65 | $item.append(`\ 66 |
        67 |

        Try on remote wiki where it was expected to be found, opens in a new tab.

        68 | ${offerPages.join('\n')} 69 |
        \ 70 | `) 71 | } else { 72 | // offerAltLineup = false 73 | $item.append(`\ 74 |
        75 |

        None of the expected places were reachable.

        76 |
        \ 77 | `) 78 | } 79 | } else { 80 | offerAltLineup = false 81 | } 82 | 83 | if (isSecureContext && offerAltLineup && !location.hostname.endsWith('localhost')) { 84 | const altContext = document.URL.replace(/^https/, 'http').replace(/\/\w+\/[\w-]+$/, '') 85 | const altLinkText = altContext.length > 55 ? altContext.substring(0, 55) + '...' : altContext 86 | $item.append(`\ 87 |
        88 |

        Try opening lineup using http, opens in a new tab.

        89 |

        ${altLinkText}.

        90 |
        91 |
        92 |

        93 |

        \ 94 | `) 95 | } 96 | } 97 | 98 | const bind = ($item, item) => 99 | $item.find('button.transport').on('click', () => { 100 | $item.find('.caption').text('waiting') 101 | 102 | // duplicatingTransport and Templage logic 103 | 104 | const params = { 105 | title: $item.parents('.page').data('data').title, 106 | create: item.create, 107 | } 108 | 109 | const req = { 110 | type: 'POST', 111 | url: item.create.source.transport, 112 | dataType: 'json', 113 | contentType: 'application/json', 114 | data: JSON.stringify(params), 115 | } 116 | 117 | $.ajax(req).done(function (page) { 118 | $item.find('.caption').text('ready') 119 | const resultPage = wiki.newPage(page) 120 | const $page = $item.parents('.page') 121 | const pageObject = lineup.atKey($page.data('key')) 122 | pageObject.become(resultPage, resultPage) 123 | page = pageObject.getRawPage() 124 | refresh.rebuildPage(pageObject, $page.empty()) 125 | }) 126 | }) 127 | 128 | module.exports = { emit, bind } 129 | -------------------------------------------------------------------------------- /lib/importer.js: -------------------------------------------------------------------------------- 1 | // An Importer plugin completes the ghost page created upon drop of a site export file. 2 | 3 | const util = require('./util') 4 | const link = require('./link') 5 | const { newPage } = require('./page') 6 | 7 | const escape = text => text.replace(/&/g, '&').replace(//g, '>') 8 | 9 | const emit = function ($item, item) { 10 | const render = function (pages) { 11 | const result = [] 12 | for (var slug in pages) { 13 | var page = pages[slug] 14 | var line = `${escape(page.title) || slug}` 15 | if (page.journal) { 16 | var date 17 | if ((date = page.journal[page.journal.length - 1].date)) { 18 | line += `   from ${util.formatElapsedTime(date)}` 19 | } else { 20 | line += `   from revision ${page.journal.length - 1}` 21 | } 22 | } 23 | result.push(line) 24 | } 25 | return result.join('
        ') 26 | } 27 | 28 | return $item.append(`\ 29 |

        30 | ${render(item.pages)} 31 |

        \ 32 | `) 33 | } 34 | 35 | const bind = ($item, item) => 36 | $item.find('a').on('click', function (e) { 37 | e.preventDefault() 38 | e.stopPropagation() 39 | let $page 40 | const slug = $(e.target).attr('href') 41 | if (!e.shiftKey) { 42 | $page = $(e.target).parents('.page') 43 | } 44 | const pageObject = newPage(item.pages[slug]) 45 | link.showResult(pageObject, { $page }) 46 | }) 47 | 48 | module.exports = { emit, bind } 49 | -------------------------------------------------------------------------------- /lib/itemz.js: -------------------------------------------------------------------------------- 1 | // The itemz module understands how we have been keeping track of 2 | // story items and their corresponding divs. It offers utility 3 | // functions used elsewere. We anticipate a more proper model eventually. 4 | 5 | const pageHandler = require('./pageHandler') 6 | const plugin = require('./plugin') 7 | const random = require('./random') 8 | 9 | const sleep = (time, done) => setTimeout(done, time) 10 | 11 | const getItem = function ($item) { 12 | if ($($item).length > 0) { 13 | return $($item).data('item') || $($item).data('staticItem') 14 | } 15 | } 16 | 17 | const removeItem = function ($item, item) { 18 | pageHandler.put($item.parents('.page:first'), { type: 'remove', id: item.id }) 19 | $item.remove() 20 | } 21 | 22 | const createItem = function ($page, $before, item) { 23 | if (!$page) { 24 | $page = $before.parents('.page') 25 | } 26 | item.id = random.itemId() 27 | const $item = $(`\ 28 |
        \ 29 | `) 30 | $item.data('item', item).data('pageElement', $page) 31 | if ($before) { 32 | $before.after($item) 33 | } else { 34 | $page.find('.story').append($item) 35 | } 36 | plugin.do($item, item) 37 | const before = getItem($before) 38 | // TODO: can we remove this sleep with better synchronization? 39 | sleep(500, () => pageHandler.put($page, { item, id: item.id, type: 'add', after: before?.id })) 40 | return $item 41 | } 42 | 43 | const replaceItem = function ($item, type, item) { 44 | const newItem = $.extend({}, item) 45 | $item.empty().off() 46 | $item.removeClass(type).addClass(newItem.type) 47 | const $page = $item.parents('.page:first') 48 | try { 49 | $item.data('pageElement', $page) 50 | $item.data('item', newItem) 51 | plugin.getPlugin(item.type, function (plugin) { 52 | plugin.emit($item, newItem) 53 | plugin.bind($item, newItem) 54 | }) 55 | } catch (err) { 56 | $item.append(`

        ${err}

        `) 57 | } 58 | return pageHandler.put($page, { type: 'edit', id: newItem.id, item: newItem }) 59 | } 60 | 61 | module.exports = { createItem, removeItem, getItem, replaceItem } 62 | -------------------------------------------------------------------------------- /lib/license.js: -------------------------------------------------------------------------------- 1 | // The license module explains federated wiki license terms 2 | // including the proper attribution of collaborators. 3 | 4 | const resolve = require('./resolve') 5 | const lineup = require('./lineup') 6 | 7 | const cc = () => `\ 8 |

        9 | 10 | Creative Commons License 11 |

        12 | This work is licensed under a 13 | 14 | Creative Commons Attribution-ShareAlike 4.0 International License 15 | . 16 |

        17 | This license applies uniformly to all contributions 18 | by all authors. Where authors quote other sources 19 | they do so within the terms of fair use or other 20 | compatiable terms. 21 |

        \ 22 | ` 23 | 24 | const authors = function (page, site) { 25 | if (!page.journal) { 26 | return '' 27 | } 28 | const done = {} 29 | const list = [] 30 | for (var action of page.journal.slice(0).reverse()) { 31 | if (action.site) { 32 | site = action.site 33 | } 34 | if (action.attribution?.site) { 35 | site = action.attribution.site 36 | } 37 | if (action.type !== 'fork' && !done[site]) { 38 | var siteURL = wiki.site(site).getDirectURL('') 39 | var siteFlag = wiki.site(site).flag() 40 | list.push( 41 | ` ${site}`, 42 | ) 43 | done[site] = true 44 | } 45 | } 46 | if (!(list.length > 0)) { 47 | return '' 48 | } 49 | return `\ 50 |

        51 | Author's Sites: 52 |

        53 | ${list.join('
        ')} 54 |

        \ 55 | ` 56 | } 57 | 58 | const provenance = function (action) { 59 | if (!action?.provenance) { 60 | return '' 61 | } 62 | return `\ 63 |

        64 | Created From: 65 |

        66 | ${resolve.resolveLinks(action.provenance)} 67 |

        \ 68 | ` 69 | } 70 | 71 | const info = function ($page) { 72 | const pageObject = lineup.atKey($page.data('key')) 73 | const page = pageObject.getRawPage() 74 | const site = pageObject.getRemoteSite(location.hostname) 75 | return cc() + authors(page, site) + provenance(page.journal[0]) 76 | } 77 | 78 | module.exports = { info } 79 | -------------------------------------------------------------------------------- /lib/lineup.js: -------------------------------------------------------------------------------- 1 | // The lineup represents a sequence of pages with possible 2 | // duplication. We maintain the lineup in parallel with 3 | // the DOM list of .page elements. Eventually lineup will 4 | // play a more central role managing calculations and 5 | // display updates. 6 | 7 | const random = require('./random') 8 | 9 | let pageByKey = {} 10 | let keyByIndex = [] 11 | 12 | // Basic manipulations that correspond to typical user activity 13 | 14 | const addPage = function (pageObject) { 15 | const key = random.randomBytes(4) 16 | pageByKey[key] = pageObject 17 | keyByIndex.push(key) 18 | return key 19 | } 20 | 21 | const changePageIndex = function (key, newIndex) { 22 | const oldIndex = keyByIndex.indexOf(key) 23 | keyByIndex.splice(oldIndex, 1) 24 | keyByIndex.splice(newIndex, 0, key) 25 | } 26 | 27 | const removeKey = function (key) { 28 | if (!keyByIndex.includes(key)) { 29 | return null 30 | } 31 | keyByIndex = keyByIndex.filter(each => key !== each) 32 | delete pageByKey[key] 33 | return key 34 | } 35 | 36 | const removeAllAfterKey = function (key) { 37 | const result = [] 38 | if (!keyByIndex.includes(key)) { 39 | return result 40 | } 41 | while (keyByIndex[keyByIndex.length - 1] !== key) { 42 | var unwanted = keyByIndex.pop() 43 | result.unshift(unwanted) 44 | delete pageByKey[unwanted] 45 | } 46 | return result 47 | } 48 | 49 | const atKey = key => pageByKey[key] 50 | 51 | const titleAtKey = key => atKey(key).getTitle() 52 | 53 | const bestTitle = function () { 54 | if (!keyByIndex.length) { 55 | return 'Wiki' 56 | } 57 | return titleAtKey(keyByIndex[keyByIndex.length - 1]) 58 | } 59 | 60 | // Debug access to internal state used by unit tests. 61 | 62 | const debugKeys = () => keyByIndex 63 | 64 | const debugReset = function () { 65 | pageByKey = {} 66 | keyByIndex = [] 67 | } 68 | 69 | // Debug self-check which corrects misalignments until we get it right 70 | 71 | const debugSelfCheck = function (keys) { 72 | if (`${keyByIndex}` === `${keys}`) { 73 | return 74 | } 75 | console.log('The lineup is out of sync with the dom.') 76 | console.log('.pages:', keys) 77 | console.log('lineup:', keyByIndex) 78 | if (`${Object.keys(keyByIndex).sort()}` !== `${Object.keys(keys).sort()}`) { 79 | return 80 | } 81 | console.log('It looks like an ordering problem we can fix.') 82 | keyByIndex = keys 83 | } 84 | 85 | // Select a few crumbs from the lineup that will take us 86 | // close to welcome-visitors on a (possibly) remote site. 87 | 88 | const leftKey = function (key) { 89 | const pos = keyByIndex.indexOf(key) 90 | if (pos < 1) { 91 | return null 92 | } 93 | return keyByIndex[pos - 1] 94 | } 95 | 96 | const crumbs = function (key, location) { 97 | let left, slug 98 | const page = pageByKey[key] 99 | const host = page.getRemoteSite(location) 100 | const result = ['view', (slug = page.getSlug())] 101 | if (slug !== 'welcome-visitors') { 102 | result.unshift('view', 'welcome-visitors') 103 | } 104 | if (host !== location && (left = leftKey(key))) { 105 | let adjacent 106 | if (!(adjacent = pageByKey[left]).isRemote()) { 107 | result.push(location, adjacent.getSlug()) 108 | } 109 | } 110 | result.unshift(host) 111 | return result 112 | } 113 | 114 | module.exports = { 115 | addPage, 116 | changePageIndex, 117 | removeKey, 118 | removeAllAfterKey, 119 | atKey, 120 | titleAtKey, 121 | bestTitle, 122 | debugKeys, 123 | debugReset, 124 | crumbs, 125 | debugSelfCheck, 126 | } 127 | -------------------------------------------------------------------------------- /lib/link.js: -------------------------------------------------------------------------------- 1 | // Here is where we attach federated semantics to internal 2 | // links. Call doInternalLink to add a new page to the display 3 | // given a page name, a place to put it an an optional site 4 | // to retrieve it from. 5 | 6 | const lineup = require('./lineup') 7 | const active = require('./active') 8 | const refresh = require('./refresh') 9 | const { asTitle, asSlug, pageEmitter } = require('./page') 10 | 11 | const createPage = function (name, loc, title = null) { 12 | let site 13 | if (loc && loc !== 'view') { 14 | site = loc 15 | } 16 | if (!title) { 17 | title = asTitle(name) 18 | } 19 | const $page = $(`\ 20 |
        21 |
        22 |

        23 |
        24 |

        ${title}

        25 |
        26 |
        27 |
        \ 28 | `) 29 | if (site) { 30 | $page.data('site', site) 31 | } 32 | return $page 33 | } 34 | 35 | const showPage = (name, loc, title = null) => 36 | createPage(name, loc, title) 37 | .appendTo('.main') 38 | .each((_i, e) => refresh.cycle($(e))) 39 | 40 | const doInternalLink = function (title, $page, site = null) { 41 | const slug = asSlug(title) 42 | if ($page) { 43 | $($page).nextAll().remove() 44 | } 45 | if ($page) { 46 | lineup.removeAllAfterKey($($page).data('key')) 47 | } 48 | showPage(slug, site, title) 49 | active.set($('.page').last()) 50 | } 51 | 52 | const showResult = function (pageObject, options = {}) { 53 | if (options.$page) { 54 | $(options.$page).nextAll().remove() 55 | } 56 | if (options.$page) { 57 | lineup.removeAllAfterKey($(options.$page).data('key')) 58 | } 59 | let slug = pageObject.getSlug() 60 | if (options.rev != null) { 61 | slug += `_rev${options.rev}` 62 | } 63 | const $page = createPage(slug).addClass('ghost') 64 | $page.appendTo($('.main')) 65 | refresh.buildPage(pageObject, $page) 66 | active.set($('.page').last()) 67 | } 68 | 69 | pageEmitter.addEventListener('show', event => { 70 | // console.log('pageEmitter handling', page) 71 | showResult(event.detail) 72 | }) 73 | 74 | module.exports = { createPage, doInternalLink, showPage, showResult } 75 | -------------------------------------------------------------------------------- /lib/neighbors.js: -------------------------------------------------------------------------------- 1 | // This module manages the display of site flags representing 2 | // fetched sitemaps stored in the neighborhood. It progresses 3 | // through a series of states which, when attached to the flags, 4 | // cause them to animate as an indication of work in progress. 5 | 6 | const link = require('./link') 7 | const wiki = require('./wiki') 8 | const neighborhood = require('./neighborhood') 9 | const util = require('./util') 10 | 11 | let sites = null 12 | let totalPages = 0 13 | 14 | const hasLinks = element => Object.hasOwn(element, 'links') 15 | 16 | // status class progression: .wait, .fetch, .fail or .done 17 | 18 | const flag = site => 19 | `\ 20 | 21 |
        22 | 23 |
        24 |
        \ 25 | ` 26 | 27 | const inject = neighborhood => (sites = neighborhood.sites) 28 | 29 | const formatNeighborTitle = function (site) { 30 | let pageCount 31 | let title = '' 32 | title += `${site}\n` 33 | try { 34 | pageCount = sites[site].sitemap.length 35 | } catch { 36 | pageCount = 0 37 | } 38 | try { 39 | if (sites[site].sitemap.some(hasLinks)) { 40 | title += `${pageCount} pages with 2-way links\n` 41 | } else { 42 | title += `${pageCount} pages\n` 43 | } 44 | } catch { 45 | console.info('+++ sitemap not valid for ', site) 46 | } 47 | if (sites[site].lastModified !== 0) { 48 | title += `Updated ${util.formatElapsedTime(sites[site].lastModified)}` 49 | if (sites[site].nextCheck - Date.now() > 0) { 50 | title += `, next refresh ${util.formatDelay(sites[site].nextCheck)}` 51 | } 52 | } 53 | return title 54 | } 55 | 56 | // evict a wiki from the neighborhood, from Eric Dobbs (via matrix) 57 | const evict = site => { 58 | const flagEl = Array.from($('.neighbor')).find(n => n.dataset.site == site) 59 | flagEl.parentElement.removeChild(flagEl) 60 | delete wiki.neighborhood[site] 61 | $('body').trigger('new-neighbor-done', site) 62 | } 63 | 64 | const bind = function () { 65 | const $neighborhood = $('.neighborhood') 66 | $('body') 67 | .on('new-neighbor', (e, site) => { 68 | $neighborhood.append(flag(site)) 69 | if (window.location.hostname != site) { 70 | const elem = document.querySelector(`footer .neighbor[data-site="${site}"]`) 71 | elem.addEventListener('dragstart', neighbor_dragstart) 72 | elem.addEventListener('dragend', neighbor_dragend) 73 | } 74 | }) 75 | .on('new-neighbor-done', () => { 76 | // let pageCount 77 | // try { 78 | // pageCount = sites[site].sitemap.length 79 | // } catch { 80 | // pageCount = 0 81 | // } 82 | totalPages = Object.values(neighborhood.sites).reduce(function (sum, site) { 83 | try { 84 | if (site.sitemapRequestInflight) { 85 | return sum 86 | } else { 87 | return sum + site.sitemap.length 88 | } 89 | } catch { 90 | return sum 91 | } 92 | }, 0) 93 | $('.searchbox .pages').text(`${totalPages} pages`) 94 | }) 95 | .on('mouseenter', '.neighbor', function (e) { 96 | const $neighbor = $(e.currentTarget) 97 | const { site } = $neighbor.data() 98 | $neighbor.find('img:first').attr('title', formatNeighborTitle(site)) 99 | }) 100 | .on('click', '.neighbor img', function (e) { 101 | // add handling refreshing neighbor that has failed 102 | if ($(e.target).parent().hasClass('fail')) { 103 | $(e.target).parent().removeClass('fail').addClass('wait') 104 | const site = $(e.target).attr('title').split('\n')[0] 105 | wiki.site(site).refresh(function () { 106 | console.log('about to retry neighbor') 107 | neighborhood.retryNeighbor(site) 108 | }) 109 | } else { 110 | link.doInternalLink('welcome-visitors', null, this.title.split('\n')[0]) 111 | } 112 | }) 113 | 114 | // Handlers for removing wiki from neighborhood 115 | 116 | const neighbor_dragstart = event => { 117 | document.querySelector('.main').addEventListener('drop', neighbor_drop) 118 | event.dataTransfer.setData('text/plain', event.target.closest('span').dataset.site) 119 | } 120 | 121 | const neighbor_dragend = () => { 122 | document.querySelector('.main').removeEventListener('drop', neighbor_drop) 123 | } 124 | 125 | const neighbor_drop = event => { 126 | event.stopPropagation() 127 | event.preventDefault() 128 | const toRemove = event.dataTransfer.getData('text/plain') 129 | if (window.location.hostname != toRemove) { 130 | console.log(`*** Removing ${toRemove} from neighborhood.`) 131 | evict(toRemove) 132 | } else { 133 | console.log("*** Origin wiki can't be removed.") 134 | } 135 | return false 136 | } 137 | } 138 | 139 | module.exports = { inject, bind } 140 | -------------------------------------------------------------------------------- /lib/page.js: -------------------------------------------------------------------------------- 1 | // Page provides a factory for pageObjects, a model that combines 2 | // the json derrived object and the site from which it came. 3 | 4 | const { formatDate } = require('./util') 5 | const random = require('./random') 6 | const revision = require('./revision') 7 | const synopsis = require('./synopsis') 8 | 9 | const pageEmitter = new EventTarget() 10 | 11 | // TODO: better home for asSlug 12 | const asSlug = name => 13 | name 14 | .replace(/\s/g, '-') 15 | .replace(/[^A-Za-z0-9-]/g, '') 16 | .toLowerCase() 17 | 18 | const asTitle = slug => slug.replace(/-/g, ' ') 19 | 20 | const nowSections = now => [ 21 | { symbol: '❄', date: now - 1000 * 60 * 60 * 24 * 366, period: 'a Year' }, 22 | { symbol: '⚘', date: now - 1000 * 60 * 60 * 24 * 31 * 3, period: 'a Season' }, 23 | { symbol: '⚪', date: now - 1000 * 60 * 60 * 24 * 31, period: 'a Month' }, 24 | { symbol: '☽', date: now - 1000 * 60 * 60 * 24 * 7, period: 'a Week' }, 25 | { symbol: '☀', date: now - 1000 * 60 * 60 * 24, period: 'a Day' }, 26 | { symbol: '⌚', date: now - 1000 * 60 * 60, period: 'an Hour' }, 27 | ] 28 | 29 | var newPage = function (json, site) { 30 | const page = json || {} 31 | if (!page.title) { 32 | page.title = 'empty' 33 | } 34 | if (!page.story) { 35 | page.story = [] 36 | } 37 | if (!page.journal) { 38 | page.journal = [] 39 | } 40 | 41 | const getRawPage = () => page 42 | 43 | const getContext = function () { 44 | const context = ['view'] 45 | if (isRemote()) { 46 | context.push(site) 47 | } 48 | const addContext = function (site) { 49 | if (site && !context.includes(site)) { 50 | context.push(site) 51 | } 52 | } 53 | for (var action of page.journal.slice(0).reverse()) { 54 | addContext(action?.site) 55 | } 56 | return context 57 | } 58 | 59 | const isPlugin = () => page.plugin != null 60 | 61 | var isRemote = () => ![undefined, null, 'view', 'origin', 'local', 'recycler'].includes(site) 62 | 63 | const isLocal = () => site === 'local' 64 | 65 | const isRecycler = () => site === 'recycler' 66 | 67 | const getRemoteSite = function (host = null) { 68 | if (isRemote()) { 69 | return site 70 | } else { 71 | return host 72 | } 73 | } 74 | 75 | const getRemoteSiteDetails = function (host = null) { 76 | const result = [] 77 | if (host || isRemote()) { 78 | result.push(getRemoteSite(host)) 79 | } 80 | if (isPlugin()) { 81 | result.push(`${page.plugin} plugin`) 82 | } 83 | return result.join('\n') 84 | } 85 | 86 | const getSlug = () => asSlug(page.title) 87 | 88 | const getNeighbors = function (host) { 89 | const neighbors = [] 90 | if (isRemote()) { 91 | neighbors.push(site) 92 | } else { 93 | if (host) { 94 | neighbors.push(host) 95 | } 96 | } 97 | // don't extend neighborhood for reference items 98 | // for (var item of page.story) { 99 | // if (item?.site) { 100 | // neighbors.push(item.site) 101 | // } 102 | // } 103 | for (var action of page.journal) { 104 | if (action?.site) { 105 | neighbors.push(action.site) 106 | } 107 | } 108 | return Array.from(new Set(neighbors)) 109 | } 110 | 111 | const getTitle = () => page.title 112 | 113 | const setTitle = title => (page.title = title) 114 | 115 | const getRevision = () => page.journal.length - 1 116 | 117 | const getDate = function () { 118 | const action = page.journal[getRevision()] 119 | if (action) { 120 | if (action.date) { 121 | return action.date 122 | } 123 | } 124 | return undefined 125 | } 126 | 127 | const getTimestamp = function () { 128 | const action = page.journal[getRevision()] 129 | if (action) { 130 | if (action.date) { 131 | return formatDate(action.date) 132 | } else { 133 | return `Revision ${getRevision()}` 134 | } 135 | } else { 136 | return 'Unrecorded Date' 137 | } 138 | } 139 | 140 | const getSynopsis = () => synopsis(page) 141 | 142 | const getLinks = function () { 143 | let pageLinks, pageLinksMap 144 | const extractPageLinks = function (collaborativeLinks, currentItem, currentIndex, array) { 145 | // extract collaborative links 146 | // - this will need extending if we also extract the id of the item containing the link 147 | try { 148 | const linkRe = /\[\[([^\]]+)\]\]/g 149 | let match = undefined 150 | while ((match = linkRe.exec(currentItem.text)) !== null) { 151 | if (!collaborativeLinks.has(asSlug(match[1]))) { 152 | collaborativeLinks.set(asSlug(match[1]), currentItem.id) 153 | } 154 | } 155 | if ('reference' === currentItem.type) { 156 | if (!collaborativeLinks.has(currentItem.slug)) { 157 | collaborativeLinks.set(currentItem.slug, currentItem.id) 158 | } 159 | } 160 | } catch (err) { 161 | console.log(`*** Error extracting links from ${currentIndex} of ${JSON.stringify(array)}`, err.message) 162 | } 163 | return collaborativeLinks 164 | } 165 | 166 | try { 167 | pageLinksMap = page.story.reduce(extractPageLinks, new Map()) 168 | } catch (error) { 169 | const err = error 170 | console.log(`+++ Extract links on ${page.slug} fails`, err) 171 | } 172 | if (pageLinksMap.size > 0) { 173 | pageLinks = Object.fromEntries(pageLinksMap) 174 | } else { 175 | pageLinks = {} 176 | } 177 | return pageLinks 178 | } 179 | 180 | const addItem = function (item) { 181 | item = Object.assign({}, { id: random.itemId() }, item) 182 | page.story.push(item) 183 | } 184 | 185 | const getItem = function (id) { 186 | for (var item of page.story) { 187 | if (item.id === id) { 188 | return item 189 | } 190 | } 191 | return null 192 | } 193 | 194 | const seqItems = function (each) { 195 | const promise = new Promise(resolve => { 196 | var emitItem = function (i) { 197 | if (i >= page.story.length) { 198 | return resolve() 199 | } 200 | return each(page.story[i] || { text: 'null' }, () => emitItem(i + 1)) 201 | } 202 | return emitItem(0) 203 | }) 204 | return promise 205 | } 206 | 207 | const addParagraph = function (text) { 208 | const type = 'paragraph' 209 | addItem({ type, text }) 210 | } 211 | 212 | // page.journal.push {type: 'add'} 213 | 214 | const seqActions = function (each) { 215 | let smaller = 0 216 | const sections = nowSections(new Date().getTime()) 217 | var emitAction = function (i) { 218 | if (i >= page.journal.length) { 219 | return 220 | } 221 | const action = page.journal[i] || {} 222 | const bigger = action.date || 0 223 | let separator = null 224 | for (var section of sections) { 225 | if (section.date > smaller && section.date < bigger) { 226 | separator = section 227 | } 228 | } 229 | smaller = bigger 230 | each({ action, separator }, () => emitAction(i + 1)) 231 | } 232 | emitAction(0) 233 | } 234 | 235 | const become = function (story, journal) { 236 | page.story = story?.getRawPage().story || [] 237 | if (journal) { 238 | page.journal = journal?.getRawPage().journal 239 | } 240 | } 241 | 242 | const siteLineup = function () { 243 | const slug = getSlug() 244 | const path = slug === 'welcome-visitors' ? 'view/welcome-visitors' : `view/welcome-visitors/view/${slug}` 245 | if (isRemote()) { 246 | // "//#{site}/#{path}" 247 | return wiki.site(site).getDirectURL(path) 248 | } else { 249 | return `/${path}` 250 | } 251 | } 252 | 253 | const notDuplicate = function (journal, action) { 254 | for (var each of journal) { 255 | if (each.id === action.id && each.date === action.date) { 256 | return false 257 | } 258 | } 259 | return true 260 | } 261 | 262 | const merge = function (update) { 263 | let action 264 | 265 | const merged = page.journal.slice() 266 | 267 | for (action of update.getRawPage().journal) { 268 | if (notDuplicate(page.journal, action)) { 269 | merged.push(action) 270 | } 271 | } 272 | merged.push({ 273 | type: 'fork', 274 | site: update.getRemoteSite(), 275 | date: new Date().getTime(), 276 | }) 277 | newPage(revision.create(999, { title: page.title, journal: merged }), site) 278 | } 279 | 280 | const apply = function (action) { 281 | revision.apply(page, action) 282 | if (action.site) { 283 | site = null 284 | } 285 | } 286 | 287 | const getCreate = function () { 288 | const isCreate = action => action.type === 'create' 289 | page.journal.reverse().find(isCreate) 290 | } 291 | 292 | return { 293 | getRawPage, 294 | getContext, 295 | isPlugin, 296 | isRemote, 297 | isLocal, 298 | isRecycler, 299 | getRemoteSite, 300 | getRemoteSiteDetails, 301 | getSlug, 302 | getNeighbors, 303 | getTitle, 304 | getLinks, 305 | setTitle, 306 | getRevision, 307 | getDate, 308 | getTimestamp, 309 | getSynopsis, 310 | addItem, 311 | getItem, 312 | addParagraph, 313 | seqItems, 314 | seqActions, 315 | become, 316 | siteLineup, 317 | merge, 318 | apply, 319 | getCreate, 320 | } 321 | } 322 | 323 | module.exports = { newPage, asSlug, asTitle, pageEmitter } 324 | -------------------------------------------------------------------------------- /lib/pageHandler.js: -------------------------------------------------------------------------------- 1 | // The pageHandler bundles fetching and storing json pages 2 | // from origin, remote and browser local storage. It handles 3 | // incremental updates and implicit forks when pages are edited. 4 | 5 | let pageHandler 6 | 7 | const state = require('./state') 8 | const revision = require('./revision') 9 | const addToJournal = require('./addToJournal') 10 | const { newPage } = require('./page') 11 | const lineup = require('./lineup') 12 | const neighborhood = require('./neighborhood') 13 | 14 | module.exports = pageHandler = {} 15 | 16 | const deepCopy = object => JSON.parse(JSON.stringify(object)) 17 | 18 | pageHandler.useLocalStorage = () => $('.login').length > 0 19 | 20 | const pageFromLocalStorage = function (slug) { 21 | let json 22 | if ((json = localStorage.getItem(slug))) { 23 | return JSON.parse(json) 24 | } else { 25 | return undefined 26 | } 27 | } 28 | 29 | var recursiveGet = function ({ pageInformation, whenGotten, whenNotGotten, localContext }) { 30 | let { slug, rev, site } = pageInformation 31 | 32 | const localBeforeOrigin = { 33 | get(slug, done) { 34 | wiki.local.get(slug, function (err, page) { 35 | // console.log [err, page] 36 | if (err) { 37 | wiki.origin.get(slug, done) 38 | } else { 39 | site = 'local' 40 | done(null, page) 41 | } 42 | }) 43 | }, 44 | } 45 | 46 | if (site) { 47 | localContext = [] 48 | } else { 49 | site = localContext.shift() 50 | } 51 | 52 | if (site === window.location.host) { 53 | site = 'origin' 54 | } 55 | if (site === null) { 56 | site = 'view' 57 | } 58 | 59 | // suggest by Claude Sonnet as an alternative to using switch. 60 | const adapter = 61 | { 62 | local: wiki.local, 63 | origin: wiki.origin, 64 | recycler: wiki.recycler, 65 | view: localBeforeOrigin, 66 | }[site] ?? wiki.site(site) 67 | 68 | adapter.get(`${slug}.json`, function (err, page) { 69 | if (!err) { 70 | // console.log 'got', site, page 71 | if (rev) { 72 | page = revision.create(rev, page) 73 | } 74 | whenGotten(newPage(page, site)) 75 | } else { 76 | if ([403, 404].includes(err.xhr?.status) || err.xhr?.status === 0) { 77 | if (localContext.length > 0) { 78 | recursiveGet({ pageInformation, whenGotten, whenNotGotten, localContext }) 79 | } else { 80 | whenNotGotten() 81 | } 82 | } else { 83 | const url = adapter.getDirectURL(pageInformation.slug) 84 | const text = `\ 85 | The page handler has run into problems with this request. 86 |
        ${JSON.stringify(pageInformation)}
        87 | The requested url. 88 |
        ${url}
        89 | The server reported status. 90 |
        ${err.xhr?.status}
        91 | The error message. 92 |
        ${err.msg}
        93 | These problems are rarely solved by reporting issues. 94 | There could be additional information reported in the browser's console.log. 95 | More information might be accessible by fetching the page outside of wiki. 96 | try-now\ 97 | ` 98 | const trouble = newPage({ title: "Trouble: Can't Get Page" }, null) 99 | trouble.addItem({ type: 'html', text }) 100 | whenGotten(trouble) 101 | } 102 | } 103 | }) 104 | } 105 | 106 | pageHandler.get = function ({ whenGotten, whenNotGotten, pageInformation }) { 107 | if (!pageInformation.site) { 108 | let localPage 109 | if ((localPage = pageFromLocalStorage(pageInformation.slug))) { 110 | if (pageInformation.rev) { 111 | localPage = revision.create(pageInformation.rev, localPage) 112 | } 113 | whenGotten(newPage(localPage, 'local')) 114 | return 115 | } 116 | } 117 | 118 | if (!pageHandler.context.length) { 119 | pageHandler.context = ['view'] 120 | } 121 | 122 | recursiveGet({ 123 | pageInformation, 124 | whenGotten, 125 | whenNotGotten, 126 | localContext: pageHandler.context.slice(), 127 | }) 128 | } 129 | 130 | pageHandler.context = [] 131 | 132 | const pushToLocal = function ($page, pagePutInfo, action) { 133 | let page 134 | if (action.type === 'create') { 135 | page = { title: action.item.title, story: [], journal: [] } 136 | } else { 137 | let site 138 | page = pageFromLocalStorage(pagePutInfo.slug) 139 | if (!page) { 140 | page = lineup.atKey($page.data('key')).getRawPage() 141 | } 142 | page.journal ||= [] 143 | if ((site = action['fork'])) { 144 | page.journal = page.journal.concat({ type: 'fork', site: site, date: new Date().getTime() }) 145 | delete action['fork'] 146 | } 147 | } 148 | revision.apply(page, action) 149 | wiki.local.put(pagePutInfo.slug, page, function () { 150 | addToJournal($page.find('.journal'), action) 151 | $page.addClass('local') 152 | }) 153 | } 154 | 155 | const pushToServer = function ($page, pagePutInfo, action) { 156 | // bundle rawPage which server will strip out 157 | const bundle = deepCopy(action) 158 | const pageObject = lineup.atKey($page.data('key')) 159 | if (action.fork || action.type === 'fork') { 160 | bundle.forkPage = deepCopy(pageObject.getRawPage()) 161 | } 162 | 163 | wiki.origin.put(pagePutInfo.slug, bundle, function (err) { 164 | if (err) { 165 | action.error = { type: err.type, msg: err.msg, response: err.xhr.responseText } 166 | pushToLocal($page, pagePutInfo, action) 167 | } else { 168 | if (pageObject?.apply) { 169 | pageObject.apply(action) 170 | } 171 | neighborhood.updateSitemap(pageObject) 172 | neighborhood.updateIndex(pageObject) 173 | addToJournal($page.find('.journal'), action) 174 | if (action.type === 'fork') { 175 | wiki.local.delete($page.attr('id')) 176 | } 177 | if (action.type !== 'fork' && action.fork) { 178 | // implicit fork, probably only affects image plugin 179 | if (action.item.type === 'image') { 180 | const index = $page.find('.item').index($page.find('#' + action.item.id).context) 181 | wiki.renderFrom(index) 182 | } 183 | } 184 | } 185 | }) 186 | } 187 | 188 | pageHandler.put = function ($page, action) { 189 | const checkedSite = function () { 190 | let site 191 | switch ((site = $page.data('site'))) { 192 | case 'origin': 193 | case 'local': 194 | case 'view': 195 | return null 196 | case location.host: 197 | return null 198 | default: 199 | return site 200 | } 201 | } 202 | 203 | // about the page we have 204 | const pagePutInfo = { 205 | slug: $page.attr('id').split('_rev')[0], 206 | rev: $page.attr('id').split('_rev')[1], 207 | site: checkedSite(), 208 | local: $page.hasClass('local'), 209 | } 210 | let forkFrom = pagePutInfo.site 211 | // console.log 'pageHandler.put', action, pagePutInfo 212 | 213 | // detect when fork to local storage 214 | if (pageHandler.useLocalStorage()) { 215 | if (pagePutInfo.site) { 216 | // console.log 'remote => local' 217 | } else if (!pagePutInfo.local) { 218 | // console.log 'origin => local' 219 | action.site = forkFrom = location.host 220 | } 221 | } 222 | // else if !pageFromLocalStorage(pagePutInfo.slug) 223 | // console.log '' 224 | // action.site = forkFrom = pagePutInfo.site 225 | // console.log 'local storage first time', action, 'forkFrom', forkFrom 226 | 227 | // tweek action before saving 228 | action.date = new Date().getTime() 229 | if (action.site === 'origin') { 230 | delete action.site 231 | } 232 | 233 | // update dom when forking 234 | $page.removeClass('plugin') 235 | if (forkFrom) { 236 | // pull remote site closer to us 237 | $page.find('h1').prop('title', location.host) 238 | $page.find('h1 img').attr('src', '/favicon.png') 239 | $page.find('h1 a').attr('href', `/view/welcome-visitors/view/${pagePutInfo.slug}`).attr('target', location.host) 240 | $page.data('site', null) 241 | $page.removeClass('remote') 242 | //STATE -- update url when site changes 243 | state.setUrl() 244 | if (action.type !== 'fork') { 245 | // bundle implicit fork with next action 246 | action.fork = forkFrom 247 | addToJournal($page.find('.journal'), { 248 | type: 'fork', 249 | site: forkFrom, 250 | date: action.date, 251 | }) 252 | } 253 | } 254 | 255 | // store as appropriate 256 | if (pageHandler.useLocalStorage() || pagePutInfo.site === 'local') { 257 | pushToLocal($page, pagePutInfo, action) 258 | } else { 259 | pushToServer($page, pagePutInfo, action) 260 | } 261 | } 262 | 263 | pageHandler.delete = function (pageObject, $page, done) { 264 | // console.log 'delete server-side' 265 | // console.log 'pageObject:', pageObject 266 | if (pageObject.isRecycler()) { 267 | wiki.recycler.delete(`${pageObject.getSlug()}.json`, function (err) { 268 | const more = () => done(err) 269 | setTimeout(more, 300) 270 | }) 271 | } else { 272 | wiki.origin.delete(`${pageObject.getSlug()}.json`, function (err) { 273 | const more = function () { 274 | if (!err) { 275 | neighborhood.deleteFromSitemap(pageObject) 276 | neighborhood.deleteFromIndex(pageObject) 277 | } 278 | done(err) 279 | } 280 | setTimeout(more, 300) 281 | }) // simulate server turnaround 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /lib/paragraph.js: -------------------------------------------------------------------------------- 1 | // The Paragraph plugin holds text that can be edited and rendered 2 | // with hyperlinks. It will eventually escape html tags but for the 3 | // moment we live dangerously. 4 | 5 | const editor = require('./editor') 6 | const resolve = require('./resolve') 7 | const itemz = require('./itemz') 8 | 9 | const type = function (text) { 10 | if (text.match(/<(i|b|p|a|h\d|hr|br|li|img|div|span|table|blockquote)\b.*?>/i)) { 11 | return 'html' 12 | } else { 13 | return 'markdown' 14 | } 15 | } 16 | 17 | const emit = ($item, item) => { 18 | for (var text of item.text.split(/\n\n+/)) { 19 | if (text.match(/\S/)) { 20 | $item.append(`

        ${resolve.resolveLinks(text)}

        `) 21 | } 22 | } 23 | } 24 | 25 | const bind = ($item, item) => 26 | $item.on('dblclick', function (e) { 27 | if (e.shiftKey) { 28 | item.type = type(item.text) 29 | return itemz.replaceItem($item, 'paragraph', item) 30 | } else { 31 | return editor.textEditor($item, item, { append: true }) 32 | } 33 | }) 34 | 35 | module.exports = { emit, bind } 36 | -------------------------------------------------------------------------------- /lib/plugin.js: -------------------------------------------------------------------------------- 1 | // The plugin module manages the dynamic retrieval of plugin 2 | // javascript including additional scripts that may be requested. 3 | 4 | let plugin 5 | module.exports = plugin = {} 6 | 7 | const delay = function (ms) { 8 | return new Promise(resolve => setTimeout(resolve, ms)) 9 | } 10 | 11 | const escape = s => 12 | ('' + s) 13 | .replace(/&/g, '&') 14 | .replace(//g, '>') 16 | .replace(/"/g, '"') 17 | .replace(/'/g, ''') 18 | .replace(/\//g, '/') 19 | 20 | // define loadScript that allows fetching a script. 21 | // see example in http://api.jquery.com/jQuery.getScript/ 22 | 23 | const loadScript = function (url) { 24 | console.log('loading url:', url) 25 | return import(url) 26 | } 27 | 28 | const scripts = [] 29 | const loadingScripts = {} 30 | const getScript = (plugin.getScript = async function (url, callback = () => {}) { 31 | if (scripts.includes(url)) { 32 | callback() 33 | } else { 34 | try { 35 | await loadScript(url) 36 | } catch (err) { 37 | console.log('getScript: Failed to load:', url, err) 38 | } 39 | 40 | scripts.push(url) 41 | callback() 42 | } 43 | }) 44 | 45 | plugin.renderFrom = async function (notifIndex) { 46 | const $items = $('.item').slice(notifIndex) 47 | 48 | // console.log "notifIndex", notifIndex, "about to render", $items.toArray() 49 | 50 | for (const itemElem of $items.toArray()) { 51 | const $item = $(itemElem) 52 | if (!$item.hasClass('textEditing')) { 53 | const item = $item.data('item') 54 | try { 55 | $item.off() 56 | await plugin.emit($item.empty(), item) 57 | await delay(0) 58 | } catch (e) { 59 | console.error(e) 60 | } 61 | } 62 | } 63 | 64 | // Binds must be called sequentially in order to store the promises used to order bind operations. 65 | // Note: The bind promises used here are for ordering "bind creation". 66 | // The ordering of "bind results" is done within the plugin.bind wrapper. 67 | for (const itemElem of $items.toArray()) { 68 | const $item = $(itemElem) 69 | const item = $item.data('item') 70 | try { 71 | const p = await plugin.get(item.type) 72 | if (p && p.bind) { 73 | await p.bind($item, item) 74 | } 75 | } catch (e) { 76 | console.error(e) 77 | } 78 | } 79 | } 80 | 81 | const emit = function (pluginEmit) { 82 | const fn = function ($item, item) { 83 | $item.addClass('emit') 84 | return pluginEmit($item, item) 85 | } 86 | return fn 87 | } 88 | 89 | const bind = function (name, pluginBind) { 90 | const fn = async function ($item, item) { 91 | // Clear out any list of consumed items. 92 | $item[0].consuming = [] 93 | const index = $('.item').index($item) 94 | const { consumes } = window.plugins[name] 95 | 96 | // Wait for all items in the lineup that produce what we consume 97 | // before calling our bind method. 98 | if (consumes) { 99 | const deps = [] 100 | consumes.forEach(function (consuming) { 101 | const producers = $(`.item:lt(${index})`).filter(consuming) 102 | // console.log(name, "consumes", consuming) 103 | // console.log(producers, "produce", consuming) 104 | if (!producers || producers.length === 0) { 105 | console.log('warn: no items in lineup that produces', consuming) 106 | } 107 | // console.log("there are #{producers.length} instances of #{consuming}") 108 | producers.each(function (_i, el) { 109 | const page_key = $(el).parents('.page').data('key') 110 | const item_id = $(el).attr('data-id') 111 | $item[0].consuming.push(`${page_key}/${item_id}`) 112 | deps.push(el.promise) 113 | }) 114 | }) 115 | await Promise.all(deps) 116 | } 117 | 118 | try { 119 | $item.removeClass('emit') 120 | let bindPromise = ($item[0].promise = (async function () { 121 | return pluginBind($item, item) 122 | })()) 123 | 124 | await bindPromise 125 | 126 | // If the plugin has the needed callback, subscribe to server side events 127 | // for the current page 128 | if (window.plugins[name].processServerEvent) { 129 | console.log('listening for server events', $item, item) 130 | // forward.init($item, item, window.plugins[name].processServerEvent) 131 | } 132 | } catch (e) { 133 | console.log('plugin emit: unexpected error', e) 134 | } 135 | } 136 | return fn 137 | } 138 | 139 | plugin.wrap = wrap 140 | function wrap(name, p) { 141 | p.emit = emit(p.emit) 142 | p.bind = bind(name, p.bind) 143 | return p 144 | } 145 | 146 | plugin.get = plugin.getPlugin = async function (name, callback = () => {}) { 147 | if (window.pluginSuccessor[name]) { 148 | wiki.log('plugin successor', name, window.pluginSuccessor[name]) 149 | name = window.pluginSuccessor[name] 150 | } 151 | if (window.plugins[name]) { 152 | callback(window.plugins[name]) 153 | return window.plugins[name] 154 | } 155 | if (!loadingScripts[name]) { 156 | loadingScripts[name] = (async function () { 157 | if (window.plugins[name]) { 158 | return window.plugins[name] 159 | } 160 | await getScript(`/plugins/${name}/${name}.js`) 161 | if (!window.plugins[name]) { 162 | await getScript(`/plugins/${name}.js`) 163 | } 164 | const p = window.plugins[name] 165 | if (p) { 166 | wrap(name, p) 167 | } 168 | return p 169 | })() 170 | } 171 | const p = await loadingScripts[name] 172 | delete loadingScripts[name] 173 | if (!p) { 174 | console.log('Could not find plugin ', name) 175 | } 176 | callback(p) 177 | return p 178 | } 179 | 180 | plugin.do = plugin.doPlugin = async function ($item, item, done = () => {}) { 181 | $item.data('item', item) 182 | await plugin.renderFrom($('.item').index($item)) 183 | return done() 184 | } 185 | 186 | plugin.emit = async function (div, item, done = () => {}) { 187 | const error = function (ex, script) { 188 | div.append(`\ 189 |
        190 | ${escape(item.text || '')} 191 |
        192 |
        \ 193 | `) 194 | if (item.text) { 195 | div.find('.error').on('dblclick', () => wiki.textEditor(div, item)) 196 | } 197 | div.find('button').on('click', function () { 198 | // only append dialog if not already done. 199 | if (!div[0].querySelector('dialog')) { 200 | div.append(`\ 201 | 202 |

        ${ex.toString()}

        203 |

        This "${item.type}" plugin won't show.

        204 |
          205 |
        • Is it available on this server? 206 |
        • Is its markup correct? 207 |
        • Can it find necessary data? 208 |
        • Has network access been interrupted? 209 |
        • Has its code been tested? 210 |
        211 |

        Developers may open debugging tools and retry the plugin.

        212 | 213 |

        Learn more 214 | 217 | About Plugins 218 | 219 | 220 |

        221 | 222 |
        \ 223 | `) 224 | } 225 | const dialog = div[0].querySelector('dialog') 226 | dialog.addEventListener('click', function (evt) { 227 | if (evt.target === dialog) { 228 | dialog.close() 229 | } 230 | }) 231 | dialog.showModal() 232 | $('.close').on('click', () => dialog.close()) 233 | $('.retry').on('click', function () { 234 | if (script.emit.length > 2) { 235 | script.emit(div, item, () => done()) 236 | } else { 237 | script.emit(div, item) 238 | done() 239 | } 240 | }) 241 | }) 242 | } 243 | 244 | div.data('pageElement', div.parents('.page')) 245 | div.data('item', item) 246 | const script = await plugin.get(item.type) 247 | try { 248 | if (script == null) { 249 | throw TypeError(`Can't find plugin for '${item.type}'`) 250 | } 251 | if (script.emit.length > 2) { 252 | await new Promise(resolve => { 253 | script.emit(div, item, function () { 254 | if (script.bind) { 255 | script.bind(div, item) 256 | } 257 | resolve() 258 | }) 259 | }) 260 | } else { 261 | script.emit(div, item) 262 | } 263 | } catch (err) { 264 | console.log('plugin error', err) 265 | error(err, script) 266 | } 267 | done() 268 | } 269 | 270 | plugin.registerPlugin = (pluginName, pluginFn) => (window.plugins[pluginName] = pluginFn($)) 271 | -------------------------------------------------------------------------------- /lib/plugins.js: -------------------------------------------------------------------------------- 1 | // This module preloads the plugins directory with a few 2 | // plugins that we can't live without. They will be 3 | // browserified along with the rest of the core javascript. 4 | const plugin = require('./plugin') 5 | 6 | window.plugins = { 7 | reference: plugin.wrap('reference', require('./reference')), 8 | factory: plugin.wrap('factory', require('./factory')), 9 | paragraph: plugin.wrap('paragraph', require('./paragraph')), 10 | //image: plugin.wrap('image', require './image') 11 | future: plugin.wrap('future', require('./future')), 12 | importer: plugin.wrap('importer', require('./importer')), 13 | } 14 | 15 | // mapping between old plugins and their successor 16 | window.pluginSuccessor = { 17 | federatedWiki: 'reference', 18 | mathjax: 'math', 19 | } 20 | -------------------------------------------------------------------------------- /lib/random.js: -------------------------------------------------------------------------------- 1 | // We create strings of hexidecimal digits representing a 2 | // given number of random bytes. We use short strings for 3 | // cache busting, medium strings for keys linking dom to 4 | // model, and, longer still strings for lifetime identity 5 | // of story elements. 6 | 7 | const randomByte = () => (((1 + Math.random()) * 0x100) | 0).toString(16).substring(1) 8 | 9 | const randomBytes = n => [...Array(n)].map(() => randomByte()).join('') 10 | 11 | const itemId = () => randomBytes(8) 12 | 13 | module.exports = { randomByte, randomBytes, itemId } 14 | -------------------------------------------------------------------------------- /lib/reference.js: -------------------------------------------------------------------------------- 1 | // The Reference plugin holds a site and page name to be 2 | // found on that site. Search, for example, produces a page of 3 | // references. Double click will edit the body of a reference 4 | // but not the name and site. 5 | 6 | const editor = require('./editor') 7 | const resolve = require('./resolve') 8 | const page = require('./page') 9 | 10 | // see http://fed.wiki.org/about-reference-plugin.html 11 | 12 | const emit = function ($item, item) { 13 | let slug = item.slug 14 | if (!slug && item.title) { 15 | slug = page.asSlug(item.title) 16 | } 17 | if (!slug) { 18 | slug = 'welcome-visitors' 19 | } 20 | const site = item.site 21 | resolve.resolveFrom(site, () => 22 | $item.append(` 23 |

        24 | 30 | ${resolve.resolveLinks(`[[${item.title || slug}]]`)} 31 | — 32 | ${resolve.resolveLinks(item.text)} 33 |

        `), 34 | ) 35 | } 36 | const bind = ($item, item) => $item.on('dblclick', () => editor.textEditor($item, item)) 37 | 38 | module.exports = { emit, bind } 39 | -------------------------------------------------------------------------------- /lib/resolve.js: -------------------------------------------------------------------------------- 1 | // The function resolveLinks converts link markup to html syntax. 2 | // The html will have a search path (the resolutionContext) encoded 3 | // into the title of tags it generates. 4 | 5 | let escape, resolve 6 | const { asSlug } = require('./page') 7 | 8 | module.exports = resolve = {} 9 | 10 | resolve.resolutionContext = [] 11 | 12 | resolve.escape = escape = string => (string || '').replace(/&/g, '&').replace(//g, '>') 13 | 14 | resolve.resolveFrom = function (addition, callback) { 15 | resolve.resolutionContext.push(addition) 16 | try { 17 | callback() 18 | } finally { 19 | resolve.resolutionContext.pop() 20 | } 21 | } 22 | 23 | // resolveLinks takes a second argument which is a substitute text sanitizer. 24 | // Plugins that do their own markup should insert themselves here but they 25 | // must escape html as part of their processing. Sanitizers must pass markers〖12〗. 26 | 27 | resolve.resolveLinks = function (string, sanitize = escape) { 28 | const stashed = [] 29 | 30 | const stash = function (text) { 31 | const here = stashed.length 32 | stashed.push(text) 33 | return `〖${here}〗` 34 | } 35 | 36 | const unstash = (match, digits) => stashed[+digits] 37 | 38 | const internal = function (match, name) { 39 | const slug = asSlug(name) 40 | const styling = name === name.trim() ? 'internal' : 'internal spaced' 41 | if (slug.length) { 42 | return stash( 43 | `${escape(name)}`, 44 | ) 45 | } else { 46 | return match 47 | } 48 | } 49 | 50 | const external = (match, href, protocol, rest) => 51 | stash( 52 | `${escape(rest)} `, 53 | ) 54 | 55 | // markup conversion happens in four phases: 56 | // - unexpected markers are adulterated 57 | // - links are found, converted, and stashed away properly escaped 58 | // - remaining text is sanitized and/or escaped 59 | // - unique markers are replaced with unstashed links 60 | 61 | string = (string || '') 62 | .replace(/〖(\d+)〗/g, '〖 $1 〗') 63 | .replace(/\[\[([^\]]+)\]\]/gi, internal) 64 | .replace(/\[((http|https|ftp):.*?) (.*?)\]/gi, external) 65 | return sanitize(string).replace(/〖(\d+)〗/g, unstash) 66 | } 67 | -------------------------------------------------------------------------------- /lib/revision.js: -------------------------------------------------------------------------------- 1 | // This module interprets journal actions in order to update 2 | // a story or even regenerate a complete story from some or 3 | // all of a journal. 4 | 5 | const apply = function (page, action) { 6 | let index 7 | const order = () => (page.story || []).map(item => item?.id) 8 | 9 | const add = function (after, item) { 10 | const index = order().indexOf(after) + 1 11 | page.story.splice(index, 0, item) 12 | } 13 | 14 | const remove = function () { 15 | let index 16 | if ((index = order().indexOf(action.id)) !== -1) { 17 | page.story.splice(index, 1) 18 | } 19 | } 20 | 21 | if (!page.story) { 22 | page.story = [] 23 | } 24 | 25 | switch (action.type) { 26 | case 'create': 27 | if (action.item) { 28 | if (action.item.title) { 29 | page.title = action.item.title 30 | } 31 | if (action.item.story) { 32 | page.story = action.item.story.slice() 33 | } 34 | } 35 | break 36 | case 'add': 37 | add(action.after, action.item) 38 | break 39 | case 'edit': 40 | if ((index = order().indexOf(action.id)) !== -1) { 41 | page.story.splice(index, 1, action.item) 42 | } else { 43 | page.story.push(action.item) 44 | } 45 | break 46 | case 'move': 47 | // construct relative addresses from absolute order 48 | index = action.order.indexOf(action.id) 49 | var after = action.order[index - 1] 50 | var item = page.story[order().indexOf(action.id)] 51 | remove() 52 | add(after, item) 53 | break 54 | case 'remove': 55 | remove() 56 | break 57 | } 58 | 59 | if (!page.journal) { 60 | page.journal = [] 61 | } 62 | if (action.fork) { 63 | // implicit fork 64 | page.journal.push({ type: 'fork', site: action.fork, date: action.date - 1 }) 65 | } 66 | page.journal.push(action) 67 | } 68 | 69 | const create = function (revIndex, data) { 70 | revIndex = +revIndex 71 | const revJournal = data.journal.slice(0, +revIndex + 1 || undefined) 72 | const revPage = { title: data.title, story: [] } 73 | for (var action of revJournal) { 74 | apply(revPage, action || {}) 75 | } 76 | return revPage 77 | } 78 | 79 | module.exports = { create, apply } 80 | -------------------------------------------------------------------------------- /lib/search.js: -------------------------------------------------------------------------------- 1 | // The search module invokes neighborhood's query function, 2 | // formats the results as story items, and then opens a 3 | // page to present them. 4 | 5 | const pageHandler = require('./pageHandler') 6 | const random = require('./random') 7 | const link = require('./link') 8 | // const active = require('./active') 9 | const { newPage } = require('./page') 10 | const resolve = require('./resolve') 11 | let page = require('./page') 12 | 13 | // from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions 14 | //const escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 15 | 16 | const deepCopy = object => JSON.parse(JSON.stringify(object)) 17 | 18 | // From reference.coffee 19 | const emit = function ($item, item) { 20 | let slug = item.slug 21 | if (item.title) { 22 | slug ||= page.asSlug(item.title) 23 | } 24 | slug ||= 'welcome-visitors' 25 | const site = item.site 26 | resolve.resolveFrom(site, () => 27 | $item.append(`\ 28 |

        29 | 35 | ${resolve.resolveLinks(`[[${item.title || slug}]]`)} 36 | — 37 | ${resolve.resolveLinks(item.text)} 38 |

        \ 39 | `), 40 | ) 41 | } 42 | const finishClick = function (e, name) { 43 | e.preventDefault() 44 | if (!e.shiftKey) { 45 | page = $(e.target).parents('.page') 46 | } 47 | link.doInternalLink(name, page, $(e.target).data('site')) 48 | return false 49 | } 50 | 51 | const createSearch = function ({ neighborhood }) { 52 | const incrementalSearch = function (searchQuery) { 53 | if (searchQuery.length < 2) { 54 | $('.incremental-search').remove() 55 | return 56 | } 57 | if ($('.incremental-search').length === 0) { 58 | const offset = $('.searchbox').position() 59 | $('
        ') 60 | .css('left', `${offset.left}px`) 61 | .css('bottom', `${offset.top + $('.searchbox').height()}px`) 62 | .addClass('incremental-search') 63 | .on('click', '.internal', function (e) { 64 | if (e.target.nodeName === 'SPAN') { 65 | e.target = $(e.target).parent()[0] 66 | } 67 | let name = $(e.target).data('pageName') 68 | // ensure that name is a string (using string interpolation) 69 | name = `${name}` 70 | pageHandler.context = $(e.target).attr('title').split(' => ') 71 | return finishClick(e, name) 72 | }) 73 | .on('click', 'img.remote', function (e) { 74 | // expand to handle click on temporary flag 75 | if ($(e.target).attr('src').startsWith('data:image/png')) { 76 | e.preventDefault() 77 | const site = $(e.target).data('site') 78 | wiki.site(site).refresh(function () {}) 79 | } else { 80 | const name = $(e.target).data('slug') 81 | pageHandler.context = [$(e.target).data('site')] 82 | return finishClick(e, name) 83 | } 84 | return false 85 | }) 86 | .appendTo($('.searchbox')) 87 | } 88 | 89 | const searchResults = neighborhood.search(searchQuery) 90 | const searchTerms = searchQuery 91 | .split(' ') 92 | .map(t => t.toLowerCase()) 93 | .filter(String) 94 | const searchHighlightRegExp = new RegExp( 95 | '\\b(' + 96 | searchQuery 97 | .split(' ') 98 | .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) 99 | .filter(String) 100 | .join('|') + 101 | ')', 102 | 'i', 103 | ) 104 | const highlightText = text => 105 | text 106 | .split(searchHighlightRegExp) 107 | .map(function (p) { 108 | if (searchTerms.includes(p.toLowerCase())) { 109 | return `{{${p}}}` 110 | } else { 111 | return p 112 | } 113 | }) 114 | .join('') 115 | const $search = $('.incremental-search').empty() 116 | if (!searchResults.finds || searchResults.finds.length === 0) { 117 | $('
        ').text('No results found').addClass('no-results').appendTo($search) 118 | } 119 | let count = 0 120 | const max_results = 100 121 | for (const result of searchResults.finds) { 122 | count++ 123 | if (count === max_results + 1) { 124 | $('
        ') 125 | .text(`${searchResults.finds.length - max_results} results omitted`) 126 | .addClass('omitted-results') 127 | .appendTo($search) 128 | } 129 | if (count > max_results) { 130 | continue 131 | } 132 | const $item = $('
        ').appendTo($search) 133 | const item = { 134 | id: random.itemId(), 135 | type: 'reference', 136 | site: result.site, 137 | slug: result.page.slug, 138 | title: highlightText(result.page.title), 139 | text: highlightText(result.page.synopsis), 140 | } 141 | emit($item, item) 142 | $item.html( 143 | $item 144 | .html() 145 | .split(new RegExp('({{.*?}})', 'i')) 146 | .map(p => { 147 | if (p.indexOf('{{') === 0) { 148 | return `${p.substring(2, p.length - 2)}` 149 | } else { 150 | return p 151 | } 152 | }) 153 | .join(''), 154 | ) 155 | } 156 | } 157 | 158 | const performSearch = function (searchQuery) { 159 | const searchResults = neighborhood.search(searchQuery) 160 | if (searchResults.finds && searchResults.finds.length === 1) { 161 | $('.incremental-search').find('.internal').trigger('click') 162 | $('.incremental-search').remove() 163 | return 164 | } 165 | $('.incremental-search').remove() 166 | const { tally } = searchResults 167 | const resultPage = {} 168 | resultPage.title = `Search for '${searchQuery}'` 169 | resultPage.story = [] 170 | resultPage.story.push({ 171 | type: 'paragraph', 172 | id: random.itemId(), 173 | text: `\ 174 | String '${searchQuery}' found on ${tally.finds || 'none'} of ${tally.pages || 'no'} pages from ${tally.sites || 'no'} sites. 175 | Text matched on ${tally.title || 'no'} titles, ${tally.text || 'no'} paragraphs, and ${tally.slug || 'no'} slugs. 176 | Elapsed time ${tally.msec} milliseconds.\ 177 | `, 178 | }) 179 | for (var result of searchResults.finds) { 180 | resultPage.story.push({ 181 | id: random.itemId(), 182 | type: 'reference', 183 | site: result.site, 184 | slug: result.page.slug, 185 | title: result.page.title, 186 | text: result.page.synopsis || '', 187 | }) 188 | } 189 | 190 | resultPage.journal = [ 191 | { 192 | type: 'create', 193 | item: { 194 | title: resultPage.title, 195 | story: deepCopy(resultPage.story), 196 | }, 197 | date: Date.now(), 198 | }, 199 | ] 200 | const pageObject = newPage(resultPage) 201 | link.showResult(pageObject) 202 | } 203 | 204 | return { 205 | incrementalSearch, 206 | performSearch, 207 | } 208 | } 209 | module.exports = createSearch 210 | -------------------------------------------------------------------------------- /lib/searchbox.js: -------------------------------------------------------------------------------- 1 | // Handle input events from the search box. There is machinery 2 | // here that supports incremental search. 3 | // We use dependency injection to break dependency loops. 4 | 5 | const createSearch = require('./search') 6 | 7 | let search = null 8 | 9 | const inject = neighborhood => (search = createSearch({ neighborhood })) 10 | 11 | const bind = function () { 12 | $('input.search').attr('autocomplete', 'off') 13 | $('input.search').on('keydown', function (e) { 14 | if (e.keyCode === 27) { 15 | $('.incremental-search').remove() 16 | } 17 | }) 18 | $('input.search').on('keypress', function (e) { 19 | if (e.keyCode !== 13) { 20 | return 21 | } // 13 == return 22 | const searchQuery = $(this).val() 23 | search.performSearch(searchQuery) 24 | $(this).val('') 25 | }) 26 | 27 | $('input.search').on('focus', function () { 28 | const searchQuery = $(this).val() 29 | search.incrementalSearch(searchQuery) 30 | }) 31 | 32 | return $('input.search').on('input', function () { 33 | const searchQuery = $(this).val() 34 | search.incrementalSearch(searchQuery) 35 | }) 36 | } 37 | 38 | module.exports = { inject, bind } 39 | -------------------------------------------------------------------------------- /lib/security.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Uses of plugin getScript to load the security plugin's client code 3 | */ 4 | 5 | const plugin = require('./plugin') 6 | 7 | module.exports = user => plugin.getScript('/security/security.js', () => window.plugins.security.setup(user)) 8 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | // The state module saves the .page lineup in the browser's location 2 | // bar and history. It also reconstructs that state when the browser 3 | // notifies us that the user has changed this sequence. 4 | 5 | let state 6 | const active = require('./active') 7 | const lineup = require('./lineup') 8 | let link = null 9 | 10 | module.exports = state = {} 11 | 12 | // FUNCTIONS and HANDLERS to manage location bar and back button 13 | 14 | state.inject = link_ => (link = link_) 15 | 16 | state.pagesInDom = () => { 17 | return Array.from(document.querySelectorAll('.page')).map(el => el.id) 18 | } 19 | 20 | state.urlPages = () => { 21 | const pathname = new URL(window.location.href).pathname 22 | return pathname 23 | .split('/') 24 | .slice(1) 25 | .filter((...[, index]) => index % 2 === 1) 26 | } 27 | 28 | // site is not held in DOM, but using jQuery data - so stick with jQuery for now. 29 | state.locsInDom = () => $.makeArray($('.page').map((_, el) => $(el).data('site') || 'view')) 30 | 31 | state.urlLocs = () => { 32 | const pathname = new URL(window.location.href).pathname 33 | return pathname 34 | .split('/') 35 | .slice(1) 36 | .filter((...[, index]) => index % 2 === 0) 37 | } 38 | 39 | state.setUrl = () => { 40 | document.title = lineup.bestTitle() 41 | if (history && history.pushState) { 42 | const locs = state.locsInDom() 43 | const pages = state.pagesInDom() 44 | const url = pages.map((page, idx) => `/${locs[idx] || 'view'}/${page}`).join('') 45 | if (url !== new URL(window.location.href).pathname) { 46 | history.pushState(null, null, url) 47 | } 48 | } 49 | } 50 | 51 | state.debugStates = () => { 52 | console.log( 53 | 'a .page keys ', 54 | Array.from($('.page')).map(each => $(each).data('key')), 55 | ) 56 | console.log('a lineup keys', lineup.debugKeys()) 57 | } 58 | 59 | state.show = () => { 60 | const oldPages = state.pagesInDom() 61 | const newPages = state.urlPages() 62 | // const oldLocs = state.locsInDom() 63 | const newLocs = state.urlLocs() 64 | 65 | if (!location.pathname || location.pathname === '/') return 66 | 67 | let matching = true 68 | for (const [idx, name] of oldPages.entries()) { 69 | if (matching && (matching = name === newPages[idx])) continue 70 | const old = $('.page:last') 71 | lineup.removeKey(old.data('key')) 72 | old.remove() 73 | } 74 | 75 | matching = true 76 | for (const [idx, name] of newPages.entries()) { 77 | if (matching && (matching = name === oldPages[idx])) continue 78 | link.showPage(name, newLocs[idx]) 79 | } 80 | 81 | if (window.debug) { 82 | state.debugStates() 83 | } 84 | 85 | active.set($('.page').last()) 86 | document.title = lineup.bestTitle() 87 | } 88 | 89 | state.first = () => { 90 | state.setUrl() 91 | const firstUrlPages = state.urlPages() 92 | const firstUrlLocs = state.urlLocs() 93 | const oldPages = state.pagesInDom() 94 | for (let idx = 0; idx < firstUrlPages.length; idx++) { 95 | const urlPage = firstUrlPages[idx] 96 | if (!oldPages.includes(urlPage) && urlPage !== '') { 97 | link.createPage(urlPage, firstUrlLocs[idx]) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/synopsis.js: -------------------------------------------------------------------------------- 1 | // The synopsis module extracts a summary from the json derrived 2 | // representation of a page. This might be from a "synopsys:" field, 3 | // but more likely it comes from text found in the first or second item. 4 | 5 | module.exports = function (page) { 6 | let { synopsis } = page 7 | if (page?.story) { 8 | const p1 = page.story[0] 9 | const p2 = page.story[1] 10 | if (p1 && p1.type === 'paragraph') { 11 | synopsis ||= p1.text 12 | } 13 | if (p2 && p2.type === 'paragraph') { 14 | synopsis ||= p2.text 15 | } 16 | if (p1 && p1.text) { 17 | synopsis ||= p1.text 18 | } 19 | if (p2 && p2.text) { 20 | synopsis ||= p2.text 21 | } 22 | synopsis ||= page.story && `A page with ${page.story.length} items.` 23 | } else { 24 | synopsis = 'A page with no story.' 25 | } 26 | // discard anything after the first line break, after trimming any at beginning 27 | synopsis = synopsis.trim().split(/\r|\n/, 1)[0] 28 | return synopsis.substring(0, 560) 29 | } 30 | -------------------------------------------------------------------------------- /lib/target.js: -------------------------------------------------------------------------------- 1 | // Target handles hovers over items and actions. Other visible 2 | // items and actions with the same id will highlight. In some cases 3 | // an event is generated inviting other pages to scroll the item 4 | // into view. Target tracks hovering even when not requested so 5 | // that highlighting can be immediate when requested. 6 | 7 | let targeting = false 8 | let $item = null 9 | let item = null 10 | let itemElem = null 11 | let action = null 12 | let consumed = null 13 | 14 | const bind = function () { 15 | $(document) 16 | .on('keydown', function (e) { 17 | if (e.keyCode === 16) { 18 | startTargeting(e) 19 | } 20 | }) 21 | .on('keyup', function (e) { 22 | if (e.keyCode === 16) { 23 | stopTargeting(e) 24 | } 25 | }) 26 | $('.main') 27 | .on('mouseenter', '.item', enterItem) 28 | .on('mouseleave', '.item', leaveItem) 29 | .on('mouseenter', '.action', enterAction) 30 | .on('mouseleave', '.action', leaveAction) 31 | .on('align-item', '.page', alignItem) 32 | .on('mouseenter', '.backlinks .remote', enterBacklink) 33 | .on('mouseleave', '.backlinks .remote', leaveBacklink) 34 | } 35 | 36 | var startTargeting = function (e) { 37 | targeting = e.shiftKey 38 | if (targeting && $item != null && $item.length != 0) { 39 | let id 40 | $('.emit').addClass('highlight') 41 | if ((id = item || action)) { 42 | $(`[data-id=${id}]`).addClass('target') 43 | const key = $(this).parents('.page:first').data('key') 44 | const place = $item.offset().top 45 | $('.page').trigger('align-item', { key, id: item, place }) 46 | } 47 | if (itemElem) { 48 | consumed = itemElem.consuming 49 | if (consumed) { 50 | consumed.forEach(i => itemFor(i).addClass('consumed')) 51 | } 52 | } 53 | } 54 | } 55 | 56 | var stopTargeting = function (e) { 57 | targeting = e.shiftKey 58 | if (!targeting) { 59 | $('.emit').removeClass('highlight') 60 | $('.item, .action').removeClass('target') 61 | $('.item').removeClass('consumed') 62 | } 63 | } 64 | 65 | const pageFor = function (pageKey) { 66 | const $page = $('.page').filter((_i, page) => $(page).data('key') === pageKey) 67 | if ($page.length === 0) { 68 | return null 69 | } 70 | if ($page.length > 1) { 71 | console.log('warning: more than one page found for', pageKey, $page) 72 | } 73 | return $page 74 | } 75 | 76 | var itemFor = function (pageItem) { 77 | const [pageKey, item] = pageItem.split('/') 78 | const $page = pageFor(pageKey) 79 | if (!$page) { 80 | return null 81 | } 82 | $item = $page.find(`.item[data-id=${item}]`) 83 | if ($item.length === 0) { 84 | return null 85 | } 86 | if ($item.length > 1) { 87 | console.log('warning: more than one item found for', pageItem, $item) 88 | } 89 | return $item 90 | } 91 | 92 | var enterItem = function () { 93 | item = ($item = $(this)).attr('data-id') 94 | itemElem = $item[0] 95 | if (targeting) { 96 | $(`[data-id=${item}]`).addClass('target') 97 | const key = $(this).parents('.page:first').data('key') 98 | const place = $item.offset().top 99 | $('.page').trigger('align-item', { key, id: item, place }) 100 | consumed = itemElem.consuming 101 | if (consumed) { 102 | consumed.forEach(i => itemFor(i).addClass('consumed')) 103 | } 104 | } 105 | } 106 | 107 | var leaveItem = () => { 108 | if (targeting) { 109 | $('.item, .action').removeClass('target') 110 | $('.item').removeClass('consumed') 111 | } 112 | item = null 113 | $item = null 114 | itemElem = null 115 | } 116 | 117 | var enterAction = function () { 118 | action = $(this).data('id') 119 | if (targeting) { 120 | $(`[data-id=${action}]`).addClass('target') 121 | const key = $(this).parents('.page:first').data('key') 122 | $('.page').trigger('align-item', { key, id: action }) 123 | } 124 | } 125 | 126 | var leaveAction = () => { 127 | if (targeting) { 128 | $(`[data-id=${action}]`).removeClass('target') 129 | } 130 | action = null 131 | } 132 | 133 | var enterBacklink = function () { 134 | item = ($item = $(this)).attr('data-id') 135 | itemElem = $item[0] 136 | if (targeting) { 137 | $(`[data-id=${item}]`).addClass('target') 138 | const key = $(this).parents('.page:first').data('key') 139 | const place = $item.offset().top 140 | $('.page').trigger('align-item', { key, id: item, place }) 141 | } 142 | } 143 | 144 | var leaveBacklink = () => { 145 | if (targeting) { 146 | $('.item, .action').removeClass('target') 147 | } 148 | item = null 149 | itemElem = null 150 | } 151 | 152 | var alignItem = function (e, align) { 153 | const $page = $(this) 154 | if ($page.data('key') === align.key) { 155 | return 156 | } 157 | $item = $page.find(`.item[data-id=${align.id}]`) 158 | if (!$item.length) { 159 | return 160 | } 161 | const place = align.place || $page.height() / 2 162 | const offset = $item.offset().top + $page.scrollTop() - place 163 | $page.stop().animate({ scrollTop: offset }, 'slow') 164 | } 165 | 166 | module.exports = { bind } 167 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | // This module collects various functions that might belong 2 | // better elsewhere. At one point we thought of uniformity 3 | // of representations but that hasn't been a strong influency. 4 | 5 | let util 6 | module.exports = util = {} 7 | 8 | // for chart plug-in 9 | util.formatTime = function (time) { 10 | const d = new Date(time > 10000000000 ? time : time * 1000) 11 | const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] 12 | let h = d.getHours() 13 | const am = h < 12 ? 'AM' : 'PM' 14 | h = h === 0 ? 12 : h > 12 ? h - 12 : h 15 | const mi = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes() 16 | return `${h}:${mi} ${am}
        ${d.getDate()} ${mo} ${d.getFullYear()}` 17 | } 18 | 19 | // for journal mouse-overs and possibly for date header 20 | util.formatDate = function (msSinceEpoch) { 21 | const d = new Date(msSinceEpoch) 22 | const wk = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] 23 | const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] 24 | const day = d.getDate() 25 | const yr = d.getFullYear() 26 | let h = d.getHours() 27 | const am = h < 12 ? 'AM' : 'PM' 28 | h = h === 0 ? 12 : h > 12 ? h - 12 : h 29 | const mi = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes() 30 | const sec = (d.getSeconds() < 10 ? '0' : '') + d.getSeconds() 31 | return `${wk} ${mo} ${day}, ${yr}
        ${h}:${mi}:${sec} ${am}` 32 | } 33 | 34 | util.formatActionTitle = function (action) { 35 | let title = '' 36 | if (action.site) { 37 | title += `${action.site}\n` 38 | } 39 | title += action.type || 'separator' 40 | if (action.date) { 41 | title += ` ${util.formatElapsedTime(action.date)}` 42 | } 43 | if (action.attribution?.page) { 44 | title += `\nfrom ${action.attribution.page}` 45 | } 46 | if (action.removedTo?.page) { 47 | title += `\nto ${action.removedTo.page}` 48 | } 49 | return title 50 | } 51 | 52 | util.formatElapsedTime = function (msSinceEpoch) { 53 | let days, hrs, mins, months, secs, weeks, years 54 | const msecs = new Date().getTime() - msSinceEpoch 55 | if ((secs = msecs / 1000) < 2) { 56 | return `${Math.floor(msecs)} milliseconds ago` 57 | } 58 | if ((mins = secs / 60) < 2) { 59 | return `${Math.floor(secs)} seconds ago` 60 | } 61 | if ((hrs = mins / 60) < 2) { 62 | return `${Math.floor(mins)} minutes ago` 63 | } 64 | if ((days = hrs / 24) < 2) { 65 | return `${Math.floor(hrs)} hours ago` 66 | } 67 | if ((weeks = days / 7) < 2) { 68 | return `${Math.floor(days)} days ago` 69 | } 70 | if ((months = days / 31) < 2) { 71 | return `${Math.floor(weeks)} weeks ago` 72 | } 73 | if ((years = days / 365) < 2) { 74 | return `${Math.floor(months)} months ago` 75 | } 76 | return `${Math.floor(years)} years ago` 77 | } 78 | 79 | util.formatDelay = function (msSinceEpoch) { 80 | let hrs, mins, secs 81 | const msecs = msSinceEpoch - Date.now() 82 | if ((secs = msecs / 1000) < 2) { 83 | return `in ${Math.floor(msecs)} milliseconds` 84 | } 85 | if ((mins = secs / 60) < 2) { 86 | return `in ${Math.floor(secs)} seconds` 87 | } 88 | if ((hrs = mins / 60) < 2) { 89 | return `in ${Math.floor(mins)} minutes` 90 | } 91 | return `in ${Math.floor(hrs)} hours` 92 | } 93 | -------------------------------------------------------------------------------- /lib/wiki.js: -------------------------------------------------------------------------------- 1 | // We have exposed many parts of the core javascript to dynamically 2 | // loaded plugins through bindings in the global "wiki". We expect 3 | // to deprecate many of these as the plugin api matures. We once used 4 | // the global to communicate between core modules but have now 5 | // moved all of that responsibility onto browserify. 6 | 7 | // We have canvased plugin repos in github.com/fedwiki to find 8 | // the known uses of wiki globals. We notice that most entry 9 | // points are used. We mark unused entries with ##. 10 | 11 | const wiki = {} 12 | 13 | // known use: (eventually all server directed xhr and some tags) 14 | 15 | const siteAdapter = require('./siteAdapter') 16 | wiki.local = siteAdapter.local 17 | wiki.origin = siteAdapter.origin 18 | wiki.recycler = siteAdapter.recycler 19 | wiki.site = siteAdapter.site 20 | 21 | // known use: wiki.asSlug wiki-plugin-reduce/client/reduce.coffee: 22 | 23 | wiki.asSlug = require('./page').asSlug 24 | wiki.newPage = require('./page').newPage 25 | 26 | // known use: wiki.createItem wiki-plugin-parse/client/parse.coffee: 27 | // known use: wiki.removeItem wiki-plugin-parse/client/parse.coffee: 28 | // known use: wiki.getItem wiki-plugin-changes/client/changes.coffee: 29 | 30 | const itemz = require('./itemz') 31 | wiki.removeItem = itemz.removeItem 32 | wiki.createItem = itemz.createItem 33 | wiki.getItem = itemz.getItem 34 | 35 | // known use: wiki.dialog wiki-plugin-changes/client/changes.coffee: 36 | // known use: wiki.dialog wiki-plugin-chart/client/chart.coffee: 37 | // known use: wiki.dialog wiki-plugin-data/client/data.coffee: 38 | // known use: wiki.dialog wiki-plugin-efficiency/client/efficiency.coffee: 39 | // known use: wiki.dialog wiki-plugin-linkmap/client/linkmap.coffee: 40 | // known use: wiki.dialog wiki-plugin-method/client/method.coffee: 41 | // known use: wiki.dialog wiki-plugin-radar/client/radar.coffee: 42 | // known use: wiki.dialog wiki-plugin-reduce/client/reduce.coffee: 43 | // known use: wiki.dialog wiki-plugin-txtzyme/client/txtzyme.coffee: 44 | 45 | const dialog = require('./dialog') 46 | wiki.dialog = dialog.open 47 | 48 | // known use: wiki.doInternalLink wiki-plugin-force/client/force.coffee: 49 | // known use: wiki.doInternalLink wiki-plugin-radar/client/radar.coffee: 50 | 51 | const link = require('./link') 52 | wiki.createPage = link.createPage //# 53 | wiki.doInternalLink = link.doInternalLink 54 | wiki.showResult = link.showResult 55 | 56 | // known use: wiki.getScript wiki-plugin-bars/client/bars.coffee: 57 | // known use: wiki.getScript wiki-plugin-code/client/code.coffee: 58 | // known use: wiki.getScript wiki-plugin-force/client/force.coffee: 59 | // known use: wiki.getScript wiki-plugin-line/client/line.coffee: 60 | // known use: wiki.getScript wiki-plugin-map/client/map.coffee: 61 | // known use: wiki.getScript wiki-plugin-mathjax/client/mathjax.coffee: 62 | // known use: wiki.getScript wiki-plugin-pushpin/client/pushpin.coffee: 63 | // known use: wiki.getScript wiki-plugin-radar/client/radar.coffee: 64 | // known use: wiki.getPlugin wiki-plugin-reduce/client/reduce.coffee: 65 | // known use: wiki.doPlugin wiki-plugin-changes/client/changes.coffee: 66 | // known use: wiki.registerPlugin wiki-plugin-changes/client/changes.coffee: 67 | 68 | const plugin = require('./plugin') 69 | wiki.getScript = plugin.getScript 70 | wiki.getPlugin = plugin.getPlugin 71 | wiki.doPlugin = plugin.doPlugin 72 | wiki.registerPlugin = plugin.registerPlugin 73 | wiki.renderFrom = plugin.renderFrom 74 | 75 | // known use: wiki.getData wiki-plugin-bars/client/bars.coffee: 76 | // known use: wiki.getData wiki-plugin-calculator/client/calculator.coffee: 77 | // known use: wiki.getData wiki-plugin-force/client/force.coffee: 78 | // known use: wiki.getData wiki-plugin-line/client/line.coffee: 79 | 80 | wiki.getData = function (vis) { 81 | let who 82 | if (vis) { 83 | const idx = $('.item').index(vis) 84 | who = $(`.item:lt(${idx})`).filter('.chart,.data,.calculator').last() 85 | if (who) { 86 | return who.data('item').data 87 | } else { 88 | return {} 89 | } 90 | } else { 91 | who = $('.chart,.data,.calculator').last() 92 | if (who) { 93 | return who.data('item').data 94 | } else { 95 | return {} 96 | } 97 | } 98 | } 99 | 100 | // known use: wiki.getDataNodes wiki-plugin-metabolism/client/metabolism.coffee: 101 | // known use: wiki.getDataNodes wiki-plugin-method/client/method.coffee: 102 | 103 | wiki.getDataNodes = function (vis) { 104 | let who 105 | if (vis) { 106 | const idx = $('.item').index(vis) 107 | who = $(`.item:lt(${idx})`).filter('.chart,.data,.calculator').toArray().reverse() 108 | return $(who) 109 | } else { 110 | who = $('.chart,.data,.calculator').toArray().reverse() 111 | return $(who) 112 | } 113 | } 114 | 115 | // known use: wiki.log wiki-plugin-calculator/client/calculator.coffee: 116 | // known use: wiki.log wiki-plugin-calendar/client/calendar.coffee: 117 | // known use: wiki.log wiki-plugin-changes/client/changes.coffee: 118 | // known use: wiki.log wiki-plugin-efficiency/client/efficiency.coffee: 119 | // known use: wiki.log wiki-plugin-parse/client/parse.coffee: 120 | // known use: wiki.log wiki-plugin-radar/client/radar.coffee: 121 | // known use: wiki.log wiki-plugin-txtzyme/client/txtzyme.coffee: 122 | 123 | wiki.log = function (...things) { 124 | if (console?.log) { 125 | console.log(...(things || [])) 126 | } 127 | } 128 | 129 | // known use: wiki.neighborhood wiki-plugin-activity/client/activity.coffee: 130 | // known use: wiki.neighborhoodObject wiki-plugin-activity/client/activity.coffee: 131 | // known use: wiki.neighborhoodObject wiki-plugin-roster/client/roster.coffee: 132 | 133 | const neighborhood = require('./neighborhood') 134 | wiki.neighborhood = neighborhood.sites 135 | wiki.neighborhoodObject = neighborhood 136 | 137 | // known use: wiki.pageHandler wiki-plugin-changes/client/changes.coffee: 138 | // known use: wiki.pageHandler wiki-plugin-map/client/map.coffee: 139 | 140 | const pageHandler = require('./pageHandler') 141 | wiki.pageHandler = pageHandler 142 | wiki.useLocalStorage = pageHandler.useLocalStorage //# 143 | 144 | // known use: wiki.resolveFrom wiki-plugin-federatedwiki/client/federatedWiki.coffee: 145 | // known use: wiki.resolveLinks wiki-plugin-chart/client/chart.coffee: 146 | // known use: wiki.resolveLinks wiki-plugin-data/client/data.coffee: 147 | // known use: wiki.resolveLinks wiki-plugin-efficiency/client/efficiency.coffee: 148 | // known use: wiki.resolveLinks wiki-plugin-federatedwiki/client/federatedWiki.coffee: 149 | // known use: wiki.resolveLinks wiki-plugin-logwatch/client/logwatch.coffee: 150 | // known use: wiki.resolveLinks wiki-plugin-mathjax/client/mathjax.coffee: 151 | 152 | const resolve = require('./resolve') 153 | wiki.resolveFrom = resolve.resolveFrom 154 | wiki.resolveLinks = resolve.resolveLinks 155 | wiki.resolutionContext = resolve.resolutionContext //# 156 | 157 | // known use: wiki.textEditor wiki-plugin-bytebeat/client/bytebeat.coffee: 158 | // known use: wiki.textEditor wiki-plugin-calculator/client/calculator.coffee: 159 | // known use: wiki.textEditor wiki-plugin-calendar/client/calendar.coffee: 160 | // known use: wiki.textEditor wiki-plugin-code/client/code.coffee: 161 | // known use: wiki.textEditor wiki-plugin-data/client/data.coffee: 162 | // known use: wiki.textEditor wiki-plugin-efficiency/client/efficiency.coffee: 163 | // known use: wiki.textEditor wiki-plugin-federatedwiki/client/federatedWiki.coffee: 164 | // known use: wiki.textEditor wiki-plugin-mathjax/client/mathjax.coffee: 165 | // known use: wiki.textEditor wiki-plugin-metabolism/client/metabolism.coffee: 166 | // known use: wiki.textEditor wiki-plugin-method/client/method.coffee: 167 | // known use: wiki.textEditor wiki-plugin-pagefold/client/pagefold.coffee: 168 | // known use: wiki.textEditor wiki-plugin-parse/client/parse.coffee: 169 | // known use: wiki.textEditor wiki-plugin-radar/client/radar.coffee: 170 | // known use: wiki.textEditor wiki-plugin-reduce/client/reduce.coffee: 171 | // known use: wiki.textEditor wiki-plugin-txtzyme/client/txtzyme.coffee: 172 | 173 | const editor = require('./editor') 174 | wiki.textEditor = editor.textEditor 175 | 176 | // known use: wiki.util wiki-plugin-activity/client/activity.coffee: 177 | 178 | wiki.util = require('./util') 179 | 180 | // known use: wiki.security views/static.html 181 | wiki.security = require('./security') 182 | 183 | // known use: require wiki-clint/lib/synopsis wiki-node-server/lib/page.coffee 184 | // known use: require wiki-clint/lib/synopsis wiki-node-server/lib/leveldb.js 185 | // known use: require wiki-clint/lib/synopsis wiki-node-server/lib/mongodb.js 186 | // known use: require wiki-clint/lib/synopsis wiki-node-server/lib/redis.js 187 | 188 | wiki.createSynopsis = require('./synopsis') //# 189 | 190 | // known uses: (none yet) 191 | wiki.lineup = require('./lineup') 192 | 193 | module.exports = wiki 194 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wiki-client", 3 | "version": "0.31.1", 4 | "description": "Federated Wiki - Client-side Javascript", 5 | "keywords": [ 6 | "wiki", 7 | "federated wiki", 8 | "wiki client" 9 | ], 10 | "author": { 11 | "name": "Ward Cunningham", 12 | "email": "ward@c2.com", 13 | "url": "http://ward.fed.wiki.org" 14 | }, 15 | "contributors": [ 16 | { 17 | "name": "Nick Niemeir", 18 | "email": "nick.niemeir@gmail.com", 19 | "url": "http://nrn.io" 20 | }, 21 | { 22 | "name": "Paul Rodwell", 23 | "email": "paul.rodwell@btinternet.com", 24 | "url": "https://rodwell.me" 25 | } 26 | ], 27 | "scripts": { 28 | "build": "npm run test && npm run build:client && npm run build:test", 29 | "build:client": "npm run clean:client; node --no-warnings scripts/build-client.mjs", 30 | "build:test": "npm run clean:test; node --no-warnings scripts/build-testclient.mjs", 31 | "prettier:format": "prettier --write './**/*.js'", 32 | "prettier:check": "prettier --check ./**/*.js", 33 | "clean": "npm run clean:client; npm run clean:test", 34 | "clean:client": "rm client/client.js client/client.js.map meta-client.json", 35 | "clean:test": "rm client/test/testclient.js", 36 | "test": "mocha test/util.js test/random.js test/page.js test/lineup.js test/drop.js test/revision.js test/resolve.js test/wiki.js", 37 | "runtests": "npm run build:test && ((sleep 1; open 'http://localhost:3000/runtests.html')&) && echo '\nBrowser will open to run tests.' && serve client", 38 | "update-authors": "node scripts/update-authors.js" 39 | }, 40 | "dependencies": { 41 | "async": "^3.2.1", 42 | "localforage": "^1.7.3", 43 | "underscore": "^1.13.6" 44 | }, 45 | "devDependencies": { 46 | "@eslint/js": "^9.26.0", 47 | "esbuild": "^0.25.4", 48 | "eslint": "^9.26.0", 49 | "expect.js": "^0.3.1", 50 | "globals": "^16.1.0", 51 | "grunt-git-authors": "^3.2.0", 52 | "minisearch": "^7.1.2", 53 | "mocha": "^11.2.2", 54 | "prettier": "^3.5.3", 55 | "serve": "^14.2.3", 56 | "sinon": "^20.0.0" 57 | }, 58 | "license": "MIT", 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/fedwiki/wiki-client" 62 | }, 63 | "bugs": { 64 | "url": "https://github.com/fedwiki/wiki-client/issues" 65 | }, 66 | "engines": { 67 | "node": ">=18.x" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/build-client.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | import fs from 'node:fs/promises' 3 | import packJSON from "../package.json" with { type: "json"}; 4 | 5 | const version = packJSON.version 6 | const now = new Date() 7 | 8 | let results = await esbuild.build({ 9 | entryPoints: ['client.js'], 10 | bundle: true, 11 | banner: { 12 | js: `/* wiki-client - ${version} - ${now.toUTCString()} */`}, 13 | minify: true, 14 | sourcemap: true, 15 | logLevel: 'info', 16 | metafile: true, 17 | outfile: 'client/client.js' 18 | }) 19 | 20 | await fs.writeFile('meta-client.json', JSON.stringify(results.metafile)) 21 | console.log('\n esbuild metadata written to \'meta-client.json\'.') 22 | -------------------------------------------------------------------------------- /scripts/build-testclient.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | import packJSON from '../package.json' with { type: 'json' } 3 | 4 | const version = packJSON.version 5 | const now = new Date() 6 | 7 | await esbuild.build({ 8 | entryPoints: ['testclient.js'], 9 | bundle: true, 10 | banner: { 11 | js: `/* wiki-client (test) - ${version} - ${now.toUTCString()} */`, 12 | }, 13 | minify: true, 14 | sourcemap: true, 15 | logLevel: 'info', 16 | outfile: 'client/test/testclient.js', 17 | }) 18 | -------------------------------------------------------------------------------- /scripts/call-graph.dot: -------------------------------------------------------------------------------- 1 | digraph call_graph { 2 | node [style=filled; fillcolor=white; color=white]; 3 | subgraph [style=filled; fillcolor=lightgray]; 4 | 5 | ready [shape=box]; 6 | ready -> begin; 7 | 8 | subgraph cluster_page { 9 | fillcolor=palegreen; 10 | label = "page"; 11 | newPage; 12 | become; 13 | } 14 | 15 | subgraph cluster_lineup { 16 | fillcolor=palegreen; 17 | label = "lineup"; 18 | addPage [label="add\npage"]; 19 | removeKey [label="remove\nkey"]; 20 | removeAllAfter [label="remove\nall\nafter"]; 21 | } 22 | 23 | subgraph cluster_link { 24 | label = "link"; 25 | fillcolor=palegreen; 26 | doInternalLink [label="internal\nlink"]; 27 | createPage [label="create\npage"]; 28 | doInternalLink -> createPage; 29 | 30 | } 31 | 32 | subgraph cluster_legacy { 33 | label = "legacy"; 34 | click_create_button [label="create\nbutton"]; 35 | begin; 36 | } 37 | begin -> first; 38 | begin -> cycle; 39 | doInternalLink -> cycle; 40 | doInternalLink -> removeAllAfter; 41 | click_create_button -> get; 42 | click_create_button -> become; 43 | click_create_button -> put; 44 | click_create_button -> rebuildPage; 45 | 46 | 47 | createPage -> page; 48 | page [shape=box] 49 | 50 | subgraph cluster_pageHandler { 51 | label = "pageHandler"; 52 | get -> trouble; put; 53 | } 54 | get -> newPage; 55 | trouble -> newPage; 56 | 57 | 58 | subgraph cluster_refresh { 59 | label = "refresh"; 60 | buildPage [label="build\npage"] 61 | rebuildPage [label="rebuild\npage"] 62 | cycle -> missing -> buildPage -> rebuildPage; 63 | } 64 | missing -> newPage; 65 | cycle -> get; 66 | cycle -> buildPage; 67 | buildPage -> addPage; 68 | rebuildPage -> story; story[shape=box]; 69 | rebuildPage -> journal; journal[shape=box]; 70 | 71 | 72 | subgraph cluster_search { 73 | label = "search"; 74 | performSearch [label="perform\nsearch"]; 75 | } 76 | performSearch -> newPage; 77 | performSearch -> createPage; 78 | performSearch -> buildPage; 79 | 80 | 81 | subgraph cluster_state { 82 | label = "state"; 83 | show; first; 84 | } 85 | show -> cycle; 86 | show -> createPage; 87 | show -> removeKey; 88 | first -> createPage; 89 | 90 | 91 | } -------------------------------------------------------------------------------- /scripts/call-sites.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR; 3 | node [style=filled; fillcolor=lightBlue]; 4 | equals -> pageHandler; 5 | createFactory -> random; 6 | createFactory -> plugin; 7 | createFactory -> pageHandler; 8 | handleHeaderClick -> lineup; 9 | handleHeaderClick -> lineup; 10 | emitControls -> actionSymbols; 11 | emitControls -> actionSymbols; 12 | emitFooter -> random; 13 | emitFooter -> random; 14 | emitTwins -> asSlug; 15 | emitTwins -> neighborhood; 16 | renderPageIntoPageElement -> lineup; 17 | renderPageIntoPageElement -> lineup; 18 | renderPageIntoPageElement -> resolve; 19 | renderPageIntoPageElement -> plugin; 20 | renderPageIntoPageElement -> addToJournal; 21 | renderPageIntoPageElement -> addToJournal; 22 | createMissingFlag -> plugin; 23 | rebuildPage -> plugin; 24 | rebuildPage -> state; 25 | buildPage -> lineup; 26 | createGhostPage -> pageHandler; 27 | createGhostPage -> newPage; 28 | createGhostPage -> neighborhood; 29 | createGhostPage -> neighborhood; 30 | whenGotten -> neighborhood; 31 | whenGotten -> pageHandler; 32 | } 33 | -------------------------------------------------------------------------------- /scripts/call-sites.pl: -------------------------------------------------------------------------------- 1 | $dep = "pageHandler|plugin|state|neighborhood|addToJournal|actionSymbols|lineup|resolve|random|pageModule|newPage|asSlug"; 2 | 3 | 4 | @lines = `cat lib/refresh.coffee`; 5 | print "digraph {\nrankdir=LR;\nnode [style=filled; fillcolor=lightBlue];\n"; 6 | for (@lines) { 7 | next if /require/; 8 | next if /^#/; 9 | $from = $1 if /^\s*(\w+)\s*=\s*(\(|->)/; 10 | while (/\b($dep)\b/g) { 11 | print "$from -> $1;\n" if $from; 12 | } 13 | } 14 | print "}\n"; -------------------------------------------------------------------------------- /scripts/requires-graph.dot: -------------------------------------------------------------------------------- 1 | digraph { node [style=filled]; 2 | 3 | actionSymbols [fillcolor=paleGreen]; 4 | 5 | active [fillcolor=gold]; 6 | active [shape=box]; 7 | 8 | addToJournal [fillcolor=gold]; 9 | util -> addToJournal [dir=back]; 10 | actionSymbols -> addToJournal [dir=back]; 11 | addToJournal [shape=box]; 12 | 13 | bind [fillcolor=paleGreen]; 14 | neighborhood -> bind [dir=back]; 15 | neighbors -> bind [dir=back]; 16 | searchbox -> bind [dir=back]; 17 | state -> bind [dir=back]; 18 | link -> bind [dir=back]; 19 | bind [shape=box]; 20 | 21 | dialog [fillcolor=paleGreen]; 22 | 23 | drop [fillcolor=paleGreen]; 24 | 25 | editor [fillcolor=paleGreen]; 26 | plugin -> editor [dir=back]; 27 | itemz -> editor [dir=back]; 28 | pageHandler -> editor [dir=back]; 29 | link -> editor [dir=back]; 30 | random -> editor [dir=back]; 31 | editor [shape=box]; 32 | 33 | factory [fillcolor=gold]; 34 | neighborhood -> factory [dir=back]; 35 | plugin -> factory [dir=back]; 36 | resolve -> factory [dir=back]; 37 | pageHandler -> factory [dir=back]; 38 | editor -> factory [dir=back]; 39 | synopsis -> factory [dir=back]; 40 | drop -> factory [dir=back]; 41 | active -> factory [dir=back]; 42 | factory [shape=box]; 43 | 44 | forward [fillcolor=gold]; 45 | forward [shape=box]; 46 | 47 | future [fillcolor=paleGreen]; 48 | resolve -> future [dir=back]; 49 | neighborhood -> future [dir=back]; 50 | lineup -> future [dir=back]; 51 | refresh -> future [dir=back]; 52 | future [shape=box]; 53 | 54 | importer [fillcolor=gold]; 55 | util -> importer [dir=back]; 56 | link -> importer [dir=back]; 57 | page -> importer [dir=back]; 58 | importer [shape=box]; 59 | 60 | itemz [fillcolor=paleGreen]; 61 | pageHandler -> itemz [dir=back]; 62 | plugin -> itemz [dir=back]; 63 | random -> itemz [dir=back]; 64 | itemz [shape=box]; 65 | 66 | legacy [fillcolor=gold]; 67 | pageHandler -> legacy [dir=back]; 68 | state -> legacy [dir=back]; 69 | active -> legacy [dir=back]; 70 | refresh -> legacy [dir=back]; 71 | lineup -> legacy [dir=back]; 72 | drop -> legacy [dir=back]; 73 | dialog -> legacy [dir=back]; 74 | link -> legacy [dir=back]; 75 | target -> legacy [dir=back]; 76 | license -> legacy [dir=back]; 77 | plugin -> legacy [dir=back]; 78 | util -> legacy [dir=back]; 79 | page -> legacy [dir=back]; 80 | page -> legacy [dir=back]; 81 | legacy [shape=box]; 82 | 83 | license [fillcolor=gold]; 84 | resolve -> license [dir=back]; 85 | lineup -> license [dir=back]; 86 | license [shape=box]; 87 | 88 | lineup [fillcolor=paleGreen]; 89 | random -> lineup [dir=back]; 90 | 91 | link [fillcolor=paleGreen]; 92 | lineup -> link [dir=back]; 93 | active -> link [dir=back]; 94 | refresh -> link [dir=back]; 95 | page -> link [dir=back]; 96 | link [shape=box]; 97 | 98 | neighborhood [fillcolor=gold]; 99 | neighborhood [shape=box]; 100 | 101 | neighbors [fillcolor=paleGreen]; 102 | link -> neighbors [dir=back]; 103 | wiki -> neighbors [dir=back]; 104 | neighborhood -> neighbors [dir=back]; 105 | util -> neighbors [dir=back]; 106 | neighbors [shape=box]; 107 | 108 | page [fillcolor=paleGreen]; 109 | util -> page [dir=back]; 110 | random -> page [dir=back]; 111 | revision -> page [dir=back]; 112 | synopsis -> page [dir=back]; 113 | 114 | pageHandler [fillcolor=gold]; 115 | state -> pageHandler [dir=back]; 116 | revision -> pageHandler [dir=back]; 117 | addToJournal -> pageHandler [dir=back]; 118 | page -> pageHandler [dir=back]; 119 | random -> pageHandler [dir=back]; 120 | lineup -> pageHandler [dir=back]; 121 | neighborhood -> pageHandler [dir=back]; 122 | pageHandler [shape=box]; 123 | 124 | paragraph [fillcolor=paleGreen]; 125 | editor -> paragraph [dir=back]; 126 | resolve -> paragraph [dir=back]; 127 | itemz -> paragraph [dir=back]; 128 | paragraph [shape=box]; 129 | 130 | plugin [fillcolor=gold]; 131 | forward -> plugin [dir=back]; 132 | plugin [shape=box]; 133 | 134 | plugins [fillcolor=paleGreen]; 135 | plugin -> plugins [dir=back]; 136 | reference -> plugins [dir=back]; 137 | factory -> plugins [dir=back]; 138 | paragraph -> plugins [dir=back]; 139 | image -> plugins [dir=back]; 140 | future -> plugins [dir=back]; 141 | importer -> plugins [dir=back]; 142 | 143 | random [fillcolor=paleGreen]; 144 | 145 | reference [fillcolor=gold]; 146 | editor -> reference [dir=back]; 147 | resolve -> reference [dir=back]; 148 | page -> reference [dir=back]; 149 | reference [shape=box]; 150 | 151 | refresh [fillcolor=gold]; 152 | pageHandler -> refresh [dir=back]; 153 | plugin -> refresh [dir=back]; 154 | state -> refresh [dir=back]; 155 | neighborhood -> refresh [dir=back]; 156 | addToJournal -> refresh [dir=back]; 157 | actionSymbols -> refresh [dir=back]; 158 | lineup -> refresh [dir=back]; 159 | resolve -> refresh [dir=back]; 160 | random -> refresh [dir=back]; 161 | page -> refresh [dir=back]; 162 | refresh [shape=box]; 163 | 164 | resolve [fillcolor=paleGreen]; 165 | page -> resolve [dir=back]; 166 | 167 | revision [fillcolor=gold]; 168 | 169 | search [fillcolor=gold]; 170 | pageHandler -> search [dir=back]; 171 | random -> search [dir=back]; 172 | link -> search [dir=back]; 173 | active -> search [dir=back]; 174 | page -> search [dir=back]; 175 | resolve -> search [dir=back]; 176 | page -> search [dir=back]; 177 | search [shape=box]; 178 | 179 | searchbox [fillcolor=paleGreen]; 180 | search -> searchbox [dir=back]; 181 | searchbox [shape=box]; 182 | 183 | security [fillcolor=gold]; 184 | plugin -> security [dir=back]; 185 | 186 | siteAdapter [fillcolor=gold]; 187 | siteAdapter [shape=box]; 188 | 189 | state [fillcolor=gold]; 190 | active -> state [dir=back]; 191 | lineup -> state [dir=back]; 192 | 193 | synopsis [fillcolor=gold]; 194 | 195 | target [fillcolor=gold]; 196 | target [shape=box]; 197 | 198 | util [fillcolor=gold]; 199 | } 200 | -------------------------------------------------------------------------------- /scripts/requires-graph.pl: -------------------------------------------------------------------------------- 1 | # read all source files in lib, generate graph of require dependencies 2 | # usage: perl require-graph.pl 3 | 4 | @new = qw" page lineup drop dialog link tempwiki neighbors searchbox bind plugins future image paragraph resolve itemz editor actionSymbols random "; 5 | 6 | for (<../lib/*.js>) { 7 | next if /wiki/; 8 | $from = $1 if /(\w+)\.js/; 9 | $color = $from ~~ @new ? 'paleGreen' : 'gold'; 10 | $dot .= "\n$from [fillcolor=$color];\n"; 11 | open F, $_; 12 | 13 | $jquery = 0; 14 | for () { 15 | if (/\brequire\b.+\.\/(\w+)\b/) { 16 | $dot .= "$1 -> $from [dir=back];\n"; 17 | 18 | } 19 | if (/^\s*\$/) { 20 | $jquery = 1; 21 | } 22 | } 23 | if ($jquery) { 24 | $dot .= "$from [shape=box];\n" 25 | } 26 | } 27 | 28 | # for (<../test/*.coffee>) { 29 | # $from = $1 if /(\w+)\.coffee/; 30 | # $color = 'lightBlue'; 31 | # $dot .= "\n\"test\\n$from\" [fillcolor=$color];\n"; 32 | # open F, $_; 33 | 34 | # for () { 35 | # if (/\brequire\b.+\.\.\/lib\/(\w+)\b/) { 36 | # $dot .= "$1 -> \"test\\n$from\" [dir=back];\n"; 37 | 38 | # } 39 | # } 40 | # } 41 | 42 | open D, '>requires-graph.dot'; 43 | print D "digraph { node [style=filled];\n$dot}\n"; 44 | -------------------------------------------------------------------------------- /scripts/update-authors.js: -------------------------------------------------------------------------------- 1 | const gitAuthors = require('grunt-git-authors') 2 | 3 | // list of contributers from prior the split out of Smallest Federated Wiki repo. 4 | const priorAuthors = [ 5 | 'Ward Cunningham ', 6 | 'Stephen Judkins ', 7 | 'Sam Goldstein ', 8 | 'Steven Black ', 9 | 'Don Park ', 10 | 'Sven Dowideit ', 11 | 'Adam Solove ', 12 | 'Nick Niemeir ', 13 | 'Erkan Yilmaz ', 14 | 'Matt Niemeir ', 15 | 'Daan van Berkel ', 16 | 'Nicholas Hallahan ', 17 | 'Ola Bini ', 18 | 'Danilo Sato ', 19 | 'Henning Schumann ', 20 | 'Michael Deardeuff ', 21 | 'Pete Hodgson ', 22 | 'Marcin Cieslak ', 23 | 'M. Kelley Harris (http://www.kelleyharris.com)', 24 | 'Ryan Bennett ', 25 | 'Paul Rodwell ', 26 | 'David Turnbull ', 27 | 'Austin King ', 28 | ] 29 | 30 | gitAuthors.updateAuthors( 31 | { 32 | priorAuthors: priorAuthors, 33 | }, 34 | (error, filename) => { 35 | if (error) { 36 | console.log('Error: ', error) 37 | } else { 38 | console.log(filename, 'updated') 39 | } 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /test/active.js: -------------------------------------------------------------------------------- 1 | const active = require('../lib/active') 2 | 3 | describe('active', function () { 4 | before(function () { 5 | $('
        ').appendTo('body') 6 | $('
        ').appendTo('body') 7 | active.set($('#active1')) 8 | }) 9 | 10 | it.skip('should detect the scroll container', () => expect(active.scrollContainer).to.be.a($)) 11 | 12 | it('should set the active div', function () { 13 | active.set($('#active2')) 14 | expect($('#active2').hasClass('active')).to.be.true 15 | }) 16 | 17 | return it('should remove previous active class', () => expect($('#active1').hasClass('active')).to.be.false) 18 | }) 19 | -------------------------------------------------------------------------------- /test/drop.js: -------------------------------------------------------------------------------- 1 | const drop = require('../lib/drop') 2 | const expect = require('expect.js') 3 | 4 | // construct mock event objects 5 | 6 | const signal = (mock, handler) => handler(mock) 7 | 8 | const mockDrop = dataTransfer => ({ 9 | preventDefault() {}, 10 | stopPropagation() {}, 11 | 12 | originalEvent: { 13 | dataTransfer, 14 | }, 15 | }) 16 | 17 | const mockUrl = (type, url) => 18 | mockDrop({ 19 | types: [type], 20 | getData() { 21 | return url 22 | }, 23 | }) 24 | 25 | const mockFile = spec => 26 | mockDrop({ 27 | types: ['File'], 28 | files: [spec], 29 | }) 30 | 31 | // test the handling of mock events 32 | 33 | describe('drop', function () { 34 | it('should handle remote pages', function () { 35 | const event = mockUrl('text/uri-list', 'http://localhost:3000/fed.wiki.org/welcome-visitors') 36 | signal( 37 | event, 38 | drop.dispatch({ 39 | page(page) { 40 | expect(page).to.eql({ slug: 'welcome-visitors', site: 'fed.wiki.org' }) 41 | }, 42 | }), 43 | ) 44 | }) 45 | 46 | it('should handle local pages', function () { 47 | const event = mockUrl('text/uri-list', 'http://fed.wiki.org/view/welcome-visitors') 48 | signal( 49 | event, 50 | drop.dispatch({ 51 | page(page) { 52 | expect(page).to.eql({ slug: 'welcome-visitors', site: 'fed.wiki.org' }) 53 | }, 54 | }), 55 | ) 56 | }) 57 | 58 | it('should handle list of pages', function () { 59 | const event = mockUrl('text/uri-list', 'http://sfw.c2.com/view/welcome-visitors/view/pattern-language') 60 | signal( 61 | event, 62 | drop.dispatch({ 63 | page(page) { 64 | expect(page).to.eql({ slug: 'pattern-language', site: 'sfw.c2.com' }) 65 | }, 66 | }), 67 | ) 68 | }) 69 | 70 | it('should handle a YouTube video', function () { 71 | const event = mockUrl('text/uri-list', 'https://www.youtube.com/watch?v=rFpDK2KhAgw') 72 | signal( 73 | event, 74 | drop.dispatch({ 75 | video(video) { 76 | expect(video).to.eql({ text: 'YOUTUBE rFpDK2KhAgw' }) 77 | }, 78 | }), 79 | ) 80 | }) 81 | 82 | it('should handle a YouTube playlist', function () { 83 | const event = mockUrl( 84 | 'text/uri-list', 85 | 'https://www.youtube.com/watch?v=ksoe4Un7bLo&list=PLze65Ckn-WXZpRzLeUPxqsEkFY6vt2hF7', 86 | ) 87 | signal( 88 | event, 89 | drop.dispatch({ 90 | video(video) { 91 | expect(video).to.eql({ text: 'YOUTUBE PLAYLIST PLze65Ckn-WXZpRzLeUPxqsEkFY6vt2hF7' }) 92 | }, 93 | }), 94 | ) 95 | }) 96 | 97 | it('should handle a YouTu.be video', function () { 98 | const event = mockUrl('text/uri-list', 'https://youtu.be/z2p4VRKgQYU') 99 | signal( 100 | event, 101 | drop.dispatch({ 102 | video(video) { 103 | expect(video).to.eql({ text: 'YOUTUBE z2p4VRKgQYU' }) 104 | }, 105 | }), 106 | ) 107 | }) 108 | 109 | it('should handle a YouTu.be playlist', function () { 110 | const event = mockUrl('text/uri-list', 'https://youtu.be/pBu6cixcaxI?list=PL0LQM0SAx601_99m2E2NPsm62pKoSCnV5') 111 | signal( 112 | event, 113 | drop.dispatch({ 114 | video(video) { 115 | expect(video).to.eql({ text: 'YOUTUBE PLAYLIST PL0LQM0SAx601_99m2E2NPsm62pKoSCnV5' }) 116 | }, 117 | }), 118 | ) 119 | }) 120 | 121 | it('should handle a vimeo video', function () { 122 | const event = mockUrl('text/uri-list', 'https://vimeo.com/90834988') 123 | signal( 124 | event, 125 | drop.dispatch({ 126 | video(video) { 127 | expect(video).to.eql({ text: 'VIMEO 90834988' }) 128 | }, 129 | }), 130 | ) 131 | }) 132 | 133 | it('should handle a archive.org video', function () { 134 | const event = mockUrl('text/uri-list', 'https://archive.org/details/IcelandJazz') 135 | signal( 136 | event, 137 | drop.dispatch({ 138 | video(video) { 139 | expect(video).to.eql({ text: 'ARCHIVE IcelandJazz' }) 140 | }, 141 | }), 142 | ) 143 | }) 144 | 145 | it('should handle a TEDX video', function () { 146 | const event = mockUrl('text/uri-list', 'http://tedxtalks.ted.com/video/Be-a-Daydream-Believer-Anne-Zac') 147 | signal( 148 | event, 149 | drop.dispatch({ 150 | video(video) { 151 | expect(video).to.eql({ text: 'TEDX Be-a-Daydream-Believer-Anne-Zac' }) 152 | }, 153 | }), 154 | ) 155 | }) 156 | 157 | it('should handle a TED video', function () { 158 | const event = mockUrl( 159 | 'text/uri-list', 160 | 'http://www.ted.com/talks/david_camarillo_why_helmets_don_t_prevent_concussions_and_what_might', 161 | ) 162 | signal( 163 | event, 164 | drop.dispatch({ 165 | video(video) { 166 | expect(video).to.eql({ 167 | text: 'TED david_camarillo_why_helmets_don_t_prevent_concussions_and_what_might', 168 | }) 169 | }, 170 | }), 171 | ) 172 | }) 173 | 174 | it('should handle text file', function () { 175 | const file = { name: 'foo.txt', type: 'text/plain' } 176 | const event = mockFile(file) 177 | signal( 178 | event, 179 | drop.dispatch({ 180 | file(data) { 181 | expect(data).to.eql(file) 182 | }, 183 | }), 184 | ) 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /test/lineup.js: -------------------------------------------------------------------------------- 1 | const lineup = require('../lib/lineup') 2 | const { newPage } = require('../lib/page') 3 | const expect = require('expect.js') 4 | 5 | describe('lineup', function () { 6 | it('should assign unique keys', function () { 7 | const pageObject = newPage() 8 | lineup.debugReset() 9 | const key1 = lineup.addPage(pageObject) 10 | const key2 = lineup.addPage(pageObject) 11 | expect(key1).to.not.equal(key2) 12 | }) 13 | 14 | it('should preserve identity', function () { 15 | const pageObject = newPage() 16 | lineup.debugReset() 17 | const key1 = lineup.addPage(pageObject) 18 | const key2 = lineup.addPage(pageObject) 19 | expect(key1).to.not.eql(null) 20 | expect(lineup.atKey(key1)).to.be(lineup.atKey(key2)) 21 | }) 22 | 23 | it('should remove a page', function () { 24 | const pageObject = newPage() 25 | lineup.debugReset() 26 | const key1 = lineup.addPage(pageObject) 27 | const key2 = lineup.addPage(pageObject) 28 | const key3 = lineup.addPage(pageObject) 29 | const result = lineup.removeKey(key2) 30 | expect([lineup.debugKeys(), result]).to.eql([[key1, key3], key2]) 31 | }) 32 | 33 | it('should remove downstream pages', function () { 34 | const pageObject = newPage() 35 | lineup.debugReset() 36 | const key1 = lineup.addPage(pageObject) 37 | const key2 = lineup.addPage(pageObject) 38 | const key3 = lineup.addPage(pageObject) 39 | const result = lineup.removeAllAfterKey(key1) 40 | expect([lineup.debugKeys(), result]).to.eql([[key1], [key2, key3]]) 41 | }) 42 | 43 | describe('crumbs', function () { 44 | const fromUri = function (uri) { 45 | lineup.debugReset() 46 | const fields = uri.split(/\//) 47 | const result = [] 48 | while (fields.length) { 49 | var host = fields.shift() 50 | result.push(lineup.addPage(newPage({ title: fields.shift() }, host))) 51 | } 52 | return result 53 | } 54 | 55 | it('should reload welcome', function () { 56 | const keys = fromUri('view/welcome-visitors') 57 | const crumbs = lineup.crumbs(keys[0], 'foo.com') 58 | expect(crumbs).to.eql(['foo.com', 'view', 'welcome-visitors']) 59 | }) 60 | 61 | it('should load remote welcome', function () { 62 | const keys = fromUri('bar.com/welcome-visitors') 63 | const crumbs = lineup.crumbs(keys[0], 'foo.com') 64 | expect(crumbs).to.eql(['bar.com', 'view', 'welcome-visitors']) 65 | }) 66 | 67 | it('should reload welcome before some-page', function () { 68 | const keys = fromUri('view/some-page') 69 | const crumbs = lineup.crumbs(keys[0], 'foo.com') 70 | expect(crumbs).to.eql(['foo.com', 'view', 'welcome-visitors', 'view', 'some-page']) 71 | }) 72 | 73 | it('should load remote welcome and some-page', function () { 74 | const keys = fromUri('bar.com/some-page') 75 | const crumbs = lineup.crumbs(keys[0], 'foo.com') 76 | expect(crumbs).to.eql(['bar.com', 'view', 'welcome-visitors', 'view', 'some-page']) 77 | }) 78 | 79 | it('should remote the adjacent local page when changing origin', function () { 80 | const keys = fromUri('view/once-local/bar.com/some-page') 81 | const crumbs = lineup.crumbs(keys[1], 'foo.com') 82 | expect(crumbs).to.eql(['bar.com', 'view', 'welcome-visitors', 'view', 'some-page', 'foo.com', 'once-local']) 83 | }) 84 | 85 | it('should remote the stacked adjacent local page when changing origin', function () { 86 | const keys = fromUri('view/stack1/view/stack2/view/once-local/bar.com/some-page') 87 | const crumbs = lineup.crumbs(keys[3], 'foo.com') 88 | expect(crumbs).to.eql(['bar.com', 'view', 'welcome-visitors', 'view', 'some-page', 'foo.com', 'once-local']) 89 | }) 90 | 91 | it('should remote the welcome rooted stacked adjacent local page when changing origin', function () { 92 | const keys = fromUri('view/welcome-visitors/view/stack2/view/once-local/bar.com/some-page') 93 | const crumbs = lineup.crumbs(keys[3], 'foo.com') 94 | expect(crumbs).to.eql(['bar.com', 'view', 'welcome-visitors', 'view', 'some-page', 'foo.com', 'once-local']) 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/mockServer.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | 3 | const simulatePageNotFound = function () { 4 | const xhrFor404 = { 5 | status: 404, 6 | } 7 | sinon.stub(jQuery, 'ajax').yieldsTo('error', xhrFor404) 8 | } 9 | 10 | const simulatePageFound = function (pageToReturn) { 11 | if (pageToReturn == null) { 12 | pageToReturn = {} 13 | } 14 | sinon.stub(jQuery, 'ajax').yieldsTo('success', pageToReturn) 15 | } 16 | 17 | module.exports = { 18 | simulatePageNotFound, 19 | simulatePageFound, 20 | } 21 | -------------------------------------------------------------------------------- /test/neighborhood.js: -------------------------------------------------------------------------------- 1 | const expect = require('expect.js') 2 | const _ = require('underscore') 3 | const miniSearch = require('minisearch') 4 | 5 | const neighborhood = require('../lib/neighborhood') 6 | 7 | describe('neighborhood', function () { 8 | describe('no neighbors', () => 9 | it('should return an empty array for our search', function () { 10 | const searchResult = neighborhood.search('query string') 11 | expect(searchResult.finds).to.eql([]) 12 | })) 13 | 14 | describe('a single neighbor with a few pages', function () { 15 | before(function () { 16 | const fakeSitemap = [ 17 | { title: 'Page One', slug: 'page-one', date: 'date1' }, 18 | { title: 'Page Two', slug: 'page-two', date: 'date2' }, 19 | { title: 'Page Three', slug: 'page-three' }, 20 | ] 21 | 22 | const fakeSiteindex = new miniSearch({ 23 | fields: ['title', 'content'], 24 | }) 25 | 26 | fakeSitemap.forEach(function (page) { 27 | fakeSiteindex.add({ 28 | id: page.slug, 29 | title: page.title, 30 | content: '', 31 | }) 32 | }) 33 | 34 | const neighbor = { 35 | sitemap: fakeSitemap, 36 | siteIndex: fakeSiteindex, 37 | } 38 | 39 | neighborhood.sites = {} 40 | return (neighborhood.sites['my-site'] = neighbor) 41 | }) 42 | 43 | it('returns all pages that match the query', function () { 44 | const searchResult = neighborhood.search('Page') 45 | expect(searchResult.finds).to.have.length(3) 46 | }) 47 | 48 | it('returns only pages that match the query', function () { 49 | const searchResult = neighborhood.search('Page T') 50 | expect(searchResult.finds).to.have.length(2) 51 | }) 52 | 53 | it('should package the results in the correct format', function () { 54 | const expectedResult = [ 55 | { 56 | site: 'my-site', 57 | page: { title: 'Page Two', slug: 'page-two', date: 'date2' }, 58 | }, 59 | ] 60 | const searchResult = neighborhood.search('Page Two') 61 | expect(searchResult.finds.site).to.eql(expectedResult.site) 62 | expect(searchResult.finds.page).to.eql(expectedResult.page) 63 | }) 64 | 65 | it.skip('searches both the slug and the title') 66 | }) 67 | 68 | describe('more than one neighbor', function () { 69 | before(function () { 70 | neighborhood.sites = {} 71 | neighborhood.sites['site-one'] = { 72 | sitemap: [ 73 | { title: 'Page One from Site 1', slug: 'page-one-from-site-1' }, 74 | { title: 'Page Two from Site 1', slug: 'page-two-from-site-1' }, 75 | { title: 'Page Three from Site 1', slug: 'page-three-from-site-1' }, 76 | ], 77 | } 78 | 79 | const site1Siteindex = new miniSearch({ 80 | fields: ['title', 'content'], 81 | }) 82 | 83 | neighborhood.sites['site-one'].sitemap.forEach(function (page) { 84 | site1Siteindex.add({ 85 | id: page.slug, 86 | title: page.title, 87 | content: '', 88 | }) 89 | }) 90 | neighborhood.sites['site-one'].siteIndex = site1Siteindex 91 | 92 | neighborhood.sites['site-two'] = { 93 | sitemap: [ 94 | { title: 'Page One from Site 2', slug: 'page-one-from-site-2' }, 95 | { title: 'Page Two from Site 2', slug: 'page-two-from-site-2' }, 96 | { title: 'Page Three from Site 2', slug: 'page-three-from-site-2' }, 97 | ], 98 | } 99 | 100 | const site2Siteindex = new miniSearch({ 101 | fields: ['title', 'content'], 102 | }) 103 | 104 | neighborhood.sites['site-two'].sitemap.forEach(function (page) { 105 | site2Siteindex.add({ 106 | id: page.slug, 107 | title: page.title, 108 | content: '', 109 | }) 110 | }) 111 | return (neighborhood.sites['site-two'].siteIndex = site2Siteindex) 112 | }) 113 | 114 | it('returns matching pages from every neighbor', function () { 115 | const searchResult = neighborhood.search('Page Two') 116 | expect(searchResult.finds).to.have.length(2) 117 | const sites = _.pluck(searchResult.finds, 'site') 118 | expect(sites.sort()).to.eql(['site-one', 'site-two'].sort()) 119 | }) 120 | }) 121 | 122 | describe('an unpopulated neighbor', function () { 123 | before(function () { 124 | neighborhood.sites = {} 125 | return (neighborhood.sites['unpopulated-site'] = {}) 126 | }) 127 | 128 | it('gracefully ignores unpopulated neighbors', function () { 129 | const searchResult = neighborhood.search('some search query') 130 | expect(searchResult.finds).to.be.empty() 131 | }) 132 | 133 | it.skip('should re-populate the neighbor') 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /test/page.js: -------------------------------------------------------------------------------- 1 | const { newPage } = require('../lib/page') 2 | const expect = require('expect.js') 3 | 4 | describe('page', function () { 5 | before(function () { 6 | const wiki = {} 7 | wiki.site = site => ({ 8 | getURL(route) { 9 | return `//${site}/${route}` 10 | }, 11 | 12 | getDirectURL(route) { 13 | return `//${site}/${route}` 14 | }, 15 | }) 16 | globalThis.wiki = wiki 17 | }) 18 | 19 | describe('newly created', function () { 20 | it('should start empty', function () { 21 | const pageObject = newPage() 22 | expect(pageObject.getSlug()).to.eql('empty') 23 | }) 24 | 25 | it('should not be remote', function () { 26 | const pageObject = newPage() 27 | expect(pageObject.isRemote()).to.be.false 28 | }) 29 | 30 | it('should have default contex', function () { 31 | const pageObject = newPage() 32 | expect(pageObject.getContext()).to.eql(['view']) 33 | }) 34 | }) 35 | 36 | describe('from json', function () { 37 | it('should have a title', function () { 38 | const pageObject = newPage({ 39 | title: 'New Page', 40 | }) 41 | expect(pageObject.getSlug()).to.eql('new-page') 42 | }) 43 | 44 | it('should have a default context', function () { 45 | const pageObject = newPage({ 46 | title: 'New Page', 47 | }) 48 | expect(pageObject.getContext()).to.eql(['view']) 49 | }) 50 | 51 | it('should have context from site and (reversed) journal', function () { 52 | const pageObject = newPage( 53 | { 54 | journal: [ 55 | { type: 'fork', site: 'one.org' }, 56 | { type: 'fork', site: 'two.org' }, 57 | ], 58 | }, 59 | 'example.com', 60 | ) 61 | expect(pageObject.getContext()).to.eql(['view', 'example.com', 'two.org', 'one.org']) 62 | }) 63 | 64 | it('should have context without duplicates', function () { 65 | const pageObject = newPage( 66 | { 67 | journal: [ 68 | { type: 'fork', site: 'one.org' }, 69 | { type: 'fork', site: 'one.org' }, 70 | ], 71 | }, 72 | 'example.com', 73 | ) 74 | expect(pageObject.getContext()).to.eql(['view', 'example.com', 'one.org']) 75 | }) 76 | 77 | it('should have neighbors from site, and journal, but not from refernces (in order, without duplicates)', function () { 78 | const pageObject = newPage( 79 | { 80 | story: [ 81 | { type: 'reference', site: 'one.org' }, 82 | { type: 'reference', site: 'two.org' }, 83 | { type: 'reference', site: 'one.org' }, 84 | ], 85 | journal: [ 86 | { type: 'fork', site: 'three.org' }, 87 | { type: 'fork', site: 'four.org' }, 88 | { type: 'fork', site: 'three.org' }, 89 | ], 90 | }, 91 | 'example.com', 92 | ) 93 | expect(pageObject.getNeighbors()).to.eql(['example.com', 'three.org', 'four.org']) 94 | }) 95 | }) 96 | 97 | describe('site info', function () { 98 | it('should report null if local', function () { 99 | const pageObject = newPage() 100 | expect(pageObject.getRemoteSite()).to.be(null) 101 | }) 102 | 103 | it('should report local host if provided', function () { 104 | const pageObject = newPage() 105 | expect(pageObject.getRemoteSite('fed.wiki.org')).to.be('fed.wiki.org') 106 | }) 107 | 108 | it('should report remote host if remote', function () { 109 | const pageObject = newPage({}, 'sfw.c2.com') 110 | expect(pageObject.getRemoteSite('fed.wiki.org')).to.be('sfw.c2.com') 111 | }) 112 | }) 113 | 114 | describe('site lineup', function () { 115 | it('should start with welcome-visitors', function () { 116 | const pageObject = newPage({ title: 'Welcome Visitors' }) 117 | expect(pageObject.siteLineup()).to.be('/view/welcome-visitors') 118 | }) 119 | 120 | it('should end on this page', function () { 121 | const pageObject = newPage({ title: 'Some Page' }) 122 | expect(pageObject.siteLineup()).to.be('/view/welcome-visitors/view/some-page') 123 | }) 124 | 125 | it('should use absolute address for remote pages', function () { 126 | const pageObject = newPage({ title: 'Some Page' }, 'fed.wiki.org') 127 | expect(pageObject.siteLineup()).to.be('//fed.wiki.org/view/welcome-visitors/view/some-page') 128 | }) 129 | }) 130 | 131 | describe('site details', function () { 132 | it('should report residence only if local', function () { 133 | const pageObject = newPage({ plugin: 'method' }) 134 | expect(pageObject.getRemoteSiteDetails()).to.be('method plugin') 135 | }) 136 | 137 | it('should report residence and local host if provided', function () { 138 | const pageObject = newPage({ plugin: 'method' }) 139 | expect(pageObject.getRemoteSiteDetails('fed.wiki.org')).to.be('fed.wiki.org\nmethod plugin') 140 | }) 141 | 142 | it('should report residence and remote host if remote', function () { 143 | const pageObject = newPage({ plugin: 'method' }, 'sfw.c2.com') 144 | expect(pageObject.getRemoteSiteDetails('fed.wiki.org')).to.be('sfw.c2.com\nmethod plugin') 145 | }) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /test/pageHandler.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore') 2 | const expect = require('expect.js') 3 | const sinon = require('sinon') 4 | 5 | const pageHandler = require('../lib/pageHandler') 6 | const mockServer = require('./mockServer') 7 | 8 | // disable reference to dom 9 | pageHandler.useLocalStorage = () => false 10 | 11 | describe('pageHandler.get', function () { 12 | it('should have an empty context', () => expect(pageHandler.context).to.eql([])) 13 | 14 | const pageInformationWithoutSite = { 15 | slug: 'slugName', 16 | rev: 'revName', 17 | } 18 | 19 | const genericPageInformation = _.extend({}, pageInformationWithoutSite, { site: 'siteName' }) 20 | 21 | const genericPageData = { 22 | journal: [], 23 | } 24 | 25 | beforeEach(function () { 26 | const wiki = {} 27 | wiki.local = { 28 | get(route, done) { 29 | done({ msg: `no page named '${route}' in browser local storage` }) 30 | }, 31 | } 32 | wiki.origin = { 33 | get(route, done) { 34 | $.ajax({ 35 | type: 'GET', 36 | dataType: 'json', 37 | url: `/${route}`, 38 | success(page) { 39 | done(null, page) 40 | }, 41 | error(xhr, type, msg) { 42 | done({ msg, xhr }, null) 43 | }, 44 | }) 45 | }, 46 | put(route, data, done) { 47 | $.ajax({ 48 | type: 'PUT', 49 | url: `/page/${route}/action`, 50 | data: { 51 | action: JSON.stringify(data), 52 | }, 53 | success() { 54 | done(null) 55 | }, 56 | error(xhr, type, msg) { 57 | done({ xhr, type, msg }) 58 | }, 59 | }) 60 | }, 61 | } 62 | wiki.site = site => ({ 63 | get(route, done) { 64 | const url = `//${site}/${route}` 65 | $.ajax({ 66 | type: 'GET', 67 | dataType: 'json', 68 | url, 69 | success(data) { 70 | done(null, data) 71 | }, 72 | error(xhr, type, msg) { 73 | done({ msg, xhr }, null) 74 | }, 75 | }) 76 | }, 77 | }) 78 | globalThis.wiki = wiki 79 | }) 80 | 81 | describe('ajax fails', function () { 82 | before(() => mockServer.simulatePageNotFound()) 83 | 84 | after(() => jQuery.ajax.restore()) 85 | 86 | it("should tell us when it can't find a page (server specified)", function () { 87 | const whenGotten = sinon.spy() 88 | const whenNotGotten = sinon.spy() 89 | 90 | pageHandler.get({ 91 | pageInformation: _.clone(genericPageInformation), 92 | whenGotten, 93 | whenNotGotten, 94 | }) 95 | 96 | expect(whenGotten.called).to.be.false 97 | expect(whenNotGotten.called).to.be.true 98 | }) 99 | 100 | it("should tell us when it can't find a page (server unspecified)", function () { 101 | const whenGotten = sinon.spy() 102 | const whenNotGotten = sinon.spy() 103 | 104 | pageHandler.get({ 105 | pageInformation: _.clone(pageInformationWithoutSite), 106 | whenGotten, 107 | whenNotGotten, 108 | }) 109 | 110 | expect(whenGotten.called).to.be.false 111 | expect(whenNotGotten.called).to.be.true 112 | }) 113 | }) 114 | 115 | describe('ajax, success', function () { 116 | before(function () { 117 | sinon.stub(jQuery, 'ajax').yieldsTo('success', genericPageData) 118 | $('
        ').appendTo('body') 119 | }) 120 | 121 | it('should get a page from specific site', function () { 122 | const whenGotten = sinon.spy() 123 | pageHandler.get({ 124 | pageInformation: _.clone(genericPageInformation), 125 | whenGotten, 126 | }) 127 | 128 | expect(whenGotten.calledOnce).to.be.true 129 | expect(jQuery.ajax.calledOnce).to.be.true 130 | expect(jQuery.ajax.args[0][0]).to.have.property('type', 'GET') 131 | expect(jQuery.ajax.args[0][0].url).to.match(new RegExp(`^//siteName/slugName\\.json`)) 132 | }) 133 | 134 | after(() => jQuery.ajax.restore()) 135 | }) 136 | 137 | describe('ajax, search', function () { 138 | before(function () { 139 | mockServer.simulatePageNotFound() 140 | return (pageHandler.context = ['view', 'example.com', 'asdf.test', 'foo.bar']) 141 | }) 142 | 143 | it('should search through the context for a page', function () { 144 | pageHandler.get({ 145 | pageInformation: _.clone(pageInformationWithoutSite), 146 | whenGotten: sinon.stub(), 147 | whenNotGotten: sinon.stub(), 148 | }) 149 | 150 | expect(jQuery.ajax.args[0][0].url).to.match(new RegExp(`^/slugName\\.json`)) 151 | expect(jQuery.ajax.args[1][0].url).to.match(new RegExp(`^//example.com/slugName\\.json`)) 152 | expect(jQuery.ajax.args[2][0].url).to.match(new RegExp(`^//asdf.test/slugName\\.json`)) 153 | expect(jQuery.ajax.args[3][0].url).to.match(new RegExp(`^//foo.bar/slugName\\.json`)) 154 | }) 155 | 156 | after(() => jQuery.ajax.restore()) 157 | }) 158 | }) 159 | 160 | describe('pageHandler.put', function () { 161 | before(function () { 162 | $('
        ').appendTo('body') 163 | sinon.stub(jQuery, 'ajax').yieldsTo('success') 164 | }) 165 | 166 | // can't test right now as expects to have access to original page, so index can be updated. 167 | it.skip('should save an action', function (done) { 168 | const action = { type: 'edit', id: 1, item: { id: 1 } } 169 | pageHandler.put($('#pageHandler3'), action) 170 | expect(jQuery.ajax.args[0][0].data).to.eql({ action: JSON.stringify(action) }) 171 | done() 172 | }) 173 | 174 | after(() => jQuery.ajax.restore()) 175 | }) 176 | -------------------------------------------------------------------------------- /test/plugin.js: -------------------------------------------------------------------------------- 1 | const plugin = require('../lib/plugin') 2 | // const sinon = require('sinon') 3 | const expect = require('expect.js') 4 | 5 | describe('plugin', function () { 6 | // const fakeDeferred = undefined 7 | let $page = null 8 | 9 | before(function () { 10 | $page = $('
        ') 11 | $page.appendTo('body') 12 | // return sinon.spy(jQuery, 'ajax'); 13 | }) 14 | 15 | after(function () { 16 | // jQuery.ajax.restore(); 17 | $page.empty() 18 | }) 19 | 20 | it('should have default reference type', () => expect(window.plugins).to.have.property('reference')) 21 | 22 | // it('should fetch a plugin script from the right location', function() { 23 | // plugin.get('activity'); 24 | // expect(jQuery.ajax.calledOnce).to.be(true); 25 | // return expect(jQuery.ajax.args[0][0].url).to.be('/plugins/activity/activity.js'); 26 | // }); 27 | 28 | it.skip('should render a plugin', function () { 29 | const item = { 30 | type: 'paragraph', 31 | text: 'blah [[Link]] asdf', 32 | } 33 | plugin.do($('#plugin'), item) 34 | expect($('#plugin').html()).to.be( 35 | '

        blah Link asdf

        ', 36 | ) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/random.js: -------------------------------------------------------------------------------- 1 | const random = require('../lib/random') 2 | const expect = require('expect.js') 3 | 4 | describe('random', function () { 5 | it('should make random bytes', function () { 6 | const a = random.randomByte() 7 | expect(a).to.be.a('string') 8 | expect(a.length).to.be(2) 9 | }) 10 | 11 | it('should make random byte strings', function () { 12 | const s = random.randomBytes(4) 13 | expect(s).to.be.a('string') 14 | expect(s.length).to.be(8) 15 | }) 16 | 17 | it('should make random item ids', function () { 18 | const s = random.itemId() 19 | expect(s).to.be.a('string') 20 | expect(s.length).to.be(16) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/refresh.js: -------------------------------------------------------------------------------- 1 | const refresh = require('../lib/refresh') 2 | const lineup = require('../lib/lineup') 3 | const mockServer = require('./mockServer') 4 | 5 | describe('refresh', function () { 6 | let $page = undefined 7 | 8 | beforeEach(function () { 9 | const wiki = {} 10 | wiki.local = { 11 | get(route, done) { 12 | done({ msg: `no page named '${route}' in browser local storage` }) 13 | }, 14 | } 15 | wiki.origin = { 16 | get(route, done) { 17 | $.ajax({ 18 | type: 'GET', 19 | dataType: 'json', 20 | url: `/${route}`, 21 | success(page) { 22 | done(null, page) 23 | }, 24 | error(xhr, type, msg) { 25 | done({ msg, xhr }, null) 26 | }, 27 | }) 28 | }, 29 | } 30 | wiki.site = site => ({ 31 | flag() { 32 | return `//${site}/favicon.png` 33 | }, 34 | 35 | getDirectURL(route) { 36 | return `//${site}/${route}` 37 | }, 38 | 39 | get(route, done) { 40 | const url = `//${site}/${route}` 41 | $.ajax({ 42 | type: 'GET', 43 | dataType: 'json', 44 | url, 45 | success(data) { 46 | done(null, data) 47 | }, 48 | error(xhr, type, msg) { 49 | done({ msg, xhr }, null) 50 | }, 51 | }) 52 | }, 53 | }) 54 | globalThis.wiki = wiki 55 | }) 56 | 57 | describe('when page not found', function () { 58 | before(function () { 59 | $page = $('
        ') 60 | $page.appendTo('body') 61 | mockServer.simulatePageNotFound() 62 | }) 63 | after(() => jQuery.ajax.restore()) 64 | 65 | it.skip('creates a ghost page', function () { 66 | let key, pageObject 67 | $page.each(refresh.cycle) 68 | expect($page.hasClass('ghost')).to.be(true) 69 | expect((key = $page.data('key'))).to.be.a('string') 70 | expect((pageObject = lineup.atKey(key))).to.be.an('object') 71 | expect(pageObject.getRawPage().story[0].type).to.be('future') 72 | }) 73 | }) 74 | 75 | describe('when page found', function () { 76 | before(function () { 77 | $page = $('
        ') 78 | $page.appendTo('body') 79 | mockServer.simulatePageFound({ title: 'asdf' }) 80 | }) 81 | after(() => jQuery.ajax.restore()) 82 | 83 | it.skip('should refresh a page', function (done) { 84 | $page.each(refresh.cycle) 85 | expect($('#refresh h1').text().trim()).to.be('asdf') 86 | done() 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/resolve.js: -------------------------------------------------------------------------------- 1 | const resolve = require('../lib/resolve') 2 | const expect = require('expect.js') 3 | 4 | // Here we test new features retlated to escaping/sanitizing text while resolving. 5 | // See other related tests at /tests/wiki.coffee 6 | 7 | const r = text => resolve.resolveLinks(text) 8 | 9 | const f = function (text) { 10 | const found = [] 11 | text 12 | .replace(/\s+/, '') 13 | .replace(/>(.*?) found.push(each)) 14 | return found 15 | } 16 | 17 | describe('resolve', function () { 18 | describe('plain text', () => 19 | it('should pass unchanged', () => expect(r('The quick brown fox.')).to.eql('The quick brown fox.'))) 20 | 21 | describe('escaping', function () { 22 | it('should encode <, >, & in plain text', () => 23 | expect(r('5 < 10 && 5 > 3')).to.eql('5 < 10 && 5 > 3')) 24 | 25 | it('should encode <, >, & in link text', () => 26 | expect(r('[[5 < 10 && 5 > 3]]')).to.contain('>5 < 10 && 5 > 3')) 27 | 28 | it('should not encode before making slugs for hrefs', () => 29 | expect(r('[[5 < 10 && 5 > 3]]')).to.contain('href="/5--10--5--3.html"')) 30 | 31 | it('should not encode before making slugs for data-page-names', () => 32 | expect(r('[[5 < 10 && 5 > 3]]')).to.contain('data-page-name="5--10--5--3"')) 33 | }) 34 | 35 | describe('multiple links', function () { 36 | it('should be kept ordered', () => 37 | expect(f(r('[[alpha]],[[beta]]&[[gamma]]'))).to.eql(['alpha', ',', 'beta', '&', 'gamma'])) 38 | 39 | it('should preserve internal before external', () => 40 | expect(f(r('[[alpha]],[http:c2.com beta]'))).to.eql(['alpha', ',', 'beta'])) 41 | 42 | it('should preserve external before internal', () => 43 | expect(f(r('[http:c2.com beta],[[alpha]]'))).to.eql(['beta', ',', 'alpha'])) 44 | }) 45 | 46 | describe('markers', () => 47 | it('should be adulterated where unexpected', () => expect(r('foo 〖12〗 bar')).to.eql('foo 〖 12 〗 bar'))) 48 | }) 49 | -------------------------------------------------------------------------------- /test/revision.js: -------------------------------------------------------------------------------- 1 | const { newPage } = require('../lib/page') 2 | const revision = require('../lib/revision') 3 | const expect = require('expect.js') 4 | 5 | // fixture -- create proper pages from model pages 6 | 7 | const id = i => `${i}0` 8 | 9 | const item = function (i, n = '') { 10 | return { type: 'paragraph', text: `t${i}${n}`, id: id(i) } 11 | } 12 | 13 | const action = function (a) { 14 | let t, v 15 | let i 16 | if (typeof a !== 'string') { 17 | return a 18 | } 19 | ;[t, i, ...v] = a.split('') 20 | switch (t) { 21 | case 'c': 22 | return { 23 | type: 'create', 24 | id: id(i), 25 | item: { 26 | title: `Create ${v}`, 27 | story: v.map(i => item(i)), 28 | }, 29 | } 30 | case 'a': 31 | if (v[0]) { 32 | return { type: 'add', id: id(i), item: item(i), after: id(v[0]) } 33 | } else { 34 | return { type: 'add', id: id(i), item: item(i) } 35 | } 36 | case 'r': 37 | return { type: 'remove', id: id(i) } 38 | case 'e': 39 | return { type: 'edit', id: id(i), item: item(i, 'edited') } 40 | case 'm': 41 | return { type: 'move', id: id(i), order: v.map(j => id(j)) } 42 | default: 43 | throw `can't model '${t}' action` 44 | } 45 | } 46 | 47 | const fixture = function (model) { 48 | model.title = model.title || `About ${model.story || model.journal}` 49 | model.story = (model.story || []).map(i => item(i)) 50 | model.journal = (model.journal || []).map(a => action(a)) 51 | return model 52 | } 53 | 54 | const expectText = version => expect(version.story.map(each => each.text)) 55 | 56 | describe('revision', function () { 57 | describe('testing helpers', function () { 58 | describe('action', function () { 59 | it('should make create actions', () => 60 | expect(action('c312')).to.eql({ 61 | type: 'create', 62 | id: '30', 63 | item: { 64 | title: 'Create 1,2', 65 | story: [ 66 | { type: 'paragraph', text: 't1', id: '10' }, 67 | { type: 'paragraph', text: 't2', id: '20' }, 68 | ], 69 | }, 70 | })) 71 | 72 | it('should make empty create actions', () => 73 | expect(action('c0')).to.eql({ type: 'create', id: '00', item: { title: 'Create ', story: [] } })) 74 | 75 | it('should make add actions', () => 76 | expect(action('a3')).to.eql({ type: 'add', id: '30', item: { type: 'paragraph', text: 't3', id: '30' } })) 77 | 78 | it('should make add after actions', () => 79 | expect(action('a31')).to.eql({ 80 | type: 'add', 81 | id: '30', 82 | item: { type: 'paragraph', text: 't3', id: '30' }, 83 | after: '10', 84 | })) 85 | 86 | it('should make remove actions', () => expect(action('r3')).to.eql({ type: 'remove', id: '30' })) 87 | 88 | it('should make edit actions', () => 89 | expect(action('e3')).to.eql({ 90 | type: 'edit', 91 | id: '30', 92 | item: { type: 'paragraph', text: 't3edited', id: '30' }, 93 | })) 94 | 95 | it('should make move actions', () => 96 | expect(action('m1321')).to.eql({ type: 'move', id: '10', order: ['30', '20', '10'] })) 97 | }) 98 | 99 | describe('fixture', function () { 100 | const data = fixture({ 101 | story: [1, 2, 3], 102 | journal: ['c12', 'a3', { type: 'foo' }], 103 | }) 104 | 105 | it('should make stories with text', () => expect(data.story.map(e => e.text)).to.eql(['t1', 't2', 't3'])) 106 | 107 | it('should make stories with ids', () => expect(data.story.map(e => e.id)).to.eql(['10', '20', '30'])) 108 | 109 | it('should make journals with actions', () => 110 | expect(data.journal.map(a => a.type)).to.eql(['create', 'add', 'foo'])) 111 | 112 | it('should make titles from the model', () => expect(data.title).to.be('About 1,2,3')) 113 | }) 114 | }) 115 | 116 | describe('applying actions', function () { 117 | it('should create a story', function () { 118 | let page 119 | revision.apply((page = {}), { type: 'create', item: { story: [{ type: 'foo' }] } }) 120 | expect(page.story).to.eql([{ type: 'foo' }]) 121 | }) 122 | 123 | it('should add an item', function () { 124 | let page 125 | revision.apply((page = {}), { type: 'add', item: { type: 'foo' } }) 126 | expect(page.story).to.eql([{ type: 'foo' }]) 127 | }) 128 | 129 | it('should edit an item', function () { 130 | let page 131 | revision.apply((page = { story: [{ type: 'foo', id: '3456' }] }), { 132 | type: 'edit', 133 | id: '3456', 134 | item: { type: 'bar', id: '3456' }, 135 | }) 136 | expect(page.story).to.eql([{ type: 'bar', id: '3456' }]) 137 | }) 138 | 139 | it('should move first item to the bottom', function () { 140 | const page = { 141 | story: [ 142 | { type: 'foo', id: '1234' }, 143 | { type: 'bar', id: '3456' }, 144 | ], 145 | } 146 | revision.apply(page, { type: 'move', id: '1234', order: ['3456', '1234'] }) 147 | expect(page.story).to.eql([ 148 | { type: 'bar', id: '3456' }, 149 | { type: 'foo', id: '1234' }, 150 | ]) 151 | }) 152 | 153 | it('should move last item to the top', function () { 154 | const page = { 155 | story: [ 156 | { type: 'foo', id: '1234' }, 157 | { type: 'bar', id: '3456' }, 158 | ], 159 | } 160 | revision.apply(page, { type: 'move', id: '3456', order: ['3456', '1234'] }) 161 | expect(page.story).to.eql([ 162 | { type: 'bar', id: '3456' }, 163 | { type: 'foo', id: '1234' }, 164 | ]) 165 | }) 166 | 167 | it('should remove an item', function () { 168 | const page = { 169 | story: [ 170 | { type: 'foo', id: '1234' }, 171 | { type: 'bar', id: '3456' }, 172 | ], 173 | } 174 | revision.apply(page, { type: 'remove', id: '1234' }) 175 | expect(page.story).to.eql([{ type: 'bar', id: '3456' }]) 176 | }) 177 | }) 178 | 179 | describe('creating revisions', function () { 180 | describe('titling', function () { 181 | it('should use create title if present', function () { 182 | const data = fixture({ journal: ['c0123'] }) 183 | const version = revision.create(0, data) 184 | expect(version.title).to.eql('Create 1,2,3') 185 | }) 186 | 187 | it('should use existing title if create title absent', function () { 188 | const data = fixture({ title: 'Foo', journal: [{ type: 'create', item: { story: [] } }] }) 189 | const version = revision.create(0, data) 190 | expect(version.title).to.eql('Foo') 191 | }) 192 | }) 193 | 194 | describe('sequencing', function () { 195 | const data = fixture({ 196 | story: [1, 2, 3], 197 | journal: ['a1', 'a21', 'a32'], 198 | }) 199 | 200 | it('should do little to an empty page', function () { 201 | const emptyPage = newPage({}).getRawPage() 202 | const version = revision.create(-1, emptyPage) 203 | expect(newPage(version).getRawPage()).to.eql(emptyPage) 204 | }) 205 | 206 | it('should shorten the journal to given revision', function () { 207 | const version = revision.create(1, data) 208 | expect(version.journal.length).to.be(2) 209 | }) 210 | 211 | it('should recreate story on given revision', function () { 212 | const version = revision.create(1, data) 213 | expectText(version).to.eql(['t1', 't2']) 214 | }) 215 | 216 | it('should accept revision as string', function () { 217 | const version = revision.create('1', data) 218 | expect(version.journal.length).to.be(2) 219 | }) 220 | }) 221 | 222 | describe('workflows', function () { 223 | describe('dragging item from another page', function () { 224 | it('should place story item on dropped position', function () { 225 | const data = fixture({ 226 | journal: ['c0135', 'a21', 'a43'], 227 | }) 228 | const version = revision.create(3, data) 229 | expectText(version).to.eql(['t1', 't2', 't3', 't4', 't5']) 230 | }) 231 | 232 | it('should place story items at the beginning when dropped position is not defined', function () { 233 | const data = fixture({ 234 | journal: ['c0135', 'a2', 'a4'], 235 | }) 236 | const version = revision.create(3, data) 237 | expectText(version).to.eql(['t4', 't2', 't1', 't3', 't5']) 238 | }) 239 | }) 240 | 241 | describe('editing items', function () { 242 | it('should replace edited stories item', function () { 243 | const data = fixture({ 244 | journal: ['c012345', 'e3', 'e1'], 245 | }) 246 | const version = revision.create(3, data) 247 | expectText(version).to.eql(['t1edited', 't2', 't3edited', 't4', 't5']) 248 | }) 249 | 250 | it('should place item at end if edited item is not found', function () { 251 | const data = fixture({ 252 | journal: ['c012345', 'e9'], 253 | }) 254 | const version = revision.create(2, data) 255 | expectText(version).to.eql(['t1', 't2', 't3', 't4', 't5', 't9edited']) 256 | }) 257 | }) 258 | 259 | describe('reordering items', function () { 260 | it('should move item up', function () { 261 | const data = fixture({ 262 | journal: ['c012345', 'm414235'], 263 | }) 264 | const version = revision.create(2, data) 265 | expectText(version).to.eql(['t1', 't4', 't2', 't3', 't5']) 266 | }) 267 | 268 | it('should move item to top', function () { 269 | const data = fixture({ 270 | journal: ['c012345', 'm441235'], 271 | }) 272 | const version = revision.create(2, data) 273 | expectText(version).to.eql(['t4', 't1', 't2', 't3', 't5']) 274 | }) 275 | 276 | it('should move item down', function () { 277 | const data = fixture({ 278 | journal: ['c012345', 'm213425'], 279 | }) 280 | const version = revision.create(2, data) 281 | expectText(version).to.eql(['t1', 't3', 't4', 't2', 't5']) 282 | }) 283 | }) 284 | 285 | describe('deleting items', () => 286 | it('should remove the story items', function () { 287 | const data = fixture({ 288 | journal: ['c012345', 'r4', 'r2'], 289 | }) 290 | const version = revision.create(3, data) 291 | expectText(version).to.eql(['t1', 't3', 't5']) 292 | })) 293 | }) 294 | }) 295 | }) 296 | -------------------------------------------------------------------------------- /test/search.js: -------------------------------------------------------------------------------- 1 | const createSearch = require('../lib/search') 2 | 3 | describe('search', () => 4 | // Can't test for right now, because performing a search 5 | // does DOM manipulation to build a page, which fails in the test runner. We'd like to isolate that DOM manipulation, but can't right now. 6 | it.skip('performs a search on the neighborhood', function () { 7 | const spyNeighborhood = { 8 | search: sinon.stub().returns([]), 9 | } 10 | const search = createSearch({ neighborhood: spyNeighborhood }) 11 | search.performSearch('some search query') 12 | 13 | expect(spyNeighborhood.search.called).to.be(true) 14 | expect(spyNeighborhood.search.args[0][0]).to.be('some search query') 15 | })) 16 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | const util = require('../lib/util') 2 | const expect = require('expect.js') 3 | 4 | const timezoneOffset = () => new Date(1333843344000).getTimezoneOffset() * 60 5 | 6 | describe('util', function () { 7 | it('should format unix time', function () { 8 | const s = util.formatTime(1333843344 + timezoneOffset()) 9 | expect(s).to.be('12:02 AM
        8 Apr 2012') 10 | }) 11 | it('should format javascript time', function () { 12 | const s = util.formatTime(1333843344000 + timezoneOffset() * 1000) 13 | expect(s).to.be('12:02 AM
        8 Apr 2012') 14 | }) 15 | it('should format revision date', function () { 16 | const s = util.formatDate(1333843344000 + timezoneOffset() * 1000) 17 | expect(s).to.be('Sun Apr 8, 2012
        12:02:24 AM') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/wiki.js: -------------------------------------------------------------------------------- 1 | const wiki = require('../lib/wiki') 2 | const expect = require('expect.js') 3 | 4 | describe('wiki', function () { 5 | describe('link resolution', function () { 6 | it('should pass free text as is', function () { 7 | const s = wiki.resolveLinks('hello world') 8 | expect(s).to.be('hello world') 9 | }) 10 | 11 | describe('internal links', function () { 12 | const s = wiki.resolveLinks('hello [[world]]') 13 | it('should be class internal', () => expect(s).to.contain('class="internal"')) 14 | it('should relative reference html', () => expect(s).to.contain('href="/world.html"')) 15 | it('should have data-page-name', () => expect(s).to.contain('data-page-name="world"')) 16 | }) 17 | 18 | describe('internal links with space', function () { 19 | const s = wiki.resolveLinks('hello [[ world]]') 20 | it('should be class spaced', () => expect(s).to.contain('class="internal spaced"')) 21 | it('should relative reference html', () => expect(s).to.contain('href="/-world.html"')) 22 | it('should have data-page-name', () => expect(s).to.contain('data-page-name="-world"')) 23 | }) 24 | 25 | describe('external links', function () { 26 | const s = wiki.resolveLinks('hello [http://world.com?foo=1&bar=2 world]') 27 | it('should be class external', () => expect(s).to.contain('class="external"')) 28 | it('should absolute reference html', () => expect(s).to.contain('href="http://world.com?foo=1&bar=2"')) 29 | it('should not have data-page-name', () => expect(s).to.not.contain('data-page-name')) 30 | }) 31 | }) 32 | 33 | describe('slug formation', function () { 34 | it('should convert capitals to lowercase', function () { 35 | const s = wiki.asSlug('WelcomeVisitors') 36 | expect(s).to.be('welcomevisitors') 37 | }) 38 | 39 | it('should convert spaces to dashes', function () { 40 | const s = wiki.asSlug(' now is the time ') 41 | expect(s).to.be('-now-is--the-time-') 42 | }) 43 | 44 | it('should pass letters, numbers and dash', function () { 45 | const s = wiki.asSlug('THX-1138') 46 | expect(s).to.be('thx-1138') 47 | }) 48 | 49 | it('should discard other puctuation', function () { 50 | const s = wiki.asSlug('(The) World, Finally.') 51 | expect(s).to.be('the-world-finally') 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /testclient.js: -------------------------------------------------------------------------------- 1 | mocha.setup('bdd') 2 | 3 | window.wiki = require('./lib/wiki') 4 | require('./lib/bind') 5 | require('./lib/plugins') 6 | 7 | require('./test/util') 8 | require('./test/active') 9 | require('./test/pageHandler') 10 | require('./test/page') 11 | require('./test/refresh') 12 | require('./test/plugin') 13 | require('./test/revision') 14 | require('./test/neighborhood') 15 | require('./test/search') 16 | require('./test/drop') 17 | require('./test/lineup') 18 | require('./test/wiki') 19 | require('./test/random') 20 | 21 | $(function () { 22 | $('

        Testing artifacts:

        ').appendTo('body') 23 | mocha.run() 24 | }) 25 | -------------------------------------------------------------------------------- /views/oops.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
        7 | 8 |

        {{msg}}

        9 |
        10 | 11 | 12 | -------------------------------------------------------------------------------- /views/static.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
        24 | {{#pages}} 25 |
        26 |
        27 | {{{story}}} 28 |
        29 |
        30 | {{/pages}} 31 |
        32 |
        33 | 36 | {{/owned}} 37 |
        38 | 39 | 40 | 41 | 42 |   43 | 44 |   45 | 46 | 47 | 48 | 49 | 50 | 51 | 60 | 61 | 62 | --------------------------------------------------------------------------------