ucode 0.1.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 (228) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +211 -0
  3. data/Gemfile +22 -0
  4. data/Gemfile.lock +406 -0
  5. data/README.md +469 -0
  6. data/Rakefile +18 -0
  7. data/TODO.new/00-README.md +66 -0
  8. data/TODO.new/01-pillar-terminology-alignment.md +69 -0
  9. data/TODO.new/02-audit-schema-design.md +255 -0
  10. data/TODO.new/03-directory-output-spec.md +203 -0
  11. data/TODO.new/04-fontist-org-contract.md +173 -0
  12. data/TODO.new/05-baseline-unicode17-coverage-audit.md +144 -0
  13. data/TODO.new/06-audit-namespace-skeleton.md +105 -0
  14. data/TODO.new/07-audit-models-port.md +132 -0
  15. data/TODO.new/08-extractors-cheap-port.md +113 -0
  16. data/TODO.new/09-extractors-expensive-port.md +99 -0
  17. data/TODO.new/10-aggregations-ucd-rewrite.md +168 -0
  18. data/TODO.new/11-differ-and-library-auditor-port.md +102 -0
  19. data/TODO.new/12-formatters-port.md +115 -0
  20. data/TODO.new/13-directory-emitter.md +147 -0
  21. data/TODO.new/14-html-face-browser.md +144 -0
  22. data/TODO.new/15-html-library-browser.md +102 -0
  23. data/TODO.new/16-cli-audit-subcommands.md +142 -0
  24. data/TODO.new/17-fontisan-cleanup-audit.md +147 -0
  25. data/TODO.new/18-fontisan-cleanup-ucd.md +156 -0
  26. data/TODO.new/19-fontisan-docs-update.md +155 -0
  27. data/TODO.new/20-canonical-resolver-4-tier.md +182 -0
  28. data/TODO.new/21-canonical-unicode17-build.md +148 -0
  29. data/TODO.new/22-implementation-order.md +176 -0
  30. data/UCODE_CHANGELOG.md +97 -0
  31. data/exe/ucode +8 -0
  32. data/lib/ucode/aggregator.rb +77 -0
  33. data/lib/ucode/audit/block_aggregator.rb +90 -0
  34. data/lib/ucode/audit/codepoint_range_coalescer.rb +42 -0
  35. data/lib/ucode/audit/context.rb +137 -0
  36. data/lib/ucode/audit/discrepancy_detector.rb +213 -0
  37. data/lib/ucode/audit/extractors/aggregations.rb +70 -0
  38. data/lib/ucode/audit/extractors/base.rb +21 -0
  39. data/lib/ucode/audit/extractors/color_capabilities.rb +143 -0
  40. data/lib/ucode/audit/extractors/coverage.rb +55 -0
  41. data/lib/ucode/audit/extractors/hinting.rb +199 -0
  42. data/lib/ucode/audit/extractors/identity.rb +65 -0
  43. data/lib/ucode/audit/extractors/licensing.rb +75 -0
  44. data/lib/ucode/audit/extractors/metrics.rb +108 -0
  45. data/lib/ucode/audit/extractors/opentype_layout.rb +71 -0
  46. data/lib/ucode/audit/extractors/provenance.rb +34 -0
  47. data/lib/ucode/audit/extractors/style.rb +88 -0
  48. data/lib/ucode/audit/extractors/variation_detail.rb +101 -0
  49. data/lib/ucode/audit/extractors.rb +31 -0
  50. data/lib/ucode/audit/plane_aggregator.rb +37 -0
  51. data/lib/ucode/audit/registry.rb +63 -0
  52. data/lib/ucode/audit/script_aggregator.rb +92 -0
  53. data/lib/ucode/audit.rb +27 -0
  54. data/lib/ucode/cache.rb +113 -0
  55. data/lib/ucode/cli.rb +272 -0
  56. data/lib/ucode/commands/build.rb +68 -0
  57. data/lib/ucode/commands/cache.rb +46 -0
  58. data/lib/ucode/commands/fetch.rb +62 -0
  59. data/lib/ucode/commands/font_coverage.rb +57 -0
  60. data/lib/ucode/commands/glyphs.rb +136 -0
  61. data/lib/ucode/commands/lookup.rb +65 -0
  62. data/lib/ucode/commands/parse.rb +62 -0
  63. data/lib/ucode/commands/site.rb +33 -0
  64. data/lib/ucode/commands.rb +19 -0
  65. data/lib/ucode/config.rb +110 -0
  66. data/lib/ucode/coordinator/indices.rb +34 -0
  67. data/lib/ucode/coordinator.rb +397 -0
  68. data/lib/ucode/database.rb +214 -0
  69. data/lib/ucode/db_builder.rb +107 -0
  70. data/lib/ucode/error.rb +96 -0
  71. data/lib/ucode/fetch/code_charts.rb +57 -0
  72. data/lib/ucode/fetch/http.rb +83 -0
  73. data/lib/ucode/fetch/ucd_zip.rb +57 -0
  74. data/lib/ucode/fetch/unihan_zip.rb +57 -0
  75. data/lib/ucode/fetch.rb +14 -0
  76. data/lib/ucode/glyphs/cell_extractor.rb +130 -0
  77. data/lib/ucode/glyphs/dvisvgm_renderer.rb +29 -0
  78. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +372 -0
  79. data/lib/ucode/glyphs/embedded_fonts/content_stream_correlator.rb +228 -0
  80. data/lib/ucode/glyphs/embedded_fonts/font_entry.rb +126 -0
  81. data/lib/ucode/glyphs/embedded_fonts/renderer.rb +47 -0
  82. data/lib/ucode/glyphs/embedded_fonts/source.rb +94 -0
  83. data/lib/ucode/glyphs/embedded_fonts/svg.rb +123 -0
  84. data/lib/ucode/glyphs/embedded_fonts/tounicode.rb +103 -0
  85. data/lib/ucode/glyphs/embedded_fonts/writer.rb +76 -0
  86. data/lib/ucode/glyphs/embedded_fonts.rb +50 -0
  87. data/lib/ucode/glyphs/grid.rb +30 -0
  88. data/lib/ucode/glyphs/grid_detector.rb +165 -0
  89. data/lib/ucode/glyphs/last_resort/cmap_index.rb +96 -0
  90. data/lib/ucode/glyphs/last_resort/contents.rb +74 -0
  91. data/lib/ucode/glyphs/last_resort/glif.rb +124 -0
  92. data/lib/ucode/glyphs/last_resort/renderer.rb +67 -0
  93. data/lib/ucode/glyphs/last_resort/source.rb +125 -0
  94. data/lib/ucode/glyphs/last_resort/svg.rb +247 -0
  95. data/lib/ucode/glyphs/last_resort/writer.rb +83 -0
  96. data/lib/ucode/glyphs/last_resort.rb +36 -0
  97. data/lib/ucode/glyphs/monolith_page_map.rb +181 -0
  98. data/lib/ucode/glyphs/mutool_renderer.rb +28 -0
  99. data/lib/ucode/glyphs/page_renderer.rb +221 -0
  100. data/lib/ucode/glyphs/path_bbox.rb +62 -0
  101. data/lib/ucode/glyphs/pdf2svg_renderer.rb +26 -0
  102. data/lib/ucode/glyphs/pdf_fetcher.rb +102 -0
  103. data/lib/ucode/glyphs/pdftocairo_renderer.rb +32 -0
  104. data/lib/ucode/glyphs/real_fonts/block_coverage.rb +45 -0
  105. data/lib/ucode/glyphs/real_fonts/coverage_auditor.rb +117 -0
  106. data/lib/ucode/glyphs/real_fonts/font_coverage_report.rb +45 -0
  107. data/lib/ucode/glyphs/real_fonts/font_locator.rb +95 -0
  108. data/lib/ucode/glyphs/real_fonts/unicode_17_blocks.rb +104 -0
  109. data/lib/ucode/glyphs/real_fonts/writer.rb +50 -0
  110. data/lib/ucode/glyphs/real_fonts.rb +32 -0
  111. data/lib/ucode/glyphs/writer.rb +250 -0
  112. data/lib/ucode/glyphs.rb +27 -0
  113. data/lib/ucode/index.rb +106 -0
  114. data/lib/ucode/index_builder.rb +94 -0
  115. data/lib/ucode/models/audit/audit_axis.rb +30 -0
  116. data/lib/ucode/models/audit/audit_diff.rb +77 -0
  117. data/lib/ucode/models/audit/audit_report.rb +137 -0
  118. data/lib/ucode/models/audit/baseline.rb +32 -0
  119. data/lib/ucode/models/audit/block_summary.rb +72 -0
  120. data/lib/ucode/models/audit/codepoint_detail.rb +45 -0
  121. data/lib/ucode/models/audit/codepoint_range.rb +39 -0
  122. data/lib/ucode/models/audit/codepoint_set_diff.rb +34 -0
  123. data/lib/ucode/models/audit/color_capabilities.rb +91 -0
  124. data/lib/ucode/models/audit/discrepancy.rb +38 -0
  125. data/lib/ucode/models/audit/duplicate_group.rb +23 -0
  126. data/lib/ucode/models/audit/embedding_type.rb +81 -0
  127. data/lib/ucode/models/audit/field_change.rb +28 -0
  128. data/lib/ucode/models/audit/fs_selection_flags.rb +65 -0
  129. data/lib/ucode/models/audit/gasp_range.rb +63 -0
  130. data/lib/ucode/models/audit/hinting.rb +99 -0
  131. data/lib/ucode/models/audit/library_summary.rb +40 -0
  132. data/lib/ucode/models/audit/licensing.rb +48 -0
  133. data/lib/ucode/models/audit/metrics.rb +111 -0
  134. data/lib/ucode/models/audit/named_instance.rb +41 -0
  135. data/lib/ucode/models/audit/opentype_layout.rb +38 -0
  136. data/lib/ucode/models/audit/plane_summary.rb +31 -0
  137. data/lib/ucode/models/audit/script_coverage_row.rb +26 -0
  138. data/lib/ucode/models/audit/script_features.rb +28 -0
  139. data/lib/ucode/models/audit/script_summary.rb +54 -0
  140. data/lib/ucode/models/audit/variation_detail.rb +42 -0
  141. data/lib/ucode/models/audit.rb +50 -0
  142. data/lib/ucode/models/bidi_bracket_pair.rb +20 -0
  143. data/lib/ucode/models/bidi_mirroring.rb +19 -0
  144. data/lib/ucode/models/binary_property_assignment.rb +26 -0
  145. data/lib/ucode/models/block.rb +36 -0
  146. data/lib/ucode/models/case_folding_rule.rb +23 -0
  147. data/lib/ucode/models/cjk_radical.rb +23 -0
  148. data/lib/ucode/models/codepoint/bidi.rb +28 -0
  149. data/lib/ucode/models/codepoint/break_segmentation.rb +22 -0
  150. data/lib/ucode/models/codepoint/case_folding.rb +25 -0
  151. data/lib/ucode/models/codepoint/casing.rb +32 -0
  152. data/lib/ucode/models/codepoint/decomposition.rb +27 -0
  153. data/lib/ucode/models/codepoint/display.rb +24 -0
  154. data/lib/ucode/models/codepoint/emoji.rb +29 -0
  155. data/lib/ucode/models/codepoint/hangul.rb +20 -0
  156. data/lib/ucode/models/codepoint/identifier.rb +30 -0
  157. data/lib/ucode/models/codepoint/indic.rb +20 -0
  158. data/lib/ucode/models/codepoint/joining.rb +20 -0
  159. data/lib/ucode/models/codepoint/normalization.rb +35 -0
  160. data/lib/ucode/models/codepoint/numeric_value.rb +35 -0
  161. data/lib/ucode/models/codepoint.rb +122 -0
  162. data/lib/ucode/models/name_alias.rb +21 -0
  163. data/lib/ucode/models/named_sequence.rb +19 -0
  164. data/lib/ucode/models/names_list_entry.rb +38 -0
  165. data/lib/ucode/models/plane.rb +36 -0
  166. data/lib/ucode/models/property_alias.rb +24 -0
  167. data/lib/ucode/models/property_value_alias.rb +26 -0
  168. data/lib/ucode/models/relationship/compat_equiv.rb +18 -0
  169. data/lib/ucode/models/relationship/cross_reference.rb +17 -0
  170. data/lib/ucode/models/relationship/footnote.rb +24 -0
  171. data/lib/ucode/models/relationship/informal_alias.rb +18 -0
  172. data/lib/ucode/models/relationship/sample_sequence.rb +24 -0
  173. data/lib/ucode/models/relationship/variation_sequence.rb +19 -0
  174. data/lib/ucode/models/relationship.rb +57 -0
  175. data/lib/ucode/models/script.rb +41 -0
  176. data/lib/ucode/models/special_casing_rule.rb +28 -0
  177. data/lib/ucode/models/standardized_variant.rb +24 -0
  178. data/lib/ucode/models/unihan_entry.rb +23 -0
  179. data/lib/ucode/models.rb +47 -0
  180. data/lib/ucode/parsers/auxiliary.rb +26 -0
  181. data/lib/ucode/parsers/base.rb +137 -0
  182. data/lib/ucode/parsers/bidi_brackets.rb +41 -0
  183. data/lib/ucode/parsers/bidi_mirroring.rb +37 -0
  184. data/lib/ucode/parsers/blocks.rb +63 -0
  185. data/lib/ucode/parsers/case_folding.rb +53 -0
  186. data/lib/ucode/parsers/cjk_radicals.rb +102 -0
  187. data/lib/ucode/parsers/derived_age.rb +59 -0
  188. data/lib/ucode/parsers/derived_core_properties.rb +60 -0
  189. data/lib/ucode/parsers/extracted_properties.rb +74 -0
  190. data/lib/ucode/parsers/name_aliases.rb +44 -0
  191. data/lib/ucode/parsers/named_sequences.rb +51 -0
  192. data/lib/ucode/parsers/names_list.rb +250 -0
  193. data/lib/ucode/parsers/property_aliases.rb +41 -0
  194. data/lib/ucode/parsers/property_value_aliases.rb +46 -0
  195. data/lib/ucode/parsers/script_extensions.rb +64 -0
  196. data/lib/ucode/parsers/scripts.rb +60 -0
  197. data/lib/ucode/parsers/special_casing.rb +62 -0
  198. data/lib/ucode/parsers/standardized_variants.rb +56 -0
  199. data/lib/ucode/parsers/unicode_data/hangul_name.rb +73 -0
  200. data/lib/ucode/parsers/unicode_data.rb +268 -0
  201. data/lib/ucode/parsers/unihan.rb +125 -0
  202. data/lib/ucode/parsers.rb +35 -0
  203. data/lib/ucode/range_entry.rb +58 -0
  204. data/lib/ucode/repo/aggregate_writer.rb +364 -0
  205. data/lib/ucode/repo/atomic_writes.rb +48 -0
  206. data/lib/ucode/repo/codepoint_writer.rb +96 -0
  207. data/lib/ucode/repo/paths.rb +122 -0
  208. data/lib/ucode/repo.rb +22 -0
  209. data/lib/ucode/site/config_emitter.rb +124 -0
  210. data/lib/ucode/site/generator.rb +178 -0
  211. data/lib/ucode/site/search_index.rb +68 -0
  212. data/lib/ucode/site/template/.gitignore +4 -0
  213. data/lib/ucode/site/template/.vitepress/config.ts +8 -0
  214. data/lib/ucode/site/template/.vitepress/theme/index.js +20 -0
  215. data/lib/ucode/site/template/char/[codepoint].md +13 -0
  216. data/lib/ucode/site/template/components/BlockView.vue +57 -0
  217. data/lib/ucode/site/template/components/CharView.vue +85 -0
  218. data/lib/ucode/site/template/components/PlaneView.vue +56 -0
  219. data/lib/ucode/site/template/components/SearchView.vue +66 -0
  220. data/lib/ucode/site/template/index.md +25 -0
  221. data/lib/ucode/site/template/package.json +18 -0
  222. data/lib/ucode/site/template/search.md +9 -0
  223. data/lib/ucode/site.rb +13 -0
  224. data/lib/ucode/version.rb +5 -0
  225. data/lib/ucode/version_resolver.rb +76 -0
  226. data/lib/ucode.rb +74 -0
  227. data/ucode.gemspec +56 -0
  228. metadata +404 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ require "ucode/repo/atomic_writes"
