okf 1.0.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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/capabilities/agent-skill.md +46 -0
  3. data/.okf/capabilities/graph-server.md +60 -0
  4. data/.okf/capabilities/index.md +20 -0
  5. data/.okf/capabilities/library-api.md +67 -0
  6. data/.okf/capabilities/linter.md +49 -0
  7. data/.okf/capabilities/read-views.md +84 -0
  8. data/.okf/capabilities/validator.md +40 -0
  9. data/.okf/cli.md +52 -0
  10. data/.okf/design/core-shell-split.md +58 -0
  11. data/.okf/design/index.md +10 -0
  12. data/.okf/design/ruby-floor.md +45 -0
  13. data/.okf/design/runtime-dependencies.md +44 -0
  14. data/.okf/design/server-trust-boundary.md +35 -0
  15. data/.okf/format/citations.md +33 -0
  16. data/.okf/format/cross-links.md +52 -0
  17. data/.okf/format/frontmatter.md +38 -0
  18. data/.okf/format/index.md +9 -0
  19. data/.okf/format/okf-format.md +43 -0
  20. data/.okf/index.md +18 -0
  21. data/.okf/log.md +9 -0
  22. data/.okf/model/bundle.md +38 -0
  23. data/.okf/model/concept.md +44 -0
  24. data/.okf/model/graph.md +44 -0
  25. data/.okf/model/index.md +8 -0
  26. data/.okf/overview.md +66 -0
  27. data/CHANGELOG.md +54 -0
  28. data/CODE_OF_CONDUCT.md +10 -0
  29. data/LICENSE.txt +201 -0
  30. data/NOTICE +10 -0
  31. data/README.md +276 -0
  32. data/exe/okf +6 -0
  33. data/lib/okf/bundle/folder.rb +94 -0
  34. data/lib/okf/bundle/graph.rb +118 -0
  35. data/lib/okf/bundle/linter/report.rb +56 -0
  36. data/lib/okf/bundle/linter.rb +416 -0
  37. data/lib/okf/bundle/reader.rb +60 -0
  38. data/lib/okf/bundle/validator/result.rb +35 -0
  39. data/lib/okf/bundle/validator.rb +131 -0
  40. data/lib/okf/bundle/writer.rb +137 -0
  41. data/lib/okf/bundle.rb +216 -0
  42. data/lib/okf/cli.rb +910 -0
  43. data/lib/okf/concept/file.rb +63 -0
  44. data/lib/okf/concept.rb +101 -0
  45. data/lib/okf/markdown/citations.rb +49 -0
  46. data/lib/okf/markdown/frontmatter.rb +55 -0
  47. data/lib/okf/markdown/links.rb +98 -0
  48. data/lib/okf/path.rb +34 -0
  49. data/lib/okf/server/app.rb +120 -0
  50. data/lib/okf/server/graph.rb +112 -0
  51. data/lib/okf/server/runner.rb +78 -0
  52. data/lib/okf/server/templates/graph.html.erb +803 -0
  53. data/lib/okf/skill/SKILL.md +133 -0
  54. data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
  55. data/lib/okf/skill/reference/SPEC.md +460 -0
  56. data/lib/okf/skill/reference/authoring.md +218 -0
  57. data/lib/okf/skill/reference/cli.md +196 -0
  58. data/lib/okf/skill/templates/concept.md +24 -0
  59. data/lib/okf/skill/templates/index.md +8 -0
  60. data/lib/okf/skill/templates/log.md +6 -0
  61. data/lib/okf/skill/templates/root-index.md +12 -0
  62. data/lib/okf/skill.rb +82 -0
  63. data/lib/okf/version.rb +5 -0
  64. data/lib/okf.rb +55 -0
  65. metadata +142 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Concept
