wiki-plugin-hitchhiker 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # wiki-plugin-hitchhiker
2
+
3
+ The house **UX layer** for the hitchhiker Federated Wiki farms. It rewilds several
4
+ built-in client behaviours *without forking or rebuilding `client.js`*, and ships some
5
+ farm-wide default pages. Piloted on the **private farm (:4243)**.
6
+
7
+ It has three parts:
8
+
9
+ ## 1. Default pages (`pages/`)
10
+ Files in `pages/` are served on **every** site of the farm (wiki-server's plugin-pages
11
+ fallback), unless a site has authored its own page of that slug. Currently:
12
+ `tools`, `changes-to-this-site` (copied from hitchhikers.earth, journals stripped),
13
+ and `about-hitchhiker-plugin`.
14
+
15
+ This plugin defines **no authorable item type** (no `factory.json`) β€” its value is the
16
+ pages and the served client script below.
17
+
18
+ ## 2. Client UX override (`client/wiki-ux.js`)
19
+ Served at `/plugins/hitchhiker/wiki-ux.js` and loaded globally on every page by the
20
+ **`wiki-security-hitchhiker`** bootstrap (a fork of `wiki-security-friends` whose
21
+ `client/security.js` injects `<script src=/plugins/hitchhiker/wiki-ux.js>`; that security
22
+ module is the only client hook that auto-runs on every page). The private farm's
23
+ `config.json` sets `security_type: hitchhiker`.
24
+
25
+ `wiki-ux.js` reaches the client only through the public `window.wiki` / `window.plugins`
26
+ surface. It installs:
27
+ - **Factory chooser** β€” replaces `window.plugins.factory.emit` with a searchable, curated,
28
+ described plugin picker (keeps the original `bind`, so all `drop.js` drag-drops survive);
29
+ wraps `bind` for extra media-URL drops.
30
+ - **Page-flag click** β†’ an in-lineup page, **inserted** to the right of the clicked page
31
+ (never wiping the lineup):
32
+ - logged in β†’ a **Tools** page: **Fork** (only for remote/blue or unsaved/yellow pages),
33
+ **πŸ” Similarity search** (only if `wiki-plugin-similarity` is installed β€” opens a ghost
34
+ report of similar pages), **Trash** (β†’ recycler, recoverable), **Clean the journal**
35
+ (ghost with a stripped journal), **Simplify** (collapse the lineup to Welcome Visitors +
36
+ the page).
37
+ - logged out β†’ a **Navigate** page: Simplify + this site's recent Changes.
38
+ - **Shift-click** the flag = Simplify directly (skip the page).
39
+ - **Page-flag drop target** β€” drop a URL / link / page-flag onto a flag: the favicon shows
40
+ a loud pulsing highlight; on drop it opens a ghost **preview card** (see the server below).
41
+
42
+ ## 3. Server component (`server/server.js`, ESM)
43
+ `GET /system/unfurl.json?url=…` β†’ a normalized **static** link-preview card
44
+ `{ url, title, description, provider, author, type, image, favicon, source }`.
45
+ - oEmbed metadata first (`@extractus/oembed-extractor`), OpenGraph/Twitter/JSON-LD fallback
46
+ (`open-graph-scraper`). Metadata only β€” never the oEmbed HTML embed (no live third-party
47
+ iframes/scripts).
48
+ - The thumbnail is fetched **server-side and inlined as a `data:` URI**, so the card makes
49
+ zero client→provider requests.
50
+ - **SSRF-guarded**: public `http`/`https` only; private/loopback/link-local ranges and
51
+ `*.localhost`/`.local`/`.internal` refused before any fetch. 30-min in-memory cache.
52
+ Never throws β€” returns a minimal fallback card offline/on error.
53
+
54
+ > ⚠️ **Private-farm only for now.** Before any public-farm deployment, harden the SSRF guard
55
+ > against redirect-following (the extractor libs do their own fetch and may follow redirects
56
+ > to internal hosts) and tighten image caps.
57
+
58
+ ## Install / build
59
+ ```sh
60
+ # Use the farm's Node (v24) β€” npm under an older node in PATH will fail.
61
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
62
+ cd ~/Code/wiki-plugins/wiki-plugin-hitchhiker
63
+ npm install # installs open-graph-scraper + @extractus/oembed-extractor
64
+ ```
65
+ There is **no build step** β€” `client/wiki-ux.js` is plain hand-written JS served as-is.
66
+
67
+ **Operational notes**
68
+ - The `server/` component and its deps must be present or the v24 pages scan can crash on a
69
+ symlinked dev plugin β€” **always `npm install` (Node v24) after a fresh clone or after
70
+ `update-wiki`**.
71
+ - `server.js` is **ESM** (matches `wiki-plugin-similarity`; loads via `import()` on the v24
72
+ farm). An ESM server silently 404s on older wiki-server that uses `require()` + Node < 22.12
73
+ β€” revisit for portability before publishing to an older/public farm.
74
+ - Wired into the farm via `~/bin/wiki-start` (`LOCAL_PLUGINS` symlink + wiki-deps entry).
75
+ `server.js` registers at startup, so **restart the farm** after changing it.
76
+ - Dev caching: the local wiki-server `staticPathOptions` is patched to `maxAge:0`, and the
77
+ security bootstrap appends `?v=<ts>` to `wiki-ux.js` on `*.localhost`, so edits appear on a
78
+ normal reload. `update-wiki` reverts the `maxAge` patch.
79
+
80
+ ## Related
81
+ - `wiki-security-hitchhiker` β€” the friends-fork that bootstraps `wiki-ux.js` (to be replaced
82
+ by an own-identity / trust-graph security module).
83
+ - `wiki-plugin-similarity` β€” sibling plugin whose ESM server pattern this mirrors; also powers
84
+ the Similarity tool.
@@ -0,0 +1,125 @@
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Hitchhiker",
5
+ "version": "0.1.0",
6
+ "description": "The house UX layer. Two farm-wide services its client needs and nothing else provides: a list of every wiki on this machine, and a link unfurler. Served by this plugin\u2019s own server and described here, not mounted by the farm."
7
+ },
8
+ "paths": {
9
+ "/system/farm-domains.json": {
10
+ "get": {
11
+ "x-fedwiki-served-by": "plugin",
12
+ "x-fedwiki-read-only": true,
13
+ "operationId": "farmDomains",
14
+ "summary": "Every wiki on this machine",
15
+ "description": "Both farms \u2014 the private one this server runs, and the mirrored public tree if there is one \u2014 as two lists of hostnames. Used by the client to offer sites you can reach.",
16
+ "responses": {
17
+ "200": {
18
+ "description": "Local and public domains.",
19
+ "content": {
20
+ "application/json": {
21
+ "schema": {
22
+ "$ref": "#/components/schemas/FarmDomains"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ },
30
+ "/system/unfurl.json": {
31
+ "get": {
32
+ "x-fedwiki-served-by": "plugin",
33
+ "x-fedwiki-read-only": true,
34
+ "operationId": "unfurl",
35
+ "summary": "What is at the other end of a link",
36
+ "description": "Fetches a URL and returns a card for it \u2014 title, description, provider, thumbnail. Tries oEmbed first, then OpenGraph. Results are cached in memory, and the fetch is guarded against being pointed at private addresses. That guard and that cache are why this stays on the plugin\u2019s own server: a farm handler holds no state between calls and has no policy about what it may fetch.",
37
+ "parameters": [
38
+ {
39
+ "name": "url",
40
+ "in": "query",
41
+ "required": true,
42
+ "description": "The link to unfurl.",
43
+ "schema": {
44
+ "type": "string"
45
+ },
46
+ "x-fedwiki-example": "https://example.com"
47
+ }
48
+ ],
49
+ "responses": {
50
+ "200": {
51
+ "description": "A card describing the link.",
52
+ "content": {
53
+ "application/json": {
54
+ "schema": {
55
+ "$ref": "#/components/schemas/Card"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "components": {
65
+ "schemas": {
66
+ "FarmDomains": {
67
+ "type": "object",
68
+ "properties": {
69
+ "local": {
70
+ "type": "array",
71
+ "items": {
72
+ "type": "string"
73
+ },
74
+ "description": "Sites of the farm this server runs."
75
+ },
76
+ "public": {
77
+ "type": "array",
78
+ "items": {
79
+ "type": "string"
80
+ },
81
+ "description": "Sites of the mirrored public tree, if present."
82
+ }
83
+ }
84
+ },
85
+ "Card": {
86
+ "type": "object",
87
+ "properties": {
88
+ "url": {
89
+ "type": "string"
90
+ },
91
+ "title": {
92
+ "type": "string"
93
+ },
94
+ "description": {
95
+ "type": "string"
96
+ },
97
+ "provider": {
98
+ "type": "string"
99
+ },
100
+ "author": {
101
+ "type": "string"
102
+ },
103
+ "type": {
104
+ "type": "string"
105
+ },
106
+ "image": {
107
+ "type": "string",
108
+ "description": "Inlined as a data URI where small enough."
109
+ },
110
+ "favicon": {
111
+ "type": "string"
112
+ },
113
+ "source": {
114
+ "type": "string",
115
+ "enum": [
116
+ "oembed",
117
+ "opengraph"
118
+ ],
119
+ "description": "Which of the two answered."
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
package/client/fish.js ADDED
@@ -0,0 +1,101 @@
1
+ // fish.js β€” parametric Mice & Fish flag generator (Fish Picker v0.2)
2
+ // Pure SVG strings, zero deps. viewBox 0 0 100 100, faces right.
3
+ // colour/school = topic Β· species/shape = person
4
+ // companion = human/AI (hidden < 24px) Β· markings = individual (variant)
5
+
6
+ function hashSeed(str){let h=2166136261>>>0;for(const c of String(str)){h^=c.charCodeAt(0);h=Math.imul(h,16777619);}return h>>>0;}
7
+ function rng(seed){let s=(seed>>>0)||1;return()=>{s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};}
8
+
9
+ const SCHOOLS={fab:28,academy:212,environment:130,garden:130,myth:150,democracy:278,liquid:262,secret:194,vision:330,ide:250,fish:200,earth:96};
10
+ function schoolHue(topic){if(!topic)return 208;const k=Object.keys(SCHOOLS).find(k=>String(topic).includes(k));return SCHOOLS[k]??208;}
11
+
12
+ function baseId(id){return String(id).replace(/-agent$/,'');}
13
+ function speciesFor(id){const b=baseId(id);if(b.startsWith('david'))return 'koi';if(b.startsWith('max'))return 'pufferfish';if(b.startsWith('mitch'))return 'pike';return 'koi';}
14
+ const poly=pts=>pts.map(p=>p.map(n=>n.toFixed(1)).join(',')).join(' ');
15
+
16
+ // --- companion: human = mouse, agent = robo-mouse. cx,cy = its centre ---
17
+ function companionSVG(isAgent,mx,my,hue){
18
+ if(isAgent){const t=`hsl(${hue} 30% 55%)`;return `<g stroke="#333" stroke-width="0.7">
19
+ <rect x="${mx-5}" y="${my-2}" width="3" height="4" fill="${t}"/><rect x="${mx+2}" y="${my-2}" width="3" height="4" fill="${t}"/>
20
+ <rect x="${mx-5}" y="${my}" width="10" height="7" rx="1.5" fill="${t}"/>
21
+ <line x1="${mx}" y1="${my-2}" x2="${mx}" y2="${my-6}"/><circle cx="${mx}" cy="${my-6.5}" r="1.1" fill="#e33"/>
22
+ <circle cx="${mx-1.5}" cy="${my+3}" r="0.9" fill="#111" stroke="none"/><circle cx="${mx+2.5}" cy="${my+3}" r="0.9" fill="#111" stroke="none"/></g>`;}
23
+ return `<g stroke="#555" stroke-width="0.6">
24
+ <circle cx="${mx-4}" cy="${my}" r="3" fill="#bfb8b0"/><circle cx="${mx+4}" cy="${my}" r="3" fill="#bfb8b0"/>
25
+ <ellipse cx="${mx}" cy="${my+3}" rx="6" ry="4.5" fill="#cfc8c0"/>
26
+ <path d="M ${mx+5} ${my+5} q 6 2 7 -2" fill="none"/>
27
+ <circle cx="${mx-1.5}" cy="${my+2.5}" r="0.8" fill="#111" stroke="none"/><circle cx="${mx+2.5}" cy="${my+2.5}" r="0.8" fill="#111" stroke="none"/>
28
+ <circle cx="${mx+0.5}" cy="${my+4.5}" r="0.7" fill="#e88" stroke="none"/></g>`;
29
+ }
30
+ const eye=(x,y,line)=>`<circle cx="${x}" cy="${y}" r="4" fill="#fff" stroke="${line}" stroke-width="0.8"/><circle cx="${x+1}" cy="${y}" r="1.8" fill="#111"/>`;
31
+
32
+ // --- species renderers: return {behind, body, clip, front, crest:[x,y]} ---
33
+ function koi(cx,cy,c,rand){
34
+ const rx=29,ry=13.5, tx=cx-rx+2;
35
+ const tail=`<path d="M ${tx} ${cy} C ${tx-16} ${cy-16} ${tx-22} ${cy-8} ${tx-15} ${cy-1}
36
+ C ${tx-22} ${cy+8} ${tx-16} ${cy+16} ${tx} ${cy} Z" fill="${c.fin}" stroke="${c.line}" stroke-width="0.8"/>`;
37
+ const dorsal=`<path d="M ${cx-15} ${cy-ry+2} Q ${cx-1} ${cy-ry-10} ${cx+13} ${cy-ry+3} Z" fill="${c.fin}" stroke="${c.line}" stroke-width="0.8"/>`;
38
+ const barbels=`<path d="M ${cx+rx-1} ${cy+2} q 4 3 3 6 M ${cx+rx-1} ${cy+3} q 5 1 6 5" fill="none" stroke="${c.line}" stroke-width="0.9" stroke-linecap="round"/>`;
39
+ const body=`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${c.body}" stroke="${c.line}" stroke-width="1"/>`;
40
+ return {behind:tail+dorsal, body, clip:`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"/>`,
41
+ front:barbels+`<path d="M ${cx+rx-1} ${cy+3.5} q 4 1 5 -1" fill="none" stroke="${c.line}" stroke-width="1"/>`,
42
+ eyeAt:[cx+rx*0.55,cy-2], crest:[cx-1,cy-ry-11], koiPatch:true};
43
+ }
44
+ function pufferfish(cx,cy,c,rand){
45
+ const r=18.5, tx=cx-r+2;
46
+ let spikes='';const N=15;
47
+ for(let i=0;i<N;i++){const a=i/N*Math.PI*2,d=0.12;
48
+ const b1=[cx+Math.cos(a-d)*r,cy+Math.sin(a-d)*r],b2=[cx+Math.cos(a+d)*r,cy+Math.sin(a+d)*r],ap=[cx+Math.cos(a)*(r+5),cy+Math.sin(a)*(r+5)];
49
+ spikes+=`<polygon points="${poly([b1,ap,b2])}" fill="${c.fin}" stroke="${c.line}" stroke-width="0.4"/>`;}
50
+ const tail=`<path d="M ${tx} ${cy} q -10 -8 -12 0 q 2 8 12 0 Z" fill="${c.fin}" stroke="${c.line}" stroke-width="0.8"/>`;
51
+ const body=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${c.body}" stroke="${c.line}" stroke-width="1"/>`;
52
+ return {behind:tail+spikes, body, clip:`<circle cx="${cx}" cy="${cy}" r="${r}"/>`,
53
+ front:`<path d="M ${cx+r-1} ${cy+4} q 3 2 5 0" fill="none" stroke="${c.line}" stroke-width="1"/>`,
54
+ eyeAt:[cx+r*0.5,cy-3], crest:[cx,cy-r-7]};
55
+ }
56
+ function pike(cx,cy,c,rand){
57
+ const rx=35,ry=9, snout=cx+rx+3;
58
+ const body=`<path d="M ${snout} ${cy} C ${cx+rx-4} ${cy-ry} ${cx-rx+8} ${cy-ry} ${cx-rx} ${cy}
59
+ C ${cx-rx+8} ${cy+ry} ${cx+rx-4} ${cy+ry} ${snout} ${cy} Z" fill="${c.body}" stroke="${c.line}" stroke-width="1"/>`;
60
+ const tx=cx-rx+2;
61
+ const tail=`<polygon points="${poly([[tx,cy],[tx-16,cy-13],[tx-8,cy],[tx-16,cy+13]])}" fill="${c.fin}" stroke="${c.line}" stroke-width="0.8"/>`;
62
+ const dorsal=`<path d="M ${cx-18} ${cy-ry+2} Q ${cx-11} ${cy-ry-7} ${cx-3} ${cy-ry+3} Z" fill="${c.fin}" stroke="${c.line}" stroke-width="0.8"/>`;
63
+ const anal=`<polygon points="${poly([[cx-16,cy+ry-1],[cx-6,cy+ry-1],[cx-11,cy+ry+7]])}" fill="${c.fin}" stroke="${c.line}" stroke-width="0.7"/>`;
64
+ return {behind:tail+dorsal+anal, body, clip:`<path d="M ${snout} ${cy} C ${cx+rx-4} ${cy-ry} ${cx-rx+8} ${cy-ry} ${cx-rx} ${cy} C ${cx-rx+8} ${cy+ry} ${cx+rx-4} ${cy+ry} ${snout} ${cy} Z"/>`,
65
+ front:`<path d="M ${cx+rx-2} ${cy+2.5} q 6 1 9 -1" fill="none" stroke="${c.line}" stroke-width="1"/>`,
66
+ eyeAt:[cx+rx*0.62,cy-2.5], crest:[cx-4,cy-ry-9]};
67
+ }
68
+ const RENDER={koi,pufferfish,pike};
69
+
70
+ function fishSVG(id,size=64){
71
+ const hue=schoolHue(id.topic);
72
+ const species=id.species||speciesFor(id.id);
73
+ const rand=rng(hashSeed(`${id.id}|${id.topic}|${id.variant??0}`));
74
+ const c={body:`hsl(${hue} 62% 56%)`,belly:`hsl(${hue} 58% 74%)`,fin:`hsl(${hue} 66% 44%)`,mark:`hsl(${hue} 60% 37%)`,line:`hsl(${hue} 45% 26%)`};
75
+ const cx=52,cy=50;
76
+ const p=(RENDER[species]||koi)(cx,cy,c,rand);
77
+ const clipId=`b${hashSeed(id.id+id.topic).toString(36)}`;
78
+
79
+ // markings β€” koi big soft patches, others stripes/spots by variant
80
+ let marks='';
81
+ if(p.koiPatch){const n=2+Math.floor(rand()*2);for(let i=0;i<n;i++){const x=cx-14+rand()*30,y=cy-9+rand()*14,rr=4+rand()*4;marks+=`<ellipse cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" rx="${rr.toFixed(1)}" ry="${(rr*0.75).toFixed(1)}" fill="${c.mark}" opacity="0.8"/>`;}}
82
+ else if(rand()<0.5){const n=2+Math.floor(rand()*3);for(let i=0;i<n;i++){const x=cx-20+ (i+0.7)*(40/n);marks+=`<rect x="${x.toFixed(1)}" y="0" width="${(1.5+rand()*1.6).toFixed(1)}" height="100" fill="${c.mark}" opacity="0.8"/>`;}}
83
+ else{const n=3+Math.floor(rand()*5);for(let i=0;i<n;i++){marks+=`<circle cx="${(cx-18+rand()*36).toFixed(1)}" cy="${(cy-8+rand()*16).toFixed(1)}" r="${(1.1+rand()*1.6).toFixed(1)}" fill="${c.mark}" opacity="0.8"/>`;}}
84
+
85
+ const companion=size>=24?companionSVG(id.type==='agent',p.crest[0],p.crest[1],hue):'';
86
+
87
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${size}" height="${size}" shape-rendering="geometricPrecision">
88
+ <defs><clipPath id="${clipId}">${p.clip}</clipPath></defs>
89
+ ${p.behind}
90
+ ${p.body}
91
+ <ellipse cx="${cx}" cy="${cy+5}" rx="24" ry="6" fill="${c.belly}" opacity="0.85" clip-path="url(#${clipId})"/>
92
+ <g clip-path="url(#${clipId})">${marks}</g>
93
+ ${p.front}
94
+ ${eye(p.eyeAt[0],p.eyeAt[1],c.line)}
95
+ ${companion}
96
+ </svg>`;
97
+ }
98
+
99
+ const API = { fishSVG, schoolHue, speciesFor, SCHOOLS };
100
+ if (typeof module !== 'undefined' && module.exports) module.exports = API;
101
+ if (typeof window !== 'undefined') window.FishPicker = API;
@@ -0,0 +1,128 @@
1
+ /*
2
+ * hitchhiker.js β€” the wiki-plugin-hitchhiker ITEM client.
3
+ *
4
+ * Unlike wiki-ux.js (injected farm-wide by wiki-security-hitchhiker), this
5
+ * file loads by the standard plugin route β€” /plugins/hitchhiker/hitchhiker.js
6
+ * β€” whenever a page carries a `hitchhiker` item. That makes it work on any
7
+ * farm the plugin is installed on, whatever its security plugin.
8
+ *
9
+ * First affordance: a thumb-sized FORK button in page content. The 18px βš‘
10
+ * flag in the journal strip is unusable on a phone; this button triggers the
11
+ * same native control, so the local/remote/recycler cases and all lineup
12
+ * bookkeeping stay the wiki-client's business. Forking needs login on the
13
+ * origin site β€” the server 403s otherwise and the flag isn't even rendered β€”
14
+ * so the button says "log in first" rather than dying silently.
15
+ *
16
+ * Item text is the house DSL (UPPERCASE first word = command):
17
+ * FORK render the fork button
18
+ * LABEL Fork this seal override the button label (optional)
19
+ */
20
+ ;(function () {
21
+ 'use strict'
22
+
23
+ var FORK_SVG =
24
+ '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="currentColor" ' +
25
+ 'style="vertical-align:-0.15em;margin-right:.4em" aria-hidden="true">' +
26
+ '<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 ' +
27
+ '2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 ' +
28
+ '1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 ' +
29
+ '8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"></path></svg>'
30
+
31
+ // Thumb-sized: comfortably past the 44px minimum tap target.
32
+ var BIG_BTN =
33
+ 'display:block;width:100%;box-sizing:border-box;min-height:48px;' +
34
+ 'font-size:1.1em;padding:12px 16px;cursor:pointer;'
35
+ var STATUS = 'font-size:0.85em;color:#666;margin:6px 2px 0;'
36
+
37
+ var COMMANDS = { FORK: 'fork', LABEL: 'label' }
38
+
39
+ var parse = function (text) {
40
+ var conf = { fork: false, label: '' }
41
+ var lines = String(text || '').split('\n')
42
+ for (var i = 0; i < lines.length; i++) {
43
+ var line = lines[i].trim()
44
+ if (!line) continue
45
+ var words = line.split(/\s+/)
46
+ var key = words[0].replace(/:$/, '').toUpperCase()
47
+ if (COMMANDS[key] === 'fork') conf.fork = true
48
+ else if (COMMANDS[key] === 'label') conf.label = words.slice(1).join(' ')
49
+ }
50
+ return conf
51
+ }
52
+
53
+ var esc = function (s) {
54
+ return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
55
+ }
56
+
57
+ var emit = function ($item, item) {
58
+ var conf = parse(item.text)
59
+ var $page = $item.parents('.page:first')
60
+ // wiki-plugin-hearth marks pages that must not offer forking.
61
+ if ($page.hasClass('hearth-page')) { $item.append('<div></div>'); return }
62
+ if (!conf.fork) {
63
+ $item.append('<p style="' + STATUS + '">hitchhiker item β€” no FORK command in its text</p>')
64
+ return
65
+ }
66
+ var host = location.host
67
+ var label = conf.label || 'Fork this page to ' + host
68
+ $item.append(
69
+ '<div class="hitchhiker-fork">' +
70
+ '<button class="hitchhiker-fork-button" style="' + BIG_BTN + '">' +
71
+ FORK_SVG + esc(label) + '</button>' +
72
+ '<p class="hitchhiker-fork-status" style="' + STATUS + '"></p>' +
73
+ '</div>'
74
+ )
75
+ var $status = $item.find('.hitchhiker-fork-status')
76
+ if (window.isAuthenticated !== true) {
77
+ $status.text('You are not logged in on ' + host + ' β€” log in first, then this button forks the page to your wiki.')
78
+ } else if (!$page.hasClass('remote') && !$page.hasClass('local')) {
79
+ $status.text('This page already lives on ' + host + '.')
80
+ }
81
+ }
82
+
83
+ var bind = function ($item, item) {
84
+ $item.on('dblclick', function () { wiki.textEditor($item, item) })
85
+ $item.find('.hitchhiker-fork-button').on('click', function (e) {
86
+ e.preventDefault()
87
+ e.stopPropagation()
88
+ var $page = $item.parents('.page:first')
89
+ var $status = $item.find('.hitchhiker-fork-status')
90
+ if ($page.hasClass('hearth-page')) return
91
+ var $flag = $page.find('.fork-page')
92
+ if ($flag.length) {
93
+ $flag.trigger('click')
94
+ } else if (window.isAuthenticated !== true) {
95
+ $status.text('You are not logged in on ' + location.host + ' β€” log in first, then tap again.')
96
+ return
97
+ } else if (/_rev\d+$/.test($page.attr('id') || '')) {
98
+ $status.text('This is an old revision β€” open the current page to fork it.')
99
+ return
100
+ } else {
101
+ // Logged in, but the "wiki" edit toggle is off, so the native flag
102
+ // isn't rendered. Fork directly β€” the same pageHandler call the flag
103
+ // makes; the server authorizes by session, not by the toggle.
104
+ var action = { type: 'fork' }
105
+ if ($page.hasClass('local')) {
106
+ $page.removeClass('local')
107
+ } else {
108
+ try {
109
+ var po = wiki.lineup.atKey($page.data('key'))
110
+ if (po && po.isRemote && po.isRemote()) action.site = po.getRemoteSite()
111
+ } catch (err) {}
112
+ }
113
+ $page.removeClass('ghost')
114
+ wiki.pageHandler.put($page, action)
115
+ }
116
+ $item.find('.hitchhiker-fork-button').prop('disabled', true).css('opacity', 0.6)
117
+ $status.text('Forked to ' + location.host + ' βœ“')
118
+ })
119
+ }
120
+
121
+ if (typeof window !== 'undefined') {
122
+ window.plugins = window.plugins || {}
123
+ window.plugins.hitchhiker = { emit: emit, bind: bind }
124
+ }
125
+ if (typeof module !== 'undefined' && module.exports) {
126
+ module.exports = { parse: parse, emit: emit, bind: bind }
127
+ }
128
+ })()
@@ -0,0 +1,140 @@
1
+ <!doctype html><html><head><meta charset="utf8">
2
+ <meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Fish Picker</title>
4
+ <style>
5
+ :root{--bg:#f4f2ee;--card:#fff;--line:#e5e1d8;--ink:#222;--mut:#777;--go:#2b6}
6
+ *{box-sizing:border-box}
7
+ html,body{margin:0;background:var(--bg);color:var(--ink);font:13.5px/1.4 system-ui,sans-serif}
8
+ .wrap{padding:12px;max-width:900px;margin:0 auto}
9
+ header{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px}
10
+ header h1{font-size:15px;margin:0} header a{font-size:12px;color:var(--mut);text-decoration:none;white-space:nowrap}
11
+ header a:hover{color:var(--ink)}
12
+ .main{display:grid;grid-template-columns:1fr;gap:12px}
13
+ @media(min-width:680px){.main{grid-template-columns:300px 1fr}}
14
+ .card{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:11px 12px}
15
+ .card h2{font-size:11px;margin:0 0 8px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut)}
16
+ label{display:block;font-size:11px;color:var(--mut);margin:8px 0 3px}
17
+ input,select{width:100%;padding:6px 7px;border:1px solid var(--line);border-radius:6px;font:inherit;background:#fbfaf7}
18
+ .two{display:flex;gap:8px}.two>*{flex:1}
19
+ .seg{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
20
+ .seg button{flex:1;padding:6px;border:0;background:#fbfaf7;cursor:pointer;font:inherit}
21
+ .seg button.on{background:var(--go);color:#fff}
22
+ .step{display:flex;align-items:center;gap:8px}.step button{width:28px;height:28px;border:1px solid var(--line);border-radius:6px;background:#fbfaf7;cursor:pointer;font-size:15px}
23
+ .row{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap}
24
+ figure{margin:0;text-align:center} .box{background:#fbfaf7;border:1px solid #eee;border-radius:6px;display:flex;align-items:center;justify-content:center}
25
+ figcaption{font-size:10px;color:var(--mut);margin-top:3px}
26
+ .note{color:var(--mut);font-size:11px;margin:6px 0 0}
27
+ details{margin-top:8px} summary{cursor:pointer;font-size:11px;color:var(--mut)}
28
+ pre{background:#f6f5f2;border-radius:6px;padding:8px;font-size:11px;max-height:130px;overflow:auto;margin:6px 0 0}
29
+ .save{display:flex;gap:10px;align-items:center;margin-top:10px}
30
+ .save button{padding:8px 16px;border:1px solid var(--go);border-radius:7px;background:var(--go);color:#fff;cursor:pointer;font:inherit}
31
+ #status{font-size:12px;color:var(--mut)}
32
+ </style></head>
33
+ <body><div class="wrap">
34
+ <header><h1>&#128031; Fish Picker</h1><a id="fs" target="_blank" rel="noopener">&#10530; Full screen</a></header>
35
+ <div class="main">
36
+ <div>
37
+ <div class="card">
38
+ <h2>Genome</h2>
39
+ <label>Domain</label><input id="domain" placeholder="david.fab.fish">
40
+ <div class="two"><div><label>Species</label><select id="species"></select></div><div><label>School</label><select id="topic"></select></div></div>
41
+ <label>Type</label><div class="seg"><button data-type="human" class="on">Human</button><button data-type="agent">Agent</button></div>
42
+ <label>Variant</label><div class="step"><button id="vd">&minus;</button><input id="v" value="0" readonly style="text-align:center"><button id="vu">+</button></div>
43
+ <label>Petname</label><input id="name" placeholder="David Bovill">
44
+ <div class="save"><button id="save">Save flag</button><span id="status"></span></div>
45
+ <p class="note" id="hint">Embed with a frame item Β· Save writes this site&rsquo;s flag (owner only).</p>
46
+ </div>
47
+ </div>
48
+ <div>
49
+ <div class="card"><h2>This flag</h2><div class="row" id="lod"></div></div>
50
+ <div class="card"><h2>Same school &mdash; the Mice</h2><div class="row" id="mice"></div>
51
+ <h2 style="margin-top:10px">Same person &mdash; other schools</h2><div class="row" id="schools"></div>
52
+ <details><summary>identity.json</summary><pre id="json"></pre></details>
53
+ </div>
54
+ </div>
55
+ </div></div>
56
+ <script src="fish.js"></script>
57
+ <script>
58
+ (function(){
59
+ var FP = window.FishPicker
60
+ var $ = function(s){ return document.querySelector(s) }
61
+ if(!FP){ document.body.innerHTML='<p style="padding:16px">Fish Picker generator failed to load (fish.js).</p>'; return }
62
+ var SPECIES=['koi','pufferfish','pike'], MICE=['david','max','mitch']
63
+ var st={ person:'david', species:'koi', topic:'fab', type:'human', variant:3, name:'David Bovill' }
64
+
65
+ $('#species').innerHTML = SPECIES.map(function(s){return '<option>'+s+'</option>'}).join('')
66
+ $('#topic').innerHTML = Object.keys(FP.SCHOOLS).map(function(t){return '<option>'+t+'</option>'}).join('')
67
+ $('#fs').href = location.href
68
+
69
+ function recId(p){ p=p||st.person; return st.type==='agent'? p+'-agent': p }
70
+ function ident(o){ o=o||{}; return { id:recId(o.person), name:st.name, type:st.type, topic:o.topic||st.topic, species:o.species||st.species, variant:st.variant } }
71
+ function box(o,size,cap){ return '<figure><div class="box" style="width:'+size+'px;height:'+size+'px">'+FP.fishSVG(o,size)+'</div><figcaption>'+cap+'</figcaption></figure>' }
72
+
73
+ function render(){
74
+ $('#person')&&($('#person').value=st.person)
75
+ $('#species').value=st.species; $('#topic').value=st.topic; $('#v').value=st.variant; $('#name').value=st.name
76
+ document.querySelectorAll('.seg button').forEach(function(b){ b.classList.toggle('on', b.dataset.type===st.type) })
77
+ $('#lod').innerHTML = [16,32,64,128].map(function(s){return box(ident(),s,s+'px')}).join('')
78
+ $('#mice').innerHTML = MICE.map(function(p){return box(ident({person:p,species:FP.speciesFor(p)}),52,p)}).join('')
79
+ $('#schools').innerHTML = ['fab','academy','myth'].map(function(t){return box(ident({topic:t}),52,t)}).join('')
80
+ $('#json').textContent = JSON.stringify({name:st.name,id:recId(),type:st.type,sponsor:st.type==='agent'?st.person:null,topicGroup:st.topic,species:st.species,variant:st.variant,publicKey:null}, null, 1)
81
+ }
82
+
83
+ $('#domain').addEventListener('input', function(e){ var p=e.target.value.split('.').filter(Boolean); if(p[0]){st.person=p[0];st.species=FP.speciesFor(p[0]);st.name=p[0]} var t=p.slice(1).find(function(x){return x in FP.SCHOOLS}); if(t)st.topic=t; render() })
84
+ $('#name').addEventListener('input', function(e){ st.name=e.target.value; render() })
85
+ $('#species').addEventListener('change', function(e){ st.species=e.target.value; render() })
86
+ $('#topic').addEventListener('change', function(e){ st.topic=e.target.value; render() })
87
+ document.querySelectorAll('.seg button').forEach(function(b){ b.addEventListener('click', function(){ st.type=b.dataset.type; render() }) })
88
+ $('#vu').addEventListener('click', function(){ st.variant++; render() })
89
+ $('#vd').addEventListener('click', function(){ st.variant=Math.max(0,st.variant-1); render() })
90
+ $('#save').addEventListener('click', saveFlag)
91
+ window.addEventListener('message', function(e){ var d=e.data; if(d&&d.type==='fishpicker:saved'){ status(d.ok?'🐟 saved to the site':('save failed'+(d.status===403?' β€” owner only':'')), !!d.ok); if(d.ok)bust() } })
92
+
93
+ // ---- persistence: POST to fedwiki's owner-gated /favicon.png, identity in a PNG tEXt chunk ----
94
+ var CRC=(function(){var t=[],c,n,k;for(n=0;n<256;n++){c=n;for(k=0;k<8;k++)c=c&1?0xedb88320^(c>>>1):c>>>1;t[n]=c>>>0}return t})()
95
+ function crc32(b){var c=0xffffffff;for(var i=0;i<b.length;i++)c=CRC[(c^b[i])&0xff]^(c>>>8);return (c^0xffffffff)>>>0}
96
+ function u32(n){return[(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]}
97
+ function pngInsertText(png,kw,tx){var d=[],i;for(i=0;i<kw.length;i++)d.push(kw.charCodeAt(i)&255);d.push(0);for(i=0;i<tx.length;i++)d.push(tx.charCodeAt(i)&255);var body=[0x74,0x45,0x58,0x74].concat(d);var chunk=u32(d.length).concat(body).concat(u32(crc32(new Uint8Array(body))));var iend=png.length-12,out=new Uint8Array(png.length+chunk.length);out.set(png.subarray(0,iend),0);out.set(new Uint8Array(chunk),iend);out.set(png.subarray(iend),iend+chunk.length);return out}
98
+ function pngReadText(png,kw){var p=8;while(p+8<=png.length){var len=(png[p]<<24)|(png[p+1]<<16)|(png[p+2]<<8)|png[p+3];var ty=String.fromCharCode(png[p+4],png[p+5],png[p+6],png[p+7]);if(ty==='tEXt'){var s='';for(var i=0;i<len;i++)s+=String.fromCharCode(png[p+8+i]);var z=s.indexOf('\0');if(z!==-1&&s.slice(0,z)===kw)return s.slice(z+1)}if(ty==='IEND')break;p+=12+len}return null}
99
+ function b64(b){var s='';for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);return btoa(s)}
100
+ function status(msg,ok){ $('#status').textContent=msg; $('#status').style.color = ok?'#2b6':(ok===false?'#c33':'#777') }
101
+
102
+ function saveFlag(){
103
+ var identity={name:st.name,id:recId(),type:st.type,topicGroup:st.topic,species:st.species,variant:st.variant,publicKey:null}
104
+ var svg=FP.fishSVG(ident(),128), img=new Image()
105
+ status('saving…')
106
+ img.onload=function(){
107
+ var cv=document.createElement('canvas'); cv.width=cv.height=64; cv.getContext('2d').drawImage(img,0,0,64,64)
108
+ cv.toBlob(function(blob){ blob.arrayBuffer().then(function(ab){
109
+ var png=pngInsertText(new Uint8Array(ab),'fishpicker',encodeURIComponent(JSON.stringify(identity)))
110
+ var dataUri='data:image/png;base64,'+b64(png)
111
+ if(window.parent && window.parent!==window){
112
+ // embedded & sandboxed (no session in here) β€” ask the authenticated page to save.
113
+ window.parent.postMessage({type:'fishpicker:save', image:dataUri}, '*')
114
+ status('saving via the page…'); return
115
+ }
116
+ fetch('/favicon.png',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({image:dataUri})})
117
+ .then(function(r){
118
+ if(r.ok){ status('🐟 saved to '+location.hostname, true); bust() }
119
+ else if(r.status===403){ status('sign in as the owner to save', false) }
120
+ else { status('save failed ('+r.status+') β€” downloaded instead', false); dl(new Blob([png],{type:'image/png'}),'favicon.png') }
121
+ }).catch(function(){ status('no wiki here β€” downloaded instead'); dl(new Blob([png],{type:'image/png'}),'favicon.png') })
122
+ }) }, 'image/png')
123
+ }
124
+ img.src='data:image/svg+xml;base64,'+btoa(unescape(encodeURIComponent(svg)))
125
+ }
126
+ function bust(){ try{ (parent.document||document).querySelectorAll('img.favicon').forEach(function(i){i.src='/favicon.png?t='+Date.now()}) }catch(e){} }
127
+ function dl(blob,name){ var a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; a.click() }
128
+
129
+ // seed from the current favicon's embedded identity, else the domain
130
+ function seed(id){ if(id){ st.person=(id.id||st.person).replace(/-agent$/,''); st.name=id.name||st.name; st.type=id.type||st.type; st.topic=id.topicGroup||st.topic; st.species=id.species||st.species; if(id.variant!=null)st.variant=id.variant } render() }
131
+ fetch('/favicon.png?t='+Date.now()).then(function(r){return r.ok?r.arrayBuffer():null}).then(function(ab){
132
+ if(ab){ var t=pngReadText(new Uint8Array(ab),'fishpicker'); if(t){ try{ return seed(JSON.parse(decodeURIComponent(t))) }catch(e){} } }
133
+ // no wiki favicon β†’ default from the frame's own host, if it looks like a site
134
+ var h=location.hostname.split('.').filter(Boolean); if(h[0]&&h[0]!=='localhost'){ st.person=h[0]; st.species=FP.speciesFor(h[0]); st.name=h[0]; var s=h.slice(1).find(function(x){return x in FP.SCHOOLS}); if(s)st.topic=s }
135
+ render()
136
+ }).catch(render)
137
+ render()
138
+ })();
139
+ </script>
140
+ </body></html>