okf 1.7.0 → 1.8.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.
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OKF
6
+ # A persistent, ordered registry of bundle references — the kernel behind the
7
+ # multi-bundle server. It is a plain JSON file (no database) under $OKF_HOME
8
+ # (default ~/.okf), so `okf registry set`/`del` and a later bare `okf server`
9
+ # share one on-disk list. Part of the shell — it reads and writes a file.
10
+ #
11
+ # registry = OKF::Registry.load
12
+ # registry.add("docs") # persists, returns the Entry
13
+ # registry.default = "docs" # moves docs to the front
14
+ # registry.rename("docs", "handbook") # new slug, same path
15
+ # registry.default # => the first Entry
16
+ # registry.listing # => [{ slug:, title:, path:, default: }]
17
+ #
18
+ # **The first entry is the default** — the bundle a bare `okf server` opens at
19
+ # `/`. That is position, not a stored slug: a slug would be a foreign key into
20
+ # this same list, and every operation would owe it referential integrity —
21
+ # carry it through a rename, re-point it after an add --as, clear it on a
22
+ # remove, and survive it dangling. Order is state the registry already keeps,
23
+ # so `default=` just moves the entry to the front and there is nothing left to
24
+ # maintain or to dangle.
25
+ #
26
+ # On disk: { "bundles" => [ { "slug" => …, "path" => absolute dir,
27
+ # "title" => label } ] }, the first row being the default. A bare array (the
28
+ # original shape) still reads.
29
+ class Registry
30
+ # One registered bundle: a unique +slug+, the absolute +path+ on disk, and a
31
+ # human-readable +title+ ("parent/dir").
32
+ Entry = Struct.new(:slug, :path, :title)
33
+
34
+ HOME_ENV = "OKF_HOME"
35
+ DEFAULT_HOME = "~/.okf"
36
+
37
+ # Slugs the ref grammar has already spoken for. `@all` means every registered
38
+ # bundle, so a bundle slugged "all" could never be named — reserve it here,
39
+ # where both slug paths pass, rather than let one register and then be
40
+ # unreachable.
41
+ RESERVED_SLUGS = %w[all].freeze
42
+
43
+ class << self
44
+ # The registry file: $OKF_HOME/registry.json, $OKF_HOME defaulting to ~/.okf.
45
+ # The env var is the only lever the CLI offers; +home+ overrides it for an
46
+ # embedding app (and the tests), which should not have to mutate a
47
+ # process-global to say which registry it means. An empty +home+ or env var
48
+ # counts as unset — expand_path("") would silently plant the registry in
49
+ # the current directory.
50
+ def path(home: nil)
51
+ env = ENV.fetch(HOME_ENV, nil)
52
+ home = nil if home.nil? || home.to_s.empty?
53
+ base = home || (env.nil? || env.empty? ? DEFAULT_HOME : env)
54
+ File.join(expand(base), "registry.json")
55
+ end
56
+
57
+ # File.expand_path raises ArgumentError on a "~nosuchuser" (or bare "~"
58
+ # with no HOME) — a bad *argument*, which the CLI must report as a usage
59
+ # error rather than let escape as a backtrace and an exit code that means
60
+ # "failing bundle".
61
+ def expand(base)
62
+ File.expand_path(base)
63
+ rescue ArgumentError => e
64
+ raise OKF::Error, "cannot expand #{base}: #{e.message}"
65
+ end
66
+
67
+ def load(home: nil)
68
+ new(path(home: home))
69
+ end
70
+
71
+ # Normalize +base+ to a url-safe slug (lowercase, dashes) — "" when nothing
72
+ # survives. This is the form a *lookup* wants: "@***" normalizes to nothing
73
+ # and must stay nothing, so it fails as a bad ref instead of resolving to
74
+ # whatever #slugify's placeholder happens to name.
75
+ def normalize(base)
76
+ base.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
77
+ end
78
+
79
+ # +base+ normalized, with a placeholder when nothing survives — for
80
+ # *minting* a slug from a directory basename, where some name must come
81
+ # out. Shared with the server's ephemeral (unregistered) bundles so both
82
+ # slug the same way.
83
+ def slugify(base)
84
+ slug = normalize(base)
85
+ slug.empty? ? "bundle" : slug
86
+ end
87
+
88
+ # Does this argument name a location rather than a slug? A separator settles
89
+ # it: #normalize maps one to a dash, so no slug can contain one. The reading
90
+ # matters because #remove takes either — and a path that matched no entry
91
+ # must not fall through to a *slug* lookup, where "./notes" strips to
92
+ # "notes" and deletes an entry pointing somewhere else entirely, reporting
93
+ # success. This is the line between the two readings.
94
+ def path_shaped?(arg)
95
+ arg.to_s.include?(File::SEPARATOR)
96
+ end
97
+
98
+ # +base+ slugified, then suffixed (-2, -3, …) until it avoids every slug in
99
+ # +taken+. Reserving is the caller's business, not this helper's: the
100
+ # ephemeral hub mints through here too, and it has no registry and no
101
+ # @refs, so a name reserved for the ref grammar would suffix it to /b/all-2/
102
+ # against a /b/all/ that does not exist. #unique_slug adds the reserved
103
+ # names because the registry is where they mean something.
104
+ def dedupe(base, taken)
105
+ slug = slugify(base)
106
+ return slug unless taken.include?(slug)
107
+
108
+ n = 2
109
+ n += 1 while taken.include?("#{slug}-#{n}")
110
+ "#{slug}-#{n}"
111
+ end
112
+ end
113
+
114
+ include Enumerable
115
+
116
+ attr_reader :path
117
+
118
+ def initialize(path)
119
+ @path = path
120
+ @entries = []
121
+ read
122
+ end
123
+
124
+ def each(&block)
125
+ @entries.each(&block)
126
+ end
127
+
128
+ def size
129
+ @entries.size
130
+ end
131
+
132
+ def empty?
133
+ @entries.empty?
134
+ end
135
+
136
+ def slugs
137
+ @entries.map(&:slug)
138
+ end
139
+
140
+ def get(slug)
141
+ @entries.find { |entry| entry.slug == slug }
142
+ end
143
+
144
+ # The default bundle a bare `okf server` selects: the first entry still on
145
+ # disk. Position decides it, but a position the hub cannot serve decides
146
+ # nothing — it drops a vanished directory rather than serving a hole, so the
147
+ # default has to skip the same ones or `registry list` would star a bundle
148
+ # `/` never opens. Falling back to the first entry when *every* one has
149
+ # vanished keeps a bare `@` failing with "points to <path>, which is not a
150
+ # directory" instead of the much worse "not a registered bundle". nil only
151
+ # when nothing is registered.
152
+ def default
153
+ @entries.find { |entry| File.directory?(entry.path) } || @entries.first
154
+ end
155
+
156
+ # Choose which bundle `/` opens, by moving that entry to the front. Persists;
157
+ # raises on an unknown slug. The ask is normalized the way registration
158
+ # normalized it, so the name the user typed at --as is the name that resolves
159
+ # here.
160
+ #
161
+ # A directory that is gone is refused, exactly as #add refuses to register
162
+ # one: both are explicit asks, and #default skips a vanished entry, so
163
+ # allowing the move would answer `default bundle → <some other slug>` to
164
+ # someone who named this one.
165
+ def default=(slug)
166
+ entry = get(self.class.normalize(slug))
167
+ raise OKF::Error, "no such bundle: #{slug}" unless entry
168
+ unless File.directory?(entry.path)
169
+ raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
170
+ "(okf registry del #{entry.slug}, or restore it)"
171
+ end
172
+
173
+ @entries.delete(entry)
174
+ @entries.unshift(entry)
175
+ write
176
+ end
177
+
178
+ # Give the bundle at +old_slug+ a new slug (its mount path and switcher name).
179
+ # The new name is slugified; a collision with another entry raises rather than
180
+ # silently suffixing — a rename is explicit. Position is untouched, so a
181
+ # renamed default stays the default with no bookkeeping.
182
+ def rename(old_slug, new_slug)
183
+ entry = get(self.class.normalize(old_slug))
184
+ raise OKF::Error, "no such bundle: #{old_slug}" unless entry
185
+
186
+ slug = explicit_slug(new_slug, entry)
187
+ entry.slug = slug
188
+ write
189
+ entry
190
+ end
191
+
192
+ # One row per bundle for the CLI list: +dir+ is the on-disk directory, +mount+
193
+ # the server path, +default+ true for the first row, +missing+ true when the
194
+ # registered directory no longer exists on disk. +default+ stays in the row
195
+ # even though it is now derivable from position — a consumer reading the JSON
196
+ # should not have to know the rule to find the bundle `/` opens.
197
+ def listing
198
+ chosen = default
199
+ @entries.map do |entry|
200
+ { slug: entry.slug, title: entry.title, dir: entry.path, mount: "/b/#{entry.slug}/",
201
+ default: entry.equal?(chosen), missing: !File.directory?(entry.path) }
202
+ end
203
+ end
204
+
205
+ # Register +dir+ (must be a readable bundle directory). Re-registering the same
206
+ # path refreshes its title in place (and its slug when +as+ is given). A
207
+ # basename-derived slug is deduped with a suffix; an explicit +as+ raises on
208
+ # collision instead — the same "explicit is explicit" rule as #rename.
209
+ # +default: true+ moves it to the front. Persists, then returns the entry.
210
+ def add(dir, as: nil, default: false)
211
+ root = self.class.expand(dir.to_s)
212
+ raise OKF::Error, "not a directory: #{dir}" unless File.directory?(root)
213
+
214
+ # The label is path arithmetic; Folder.load would parse every markdown
215
+ # file in the bundle to hand back its own basename.
216
+ title = Bundle::Folder.label(root)
217
+ entry = @entries.find { |candidate| candidate.path == root }
218
+ if entry
219
+ entry.title = title
220
+ entry.slug = explicit_slug(as, entry) if as
221
+ else
222
+ slug = as ? explicit_slug(as, nil) : unique_slug(File.basename(root), nil)
223
+ entry = Entry.new(slug, root, title)
224
+ @entries << entry
225
+ end
226
+ if default
227
+ @entries.delete(entry)
228
+ @entries.unshift(entry)
229
+ end
230
+ write
231
+ entry
232
+ end
233
+
234
+ # Remove the entry named by +slug+ (or whose path matches). Returns the removed
235
+ # entry, or nil when nothing matched. Removing the default needs no cleanup —
236
+ # the next entry is first, and so is the default. Persists on change.
237
+ def remove(slug)
238
+ # Slug-or-dir, so the normalized reading comes *last*: "./docs" must mean
239
+ # the directory while one is registered under that path, and only fall
240
+ # back to naming the "docs" slug when no path matches.
241
+ target = get(slug) ||
242
+ @entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
243
+ (self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
244
+ return nil unless target
245
+
246
+ @entries.delete(target)
247
+ write
248
+ target
249
+ end
250
+
251
+ private
252
+
253
+ # A basename-derived slug: silently deduped with a numeric suffix, around the
254
+ # reserved names as well as the taken ones — so a directory named all/
255
+ # registers as "all-2". This is the minting path, where the gem invents a name
256
+ # and a suffix is expected; #explicit_slug refuses instead, because there the
257
+ # name is the user's.
258
+ def unique_slug(base, skip)
259
+ taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
260
+ self.class.dedupe(base, taken + RESERVED_SLUGS)
261
+ end
262
+
263
+ # An explicitly requested slug (--as, rename): normalized, and a collision
264
+ # with another entry is an error, never a silent suffix. Nothing surviving
265
+ # normalization is an error too — the same rule as a collision, since
266
+ # answering `--as "***"` with the placeholder slug would substitute a name
267
+ # the user did not choose.
268
+ def explicit_slug(base, skip)
269
+ slug = self.class.normalize(base)
270
+ raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
271
+
272
+ # Reserved names are refused, never suffixed: substituting "all-2" for the
273
+ # "all" they asked for is exactly the name-they-did-not-choose the rule
274
+ # below forbids.
275
+ if RESERVED_SLUGS.include?(slug)
276
+ raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
277
+ end
278
+
279
+ taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
280
+ # Refusing is the "never substitute a name you chose" rule, but a refusal
281
+ # with no way forward is a dead end: the slug is spoken for by another
282
+ # entry, so say which move frees it.
283
+ raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if taken.include?(slug)
284
+
285
+ slug
286
+ end
287
+
288
+ def read
289
+ return unless File.exist?(@path)
290
+
291
+ data = JSON.parse(File.read(@path, encoding: "UTF-8"))
292
+ rows = data.is_a?(Hash) ? Array(data["bundles"]) : Array(data) # bare array: the original shape
293
+ @entries = rows.map { |row| entry_from(row) }
294
+ normalize_slugs
295
+ rescue JSON::ParserError => e
296
+ malformed("#{e.message} (fix or delete the file)")
297
+ rescue SystemCallError => e
298
+ malformed(e.message)
299
+ end
300
+
301
+ # One row to an Entry, with the shape checked. Valid JSON is not a valid
302
+ # registry: the parse error above tells the user to fix the file by hand,
303
+ # which invites a row with no "path" — that must fail here as a usage error,
304
+ # not survive to crash a File.directory? call three frames away.
305
+ def entry_from(row)
306
+ unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["path"].is_a?(String) && !row["path"].empty?
307
+ malformed('every entry needs a "slug" and a "path" (fix or delete the file)')
308
+ end
309
+ Entry.new(row["slug"], row["path"], row["title"] || File.basename(row["path"]))
310
+ end
311
+
312
+ # Slugs enter this list three ways — minted from a basename, asked for with
313
+ # --as, and read from this file — and the first two normalize. The third did
314
+ # not, and that asymmetry is the whole bug: the file could hold a name the
315
+ # listing prints and nothing else can reach. `@my-docs` misses "My Docs", and
316
+ # so do #rename and #default=, which look it up through the very
317
+ # normalization the read skipped — so the two verbs that could fix the entry
318
+ # are the two that cannot see it, leaving hand-editing JSON as the only way
319
+ # out. Reserved names are the same story with a different cause, so they take
320
+ # the same cure rather than a second one.
321
+ #
322
+ # A slug registration would have produced unchanged is left exactly as it is —
323
+ # including one already carrying a suffix — so repairing a sick entry never
324
+ # renames a healthy one. Everything else is minted around the names the other
325
+ # entries hold, through the same call registration makes. The next write
326
+ # persists it.
327
+ def normalize_slugs
328
+ @entries.each do |entry|
329
+ next if usable_slug?(entry.slug)
330
+
331
+ entry.slug = unique_slug(entry.slug, entry)
332
+ end
333
+ end
334
+
335
+ # A stored slug that registration would have handed back untouched:
336
+ # normalized, non-empty, and not a name the ref grammar has taken.
337
+ def usable_slug?(slug)
338
+ !slug.empty? && slug == self.class.normalize(slug) && !RESERVED_SLUGS.include?(slug)
339
+ end
340
+
341
+ def malformed(detail)
342
+ raise OKF::Error, "malformed registry at #{@path}: #{detail}"
343
+ end
344
+
345
+ # Write-to-temp then rename, so a concurrent reader (another verb, a booting
346
+ # server) never sees a torn file — the same promotion the bundle Writer uses.
347
+ # Two racing writers stay last-writer-wins; the registry is a per-user file.
348
+ def write
349
+ FileUtils.mkdir_p(File.dirname(@path))
350
+ rows = @entries.map { |entry| { "slug" => entry.slug, "path" => entry.path, "title" => entry.title } }
351
+ payload = { "bundles" => rows }
352
+ tmp = "#{@path}.tmp-#{Process.pid}"
353
+ begin
354
+ File.write(tmp, JSON.pretty_generate(payload) + "\n")
355
+ File.rename(tmp, @path)
356
+ rescue StandardError
357
+ # A failed write must not leave its scratch file behind: the registry
358
+ # lives in the user's $OKF_HOME, and litter there outlives the error.
359
+ FileUtils.rm_f(tmp)
360
+ raise
361
+ end
362
+ rescue SystemCallError => e
363
+ # #read already turns an errno into an OKF::Error, and every registry verb
364
+ # rescues exactly that — so letting one out of #write hands the user a
365
+ # backtrace under exit 1, a code the CLI spends on "non-conformant bundle".
366
+ # An unwritable $OKF_HOME is a usage error, and it says which file and why.
367
+ raise OKF::Error, "cannot write registry at #{@path}: #{e.message}"
368
+ end
369
+ end
370
+ end
@@ -31,11 +31,18 @@ module OKF
31
31
  # dir, content} ] } (JSON; content read live from disk,
32
32
  # like a body — the log is the file that changes most)
33
33
  class App
34
- def initialize(folder, title: nil, link: nil, layout: "cose")
34
+ # +siblings+/+self_slug+/+hub_path+ are set only when this app is hosted under
35
+ # a hub (OKF::Server::Hub): the other bundles the in-page switcher offers, this
36
+ # bundle's own mount slug, and the hub root. They stay nil for a standalone
37
+ # server and for `okf render`, so a static file never advertises a switcher.
38
+ def initialize(folder, title: nil, link: nil, layout: "cose", siblings: nil, self_slug: nil, hub_path: nil)
35
39
  @folder = folder
36
40
  @title = title
37
41
  @link = link
38
42
  @layout = layout
43
+ @siblings = siblings
44
+ @self_slug = self_slug
45
+ @hub_path = hub_path
39
46
  end
40
47
 
41
48
  def call(env)
@@ -62,6 +69,11 @@ module OKF
62
69
  Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout, embed: embed_payload).render
63
70
  end
64
71
 
72
+ # The 404 both this app and the Hub answer with, so the two cannot drift.
73
+ def self.not_found
74
+ [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
75
+ end
76
+
65
77
  private
66
78
 
67
79
  # The minimal graph snapshot taken at boot — drives the page and the indexes.
@@ -100,7 +112,10 @@ module OKF
100
112
  end
101
113
 
102
114
  def page
103
- @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
115
+ @page ||= Graph.new(
116
+ graph, title: @title || @folder.name, link: @link, layout: @layout,
117
+ siblings: @siblings, self_slug: @self_slug, hub_path: @hub_path
118
+ ).render
104
119
  end
105
120
 
106
121
  # Everything the on-demand endpoints would serve, baked for render mode. The
@@ -168,11 +183,11 @@ module OKF
168
183
  end
169
184
 
170
185
  def not_found
171
- [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
186
+ self.class.not_found
172
187
  end
173
188
 
174
189
  def html_escape(str)
175
- str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
190
+ Rack::Utils.escape_html(str.to_s)
176
191
  end
177
192
  end
178
193
  end