7
+
8
+ module Ucode
9
+ module Site
10
+ # Writes `site/.vitepress/config.ts` from the JSON output tree.
11
+ #
12
+ # Reads:
13
+ # output/planes/<n>.json → top nav (17 planes)
14
+ # output/blocks/index.json → sidebar groups (one per plane)
15
+ #
16
+ # Writes:
17
+ # site/.vitepress/config.ts — TypeScript module that exports the
18
+ # Vitepress `defineConfig` payload. Idempotent via AtomicWrites.
19
+ #
20
+ # **Pure**: reads JSON, writes TS. No Vitepress dependency on the
21
+ # Ruby side; the site is a thin shell that imports this generated
22
+ # config and re-exports `defineConfig(config)`.
23
+ class ConfigEmitter
24
+ include Repo::AtomicWrites
25
+
26
+ # @param output_root [String, Pathname] dataset root
27
+ # @param site_root [String, Pathname] Vitepress project root
28
+ def initialize(output_root:, site_root:)
29
+ @output_root = Pathname.new(output_root)
30
+ @site_root = Pathname.new(site_root)
31
+ end
32
+
33
+ # @return [Pathname] the config file path that #emit writes
34
+ def target_path
35
+ @site_root.join(".vitepress", "config.ts")
36
+ end
37
+
38
+ # Read the output tree, render config.ts, write atomically.
39
+ # @return [Boolean] true if the file was (re)written, false if skipped
40
+ def emit
41
+ write_atomic(target_path, render(load_planes, load_blocks))
42
+ end
43
+
44
+ private
45
+
46
+ def load_planes
47
+ (0..16).map do |n|
48
+ path = @output_root.join("planes", "#{n}.json")
49
+ next nil unless path.exist?
50
+
51
+ JSON.parse(path.read).tap do |plane|
52
+ plane["number"] = n
53
+ end
54
+ end.compact
55
+ end
56
+
57
+ def load_blocks
58
+ path = @output_root.join("blocks", "index.json")
59
+ return [] unless path.exist?
60
+
61
+ JSON.parse(path.read)
62
+ end
63
+
64
+ def render(planes, blocks)
65
+ <<~TS
66
+ // AUTO-GENERATED by Ucode::Site::ConfigEmitter. Do not edit.
67
+ import { defineConfig } from "vitepress";
68
+
69
+ export const planes = #{planes_to_ts(planes)};
70
+
71
+ export const blocks = #{blocks_to_ts(blocks)};
72
+
73
+ export default defineConfig({
74
+ title: "ucode",
75
+ description: "Unicode Character Database — code charts, properties, relationships",
76
+ cleanUrls: true,
77
+ themeConfig: {
78
+ siteTitle: "ucode",
79
+ nav: [
80
+ { text: "Planes", link: "/plane/0" },
81
+ { text: "Search", link: "/search" },
82
+ ],
83
+ sidebar: sidebar(planes, blocks),
84
+ socialLinks: [
85
+ { icon: "github", link: "https://github.com/fontist/ucode" },
86
+ ],
87
+ search: { provider: "local" },
88
+ },
89
+ });
90
+
91
+ function sidebar(planes, blocks) {
92
+ const groups = {};
93
+ for (const plane of planes) {
94
+ groups[plane.number] = {
95
+ text: plane.abbrev + " — " + plane.name,
96
+ items: blocks
97
+ .filter((b) => b.plane_number === plane.number)
98
+ .map((b) => ({ text: b.name, link: "/block/" + b.id })),
99
+ };
100
+ }
101
+ // Sidebar per top-level section: plane pages share one sidebar,
102
+ // block pages get their plane's sidebar.
103
+ return {
104
+ "/plane/": Object.values(groups),
105
+ "/block/": groups,
106
+ };
107
+ }
108
+ TS
109
+ end
110
+
111
+ def planes_to_ts(planes)
112
+ JSON.pretty_generate(planes.map { |p|
113
+ { number: p["number"], name: p["name"], abbrev: p["abbrev"] }
114
+ })
115
+ end
116
+
117
+ def blocks_to_ts(blocks)
118
+ JSON.pretty_generate(blocks.map { |b|
119
+ { id: b["id"], name: b["name"], plane_number: b["plane_number"] }
120
+ })
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "find"
5
+ require "json"
6
+ require "pathname"
7
+
8
+ require "ucode/repo/atomic_writes"
9
+ require "ucode/site/config_emitter"
10
+ require "ucode/site/search_index"
11
+
12
+ module Ucode
13
+ module Site
14
+ # Orchestrates `ucode site init` and `ucode site build`.
15
+ #
16
+ # **init** copies the static Vitepress template from
17
+ # `lib/ucode/site/template/` into the user's `site/`. The template
18
+ # ships package.json, theme, and the dynamic route components
19
+ # (`char/[codepoint].vue`, `block/[id].vue`, `plane/[n].md` stub).
20
+ #
21
+ # **build** regenerates only the parts that depend on the dataset:
22
+ # - `.vitepress/config.ts` — ConfigEmitter
23
+ # - `public/data/` — symlinked or copied from `output/`
24
+ # - `public/data/index/search.json` — SearchIndex
25
+ # - `plane/<n>.md` — one thin stub per plane (frontmatter)
26
+ # - `block/<id>.md` — one thin stub per block
27
+ #
28
+ # Static pages are markdown stubs that mount a Vue component; the
29
+ # component fetches the JSON for that plane/block at runtime. This
30
+ # keeps the generator cheap (~363 small writes) and the per-character
31
+ # route dynamic (~160k static pages is infeasible).
32
+ #
33
+ # **Idempotent**: every write goes through AtomicWrites.
34
+ class Generator
35
+ include Repo::AtomicWrites
36
+
37
+ TemplateDir = File.expand_path("template", __dir__).freeze
38
+ private_constant :TemplateDir
39
+
40
+ # @param output_root [String, Pathname] dataset root (read)
41
+ # @param site_root [String, Pathname] Vitepress project root (write)
42
+ def initialize(output_root:, site_root:)
43
+ @output_root = Pathname.new(output_root)
44
+ @site_root = Pathname.new(site_root)
45
+ end
46
+
47
+ # Copy the static template into `site_root`. No-op for any file
48
+ # that already exists with identical content (AtomicWrites).
49
+ # @return [Integer] number of files written
50
+ def init
51
+ count = 0
52
+ each_template_file do |src, rel|
53
+ dst = @site_root.join(rel)
54
+ count += 1 if write_atomic(dst, src.read)
55
+ end
56
+ count
57
+ end
58
+
59
+ # Regenerate config + pages + search index from the current
60
+ # `output/` tree. Returns a tally of what changed.
61
+ # @return [Hash{Symbol => Integer}]
62
+ def build
63
+ tally = { config: 0, pages: 0, search: 0, data_link: 0 }
64
+
65
+ tally[:config] = config_emitter.emit ? 1 : 0
66
+ tally[:pages] = write_pages
67
+ tally[:search] = search_index.build ? 1 : 0
68
+ tally[:data_link] = link_data_dir ? 1 : 0
69
+
70
+ tally
71
+ end
72
+
73
+ private
74
+
75
+ def config_emitter
76
+ ConfigEmitter.new(output_root: @output_root, site_root: @site_root)
77
+ end
78
+
79
+ def search_index
80
+ SearchIndex.new(@output_root)
81
+ end
82
+
83
+ # Walk TemplateDir, yielding (src_path, relative_path) for each file.
84
+ def each_template_file(&block)
85
+ return unless Dir.exist?(TemplateDir)
86
+
87
+ Find.find(TemplateDir) do |src|
88
+ next if File.directory?(src)
89
+
90
+ rel = Pathname.new(src).relative_path_from(Pathname.new(TemplateDir))
91
+ yield Pathname.new(src), rel
92
+ end
93
+ end
94
+
95
+ # Write plane and block markdown stubs from the output tree.
96
+ def write_pages
97
+ count = 0
98
+ count += write_plane_pages
99
+ count += write_block_pages
100
+ count
101
+ end
102
+
103
+ def write_plane_pages
104
+ planes_dir = @output_root.join("planes")
105
+ return 0 unless planes_dir.directory?
106
+
107
+ planes_dir.children.sort.sum do |path|
108
+ next 0 unless path.file? && path.extname == ".json"
109
+
110
+ plane = JSON.parse(path.read)
111
+ n = plane["number"]
112
+ next 0 unless n
113
+
114
+ payload = plane_md(n)
115
+ write_atomic(@site_root.join("plane", "#{n}.md"), payload) ? 1 : 0
116
+ end
117
+ end
118
+
119
+ def write_block_pages
120
+ blocks = read_json_list(@output_root.join("blocks", "index.json"))
121
+ blocks.sum do |block|
122
+ id = block["id"]
123
+ next 0 unless id
124
+
125
+ payload = block_md(id)
126
+ write_atomic(@site_root.join("block", "#{id}.md"), payload) ? 1 : 0
127
+ end
128
+ end
129
+
130
+ def read_json_list(path)
131
+ return [] unless path&.exist?
132
+
133
+ JSON.parse(path.read)
134
+ end
135
+
136
+ def plane_md(plane_number)
137
+ <<~MD
138
+ ---
139
+ layout: plane
140
+ title: "Plane #{plane_number}"
141
+ plane: #{plane_number}
142
+ ---
143
+
144
+ <PlaneView plane="#{plane_number}" />
145
+ MD
146
+ end
147
+
148
+ def block_md(block_id)
149
+ <<~MD
150
+ ---
151
+ layout: block
152
+ title: "#{block_id}"
153
+ block: "#{block_id}"
154
+ ---
155
+
156
+ <BlockView block="#{block_id}" />
157
+ MD
158
+ end
159
+
160
+ # Vitepress serves `site/public/` at the site root. Symlink the
161
+ # dataset into `public/data/` so the Vue components can fetch
162
+ # `/data/...` URLs. Falls back to a recursive copy on filesystems
163
+ # that don't support symlinks.
164
+ def link_data_dir
165
+ link = @site_root.join("public", "data")
166
+ return 0 if link.symlink? && link.dirname.exist?
167
+
168
+ link.dirname.mkpath
169
+ begin
170
+ File.symlink(@output_root.relative_path_from(link.dirname).to_s, link.to_s)
171
+ rescue SystemCallError
172
+ FileUtils.cp_r(@output_root, link)
173
+ end
174
+ 1
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ require "ucode/repo/atomic_writes"
7
+ require "ucode/repo/paths"
8
+
9
+ module Ucode
10
+ module Site
11
+ # Builds the client-side search payload consumed by MiniSearch.
12
+ #
13
+ # Input: `output/index/labels.json` written by `Repo::AggregateWriter`
14
+ # — a flat `{ "U+XXXX" => { name, gc, sc } }` map (~160k entries,
15
+ # ~5 MB raw).
16
+ #
17
+ # Output: `output/index/search.json` — an array of `{ id, name, gc, sc }`
18
+ # objects, ready to feed `new MiniSearch(payload, ...)`.
19
+ #
20
+ # **Streaming**: labels.json is parsed incrementally via the stdlib
21
+ # JSON parser; the entire payload is materialised once for atomic
22
+ # write. For ~160k codepoints this peaks around ~30 MB — acceptable
23
+ # for a build-time tool.
24
+ #
25
+ # **Idempotent**: re-runs are byte-compared no-ops via AtomicWrites.
26
+ class SearchIndex
27
+ include Repo::AtomicWrites
28
+
29
+ # @param output_root [String, Pathname]
30
+ def initialize(output_root)
31
+ @output_root = Pathname.new(output_root)
32
+ end
33
+
34
+ # Build and write `search.json`. Returns the entry count, or nil
35
+ # if labels.json is absent (nothing to index).
36
+ # @return [Integer, nil]
37
+ def build
38
+ labels = load_labels
39
+ return nil unless labels
40
+
41
+ entries = labels.map { |cp_id, meta| entry_for(cp_id, meta) }
42
+ payload = JSON.generate(entries)
43
+ write_atomic(target_path, payload)
44
+ entries.size
45
+ end
46
+
47
+ # The path that #build writes to. Exposed so specs and the site
48
+ # generator can reference it without duplicating the convention.
49
+ # @return [Pathname]
50
+ def target_path
51
+ Pathname(@output_root).join("index", "search.json")
52
+ end
53
+
54
+ private
55
+
56
+ def load_labels
57
+ path = Repo::Paths.labels_index_path(@output_root)
58
+ return nil unless path.exist?
59
+
60
+ JSON.parse(path.read)
61
+ end
62
+
63
+ def entry_for(cp_id, meta)
64
+ { id: cp_id, name: meta["name"], gc: meta["gc"], sc: meta["sc"] }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .vitepress/dist/
3
+ .vitepress/cache/
4
+ public/data/
@@ -0,0 +1,8 @@
1
+ // Placeholder config; replaced by `ucode site build` (which writes
2
+ // config.ts from the current output/ tree). Safe to delete.
3
+ import { defineConfig } from "vitepress";
4
+
5
+ export default defineConfig({
6
+ title: "ucode",
7
+ description: "Run `ucode site build` to regenerate this config from output/.",
8
+ });
@@ -0,0 +1,20 @@
1
+ import DefaultTheme from "vitepress/theme";
2
+ import { defineComponent, h } from "vue";
3
+ import PlaneView from "../components/PlaneView.vue";
4
+ import BlockView from "../components/BlockView.vue";
5
+ import CharView from "../components/CharView.vue";
6
+
7
+ export default {
8
+ ...DefaultTheme,
9
+ Layout: defineComponent({
10
+ name: "UcodeLayout",
11
+ setup() {
12
+ return () => h(DefaultTheme.Layout, null, {});
13
+ },
14
+ }),
15
+ enhanceApp({ app }) {
16
+ app.component("PlaneView", PlaneView);
17
+ app.component("BlockView", BlockView);
18
+ app.component("CharView", CharView);
19
+ },
20
+ };
@@ -0,0 +1,13 @@
1
+ ---
2
+ layout: doc
3
+ ---
4
+
5
+ <script setup>
6
+ import { useRoute } from "vitepress";
7
+ import CharView from "../components/CharView.vue";
8
+
9
+ const route = useRoute();
10
+ const codepoint = route.params.codepoint;
11
+ </script>
12
+
13
+ <CharView :codepoint="codepoint" />
@@ -0,0 +1,57 @@
1
+ <script setup>
2
+ import { ref, onMounted, computed } from "vue";
3
+
4
+ const props = defineProps({ block: { type: String, required: true } });
5
+
6
+ const blockMeta = ref(null);
7
+ const cpToBlock = ref({});
8
+ const labels = ref({});
9
+
10
+ const cells = computed(() => {
11
+ if (!blockMeta.value) return [];
12
+ return blockMeta.value.codepoint_ids.map((cpId) => ({
13
+ id: cpId,
14
+ label: labels.value[cpId]?.name,
15
+ glyphUrl: `/data/blocks/${props.block}/${cpId}/glyph.svg`,
16
+ detailUrl: `/char/${cpId.slice(2)}`,
17
+ }));
18
+ });
19
+
20
+ onMounted(async () => {
21
+ const [blockRes, labelsRes] = await Promise.all([
22
+ fetch(`/data/blocks/${props.block}.json`),
23
+ fetch(`/data/index/labels.json`),
24
+ ]);
25
+ blockMeta.value = await blockRes.json();
26
+ labels.value = await labelsRes.json();
27
+ });
28
+ </script>
29
+
30
+ <template>
31
+ <div class="block">
32
+ <header v-if="blockMeta">
33
+ <h1>{{ blockMeta.name }}</h1>
34
+ <p class="lead">
35
+ <code>{{ blockMeta.id }}</code>
36
+ — U+{{ blockMeta.range_first.toString(16).toUpperCase().padStart(4, "0") }}
37
+ – U+{{ blockMeta.range_last.toString(16).toUpperCase().padStart(4, "0") }}
38
+ ({{ cells.length }} codepoints)
39
+ </p>
40
+ </header>
41
+
42
+ <section class="grid">
43
+ <a v-for="cell in cells" :key="cell.id" :href="cell.detailUrl" class="cell">
44
+ <img :src="cell.glyphUrl" :alt="cell.label" loading="lazy" />
45
+ <span class="cp-id">{{ cell.id }}</span>
46
+ </a>
47
+ </section>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .lead { color: var(--vp-c-text-2); font-family: monospace; }
53
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 1px; background: var(--vp-c-divider); border: 1px solid var(--vp-c-divider); }
54
+ .cell { display: flex; flex-direction: column; align-items: center; padding: 0.5rem; background: var(--vp-c-bg); text-decoration: none; }
55
+ .cell img { width: 64px; height: 64px; object-fit: contain; }
56
+ .cp-id { font-family: monospace; font-size: 0.7rem; color: var(--vp-c-text-3); margin-top: 0.25rem; }
57
+ </style>
@@ -0,0 +1,85 @@
1
+ <script setup>
2
+ import { ref, onMounted, computed } from "vue";
3
+
4
+ const props = defineProps({ codepoint: { type: [String, Number], required: true } });
5
+
6
+ const cpData = ref(null);
7
+ const cpToBlock = ref({});
8
+ const error = ref(null);
9
+
10
+ const cpId = computed(() => {
11
+ const n = Number(props.codepoint);
12
+ return `U+${n.toString(16).toUpperCase().padStart(4, "0")}`;
13
+ });
14
+
15
+ const blockId = computed(() => cpToBlock.value[cpId.value]);
16
+ const glyphUrl = computed(() =>
17
+ blockId.value ? `/data/blocks/${blockId.value}/${cpId.value}/glyph.svg` : null
18
+ );
19
+
20
+ onMounted(async () => {
21
+ try {
22
+ const [mappingRes] = await Promise.all([
23
+ fetch(`/data/index/codepoint_to_block.json`),
24
+ ]);
25
+ cpToBlock.value = await mappingRes.json();
26
+ if (!blockId.value) {
27
+ error.value = `No block found for ${cpId.value}`;
28
+ return;
29
+ }
30
+ const dataRes = await fetch(`/data/blocks/${blockId.value}/${cpId.value}/index.json`);
31
+ if (!dataRes.ok) {
32
+ error.value = `No data file for ${cpId.value} (HTTP ${dataRes.status})`;
33
+ return;
34
+ }
35
+ cpData.value = await dataRes.json();
36
+ } catch (e) {
37
+ error.value = e.message;
38
+ }
39
+ });
40
+
41
+ const propertyRows = computed(() => {
42
+ if (!cpData.value) return [];
43
+ return Object.entries(cpData.value)
44
+ .filter(([_, v]) => v !== null && v !== undefined && v !== "")
45
+ .map(([k, v]) => ({ key: k, value: JSON.stringify(v) }));
46
+ });
47
+ </script>
48
+
49
+ <template>
50
+ <div class="char">
51
+ <header>
52
+ <h1><code>{{ cpId }}</code></h1>
53
+ <p v-if="cpData" class="name">{{ cpData.name }}</p>
54
+ <p v-if="error" class="error">{{ error }}</p>
55
+ </header>
56
+
57
+ <div v-if="cpData" class="body">
58
+ <aside v-if="glyphUrl" class="glyph">
59
+ <img :src="glyphUrl" :alt="cpData.name" />
60
+ </aside>
61
+
62
+ <section class="props">
63
+ <h2>Properties</h2>
64
+ <table>
65
+ <tbody>
66
+ <tr v-for="row in propertyRows" :key="row.key">
67
+ <th>{{ row.key }}</th>
68
+ <td><code>{{ row.value }}</code></td>
69
+ </tr>
70
+ </tbody>
71
+ </table>
72
+ </section>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <style scoped>
78
+ .name { font-family: monospace; color: var(--vp-c-text-2); }
79
+ .error { color: var(--vp-c-danger-1); }
80
+ .body { display: grid; grid-template-columns: 200px 1fr; gap: 2rem; }
81
+ .glyph img { width: 100%; height: auto; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-alt); }
82
+ .props table { border-collapse: collapse; }
83
+ .props th, .props td { text-align: left; padding: 0.25rem 0.75rem; border-bottom: 1px solid var(--vp-c-divider); vertical-align: top; }
84
+ .props th { font-weight: 500; color: var(--vp-c-text-2); white-space: nowrap; }
85
+ </style>
@@ -0,0 +1,56 @@
1
+ <script setup>
2
+ import { ref, onMounted, computed } from "vue";
3
+
4
+ const props = defineProps({ plane: { type: [String, Number], required: true } });
5
+
6
+ const planeMeta = ref(null);
7
+ const blocksIndex = ref([]);
8
+
9
+ const planeNumber = computed(() => Number(props.plane));
10
+ const blocksForPlane = computed(() =>
11
+ blocksIndex.value.filter((b) => b.plane_number === planeNumber.value)
12
+ );
13
+
14
+ onMounted(async () => {
15
+ const [planeRes, blocksRes] = await Promise.all([
16
+ fetch(`/data/planes/${planeNumber.value}.json`),
17
+ fetch(`/data/blocks/index.json`),
18
+ ]);
19
+ planeMeta.value = await planeRes.json();
20
+ blocksIndex.value = await blocksRes.json();
21
+ });
22
+ </script>
23
+
24
+ <template>
25
+ <div class="plane">
26
+ <header v-if="planeMeta">
27
+ <h1>
28
+ <code>U+{{ planeNumber.toString(16).toUpperCase().padStart(2, "0") }}0000</code>
29
+
30
+ <code>U+{{ planeNumber.toString(16).toUpperCase().padStart(2, "0") }}FFFF</code>
31
+ </h1>
32
+ <p class="lead">{{ planeMeta.name }} ({{ planeMeta.abbrev }})</p>
33
+ </header>
34
+
35
+ <section>
36
+ <h2>Blocks ({{ blocksForPlane.length }})</h2>
37
+ <ul class="block-list">
38
+ <li v-for="b in blocksForPlane" :key="b.id">
39
+ <a :href="`/block/${b.id}`">{{ b.name }}</a>
40
+ <small>
41
+ U+{{ b.first_cp.toString(16).toUpperCase().padStart(4, "0") }}
42
+
43
+ U+{{ b.last_cp.toString(16).toUpperCase().padStart(4, "0") }}
44
+ </small>
45
+ </li>
46
+ </ul>
47
+ </section>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .lead { color: var(--vp-c-text-2); }
53
+ .block-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.5rem; }
54
+ .block-list li { padding: 0.5rem 0.75rem; border: 1px solid var(--vp-c-divider); border-radius: 4px; }
55
+ .block-list small { display: block; color: var(--vp-c-text-3); font-family: monospace; }
56
+ </style>
@@ -0,0 +1,66 @@
1
+ <script setup>
2
+ import { ref, onMounted, computed } from "vue";
3
+ import MiniSearch from "minisearch";
4
+
5
+ const query = ref("");
6
+ const results = ref([]);
7
+ const status = ref("loading");
8
+ let index = null;
9
+
10
+ onMounted(async () => {
11
+ try {
12
+ const res = await fetch("/data/index/search.json");
13
+ const docs = await res.json();
14
+ index = new MiniSearch({
15
+ fields: ["name", "id"],
16
+ storeFields: ["name", "gc", "sc"],
17
+ idField: "id",
18
+ });
19
+ await index.addAllAsync(docs);
20
+ status.value = "ready";
21
+ } catch (e) {
22
+ status.value = "error";
23
+ }
24
+ });
25
+
26
+ const run = () => {
27
+ if (!index || query.value.trim().length < 2) {
28
+ results.value = [];
29
+ return;
30
+ }
31
+ results.value = index.search(query.value, { prefix: true, fuzzy: 0.2, limit: 50 });
32
+ };
33
+ </script>
34
+
35
+ <template>
36
+ <div class="search">
37
+ <h1>Search</h1>
38
+ <p v-if="status === 'loading'">Loading index…</p>
39
+ <p v-else-if="status === 'error'">Failed to load search index.</p>
40
+ <div v-else>
41
+ <input
42
+ v-model="query"
43
+ @input="run"
44
+ placeholder="Search by name (e.g. LATIN CAPITAL LETTER A) or U+0041"
45
+ autofocus
46
+ />
47
+ <p class="meta">{{ results.length }} result(s)</p>
48
+ <ul class="results">
49
+ <li v-for="r in results" :key="r.id">
50
+ <a :href="`/char/${r.id.slice(2)}`">
51
+ <code>{{ r.id }}</code> — {{ r.name }}
52
+ </a>
53
+ <small v-if="r.gc || r.sc">({{ [r.gc, r.sc].filter(Boolean).join(", ") }})</small>
54
+ </li>
55
+ </ul>
56
+ </div>
57
+ </div>
58
+ </template>
59
+
60
+ <style scoped>
61
+ input { width: 100%; padding: 0.5rem 0.75rem; font-size: 1rem; border: 1px solid var(--vp-c-divider); border-radius: 4px; background: var(--vp-c-bg); color: var(--vp-c-text-1); }
62
+ .meta { color: var(--vp-c-text-3); font-size: 0.85rem; }
63
+ .results { list-style: none; padding: 0; }
64
+ .results li { padding: 0.5rem 0; border-bottom: 1px solid var(--vp-c-divider); }
65
+ .results small { color: var(--vp-c-text-3); margin-left: 0.5rem; }
66
+ </style>