okf 1.9.0 → 1.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +493 -133
- data/README.md +101 -10
- data/lib/okf/bundle/folder.rb +4 -0
- data/lib/okf/bundle.rb +25 -1
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +495 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +127 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +179 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +88 -0
- data/lib/okf/cli/tags.rb +122 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +418 -1703
- data/lib/okf/render/graph/template.html.erb +1020 -61
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +10 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +14 -10
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +3 -2
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/reference/cli.md +22 -4
- data/lib/okf/version.rb +1 -1
- metadata +19 -1
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Server
|
|
5
|
+
class Hub
|
|
6
|
+
# The page a stale bookmark lands on — and the only page in the product a
|
|
7
|
+
# reader reaches by being wrong. So it is deliberately *not* a design of
|
|
8
|
+
# its own: it is the app shell with nothing to show. The same 76px rail
|
|
9
|
+
# with the ruby mark and the theme toggle, the same topbar, the same
|
|
10
|
+
# `.search` component in the same position, the same row anatomy the
|
|
11
|
+
# palette uses for a bundle, the same red-underlined head. Only the main
|
|
12
|
+
# column differs. A 404 that looks like a different app tells a reader
|
|
13
|
+
# they left the product, which is the opposite of the truth.
|
|
14
|
+
#
|
|
15
|
+
# Everything a reader needs is rendered here, in Ruby: the asked path, the
|
|
16
|
+
# guess, the list. This is where someone lands when something has already
|
|
17
|
+
# gone wrong, and a page that needs JavaScript to say what happened has
|
|
18
|
+
# picked the worst moment to need it. The script adds the live filter, the
|
|
19
|
+
# count, and ↑↓/⏎/esc — enhancements, every one of which the page is
|
|
20
|
+
# complete without.
|
|
21
|
+
#
|
|
22
|
+
# The tokens, rail and topbar rules are copied from the graph template.
|
|
23
|
+
# They are copied rather than shared because there is one other page that
|
|
24
|
+
# wants them and an abstraction for two callers is a worse answer than two
|
|
25
|
+
# copies; keep them in step if that template moves.
|
|
26
|
+
class NotFound
|
|
27
|
+
STYLE = <<~CSS
|
|
28
|
+
:root{
|
|
29
|
+
--bg:#ffffff; --panel:#ffffff; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
|
|
30
|
+
--muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
|
|
31
|
+
--accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --ok:#1a9e5f; --warn:#9a6700;
|
|
32
|
+
--rail:#15171c; --rail-ink:#8b919c; --rail-ink-hi:#dfe2e7;
|
|
33
|
+
}
|
|
34
|
+
:root[data-theme=dark]{
|
|
35
|
+
--bg:#17191e; --panel:#1d2026; --canvas:#111318; --ink:#eceef1; --ink-soft:#d7dae0;
|
|
36
|
+
--muted:#9aa0aa; --faint:#6b7178; --line:#2a2e36; --line-2:#232830;
|
|
37
|
+
--accent:#f5433b; --accent-ink:#ff726b; --accent-soft:#3a1f1e; --ok:#37c07f; --warn:#d4a72c;
|
|
38
|
+
--rail:#0d0e11; --rail-ink:#868c98; --rail-ink-hi:#e2e5ea;
|
|
39
|
+
}
|
|
40
|
+
*{box-sizing:border-box}
|
|
41
|
+
html,body{margin:0;height:100%}
|
|
42
|
+
body{background:var(--bg);color:var(--ink);
|
|
43
|
+
font:14px/1.55 'Poppins',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
|
44
|
+
|
|
45
|
+
/* ── app shell: rail + main ── */
|
|
46
|
+
#app{display:grid;grid-template-columns:76px 1fr;height:100vh;height:100dvh}
|
|
47
|
+
#rail{grid-column:1;background:var(--rail);display:flex;flex-direction:column;align-items:center;gap:3px;padding:12px 8px;overflow:hidden}
|
|
48
|
+
.rail-brand{width:34px;height:34px;margin:2px 0 10px;flex:none;display:block;border-radius:9px}
|
|
49
|
+
.rail-brand svg{width:34px;height:34px;display:block}
|
|
50
|
+
.rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
51
|
+
.rail-sp{flex:1}
|
|
52
|
+
.rail-tools{display:flex;flex-direction:column;gap:6px;align-items:center}
|
|
53
|
+
.rail-tools .btn{color:var(--rail-ink);border-color:transparent;background:rgba(255,255,255,.05)}
|
|
54
|
+
.rail-tools .btn:hover{color:var(--rail-ink-hi);background:rgba(255,255,255,.1);border-color:transparent}
|
|
55
|
+
|
|
56
|
+
/* ── main column ── */
|
|
57
|
+
#main{grid-column:2;display:flex;flex-direction:column;min-width:0}
|
|
58
|
+
#topbar{flex:none;display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
59
|
+
.bar-brand{display:none;width:26px;height:26px;flex:none}
|
|
60
|
+
.bar-brand svg{width:26px;height:26px;display:block}
|
|
61
|
+
|
|
62
|
+
/* ── shared controls (verbatim from the template) ── */
|
|
63
|
+
.field{height:34px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink);
|
|
64
|
+
font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
65
|
+
.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
66
|
+
.field::placeholder{color:var(--muted)}
|
|
67
|
+
/* Sits where the graph page's box sits — right after the mark, not flush
|
|
68
|
+
to the far edge: same component, same place, found by habit. */
|
|
69
|
+
.search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
|
|
70
|
+
.search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
|
|
71
|
+
.search input{width:100%;padding-left:33px;padding-right:52px}
|
|
72
|
+
/* The count sits on the control that filters, not in a heading that
|
|
73
|
+
repeats it — the number belongs to what you are filtering. */
|
|
74
|
+
.s-cnt{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:11px;color:var(--faint);
|
|
75
|
+
font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
|
|
76
|
+
/* ── the bridge, verbatim from the graph page ──
|
|
77
|
+
The dead end is the same event here as there — the box in the topbar
|
|
78
|
+
came up empty and there is somewhere else to look — so it is the same
|
|
79
|
+
component in the same place, under the box that disappointed you,
|
|
80
|
+
rather than a second dialect of the same idea two pages apart. ── */
|
|
81
|
+
.s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel);
|
|
82
|
+
border:1px solid var(--line);border-radius:11px;box-shadow:0 10px 30px rgba(0,0,0,.16);
|
|
83
|
+
padding:11px 13px;font-size:12.5px}
|
|
84
|
+
.s-bridge[hidden]{display:none}
|
|
85
|
+
.sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
86
|
+
.sb-msg b{color:var(--ink);font-weight:600}
|
|
87
|
+
.sb-row{display:flex;gap:8px;flex-wrap:wrap}
|
|
88
|
+
.sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px;
|
|
89
|
+
padding:5px 10px;border:1px solid var(--line);border-radius:8px;background:var(--line-2);color:var(--muted);
|
|
90
|
+
transition:color .15s,border-color .15s}
|
|
91
|
+
.sb-act:hover{color:var(--ink);border-color:var(--accent)}
|
|
92
|
+
.sb-act:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
93
|
+
.sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
|
|
94
|
+
.sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
|
|
95
|
+
border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
|
|
96
|
+
.btn{cursor:pointer;display:inline-grid;place-items:center;width:34px;height:34px;border-radius:9px;border:1px solid var(--line);
|
|
97
|
+
background:var(--panel);color:var(--muted);transition:color .15s,border-color .15s,background .15s;font-family:inherit}
|
|
98
|
+
.btn:hover{color:var(--ink);border-color:var(--accent)}
|
|
99
|
+
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
100
|
+
.btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
101
|
+
#btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none}
|
|
102
|
+
|
|
103
|
+
/* ── the column ──
|
|
104
|
+
Left-anchored under the rail and the bar, never centred: a card
|
|
105
|
+
floating in the middle would read as a different page's chrome, and
|
|
106
|
+
the two things this page shares with every other view are anchored
|
|
107
|
+
hard left. 880px because the rows below carry a right-aligned fact
|
|
108
|
+
column, and at 620 those columns had nowhere to be. ── */
|
|
109
|
+
#views{flex:1;min-height:0;position:relative;background:var(--bg);overflow:auto;display:flex;flex-direction:column}
|
|
110
|
+
#col{width:100%;max-width:880px;padding:40px 28px 28px;flex:1}
|
|
111
|
+
|
|
112
|
+
/* ── what was asked for ──
|
|
113
|
+
The heading and the path swap the usual sizes. A reader arrives here
|
|
114
|
+
already knowing they are lost — the URL bar said so — so "not found"
|
|
115
|
+
is the small word and the path they typed is the large one. Setting
|
|
116
|
+
it in mono at 27px is what makes a dropped slash or a truncated slug
|
|
117
|
+
legible as a *shape*, which is the one thing this page can do that
|
|
118
|
+
the address bar cannot. ── */
|
|
119
|
+
.eyebrow{margin:0 0 6px;font-size:11px;font-weight:600;text-transform:uppercase;
|
|
120
|
+
letter-spacing:.08em;color:var(--faint)}
|
|
121
|
+
h1{font-size:27px;font-weight:600;letter-spacing:-.02em;margin:0;padding-bottom:12px;position:relative;
|
|
122
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
|
|
123
|
+
overflow-wrap:anywhere;line-height:1.25}
|
|
124
|
+
h1::after{content:"";position:absolute;left:0;bottom:0;width:34px;height:3px;border-radius:3px;background:var(--accent)}
|
|
125
|
+
.fact{margin:14px 0 0;color:var(--muted);font-size:13.5px}
|
|
126
|
+
.fact code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:var(--ink-soft)}
|
|
127
|
+
|
|
128
|
+
/* ── the near miss ──
|
|
129
|
+
The one place this page raises its voice, and it earns it by being
|
|
130
|
+
the whole answer when it is right. A sentence in muted grey asks the
|
|
131
|
+
reader to read, parse and then aim; a row asks them to press Enter.
|
|
132
|
+
So the guess wears the same anatomy as the list below it, one step
|
|
133
|
+
brighter, and the keyboard already points at it. ── */
|
|
134
|
+
.miss{margin:24px 0 0}
|
|
135
|
+
.miss[hidden]{display:none}
|
|
136
|
+
.miss .lbl{margin:0 0 6px}
|
|
137
|
+
|
|
138
|
+
.lbl{margin:30px 0 8px;font-size:10.5px;font-weight:600;text-transform:uppercase;
|
|
139
|
+
letter-spacing:.06em;color:var(--faint)}
|
|
140
|
+
|
|
141
|
+
/* ── the rows: the /b/ list's anatomy, which carries the folder ──
|
|
142
|
+
The folder is not decoration here. A server hosting `site/.okf`,
|
|
143
|
+
`minifts/.okf` and `okf-core/.okf` has three bundles whose titles are
|
|
144
|
+
nearly the same word, and the directory they came from is the only
|
|
145
|
+
thing that tells them apart. The old row omitted exactly that. ── */
|
|
146
|
+
#blist{list-style:none;margin:0;padding:0}
|
|
147
|
+
#blist li[hidden]{display:none}
|
|
148
|
+
.brow{list-style:none}
|
|
149
|
+
.brow a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1px 20px;align-items:center;
|
|
150
|
+
padding:9px 13px;border-radius:10px;text-decoration:none;color:var(--ink);
|
|
151
|
+
box-shadow:inset 3px 0 0 var(--edge,transparent);transition:background .12s}
|
|
152
|
+
.brow a:hover{background:var(--line-2)}
|
|
153
|
+
/* The cursor is one look wherever it stands — on the near miss, on a
|
|
154
|
+
filtered bundle, on a concept hit — because ↑↓ walks all three as
|
|
155
|
+
one list. The guess is not styled at all: it is simply where the
|
|
156
|
+
cursor starts, which is why it is lit on arrival and stops being lit
|
|
157
|
+
the moment you type. */
|
|
158
|
+
/* The palette's own selection is this neutral fill, so the cursor
|
|
159
|
+
matches it rather than inventing a louder one; the accent *edge* is
|
|
160
|
+
what makes the move visible, and it is already this page's device
|
|
161
|
+
for saying which row to look at. */
|
|
162
|
+
.brow a.active{--edge:var(--accent);background:var(--line-2)}
|
|
163
|
+
.brow a.active .dbadge{background:rgba(128,128,128,.22)}
|
|
164
|
+
.brow a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
165
|
+
/* No edge at all on a healthy row. A rule per bundle is six marks
|
|
166
|
+
saying "nothing to report", and a page where everything is marked
|
|
167
|
+
is a page where the one thing that matters is not. */
|
|
168
|
+
.brow[data-health=warn] a{--edge:var(--warn)}
|
|
169
|
+
.brow[data-health=error] a{--edge:var(--accent)}
|
|
170
|
+
.brow[data-health=missing] a{--edge:var(--faint)}
|
|
171
|
+
.b-id{grid-column:1;grid-row:1;display:flex;align-items:baseline;gap:9px;min-width:0}
|
|
172
|
+
.b-title{font-weight:500;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
173
|
+
.slug{color:var(--faint);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;flex:none}
|
|
174
|
+
.dbadge{padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:10.5px;flex:none}
|
|
175
|
+
/* Truncate a long path from the *left*: the tail (…/repo/.okf) is what
|
|
176
|
+
identifies it, and clipping the tail identifies nothing. An rtl box
|
|
177
|
+
puts the ellipsis at the front, and the inner <bdi> keeps the path
|
|
178
|
+
itself one ltr run so a leading "/" cannot reorder to the far end. */
|
|
179
|
+
.b-dir{grid-column:1;grid-row:2;font-size:11.5px;color:var(--faint);
|
|
180
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
181
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
|
|
182
|
+
.b-dir bdi{direction:ltr}
|
|
183
|
+
/* fixed slots, right-aligned: read down the page as much as across,
|
|
184
|
+
and a ragged column of numbers is a column nobody scans */
|
|
185
|
+
.b-meta{grid-column:2;grid-row:1/3;display:flex;align-items:baseline;gap:20px;flex:none;
|
|
186
|
+
font-size:12px;color:var(--faint)}
|
|
187
|
+
.b-cnt{min-width:6.5rem;text-align:right;font-variant-numeric:tabular-nums}
|
|
188
|
+
/* Colour marks the exception only. Six green "no problems" spends the
|
|
189
|
+
page's whole palette on nothing to report — and makes the one real
|
|
190
|
+
warning harder to find, which is the opposite of the job. */
|
|
191
|
+
.b-health{min-width:7rem;color:var(--muted)}
|
|
192
|
+
.brow[data-health=warn] .b-health{color:var(--warn)}
|
|
193
|
+
.brow[data-health=error] .b-health{color:var(--accent-ink)}
|
|
194
|
+
.brow[data-health=missing] .b-title{color:var(--faint);font-weight:400}
|
|
195
|
+
#hits[hidden]{display:none}
|
|
196
|
+
#hitlist{list-style:none;margin:0;padding:0}
|
|
197
|
+
#hitnote{margin:10px 0 0;padding:0 13px;color:var(--faint);font-size:12px}
|
|
198
|
+
/* a snippet is prose, so it drops the path row's mono and rtl clipping */
|
|
199
|
+
.hit-s{font-family:inherit;direction:ltr;color:var(--muted);font-size:12.5px}
|
|
200
|
+
#bnone code{background:var(--line-2);border-radius:5px;padding:1px 5px;
|
|
201
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
|
202
|
+
|
|
203
|
+
/* ≤768px: the rail is a whole drawer on the graph page; a 404 has
|
|
204
|
+
nothing to put in one, so it folds away and the mark moves into the bar. */
|
|
205
|
+
@media (max-width:768px){
|
|
206
|
+
#app{grid-template-columns:1fr}
|
|
207
|
+
#rail{display:none}
|
|
208
|
+
#main{grid-column:1}
|
|
209
|
+
.bar-brand{display:block}
|
|
210
|
+
.search{flex-basis:140px}
|
|
211
|
+
#col{padding:28px 16px 20px}
|
|
212
|
+
h1{font-size:20px}
|
|
213
|
+
/* the fact columns stop being columns and become a third line */
|
|
214
|
+
.brow a{grid-template-columns:minmax(0,1fr)}
|
|
215
|
+
.b-meta{grid-column:1;grid-row:3;margin-top:3px;gap:14px}
|
|
216
|
+
.b-cnt,.b-health{min-width:0}
|
|
217
|
+
}
|
|
218
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
219
|
+
CSS
|
|
220
|
+
|
|
221
|
+
# The ruby mark, the graph template's own. It appears twice — once in the
|
|
222
|
+
# rail, once in the bar the rail folds into below 768px — because the two
|
|
223
|
+
# are never visible at the same time.
|
|
224
|
+
MARK = '<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/>' \
|
|
225
|
+
'<polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/>' \
|
|
226
|
+
'<polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/>' \
|
|
227
|
+
'<polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/>' \
|
|
228
|
+
'<polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/>' \
|
|
229
|
+
'<polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>'
|
|
230
|
+
|
|
231
|
+
# Filter, count and keyboard — the three things a static document cannot
|
|
232
|
+
# do. Every row is already in the DOM; this only hides some of them.
|
|
233
|
+
# Filter, count, keyboard, and the fallback to searching every bundle —
|
|
234
|
+
# the four things a static document cannot do. Everything else on this
|
|
235
|
+
# page is already in the DOM before this runs.
|
|
236
|
+
SCRIPT = <<~JS
|
|
237
|
+
(function(){
|
|
238
|
+
'use strict';
|
|
239
|
+
var root=document.documentElement;
|
|
240
|
+
document.getElementById('btn-theme').addEventListener('click',function(){
|
|
241
|
+
var next=root.getAttribute('data-theme')==='dark'?'light':'dark';
|
|
242
|
+
root.setAttribute('data-theme',next);
|
|
243
|
+
try{localStorage.setItem('okf-theme',next);}catch(e){}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
var q=document.getElementById('q');
|
|
247
|
+
if(!q) return;
|
|
248
|
+
var list=document.getElementById('blist'),
|
|
249
|
+
chip=document.getElementById('bar-count'), total=list.children.length,
|
|
250
|
+
missBox=document.querySelector('.miss'),
|
|
251
|
+
miss=missBox?missBox.querySelector('a'):null,
|
|
252
|
+
hits=document.getElementById('hits'), hitList=document.getElementById('hitlist'),
|
|
253
|
+
hitNote=document.getElementById('hitnote'), goBtn=document.getElementById('go-search'),
|
|
254
|
+
bridge=document.getElementById('s-bridge'),
|
|
255
|
+
bridgeMsg=bridge.querySelector('.sb-msg'),
|
|
256
|
+
goBtn=document.getElementById('sb-go'), clearBtn=document.getElementById('sb-clear'),
|
|
257
|
+
endpoint=q.getAttribute('data-search'), mount=q.getAttribute('data-mount'),
|
|
258
|
+
searched=null;
|
|
259
|
+
|
|
260
|
+
/* Moving through the list is Tab's job, and Tab already does it: every
|
|
261
|
+
row is an <a href>, a filtered-out row is display:none and drops out
|
|
262
|
+
of the order on its own, and Shift-Tab goes back. A hand-rolled ↑↓
|
|
263
|
+
cursor was a second, worse focus model living beside the real one —
|
|
264
|
+
it lit rows the browser did not consider focused, it was invisible
|
|
265
|
+
to a screen reader, and keeping the two in step is what left two
|
|
266
|
+
rows highlighted at once.
|
|
267
|
+
What is left is not a cursor: one row is marked, it is whatever ⏎
|
|
268
|
+
would open *right now*, and it never moves on its own. It stands
|
|
269
|
+
down the moment the caret leaves the box, because past that point ⏎
|
|
270
|
+
belongs to whatever Tab has focused. */
|
|
271
|
+
function shownRows(){
|
|
272
|
+
return Array.prototype.filter.call(list.children,function(li){return !li.hidden;});
|
|
273
|
+
}
|
|
274
|
+
function target(){
|
|
275
|
+
if(miss&&q.value.trim()==='') return miss;
|
|
276
|
+
var first=shownRows()[0];
|
|
277
|
+
if(first) return first.firstChild;
|
|
278
|
+
return hitList.children.length ? hitList.children[0].firstChild : null;
|
|
279
|
+
}
|
|
280
|
+
function unmark(){
|
|
281
|
+
Array.prototype.forEach.call(document.querySelectorAll('a.active'),function(a){a.classList.remove('active');});
|
|
282
|
+
}
|
|
283
|
+
function mark(){
|
|
284
|
+
unmark();
|
|
285
|
+
var t=target();
|
|
286
|
+
if(t) t.classList.add('active');
|
|
287
|
+
}
|
|
288
|
+
/* Total while the box is empty, matched/total once it is filtering —
|
|
289
|
+
the same grammar the graph page's own count chip uses. */
|
|
290
|
+
function count(shown){
|
|
291
|
+
chip.textContent = q.value.trim()==='' ? String(total) : shown+'/'+total;
|
|
292
|
+
}
|
|
293
|
+
function filter(){
|
|
294
|
+
var needle=q.value.trim().toLowerCase();
|
|
295
|
+
Array.prototype.forEach.call(list.children,function(li){
|
|
296
|
+
li.hidden = needle!=='' && li.getAttribute('data-hay').indexOf(needle)===-1;
|
|
297
|
+
});
|
|
298
|
+
/* The guess answers "what did you *ask* for", so a query supersedes
|
|
299
|
+
it outright rather than sitting above the answer to a different
|
|
300
|
+
question. */
|
|
301
|
+
if(missBox) missBox.hidden = needle!=='';
|
|
302
|
+
var vis=shownRows();
|
|
303
|
+
/* The bridge is the graph page's own dead-end panel, doing the same
|
|
304
|
+
job for the same reason: the box came up empty and there is
|
|
305
|
+
somewhere else to look. It drops under the box rather than into
|
|
306
|
+
the list, because that is where the reader is looking when the
|
|
307
|
+
filter disappoints them. */
|
|
308
|
+
var dead = needle!=='' && vis.length===0;
|
|
309
|
+
bridge.hidden = !dead;
|
|
310
|
+
if(dead){
|
|
311
|
+
bridgeMsg.innerHTML='No bundle matches \u201c<b></b>\u201d';
|
|
312
|
+
bridgeMsg.querySelector('b').textContent=q.value.trim();
|
|
313
|
+
}
|
|
314
|
+
/* A new query invalidates the last search — the hits below belong to
|
|
315
|
+
a word nobody is looking at any more. */
|
|
316
|
+
if(needle!==searched){hits.hidden=true;hitList.innerHTML='';hitNote.textContent='';searched=null;}
|
|
317
|
+
count(vis.length);
|
|
318
|
+
mark();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* The dead end, and the way through it. A bundle list cannot answer
|
|
322
|
+
"where is the thing about decay?" — but the hub can, because it
|
|
323
|
+
searches inside every bundle it hosts. So a query that matches no
|
|
324
|
+
*bundle* is offered the search that does match, rather than being
|
|
325
|
+
told no twice. Same escalation the graph page's search box makes
|
|
326
|
+
when its own bundle comes up empty. */
|
|
327
|
+
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){
|
|
328
|
+
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
|
329
|
+
function hitRow(c){
|
|
330
|
+
var href=mount+'/'+encodeURIComponent(c.slug)+'/?select='+encodeURIComponent(c.id);
|
|
331
|
+
return '<li class="brow"><a href="'+esc(href)+'">'+
|
|
332
|
+
'<span class="b-id"><span class="b-title">'+esc(c.title||c.id)+'</span>'+
|
|
333
|
+
(c.type?'<span class="dbadge">'+esc(c.type)+'</span>':'')+
|
|
334
|
+
'<span class="slug">@'+esc(c.slug)+'</span></span>'+
|
|
335
|
+
(c.snippet?'<span class="b-dir hit-s">'+esc(c.snippet)+'</span>':'')+
|
|
336
|
+
'</a></li>';
|
|
337
|
+
}
|
|
338
|
+
function search(){
|
|
339
|
+
var term=q.value.trim();
|
|
340
|
+
if(!term||!endpoint||searched===term.toLowerCase()) return;
|
|
341
|
+
searched=term.toLowerCase();
|
|
342
|
+
hits.hidden=false;hitList.innerHTML='';hitNote.textContent='searching every bundle…';
|
|
343
|
+
var req=new XMLHttpRequest();
|
|
344
|
+
req.open('GET',endpoint+'?q='+encodeURIComponent(term));
|
|
345
|
+
req.onload=function(){
|
|
346
|
+
if(q.value.trim().toLowerCase()!==searched) return;
|
|
347
|
+
var data;
|
|
348
|
+
try{data=JSON.parse(req.responseText);}catch(e){data=null;}
|
|
349
|
+
var found=(data&&data.results)||[];
|
|
350
|
+
hitList.innerHTML=found.map(hitRow).join('');
|
|
351
|
+
hitNote.textContent = found.length ? (data.truncated?'showing '+found.length+' of '+data.total+' — narrow the search':'')
|
|
352
|
+
: 'No concept matches either.';
|
|
353
|
+
mark();
|
|
354
|
+
};
|
|
355
|
+
req.onerror=function(){hitNote.textContent='search is unavailable right now';};
|
|
356
|
+
req.send();
|
|
357
|
+
}
|
|
358
|
+
goBtn.addEventListener('click',function(){search();q.focus();});
|
|
359
|
+
clearBtn.addEventListener('click',function(){q.value='';filter();q.focus();});
|
|
360
|
+
|
|
361
|
+
q.addEventListener('input',filter);
|
|
362
|
+
/* The mark means "⏎ opens this", so it is only true while the caret is
|
|
363
|
+
in the box. Tab away and ⏎ belongs to the focus ring instead. */
|
|
364
|
+
q.addEventListener('blur',unmark);
|
|
365
|
+
q.addEventListener('focus',mark);
|
|
366
|
+
q.addEventListener('keydown',function(ev){
|
|
367
|
+
if(ev.key==='Enter'){
|
|
368
|
+
ev.preventDefault();
|
|
369
|
+
var t=target();
|
|
370
|
+
/* Nothing to open means the list is the wrong place to be looking,
|
|
371
|
+
so ⏎ escalates rather than doing nothing. */
|
|
372
|
+
if(t) location.href=t.href;
|
|
373
|
+
else if(!bridge.hidden) search();
|
|
374
|
+
}
|
|
375
|
+
else if(ev.key==='Escape'){q.value='';filter();}
|
|
376
|
+
});
|
|
377
|
+
/* `/` reaches the box from anywhere on the page, the same key the graph
|
|
378
|
+
page binds — a reader who tabbed into the list and changed their
|
|
379
|
+
mind should not have to tab back out of it. */
|
|
380
|
+
addEventListener('keydown',function(ev){
|
|
381
|
+
if(ev.key!=='/'||ev.target===q||ev.metaKey||ev.ctrlKey||ev.altKey) return;
|
|
382
|
+
if(ev.target.matches&&ev.target.matches('input,select,textarea')) return;
|
|
383
|
+
ev.preventDefault();q.focus();q.select();
|
|
384
|
+
});
|
|
385
|
+
q.focus();
|
|
386
|
+
mark();
|
|
387
|
+
})();
|
|
388
|
+
JS
|
|
389
|
+
|
|
390
|
+
# +path+ is what was asked for, +slug+ the bundle name inside it (nil
|
|
391
|
+
# when the path was not under the mount at all), +rows+ the hosted
|
|
392
|
+
# bundles in the shape the manager already describes them, +base+ the
|
|
393
|
+
# prefix a host mounted the hub under, +mount+ the /b prefix.
|
|
394
|
+
def initialize(path, slug, rows, base, mount)
|
|
395
|
+
@path = path
|
|
396
|
+
@slug = slug
|
|
397
|
+
@rows = rows
|
|
398
|
+
@base = base
|
|
399
|
+
@mount = mount
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.page(*args)
|
|
403
|
+
new(*args).page
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def page
|
|
407
|
+
shell(head + guess + listing)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
private
|
|
411
|
+
|
|
412
|
+
# The heading is the path, not the verdict. A reader arrives already
|
|
413
|
+
# knowing they are lost, so "Not found" is the small word and what they
|
|
414
|
+
# actually asked for is the large one — set in mono, where a dropped
|
|
415
|
+
# slash or a truncated slug is legible as a shape rather than as prose.
|
|
416
|
+
def head
|
|
417
|
+
%(<p class="eyebrow">Not found</p>) +
|
|
418
|
+
%(<h1>#{escape(@path)}</h1>) +
|
|
419
|
+
fact
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# One line, and only where it adds something the heading did not. When a
|
|
423
|
+
# missing separator explains the whole trip, say that; otherwise teach
|
|
424
|
+
# the shape, which is what someone who typed the path by hand needs.
|
|
425
|
+
def fact
|
|
426
|
+
if hosted?(segment)
|
|
427
|
+
return %(<p class="fact">That bundle is served at ) +
|
|
428
|
+
%(<code>#{escape(@mount)}/#{escape(segment)}/</code> — the <code>#{escape(@mount)}/</code> is missing.</p>)
|
|
429
|
+
end
|
|
430
|
+
if dropped_slash
|
|
431
|
+
return %(<p class="fact">That looks like <code>#{escape(@mount)}/#{escape(dropped_slash)}/</code> ) +
|
|
432
|
+
%(with the slash after <code>#{escape(@mount)}</code> dropped.</p>)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
%(<p class="fact">Bundles are served at <code>#{escape(@mount)}/<name>/</code>.</p>)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The guess that saves the trip, or nothing at all. A suggestion nobody
|
|
439
|
+
# can use is worse than none: it sends a reader to a second wrong page
|
|
440
|
+
# and spends the trust the first one already dented.
|
|
441
|
+
#
|
|
442
|
+
# It is a row rather than a sentence because a sentence asks a reader to
|
|
443
|
+
# read it, parse it, and then go aiming; a row is already the thing they
|
|
444
|
+
# were looking for, and ⏎ is already pointed at it.
|
|
445
|
+
def guess
|
|
446
|
+
best = nearest_row
|
|
447
|
+
return "" unless best
|
|
448
|
+
|
|
449
|
+
%(<div class="miss"><p class="lbl">Closest match</p>) +
|
|
450
|
+
row_html(best, active: true) +
|
|
451
|
+
%(</div>)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def nearest_row
|
|
455
|
+
@guess = candidates.map { |c| nearest(c) }.compact.first unless defined?(@guess)
|
|
456
|
+
@guess
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# What to measure the hosted slugs against. Normally the slug the router
|
|
460
|
+
# parsed out; when there was none, the path's own first segment — because
|
|
461
|
+
# the commonest way a hand-typed URL fails is the separator, and
|
|
462
|
+
# `/bnotes/` carries the answer in plain sight while the router, which
|
|
463
|
+
# only ever looks under the mount, sees nothing at all.
|
|
464
|
+
#
|
|
465
|
+
# The whole segment is tried first, so a bundle genuinely named `borders`
|
|
466
|
+
# beats `orders` reached by eating the mount letter.
|
|
467
|
+
def candidates
|
|
468
|
+
return [ @slug.to_s ] unless OKF.blank?(@slug)
|
|
469
|
+
|
|
470
|
+
return [] if segment.empty?
|
|
471
|
+
|
|
472
|
+
remainder.nil? ? [ segment ] : [ segment, remainder ]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def segment
|
|
476
|
+
@segment ||= @path.to_s.sub(%r{\A/+}, "").sub(%r{/.*\z}m, "")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# The first path segment with the mount's own letters taken off the
|
|
480
|
+
# front, or nil where that is not what the path looks like.
|
|
481
|
+
def remainder
|
|
482
|
+
bare = @mount.to_s.sub(%r{\A/+}, "")
|
|
483
|
+
return nil if bare.empty? || !segment.start_with?(bare) || segment.length <= bare.length
|
|
484
|
+
|
|
485
|
+
segment[bare.length..-1]
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# A dropped separator is worth naming outright, but only on evidence that
|
|
489
|
+
# leaves no room for a second reading: the remainder has to be a hosted
|
|
490
|
+
# slug *exactly*, and the whole segment must not be one — a bundle really
|
|
491
|
+
# called `borders` is reached by adding `/b/`, not by moving a slash.
|
|
492
|
+
# Everything short of that gets the general sentence, which points at the
|
|
493
|
+
# same fix without claiming to know what happened.
|
|
494
|
+
def dropped_slash
|
|
495
|
+
return nil if hosted?(segment)
|
|
496
|
+
|
|
497
|
+
hosted?(remainder) ? remainder : nil
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def hosted?(name)
|
|
501
|
+
!OKF.blank?(name) && @rows.any? { |row| row[:slug].to_s == name }
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Edit distance, with a shortcut for a shared prefix. Truncation is the
|
|
505
|
+
# commonest way a slug comes out wrong — a URL copied short, a tab
|
|
506
|
+
# completion abandoned — and plain Levenshtein scores `ord` three edits
|
|
507
|
+
# from `orders`, which is far. Treating a prefix as one edit is what
|
|
508
|
+
# makes the guess land on the case that actually happens.
|
|
509
|
+
#
|
|
510
|
+
# The threshold scales with length so a short slug cannot match
|
|
511
|
+
# everything (`a` is one edit from `b`) and a long one is not held to an
|
|
512
|
+
# absolute that means nothing at its size.
|
|
513
|
+
def nearest(asked)
|
|
514
|
+
return nil if OKF.blank?(asked)
|
|
515
|
+
|
|
516
|
+
lower = asked.downcase
|
|
517
|
+
best = nil
|
|
518
|
+
score = nil
|
|
519
|
+
@rows.each do |row|
|
|
520
|
+
candidate = row[:slug].to_s.downcase
|
|
521
|
+
edits = distance(lower, candidate)
|
|
522
|
+
edits = 1 if edits > 1 && (candidate.start_with?(lower) || lower.start_with?(candidate))
|
|
523
|
+
next if score && edits >= score
|
|
524
|
+
|
|
525
|
+
best = row
|
|
526
|
+
score = edits
|
|
527
|
+
end
|
|
528
|
+
return nil unless best
|
|
529
|
+
|
|
530
|
+
score <= threshold(lower, best[:slug].to_s) ? best : nil
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def threshold(asked, candidate)
|
|
534
|
+
[ 3, ([ asked.length, candidate.length ].max / 3.0).ceil ].min
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Levenshtein over one rolling row — the whole matrix is never needed,
|
|
538
|
+
# only the previous line of it.
|
|
539
|
+
def distance(from, to)
|
|
540
|
+
row = (0..to.length).to_a
|
|
541
|
+
from.length.times do |i|
|
|
542
|
+
prev = row[0]
|
|
543
|
+
row[0] = i + 1
|
|
544
|
+
to.length.times do |j|
|
|
545
|
+
carried = row[j + 1]
|
|
546
|
+
cost = from[i] == to[j] ? 0 : 1
|
|
547
|
+
row[j + 1] = [ row[j + 1] + 1, row[j] + 1, prev + cost ].min
|
|
548
|
+
prev = carried
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
row[to.length]
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# A hub with nothing registered is a different failure from a slug that
|
|
555
|
+
# matched nothing, and saying "no bundle matches" there would blame the
|
|
556
|
+
# reader's query for the server's own empty list.
|
|
557
|
+
def listing
|
|
558
|
+
if @rows.empty?
|
|
559
|
+
return %(<p class="lbl">Nothing to open</p>) +
|
|
560
|
+
%(<p id="bnone">No bundles are registered on this server. ) +
|
|
561
|
+
%(Register one with <code>okf registry set <dir></code>, then restart <code>okf server</code>.</p>)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
%(<p class="lbl">Bundles on this server</p><ul id="blist">#{@rows.map { |row| row_html(row) }.join}</ul>) +
|
|
565
|
+
%(<div id="hits" hidden><p class="lbl">Concepts, across every bundle</p>) +
|
|
566
|
+
%(<ul id="hitlist"></ul><p id="hitnote"></p></div>)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# One row, used twice: in the list, and as the near miss above it. The
|
|
570
|
+
# guess being the *same object* as a list entry is the whole point —
|
|
571
|
+
# whatever a reader learns to read here reads the same there.
|
|
572
|
+
def row_html(row, active: false)
|
|
573
|
+
badge = row[:default] ? %(<span class="dbadge">default</span>) : ""
|
|
574
|
+
hay = "#{row[:slug]} #{row[:title]} #{row[:dir]}".downcase
|
|
575
|
+
here = active ? %( class="active") : ""
|
|
576
|
+
%(<li class="brow" data-health="#{escape(row[:health])}" data-hay="#{escape(hay)}">) +
|
|
577
|
+
%(<a href="#{link(row[:slug])}"#{here}>) +
|
|
578
|
+
%(<span class="b-id"><span class="b-title">#{escape(row[:title])}</span>) +
|
|
579
|
+
%(<span class="slug">@#{escape(row[:slug])}</span>#{badge}</span>) +
|
|
580
|
+
%(<span class="b-dir" title="#{escape(row[:dir])}"><bdi>#{escape(row[:dir])}</bdi></span>) +
|
|
581
|
+
%(<span class="b-meta"><span class="b-cnt">#{escape(count_word(row[:count]))}</span>) +
|
|
582
|
+
%(<span class="b-health">#{escape(row[:word])}</span></span>) +
|
|
583
|
+
%(</a></li>)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def count_word(count)
|
|
587
|
+
return "—" if count.nil?
|
|
588
|
+
|
|
589
|
+
concepts(count)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def concepts(count)
|
|
593
|
+
"#{count} #{count == 1 ? "concept" : "concepts"}"
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Absolute, prefix-carrying: this page is reached at paths of every
|
|
597
|
+
# depth (/b/ghost/, /elsewhere), so a relative link would resolve
|
|
598
|
+
# somewhere different depending on how the reader got here.
|
|
599
|
+
def link(slug)
|
|
600
|
+
"#{escape(@base)}#{@mount}/#{escape(slug)}/"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# An empty hub gets no search box: a control that filters nothing only
|
|
604
|
+
# wastes the one action a reader has left. The count starts as the total
|
|
605
|
+
# and the script swaps in n/total the moment it filters.
|
|
606
|
+
def shell(body)
|
|
607
|
+
search = @rows.empty? ? "" : search_box(@rows.length)
|
|
608
|
+
home = %(href="#{escape(@base)}#{@mount}/" title="All bundles" aria-label="All bundles")
|
|
609
|
+
<<~HTML
|
|
610
|
+
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
611
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
612
|
+
<meta name="color-scheme" content="dark light">
|
|
613
|
+
<title>OKF · not found</title>
|
|
614
|
+
<script>/* Resolve the theme before first paint, so there is no flash. */
|
|
615
|
+
(function(){try{var t=localStorage.getItem('okf-theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
|
|
616
|
+
<style>#{STYLE}</style>
|
|
617
|
+
</head><body>
|
|
618
|
+
<div id="app">
|
|
619
|
+
<nav id="rail" aria-label="Bundles">
|
|
620
|
+
<a class="rail-brand" #{home}>#{MARK}</a>
|
|
621
|
+
<div class="rail-sp"></div>
|
|
622
|
+
<div class="rail-tools">#{theme_button}</div>
|
|
623
|
+
</nav>
|
|
624
|
+
<div id="main">
|
|
625
|
+
<header id="topbar">
|
|
626
|
+
<a class="bar-brand" #{home}>#{MARK}</a>
|
|
627
|
+
#{search}
|
|
628
|
+
</header>
|
|
629
|
+
<div id="views"><div id="col">#{body}</div></div>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
<script>#{SCRIPT}</script>
|
|
633
|
+
</body></html>
|
|
634
|
+
HTML
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def search_box(total)
|
|
638
|
+
%(<label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>) +
|
|
639
|
+
%(<input id="q" class="field" type="text" placeholder="find a bundle…" autofocus ) +
|
|
640
|
+
%(autocomplete="off" spellcheck="false" aria-label="Find a bundle" ) +
|
|
641
|
+
%(data-search="#{escape(@base)}/search" data-mount="#{escape(@base)}#{@mount}">) +
|
|
642
|
+
%(<span class="s-cnt" id="bar-count">#{total}</span>) +
|
|
643
|
+
%(<div id="s-bridge" class="s-bridge" role="status" hidden><div class="sb-msg"></div>) +
|
|
644
|
+
%(<div class="sb-row">) +
|
|
645
|
+
%(<button type="button" class="sb-act primary" id="sb-go">Search every bundle <kbd>⏎</kbd></button>) +
|
|
646
|
+
%(<button type="button" class="sb-act" id="sb-clear">Clear <kbd>esc</kbd></button>) +
|
|
647
|
+
%(</div></div></label>)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def theme_button
|
|
651
|
+
%(<button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">) +
|
|
652
|
+
%(<svg class="moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg>) +
|
|
653
|
+
%(<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/>) +
|
|
654
|
+
%(<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.5 1.5M17.3 17.3l1.5 1.5M18.8 5.2l-1.5 1.5M6.7 17.3l-1.5 1.5"/></svg></button>)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def escape(str)
|
|
658
|
+
Rack::Utils.escape_html(str.to_s)
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|