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.
- checksums.yaml +7 -0
- data/.okf/capabilities/agent-skill.md +46 -0
- data/.okf/capabilities/graph-server.md +60 -0
- data/.okf/capabilities/index.md +20 -0
- data/.okf/capabilities/library-api.md +67 -0
- data/.okf/capabilities/linter.md +49 -0
- data/.okf/capabilities/read-views.md +84 -0
- data/.okf/capabilities/validator.md +40 -0
- data/.okf/cli.md +52 -0
- data/.okf/design/core-shell-split.md +58 -0
- data/.okf/design/index.md +10 -0
- data/.okf/design/ruby-floor.md +45 -0
- data/.okf/design/runtime-dependencies.md +44 -0
- data/.okf/design/server-trust-boundary.md +35 -0
- data/.okf/format/citations.md +33 -0
- data/.okf/format/cross-links.md +52 -0
- data/.okf/format/frontmatter.md +38 -0
- data/.okf/format/index.md +9 -0
- data/.okf/format/okf-format.md +43 -0
- data/.okf/index.md +18 -0
- data/.okf/log.md +9 -0
- data/.okf/model/bundle.md +38 -0
- data/.okf/model/concept.md +44 -0
- data/.okf/model/graph.md +44 -0
- data/.okf/model/index.md +8 -0
- data/.okf/overview.md +66 -0
- data/CHANGELOG.md +54 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +276 -0
- data/exe/okf +6 -0
- data/lib/okf/bundle/folder.rb +94 -0
- data/lib/okf/bundle/graph.rb +118 -0
- data/lib/okf/bundle/linter/report.rb +56 -0
- data/lib/okf/bundle/linter.rb +416 -0
- data/lib/okf/bundle/reader.rb +60 -0
- data/lib/okf/bundle/validator/result.rb +35 -0
- data/lib/okf/bundle/validator.rb +131 -0
- data/lib/okf/bundle/writer.rb +137 -0
- data/lib/okf/bundle.rb +216 -0
- data/lib/okf/cli.rb +910 -0
- data/lib/okf/concept/file.rb +63 -0
- data/lib/okf/concept.rb +101 -0
- data/lib/okf/markdown/citations.rb +49 -0
- data/lib/okf/markdown/frontmatter.rb +55 -0
- data/lib/okf/markdown/links.rb +98 -0
- data/lib/okf/path.rb +34 -0
- data/lib/okf/server/app.rb +120 -0
- data/lib/okf/server/graph.rb +112 -0
- data/lib/okf/server/runner.rb +78 -0
- data/lib/okf/server/templates/graph.html.erb +803 -0
- data/lib/okf/skill/SKILL.md +133 -0
- data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
- data/lib/okf/skill/reference/SPEC.md +460 -0
- data/lib/okf/skill/reference/authoring.md +218 -0
- data/lib/okf/skill/reference/cli.md +196 -0
- data/lib/okf/skill/templates/concept.md +24 -0
- data/lib/okf/skill/templates/index.md +8 -0
- data/lib/okf/skill/templates/log.md +6 -0
- data/lib/okf/skill/templates/root-index.md +12 -0
- data/lib/okf/skill.rb +82 -0
- data/lib/okf/version.rb +5 -0
- data/lib/okf.rb +55 -0
- 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
|
data/lib/okf/concept.rb
ADDED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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("&", "&").gsub('"', """).gsub("<", "<").gsub(">", ">")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|