5
+ # A single concept file on disk — the ActiveRecord-style handle over one `.md`.
6
+ # Wraps a pure OKF::Concept with load/save/delete/reload side effects. Part of
7
+ # the shell (it does I/O); the pure Concept knows nothing about it.
8
+ #
9
+ # file = OKF::Concept::File.read(root: "docs", path: "tables/orders.md")
10
+ # file.concept # => OKF::Concept (pure)
11
+ # file.concept.links # interrogate it in memory
12
+ # file.save # write concept.to_markdown back to disk
13
+ # file.delete
14
+ #
15
+ # NOTE: this class is named File, which shadows Ruby's File inside the
16
+ # OKF::Concept namespace — every filesystem call here uses ::File explicitly.
17
+ class File
18
+ attr_reader :root, :path, :concept
19
+
20
+ # Read a concept file from disk into a handle.
21
+ def self.read(root:, path:)
22
+ new(root: root, path: path).reload
23
+ end
24
+
25
+ # Write a concept's markdown to disk under +root+ and return the handle.
26
+ def self.write(root:, concept:)
27
+ new(root: root, path: concept.path, concept: concept).save
28
+ end
29
+
30
+ def initialize(root:, path:, concept: nil)
31
+ @root = ::File.expand_path(root.to_s)
32
+ @path = Path.normalize_relative!(path)
33
+ @concept = concept
34
+ end
35
+
36
+ # Absolute on-disk path, guarded so it cannot escape the bundle root.
37
+ def absolute_path
38
+ Path.join_under!(@root, @path)
39
+ end
40
+
41
+ def save
42
+ raise Error, "no concept to save" if @concept.nil?
43
+
44
+ target = absolute_path
45
+ ::FileUtils.mkdir_p(::File.dirname(target))
46
+ ::File.write(target, @concept.to_markdown, encoding: "UTF-8")
47
+ self
48
+ end
49
+
50
+ def delete
51
+ ::FileUtils.rm_f(absolute_path)
52
+ self
53
+ end
54
+
55
+ def reload
56
+ content = ::File.read(absolute_path, encoding: "UTF-8")
57
+ frontmatter, body = Markdown::Frontmatter.parse(content)
58
+ @concept = Concept.new(path: @path, frontmatter: frontmatter, body: body)
59
+ self
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Concept
5
+ # Reserved filenames (spec §3.1): defined at any level of the hierarchy and
6
+ # never concept documents. The single source of truth for "concept vs
7
+ # reserved" — OKF::Bundle and OKF::Bundle::Validator ask through Concept.reserved?.
8
+ RESERVED_FILENAMES = %w[index.md log.md].freeze
9
+
10
+ # The lint checks that apply to a single concept out of bundle context. The
11
+ # rest (orphan, backlog, duplicate_title, …) need the whole bundle.
12
+ CONCEPT_SCOPED_CHECKS = %i[
13
+ stub missing_title missing_description missing_timestamp
14
+ uncited_external self_link unused_reference_def undefined_reference
15
+ ].freeze
16
+
17
+ # Whether a bundle-relative path names a reserved file rather than a concept.
18
+ # `::File` is explicit: OKF::Concept::File (the on-disk handle) shadows Ruby's
19
+ # File inside this namespace.
20
+ def self.reserved?(path)
21
+ RESERVED_FILENAMES.include?(::File.basename(path))
22
+ end
23
+
24
+ attr_reader :path, :frontmatter, :body
25
+
26
+ def initialize(path:, frontmatter:, body:)
27
+ @path = Path.normalize_relative!(path)
28
+ @frontmatter = Markdown::Frontmatter.stringify_keys(frontmatter)
29
+ @body = body.to_s
30
+ end
31
+
32
+ # Stable node identity. A concept may pin an explicit `id` in its frontmatter
33
+ # (any scalar; blank is ignored); otherwise it is the bundle-relative path with
34
+ # the `.md` suffix stripped — i.e. "folder/filename". Because cross-links are
35
+ # file paths, OKF::Bundle maps a resolved link path back to the concept there
36
+ # and uses *its* id, so a custom id still resolves edges correctly.
37
+ def id
38
+ explicit = frontmatter["id"].to_s.strip
39
+ explicit.empty? ? path.sub(/\.md\z/, "") : explicit
40
+ end
41
+
42
+ def type
43
+ frontmatter["type"]
44
+ end
45
+
46
+ def title
47
+ frontmatter["title"]
48
+ end
49
+
50
+ def description
51
+ frontmatter["description"]
52
+ end
53
+
54
+ # Canonical URI of the underlying asset (spec §4.1), when the concept is bound
55
+ # to one. Absent for concepts describing purely abstract ideas.
56
+ def resource
57
+ frontmatter["resource"]
58
+ end
59
+
60
+ def tags
61
+ frontmatter["tags"]
62
+ end
63
+
64
+ def timestamp
65
+ frontmatter["timestamp"]
66
+ end
67
+
68
+ def reserved?
69
+ self.class.reserved?(path)
70
+ end
71
+
72
+ # ── analysis (pure; the same primitives the graph/linter use) ──
73
+
74
+ # Raw markdown cross-link targets in the body, in document order (spec §5).
75
+ def links
76
+ Markdown::Links.extract(body)
77
+ end
78
+
79
+ # Citation link targets under the `# Citations` section (spec §8), or [].
80
+ def citations
81
+ Markdown::Citations.targets(body)
82
+ end
83
+
84
+ # Body links that point outside the bundle — external URLs and mailto:.
85
+ def external_links
86
+ links.select { |raw| raw.match?(Markdown::Links::SCHEME) || raw.start_with?("mailto:") }
87
+ end
88
+
89
+ # Serialize back to a markdown document (frontmatter + body) — the inverse of
90
+ # Markdown::Frontmatter.parse.
91
+ def to_markdown
92
+ Markdown::Frontmatter.dump(frontmatter, body)
93
+ end
94
+
95
+ # Lint this concept in isolation: the concept-scoped checks only (a lone
96
+ # concept has no bundle to judge reachability, backlog, or duplicate titles).
97
+ def lint(**options)
98
+ Bundle.new(concepts: [ self ]).lint(only: CONCEPT_SCOPED_CHECKS, **options)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Markdown
5
+ # Parses the conventional `# Citations` section of a concept body (spec §8): the
6
+ # block of external sources listed at the bottom of a document. Pure and
7
+ # fence-aware, mirroring Links; it reuses Links.extract to pull the citation link
8
+ # targets so citations and cross-links agree on what counts as a link.
9
+ module Citations
10
+ # A markdown ATX heading line: 1–6 `#`, whitespace, then the heading text.
11
+ HEADING = /\A(\#{1,6})\s+(.*?)\s*\z/.freeze
12
+ CITATIONS = /\ACitations\z/i.freeze
13
+
14
+ module_function
15
+
16
+ # The body text under a `# Citations` heading, up to the next heading at the
17
+ # same or higher level, or nil when there is no Citations section.
18
+ def section(body)
19
+ lines = []
20
+ level = nil
21
+ in_fence = false
22
+ body.to_s.each_line do |line|
23
+ if Links::FENCE.match?(line.strip)
24
+ in_fence = !in_fence
25
+ lines << line unless level.nil?
26
+ next
27
+ end
28
+
29
+ heading = in_fence ? nil : HEADING.match(line.strip)
30
+ if level.nil?
31
+ next unless heading && CITATIONS.match?(heading[2])
32
+
33
+ level = heading[1].length
34
+ elsif heading && heading[1].length <= level
35
+ break
36
+ else
37
+ lines << line
38
+ end
39
+ end
40
+ lines.join unless level.nil?
41
+ end
42
+
43
+ # Citation link targets within the `# Citations` section (empty when absent).
44
+ def targets(body)
45
+ Links.extract(section(body).to_s)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ # Primitives for parsing structure out of a markdown document — the format layer
5
+ # (§4 frontmatter, §5 links, §8 citations) shared by Concept, Bundle, and the
6
+ # analyzers. Pure string-in/string-out; no disk, no domain knowledge.
7
+ module Markdown
8
+ module Frontmatter
9
+ class ParseError < Error
10
+ end
11
+
12
+ FRONTMATTER_PATTERN = /\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n|\z)/m.freeze
13
+
14
+ # Psych gained keyword arguments for safe_load in 3.1 (Ruby 2.6); older
15
+ # versions take the permitted classes positionally.
16
+ PSYCH_KEYWORDS = Gem::Version.new(Psych::VERSION) >= Gem::Version.new("3.1.0")
17
+
18
+ module_function
19
+
20
+ def parse(content)
21
+ text = content.to_s
22
+ raise ParseError, "content is not valid UTF-8" unless text.encoding == Encoding::UTF_8 && text.valid_encoding?
23
+
24
+ match = FRONTMATTER_PATTERN.match(text)
25
+ raise ParseError, "missing YAML frontmatter" unless match
26
+
27
+ data = load_yaml(match[1])
28
+ raise ParseError, "frontmatter must be a mapping" unless data.nil? || data.is_a?(Hash)
29
+
30
+ body = text[match.end(0)..-1] || ""
31
+ [ stringify_keys(data || {}), body.sub(/\A\n/, "") ]
32
+ rescue Psych::SyntaxError => e
33
+ raise ParseError, "invalid YAML frontmatter: #{e.message}"
34
+ end
35
+
36
+ def dump(frontmatter, body)
37
+ attrs = stringify_keys(frontmatter || {})
38
+ yaml = YAML.dump(attrs).sub(/\A---[ \t]*\n/, "")
39
+ "---\n#{yaml}---\n\n#{body}"
40
+ end
41
+
42
+ def stringify_keys(hash)
43
+ hash.to_h.map { |key, value| [ key.to_s, value ] }.to_h
44
+ end
45
+
46
+ def load_yaml(text)
47
+ if PSYCH_KEYWORDS
48
+ YAML.safe_load(text, permitted_classes: [ Date, Time ], aliases: false)
49
+ else
50
+ YAML.safe_load(text, [ Date, Time ], [], false)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Markdown
5
+ # Markdown cross-link extraction and resolution — the single source of truth for
6
+ # "which concepts does this body point at". Shared by OKF::Bundle::Graph (to build edges)
7
+ # and OKF::Bundle::Validator (to warn on broken cross-links, §5.3), so both agree on what
8
+ # counts as a link and where it resolves.
9
+ module Links
10
+ FENCE = /\A(```|~~~)/.freeze
11
+ # Inline code span: a run of N backticks, the shortest content (which may hold
12
+ # shorter backtick runs, per CommonMark), then a matching run of N backticks.
13
+ # Links inside inline code render as literal text, not edges, so these are
14
+ # blanked before scanning — the inline analogue of FENCE.
15
+ CODE_SPAN = /(`+).*?\1/.freeze
16
+ # Inline link [text](target) or [text](target "title"); (?<!!) skips images.
17
+ INLINE_LINK = /(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.freeze
18
+ # Reference-style use: full [text][label] or collapsed [label][]; (?<!!) skips
19
+ # images. group 1 = text/label, group 2 = the explicit label (empty if collapsed).
20
+ REFERENCE_LINK = /(?<!!)\[([^\]]*)\]\[([^\]]*)\]/.freeze
21
+ # Reference definition: [label]: target (optionally followed by a "title").
22
+ DEFINITION = /\A[ \t]{0,3}\[([^\]]+)\]:[ \t]*(\S+)/.freeze
23
+ SCHEME = %r{\A[a-z][a-z0-9+.-]*://}.freeze
24
+
25
+ module_function
26
+
27
+ # Raw link targets in +body+, in document order, skipping fenced code blocks
28
+ # and image links. Handles both inline links and standard reference-style links
29
+ # (resolving [text][label] against its [label]: target definition). Targets
30
+ # keep any +#anchor+ — {resolve} strips it.
31
+ def extract(body)
32
+ text = body.to_s
33
+ definitions = reference_definitions(text)
34
+ found = []
35
+ each_prose_line(text) do |line|
36
+ found.concat(line.scan(INLINE_LINK).flatten)
37
+ line.scan(REFERENCE_LINK).each do |label, explicit|
38
+ key = (explicit.empty? ? label : explicit).strip.downcase
39
+ target = definitions[key]
40
+ found << target if target
41
+ end
42
+ end
43
+ found
44
+ end
45
+
46
+ # Map every reference definition (+[label]: target+) to its target, keyed by
47
+ # the lowercased label. Definitions may appear anywhere, so they are collected
48
+ # before uses are resolved.
49
+ def reference_definitions(text)
50
+ definitions = {}
51
+ each_prose_line(text) do |line|
52
+ match = DEFINITION.match(line)
53
+ definitions[match[1].strip.downcase] = match[2] if match
54
+ end
55
+ definitions
56
+ end
57
+
58
+ # Yield each line outside a fenced code block, with inline code spans blanked.
59
+ # Both exclusions mirror the rendered document: fenced and inline code are
60
+ # literal text, so a link written inside them is not a cross-link.
61
+ def each_prose_line(text)
62
+ in_fence = false
63
+ text.each_line do |line|
64
+ if FENCE.match?(line.strip)
65
+ in_fence = !in_fence
66
+ next
67
+ end
68
+ yield line.gsub(CODE_SPAN, " ") unless in_fence
69
+ end
70
+ end
71
+
72
+ # Resolve a raw link target to a bundle-relative +.md+ path, or +nil+ when the
73
+ # target is not an in-scope markdown cross-link (external scheme, mailto,
74
+ # non-+.md+, directory, or empty). A relative link that escapes the bundle root
75
+ # is returned verbatim, so the validator can flag it "not found" and the graph
76
+ # can drop it.
77
+ #
78
+ # @param from [String] bundle-relative path of the source file, e.g. "features/x.md"
79
+ # @param bundle [String] path to the bundle root
80
+ def resolve(raw, from:, bundle:)
81
+ target = raw.to_s.split("#", 2).first.to_s
82
+ return nil if target.empty? || target.end_with?("/")
83
+ return nil if target.match?(SCHEME) || target.start_with?("mailto:")
84
+ return nil unless target.end_with?(".md")
85
+ return target.sub(%r{\A/+}, "") if target.start_with?("/")
86
+
87
+ bundle_abs = File.expand_path(bundle)
88
+ source_dir = File.dirname(File.expand_path(from, bundle_abs))
89
+ candidate = File.expand_path(target, source_dir)
90
+ if candidate == bundle_abs || candidate.start_with?("#{bundle_abs}/")
91
+ Pathname.new(candidate).relative_path_from(Pathname.new(bundle_abs)).to_s
92
+ else
93
+ target
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/okf/path.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Path
5
+ class Error < OKF::Error
6
+ end
7
+
8
+ def self.normalize_relative!(path)
9
+ value = path.to_s
10
+ raise Error, "path is blank" if value.empty?
11
+ raise Error, "path contains null byte" if value.include?("\0")
12
+ raise Error, "path must be relative" if value.start_with?("/")
13
+ raise Error, "path must use forward slashes" if value.include?("\\")
14
+
15
+ parts = value.split("/")
16
+ if parts.any? { |part| part.empty? || part == "." || part == ".." }
17
+ raise Error, "path contains unsafe segment"
18
+ end
19
+
20
+ parts.join("/")
21
+ end
22
+
23
+ def self.join_under!(root, path)
24
+ relative = normalize_relative!(path)
25
+ expanded_root = File.expand_path(root.to_s)
26
+ expanded_path = File.expand_path(File.join(expanded_root, relative))
27
+ unless expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}#{File::SEPARATOR}")
28
+ raise Error, "path escapes bundle root"
29
+ end
30
+
31
+ expanded_path
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ require "okf/server/graph"
6
+
7
+ module OKF
8
+ module Server
9
+ # HTTP access to a bundle's knowledge graph — a Rack app, so it runs under any
10
+ # Rack server (WEBrick, via `okf server`) and can be mounted in a Rails app:
11
+ #
12
+ # mount OKF::Server::App.new(folder) => "/knowledge"
13
+ #
14
+ # The page (OKF::Server::Graph) boots from a *minimal* graph (id + title + edges
15
+ # + type/tag indexes) and pulls each concept's markdown body and description from
16
+ # here on demand, so the initial payload stays small and bodies are read live
17
+ # from disk (edits show without a restart). Part of the shell — it does I/O.
18
+ #
19
+ # GET / the interactive graph page (text/html)
20
+ # GET /node?id=… the concept's raw markdown body (text/markdown)
21
+ # GET /node/meta?id=… its description, as an escaped HTML fragment
22
+ # GET /catalog rich per-concept metadata for the catalog/files/stats
23
+ # views: { concepts: [ {id, title, type, description,
24
+ # tags, timestamp, status, area, dir, links_*} ] } (JSON)
25
+ # GET /tags the tag index { tag => [id, …] } (JSON)
26
+ # GET /types the type index { type => [id, …] } (JSON)
27
+ class App
28
+ def initialize(folder, title: nil, link: nil, layout: "cose")
29
+ @folder = folder
30
+ @title = title
31
+ @link = link
32
+ @layout = layout
33
+ end
34
+
35
+ def call(env)
36
+ request = Rack::Request.new(env)
37
+ return not_found unless request.get?
38
+
39
+ case request.path_info
40
+ when "", "/" then respond("text/html; charset=utf-8", page)
41
+ when "/node" then node_body(request.params["id"])
42
+ when "/node/meta" then node_meta(request.params["id"])
43
+ when "/catalog" then respond_json(catalog)
44
+ when "/tags" then respond_json(graph.tag_index)
45
+ when "/types" then respond_json(graph.type_index)
46
+ else not_found
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # The minimal graph snapshot taken at boot — drives the page and the indexes.
53
+ def graph
54
+ @graph ||= @folder.graph(minimal: true)
55
+ end
56
+
57
+ # Rich per-concept metadata the Catalog, Files and Stats views need but the
58
+ # lean graph payload deliberately omits — descriptions, tags, timestamps,
59
+ # status, folder, and in/out link degree. Fetched once, lazily, by the client
60
+ # so the graph's first paint stays minimal. The shape is built by the pure
61
+ # OKF::Bundle#catalog, shared with the `okf catalog/files/tags/stats` CLI views.
62
+ def catalog
63
+ { concepts: @folder.catalog }
64
+ end
65
+
66
+ def page
67
+ @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
68
+ end
69
+
70
+ def node_body(id)
71
+ concept = concept_for(id)
72
+ return not_found if concept.nil?
73
+
74
+ respond("text/markdown; charset=utf-8", concept.body)
75
+ end
76
+
77
+ def node_meta(id)
78
+ concept = concept_for(id)
79
+ return not_found if concept.nil?
80
+
81
+ respond("text/html; charset=utf-8", description_fragment(concept))
82
+ end
83
+
84
+ # Resolve an id to its concept, read live from disk. The id is only ever a key
85
+ # into the bundle's id→path map, so it cannot name a file outside the bundle
86
+ # (and Concept::File re-guards the path); an unknown id, a since-deleted file,
87
+ # or one that no longer parses all map to 404.
88
+ def concept_for(id)
89
+ return nil if id.nil? || id.empty?
90
+
91
+ @folder.concept(id)&.concept
92
+ rescue OKF::Error, SystemCallError
93
+ nil
94
+ end
95
+
96
+ def description_fragment(concept)
97
+ description = concept.description.to_s
98
+ return %(<span class="empty">no description</span>) if description.strip.empty?
99
+
100
+ html_escape(description)
101
+ end
102
+
103
+ def respond(content_type, body)
104
+ [ 200, { "content-type" => content_type }, [ body.to_s ] ]
105
+ end
106
+
107
+ def respond_json(object)
108
+ respond("application/json; charset=utf-8", JSON.generate(object))
109
+ end
110
+
111
+ def not_found
112
+ [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
113
+ end
114
+
115
+ def html_escape(str)
116
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Server
5
+ # Renders an OKF::Bundle::Graph as the interactive graph page served by
6
+ # OKF::Server::App. The markup lives in templates/graph.html.erb; #render
7
+ # returns the HTML string.
8
+ #
9
+ # The page boots from a *minimal* payload — nodes carry only id + title, plus
10
+ # compact TYPES/TAGS inverted indexes for colouring and filtering — and pulls
11
+ # each concept's markdown body (and metadata) from the server on demand via
12
+ # fetch, rendering it client-side with marked. Node bodies are therefore NOT
13
+ # embedded here.
14
+ #
15
+ # NOTE (trust boundary): the page loads Cytoscape + marked from a CDN and
16
+ # renders concept markdown without sanitization, so only serve bundles you
17
+ # trust. The inline-<script> data is </script>-escaped by #json_for_script
18
+ # (stdlib ERB does not auto-escape).
19
+ class Graph
20
+ TEMPLATE = File.expand_path("templates/graph.html.erb", __dir__)
21
+ LAYOUTS = %w[cose concentric breadthfirst circle grid].freeze
22
+
23
+ # Node-diameter range in px; the template scales within it by node degree.
24
+ MIN_SIZE = 24
25
+ MAX_SIZE = 70
26
+
27
+ # The 6-character JSON unicode escape for `<` (backslash, u, 0, 0, 3, c),
28
+ # built from the backslash code point so no literal escape appears here.
29
+ LT_ESCAPE = (92.chr(Encoding::UTF_8) + "u003c").freeze
30
+
31
+ # +node_endpoint+/+meta_endpoint+ are the (mount-relative) URLs the page
32
+ # fetches a concept's raw markdown and metadata fragment from — relative so
33
+ # the page works whether served at "/" or mounted under a Rails prefix.
34
+ def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta")
35
+ @graph = graph
36
+ @title = title
37
+ @link = link
38
+ @layout = layout
39
+ @node_endpoint = node_endpoint
40
+ @meta_endpoint = meta_endpoint
41
+ end
42
+
43
+ def render
44
+ ERB.new(File.read(TEMPLATE, encoding: "UTF-8")).result(binding)
45
+ end
46
+
47
+ private
48
+
49
+ def graph_name
50
+ @title.to_s.empty? ? "OKF Knowledge Graph" : @title
51
+ end
52
+
53
+ def escaped_name
54
+ html_escape(graph_name)
55
+ end
56
+
57
+ def og_title
58
+ html_escape("OKF — #{graph_name}")
59
+ end
60
+
61
+ def og_desc
62
+ html_escape("#{@graph.nodes.length} concepts · interactive Open Knowledge Format knowledge graph")
63
+ end
64
+
65
+ def source_link
66
+ return "" if @link.to_s.empty?
67
+
68
+ %( <a class="src" href="#{html_escape(@link)}" target="_blank" rel="noopener">source ↗</a>)
69
+ end
70
+
71
+ def nodes_json
72
+ json_for_script(@graph.nodes)
73
+ end
74
+
75
+ def edges_json
76
+ json_for_script(@graph.edges)
77
+ end
78
+
79
+ # { type => [id, …] } — the client builds an id→type map for node colour.
80
+ def types_json
81
+ json_for_script(@graph.type_index)
82
+ end
83
+
84
+ # { tag => [id, …] } — the client derives a node's tags and offers filters.
85
+ def tags_json
86
+ json_for_script(@graph.tag_index)
87
+ end
88
+
89
+ # JSON-encode for safe embedding in an inline <script>: escaping every `<` to
90
+ # its JSON unicode escape neutralizes </script>, <!-- and <script in one
91
+ # stroke, and the result stays valid JSON *and* JavaScript.
92
+ def json_for_script(obj)
93
+ JSON.generate(coerce(obj)).gsub("<") { LT_ESCAPE }
94
+ end
95
+
96
+ # Coerce any non-JSON-native scalar (e.g. a YAML Date/Time in tags) to a
97
+ # string, leaving numbers/booleans/nil native.
98
+ def coerce(obj)
99
+ case obj
100
+ when Hash then obj.transform_values { |value| coerce(value) }
101
+ when Array then obj.map { |value| coerce(value) }
102
+ when String, Integer, Float, true, false, nil then obj
103
+ else obj.to_s
104
+ end
105
+ end
106
+
107
+ def html_escape(str)
108
+ str.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
109
+ end
110
+ end
111
+ end
112
+ end