ucode 0.1.0 → 0.2.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/TODO.full/00-README.md +116 -0
  4. data/TODO.full/01-panglyph-vision.md +112 -0
  5. data/TODO.full/02-panglyph-repo-bootstrap.md +184 -0
  6. data/TODO.full/03-panglyph-font-builder.md +201 -0
  7. data/TODO.full/04-panglyph-publish-pipeline.md +126 -0
  8. data/TODO.full/05-ucode-0-1-1-release.md +139 -0
  9. data/TODO.full/06-fontisan-remove-audit.md +142 -0
  10. data/TODO.full/07-fontisan-remove-ucd.md +125 -0
  11. data/TODO.full/08-archive-private-bin-build.md +143 -0
  12. data/TODO.full/09-archive-public-structure.md +164 -0
  13. data/TODO.full/10-fontist-org-woff-glyphs.md +131 -0
  14. data/TODO.full/11-fontist-org-audit-coverage.md +140 -0
  15. data/TODO.full/12-implementation-order.md +216 -0
  16. data/TODO.full/13-fontisan-font-writer-api.md +189 -0
  17. data/TODO.full/14-fontisan-table-writers.md +66 -0
  18. data/TODO.full/15-panglyph-builder-real.md +82 -0
  19. data/TODO.full/16-archive-public-sync-workflows.md +167 -0
  20. data/TODO.full/17-fontist-org-font-picker.md +73 -0
  21. data/TODO.full/18-comprehensive-spec-coverage.md +64 -0
  22. data/TODO.full/19-ucode-0-1-2-patch.md +32 -0
  23. data/TODO.full/20-fontisan-0-2-23-release.md +52 -0
  24. data/TODO.new/00-README.md +30 -0
  25. data/TODO.new/23-universal-glyph-set-source-map.md +312 -0
  26. data/TODO.new/24-universal-glyph-set-build.md +189 -0
  27. data/TODO.new/25-font-audit-against-universal-set.md +195 -0
  28. data/TODO.new/26-missing-glyph-reporter.md +189 -0
  29. data/TODO.new/27-fontist-org-consumer-integration.md +200 -0
  30. data/TODO.new/28-implementation-order-update.md +187 -0
  31. data/TODO.new/29-universal-set-curation-uc17.md +312 -0
  32. data/TODO.new/30-tier1-font-acquisition.md +241 -0
  33. data/TODO.new/31-universal-set-production-build.md +205 -0
  34. data/TODO.new/32-uc17-coverage-matrix.md +165 -0
  35. data/TODO.new/33-specialist-font-acquisition-refresh.md +138 -0
  36. data/TODO.new/34-pillar2-content-stream-correlator.md +147 -0
  37. data/TODO.new/35-universal-set-production-run.md +160 -0
  38. data/TODO.new/36-per-font-coverage-audit.md +145 -0
  39. data/TODO.new/37-coverage-highlight-reporter.md +125 -0
  40. data/TODO.new/38-fontist-org-glyph-consumer.md +141 -0
  41. data/TODO.new/39-implementation-order-update-32-38.md +258 -0
  42. data/TODO.new/40-archive-private-uses-ucode-audit.md +124 -0
  43. data/TODO.new/41-ucode-unicode-archive-bridge.md +160 -0
  44. data/config/specialist_fonts.yml +102 -0
  45. data/config/unicode17_tier1_fonts.yml +42 -0
  46. data/config/unicode17_universal_glyph_set.yml +293 -0
  47. data/lib/ucode/audit/block_aggregator.rb +57 -29
  48. data/lib/ucode/audit/browser/face_page.rb +128 -0
  49. data/lib/ucode/audit/browser/glyph_panel.rb +124 -0
  50. data/lib/ucode/audit/browser/library_page.rb +74 -0
  51. data/lib/ucode/audit/browser/missing_glyph_page.rb +87 -0
  52. data/lib/ucode/audit/browser/template.rb +47 -0
  53. data/lib/ucode/audit/browser/templates/face.css +200 -0
  54. data/lib/ucode/audit/browser/templates/face.html.erb +41 -0
  55. data/lib/ucode/audit/browser/templates/face.js +298 -0
  56. data/lib/ucode/audit/browser/templates/library.css +119 -0
  57. data/lib/ucode/audit/browser/templates/library.html.erb +42 -0
  58. data/lib/ucode/audit/browser/templates/library.js +99 -0
  59. data/lib/ucode/audit/browser/templates/missing_glyph_page.css +119 -0
  60. data/lib/ucode/audit/browser/templates/missing_glyph_page.html.erb +58 -0
  61. data/lib/ucode/audit/browser/templates/missing_glyph_page.js +2 -0
  62. data/lib/ucode/audit/browser.rb +32 -0
  63. data/lib/ucode/audit/context.rb +27 -1
  64. data/lib/ucode/audit/coverage_reference.rb +103 -0
  65. data/lib/ucode/audit/differ.rb +121 -0
  66. data/lib/ucode/audit/emitter/block_emitter.rb +52 -0
  67. data/lib/ucode/audit/emitter/codepoint_emitter.rb +87 -0
  68. data/lib/ucode/audit/emitter/collection_emitter.rb +80 -0
  69. data/lib/ucode/audit/emitter/face_directory.rb +212 -0
  70. data/lib/ucode/audit/emitter/glyph_emitter.rb +48 -0
  71. data/lib/ucode/audit/emitter/index_emitter.rb +149 -0
  72. data/lib/ucode/audit/emitter/library_emitter.rb +96 -0
  73. data/lib/ucode/audit/emitter/paths.rb +312 -0
  74. data/lib/ucode/audit/emitter/plane_emitter.rb +29 -0
  75. data/lib/ucode/audit/emitter/script_emitter.rb +29 -0
  76. data/lib/ucode/audit/emitter.rb +29 -0
  77. data/lib/ucode/audit/extractors/aggregations.rb +31 -2
  78. data/lib/ucode/audit/face_auditor.rb +86 -0
  79. data/lib/ucode/audit/formatters/audit_diff_text.rb +112 -0
  80. data/lib/ucode/audit/formatters/audit_text.rb +411 -0
  81. data/lib/ucode/audit/formatters/color.rb +48 -0
  82. data/lib/ucode/audit/formatters/library_summary_text.rb +98 -0
  83. data/lib/ucode/audit/formatters/text_formatter.rb +83 -0
  84. data/lib/ucode/audit/formatters.rb +23 -0
  85. data/lib/ucode/audit/library_aggregator.rb +86 -0
  86. data/lib/ucode/audit/library_auditor.rb +105 -0
  87. data/lib/ucode/audit/reference_factory.rb +66 -0
  88. data/lib/ucode/audit/release/emitter.rb +152 -0
  89. data/lib/ucode/audit/release/face_card.rb +93 -0
  90. data/lib/ucode/audit/release/formula_audits.rb +50 -0
  91. data/lib/ucode/audit/release/library_index_builder.rb +78 -0
  92. data/lib/ucode/audit/release/manifest_builder.rb +127 -0
  93. data/lib/ucode/audit/release.rb +42 -0
  94. data/lib/ucode/audit/ucd_only_reference.rb +81 -0
  95. data/lib/ucode/audit/universal_set_reference.rb +136 -0
  96. data/lib/ucode/audit.rb +32 -0
  97. data/lib/ucode/cli.rb +366 -41
  98. data/lib/ucode/commands/audit/browser_command.rb +82 -0
  99. data/lib/ucode/commands/audit/collection_command.rb +103 -0
  100. data/lib/ucode/commands/audit/compare_command.rb +188 -0
  101. data/lib/ucode/commands/audit/font_command.rb +140 -0
  102. data/lib/ucode/commands/audit/library_command.rb +87 -0
  103. data/lib/ucode/commands/audit.rb +19 -0
  104. data/lib/ucode/commands/block_feed.rb +73 -0
  105. data/lib/ucode/commands/build.rb +4 -0
  106. data/lib/ucode/commands/canonical_build.rb +137 -0
  107. data/lib/ucode/commands/fetch.rb +49 -15
  108. data/lib/ucode/commands/glyphs.rb +25 -67
  109. data/lib/ucode/commands/lookup.rb +11 -11
  110. data/lib/ucode/commands/parse.rb +7 -5
  111. data/lib/ucode/commands/release.rb +114 -0
  112. data/lib/ucode/commands/universal_set.rb +207 -0
  113. data/lib/ucode/commands.rb +5 -0
  114. data/lib/ucode/coordinator/indices.rb +49 -2
  115. data/lib/ucode/coordinator.rb +138 -5
  116. data/lib/ucode/error.rb +30 -2
  117. data/lib/ucode/fetch/font_fetcher/result.rb +39 -0
  118. data/lib/ucode/fetch/font_fetcher.rb +16 -0
  119. data/lib/ucode/fetch/specialist_font_fetcher.rb +280 -0
  120. data/lib/ucode/fetch.rb +7 -3
  121. data/lib/ucode/glyphs/pipeline.rb +106 -0
  122. data/lib/ucode/glyphs/real_fonts/cmap_cache.rb +74 -0
  123. data/lib/ucode/glyphs/real_fonts.rb +1 -0
  124. data/lib/ucode/glyphs/resolver.rb +62 -0
  125. data/lib/ucode/glyphs/source.rb +48 -0
  126. data/lib/ucode/glyphs/source_builder.rb +61 -0
  127. data/lib/ucode/glyphs/source_config/coverage_assertion.rb +79 -0
  128. data/lib/ucode/glyphs/source_config/gap_report.rb +54 -0
  129. data/lib/ucode/glyphs/source_config.rb +104 -0
  130. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +63 -0
  131. data/lib/ucode/glyphs/sources/pillar3_last_resort.rb +51 -0
  132. data/lib/ucode/glyphs/sources/tier1_real_font.rb +104 -0
  133. data/lib/ucode/glyphs/sources.rb +20 -0
  134. data/lib/ucode/glyphs/universal_set/builder.rb +161 -0
  135. data/lib/ucode/glyphs/universal_set/coverage_report.rb +139 -0
  136. data/lib/ucode/glyphs/universal_set/idempotency.rb +86 -0
  137. data/lib/ucode/glyphs/universal_set/manifest_accumulator.rb +195 -0
  138. data/lib/ucode/glyphs/universal_set/manifest_writer.rb +61 -0
  139. data/lib/ucode/glyphs/universal_set/pre_build_check.rb +197 -0
  140. data/lib/ucode/glyphs/universal_set/validator.rb +204 -0
  141. data/lib/ucode/glyphs/universal_set.rb +45 -0
  142. data/lib/ucode/glyphs.rb +7 -0
  143. data/lib/ucode/models/audit/baseline.rb +6 -0
  144. data/lib/ucode/models/audit/block_summary.rb +7 -0
  145. data/lib/ucode/models/audit/codepoint_provenance.rb +39 -0
  146. data/lib/ucode/models/audit/release_face.rb +42 -0
  147. data/lib/ucode/models/audit/release_formula.rb +33 -0
  148. data/lib/ucode/models/audit/release_manifest.rb +43 -0
  149. data/lib/ucode/models/audit/release_universal_set.rb +37 -0
  150. data/lib/ucode/models/audit.rb +9 -0
  151. data/lib/ucode/models/block.rb +2 -0
  152. data/lib/ucode/models/build_report.rb +109 -0
  153. data/lib/ucode/models/codepoint/glyph.rb +42 -0
  154. data/lib/ucode/models/codepoint.rb +3 -0
  155. data/lib/ucode/models/glyph_source.rb +86 -0
  156. data/lib/ucode/models/glyph_source_map.rb +138 -0
  157. data/lib/ucode/models/specialist_font.rb +70 -0
  158. data/lib/ucode/models/specialist_font_manifest.rb +48 -0
  159. data/lib/ucode/models/unihan_entry.rb +81 -9
  160. data/lib/ucode/models/unihan_field.rb +21 -0
  161. data/lib/ucode/models/universal_set_entry.rb +47 -0
  162. data/lib/ucode/models/universal_set_manifest.rb +78 -0
  163. data/lib/ucode/models/validation_report.rb +99 -0
  164. data/lib/ucode/models.rb +9 -0
  165. data/lib/ucode/parsers/named_sequences.rb +5 -5
  166. data/lib/ucode/parsers/unihan.rb +50 -19
  167. data/lib/ucode/repo/aggregate_writer.rb +82 -288
  168. data/lib/ucode/repo/block_feed_emitter.rb +153 -0
  169. data/lib/ucode/repo/build_report_accumulator.rb +138 -0
  170. data/lib/ucode/repo/build_report_writer.rb +46 -0
  171. data/lib/ucode/repo/build_validator.rb +229 -0
  172. data/lib/ucode/repo/codepoint_writer.rb +50 -1
  173. data/lib/ucode/repo/paths.rb +8 -0
  174. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  175. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  176. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  177. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  178. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  179. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  180. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  181. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  182. data/lib/ucode/repo/writers.rb +20 -0
  183. data/lib/ucode/repo.rb +5 -0
  184. data/lib/ucode/version.rb +1 -1
  185. data/schema/block-feed.output.schema.yml +134 -0
  186. metadata +158 -4
  187. data/Gemfile.lock +0 -406
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ucode/repo/atomic_writes"
4
+ require "ucode/repo/paths"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/blocks/<ID>.json` for every block plus
10
+ # `output/blocks/index.json` as a summary.
11
+ #
12
+ # One of the eight per-concern writers split out from
13
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
14
+ class BlocksWriter
15
+ include AtomicWrites
16
+
17
+ # @param output_root [Pathname]
18
+ # @param blocks [Array<Ucode::Models::Block>]
19
+ # @param block_codepoint_ids [Hash{String => Array<String>}]
20
+ # block_id → sorted cp_id list, accumulated during the
21
+ # streaming pass
22
+ # @param block_ages [Hash{String => String}] block_id → earliest
23
+ # DerivedAge string; nil entries get written as nil
24
+ def initialize(output_root:, blocks:, block_codepoint_ids:, block_ages:)
25
+ @output_root = output_root
26
+ @blocks = blocks
27
+ @block_codepoint_ids = block_codepoint_ids
28
+ @block_ages = block_ages
29
+ end
30
+
31
+ # @return [Integer] number of files written (one per block plus
32
+ # one for the index)
33
+ def write
34
+ count = @blocks.sum do |block|
35
+ block.age = @block_ages[block.id]
36
+ path = Paths.block_metadata_path(@output_root, block.id)
37
+ write_atomic(path, block_payload(block)) ? 1 : 0
38
+ end
39
+ count + write_blocks_index
40
+ end
41
+
42
+ private
43
+
44
+ def write_blocks_index
45
+ path = Paths.blocks_index_path(@output_root)
46
+ summary = @blocks.map do |block|
47
+ {
48
+ "id" => block.id,
49
+ "name" => block.name,
50
+ "first_cp" => block.range_first,
51
+ "last_cp" => block.range_last,
52
+ "plane_number" => block.plane_number,
53
+ "age" => @block_ages[block.id],
54
+ }
55
+ end
56
+ write_atomic(path, to_pretty_json(summary)) ? 1 : 0
57
+ end
58
+
59
+ def block_payload(block)
60
+ to_pretty_json(
61
+ "id" => block.id,
62
+ "name" => block.name,
63
+ "range_first" => block.range_first,
64
+ "range_last" => block.range_last,
65
+ "plane_number" => block.plane_number,
66
+ "age" => @block_ages[block.id],
67
+ "codepoint_ids" => (@block_codepoint_ids[block.id] || []),
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/repo/atomic_writes"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/enums.json` carrying the full property-alias
10
+ # and property-value-alias tables.
11
+ #
12
+ # One of the eight per-concern writers split out from
13
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
14
+ class EnumsWriter
15
+ include AtomicWrites
16
+
17
+ # @param output_root [Pathname]
18
+ # @param property_aliases [Array<Ucode::Models::PropertyAlias>]
19
+ # @param property_value_aliases [Array<Ucode::Models::PropertyValueAlias>]
20
+ def initialize(output_root:, property_aliases:, property_value_aliases:)
21
+ @output_root = output_root
22
+ @property_aliases = property_aliases
23
+ @property_value_aliases = property_value_aliases
24
+ end
25
+
26
+ # @return [Integer] 1 if written, 0 otherwise
27
+ def write
28
+ path = Pathname(@output_root).join("enums.json")
29
+ payload = {
30
+ "properties" => @property_aliases.map(&:to_yaml_hash),
31
+ "property_values" => @property_value_aliases.map(&:to_yaml_hash),
32
+ }
33
+ write_atomic(path, to_pretty_json(payload)) ? 1 : 0
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/repo/atomic_writes"
5
+ require "ucode/repo/paths"
6
+
7
+ module Ucode
8
+ module Repo
9
+ module Writers
10
+ # Writes the three lookup indexes:
11
+ #
12
+ # output/index/names.json (cp_id → name)
13
+ # output/index/labels.json (cp_id → {name, gc, sc, cc, bc, mir})
14
+ # output/index/codepoint_to_block.json (cp_id → block_id)
15
+ #
16
+ # One of the eight per-concern writers split out from
17
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
18
+ class IndexesWriter
19
+ include AtomicWrites
20
+
21
+ # @param output_root [Pathname]
22
+ # @param names [Hash{String => String}] cp_id → name
23
+ # @param labels [Hash{String => Hash}] cp_id → label fields
24
+ # @param cp_to_block [Hash{String => String}] cp_id → block_id
25
+ def initialize(output_root:, names:, labels:, cp_to_block:)
26
+ @output_root = output_root
27
+ @names = names
28
+ @labels = labels
29
+ @cp_to_block = cp_to_block
30
+ end
31
+
32
+ # @return [Integer] number of index files written (always 3
33
+ # when the directory is reachable)
34
+ def write
35
+ count = 0
36
+ count += 1 if write_atomic(Paths.names_index_path(@output_root),
37
+ to_pretty_json(@names))
38
+ count += 1 if write_atomic(Paths.labels_index_path(@output_root),
39
+ to_pretty_json(@labels))
40
+ count += 1 if write_atomic(codepoint_to_block_path,
41
+ to_pretty_json(@cp_to_block))
42
+ count
43
+ end
44
+
45
+ private
46
+
47
+ def codepoint_to_block_path
48
+ Pathname(@output_root).join("index", "codepoint_to_block.json")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "time"
6
+
7
+ require "ucode/repo/atomic_writes"
8
+ require "ucode/repo/paths"
9
+
10
+ module Ucode
11
+ module Repo
12
+ module Writers
13
+ # Writes `output/manifest.json`. The `generated_at` timestamp is
14
+ # preserved across no-op re-runs (same content keys → keep old
15
+ # timestamp) so the byte content is byte-idempotent.
16
+ #
17
+ # One of the eight per-concern writers split out from
18
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
19
+ class ManifestWriter
20
+ include AtomicWrites
21
+
22
+ # Fields that define the manifest's semantic content. When
23
+ # these match the existing manifest on disk, we preserve the
24
+ # old `generated_at` so re-runs are byte-idempotent.
25
+ CONTENT_KEYS = %w[
26
+ ucd_version codepoint_count glyph_count schema_version
27
+ ].freeze
28
+ private_constant :CONTENT_KEYS
29
+
30
+ SCHEMA_VERSION = "1"
31
+ private_constant :SCHEMA_VERSION
32
+
33
+ # @param output_root [Pathname]
34
+ # @param ucd_version [String]
35
+ # @param codepoint_count [Integer]
36
+ # @param glyph_count [Integer]
37
+ def initialize(output_root:, ucd_version:, codepoint_count:, glyph_count:)
38
+ @output_root = output_root
39
+ @ucd_version = ucd_version
40
+ @codepoint_count = codepoint_count
41
+ @glyph_count = glyph_count
42
+ end
43
+
44
+ # @return [Integer] 1 if written, 0 otherwise
45
+ def write
46
+ path = Paths.manifest_path(@output_root)
47
+ content = {
48
+ "ucd_version" => @ucd_version,
49
+ "codepoint_count" => @codepoint_count,
50
+ "glyph_count" => @glyph_count,
51
+ "schema_version" => SCHEMA_VERSION,
52
+ }
53
+ ts = preserved_or_new_timestamp(path, content)
54
+ payload = content.merge("generated_at" => ts)
55
+ write_atomic(path, to_pretty_json(payload)) ? 1 : 0
56
+ end
57
+
58
+ private
59
+
60
+ def preserved_or_new_timestamp(path, content)
61
+ existing = read_manifest(path)
62
+ return Time.now.utc.iso8601 unless existing
63
+
64
+ unchanged = CONTENT_KEYS.all? { |k| existing[k] == content[k] }
65
+ unchanged ? existing["generated_at"] : Time.now.utc.iso8601
66
+ end
67
+
68
+ def read_manifest(path)
69
+ return nil unless path.exist?
70
+
71
+ JSON.parse(path.read)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/repo/atomic_writes"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes one file per named sequence under
10
+ # `output/named_sequences/<slug>.json`. Empty input writes nothing.
11
+ #
12
+ # One of the eight per-concern writers split out from
13
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
14
+ class NamedSequencesWriter
15
+ include AtomicWrites
16
+
17
+ # @param output_root [Pathname]
18
+ # @param named_sequences [Array<Ucode::Models::NamedSequence>]
19
+ def initialize(output_root:, named_sequences:)
20
+ @output_root = output_root
21
+ @named_sequences = named_sequences
22
+ end
23
+
24
+ # @return [Integer] number of named-sequence files written
25
+ def write
26
+ return 0 if @named_sequences.nil? || @named_sequences.empty?
27
+
28
+ dir = Pathname(@output_root).join("named_sequences")
29
+ @named_sequences.sum do |ns|
30
+ path = dir.join("#{slug_for(ns)}.json")
31
+ write_atomic(path, ns.to_json(pretty: true)) ? 1 : 0
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Slug derived from the name: downcase, non-alphanumerics → "_".
38
+ def slug_for(named_sequence)
39
+ named_sequence.name
40
+ .downcase
41
+ .gsub(/[^a-z0-9]+/, "_")
42
+ .gsub(/^_+|_+$/, "")
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ucode/repo/atomic_writes"
4
+ require "ucode/repo/paths"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/planes/<n>.json` for every plane (0..16).
10
+ #
11
+ # One of the eight per-concern writers split out from
12
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
13
+ class PlanesWriter
14
+ include AtomicWrites
15
+
16
+ # Static metadata for the 17 Unicode planes. Planes 4–13 are
17
+ # unassigned in Unicode 17; their entries use placeholder names.
18
+ PLANE_TABLE = {
19
+ 0 => ["Basic Multilingual Plane", "BMP"],
20
+ 1 => ["Supplementary Multilingual Plane", "SMP"],
21
+ 2 => ["Supplementary Ideographic Plane", "SIP"],
22
+ 3 => ["Tertiary Ideographic Plane", "TIP"],
23
+ 4 => ["Unassigned Plane 4", "—"],
24
+ 5 => ["Unassigned Plane 5", "—"],
25
+ 6 => ["Unassigned Plane 6", "—"],
26
+ 7 => ["Unassigned Plane 7", "—"],
27
+ 8 => ["Unassigned Plane 8", "—"],
28
+ 9 => ["Unassigned Plane 9", "—"],
29
+ 10 => ["Unassigned Plane 10", "—"],
30
+ 11 => ["Unassigned Plane 11", "—"],
31
+ 12 => ["Unassigned Plane 12", "—"],
32
+ 13 => ["Unassigned Plane 13", "—"],
33
+ 14 => ["Supplementary Special-purpose Plane", "SSP"],
34
+ 15 => ["Supplementary Private Use Area-A", "SPUA-A"],
35
+ 16 => ["Supplementary Private Use Area-B", "SPUA-B"],
36
+ }.freeze
37
+ private_constant :PLANE_TABLE
38
+
39
+ # @param output_root [Pathname]
40
+ # @param blocks [Array<Ucode::Models::Block>]
41
+ def initialize(output_root:, blocks:)
42
+ @output_root = output_root
43
+ @blocks = blocks
44
+ end
45
+
46
+ # @return [Integer] number of plane files written (always 17
47
+ # when the directory is reachable; one per plane number)
48
+ def write
49
+ plane_block_ids = group_block_ids_by_plane
50
+ count = 0
51
+ (0..16).each do |n|
52
+ path = Paths.plane_metadata_path(@output_root, n)
53
+ count += 1 if write_atomic(path, plane_payload(n, plane_block_ids[n] || []))
54
+ end
55
+ count
56
+ end
57
+
58
+ private
59
+
60
+ def group_block_ids_by_plane
61
+ @blocks.each_with_object(Hash.new { |h, k| h[k] = [] }) do |block, h|
62
+ h[block.plane_number] << block.id
63
+ end
64
+ end
65
+
66
+ def plane_payload(plane_number, block_ids)
67
+ name, abbrev = PLANE_TABLE.fetch(plane_number)
68
+ range_first = plane_number * 0x10000
69
+ range_last = range_first + 0xFFFF
70
+ to_pretty_json(
71
+ "number" => plane_number,
72
+ "name" => name,
73
+ "abbrev" => abbrev,
74
+ "range_first" => range_first,
75
+ "range_last" => range_last,
76
+ "block_ids" => block_ids,
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ucode/coordinator"
5
+ require "ucode/repo/atomic_writes"
6
+ require "ucode/repo/paths"
7
+
8
+ module Ucode
9
+ module Repo
10
+ module Writers
11
+ # Writes one file per relationship table under
12
+ # `output/relationships/`. The set of tables is enumerated by
13
+ # `Coordinator::Indices#each_relationship` (see Candidate 1 of the
14
+ # 2026-06-29 review).
15
+ #
16
+ # One of the eight per-concern writers split out from
17
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
18
+ class RelationshipsWriter
19
+ include AtomicWrites
20
+
21
+ # @param output_root [Pathname]
22
+ # @param indices [Ucode::Coordinator::Indices]
23
+ def initialize(output_root:, indices:)
24
+ @output_root = output_root
25
+ @indices = indices
26
+ end
27
+
28
+ # @return [Integer] number of relationship files written
29
+ def write
30
+ @indices.each_relationship.sum do |slug, records|
31
+ write_relationship_file(slug, records)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def write_relationship_file(slug, records)
38
+ return 0 if records.nil? || records.empty?
39
+
40
+ path = Pathname(@output_root).join("relationships", "#{slug}.json")
41
+ write_atomic(path, relationship_payload(records)) ? 1 : 0
42
+ end
43
+
44
+ # records is Hash<Integer, Record>, Hash<Integer, Array<Record>>,
45
+ # Hash<String, Record>, or Hash<String, Array<Record>>.
46
+ def relationship_payload(records)
47
+ payload = records.each_with_object({}) do |(key, value), h|
48
+ h[key_to_cp_id(key)] = serialize_value(value)
49
+ end
50
+ to_pretty_json(payload)
51
+ end
52
+
53
+ # Integer codepoint keys are formatted as "U+XXXX"; string id
54
+ # keys (cjk_radicals, standardized_variants) pass through.
55
+ def key_to_cp_id(key)
56
+ key.is_a?(Integer) ? Paths.cp_id(key) : key
57
+ end
58
+
59
+ def serialize_value(value)
60
+ return value.map { |v| serialize_one(v) } if value.is_a?(Array)
61
+
62
+ serialize_one(value)
63
+ end
64
+
65
+ def serialize_one(record)
66
+ record.to_yaml_hash
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ucode/repo/atomic_writes"
4
+ require "ucode/repo/paths"
5
+
6
+ module Ucode
7
+ module Repo
8
+ module Writers
9
+ # Writes `output/scripts/<code>.json` for every assigned script.
10
+ #
11
+ # One of the eight per-concern writers split out from
12
+ # AggregateWriter — see Candidate 5 of the 2026-06-29 review.
13
+ class ScriptsWriter
14
+ include AtomicWrites
15
+
16
+ # @param output_root [Pathname]
17
+ # @param scripts [Array<Ucode::Models::Script>] from
18
+ # Coordinator::Indices
19
+ # @param script_codepoint_ids [Hash{String => Array<String>}]
20
+ # ISO 15924 code → cp_id list, accumulated during the
21
+ # streaming pass
22
+ def initialize(output_root:, scripts:, script_codepoint_ids:)
23
+ @output_root = output_root
24
+ @scripts = scripts
25
+ @script_codepoint_ids = script_codepoint_ids
26
+ end
27
+
28
+ # @return [Integer] number of script files written
29
+ def write
30
+ count = 0
31
+ @scripts.group_by(&:code).each do |code, ranges|
32
+ next if code.nil? || code.empty?
33
+
34
+ path = Paths.script_metadata_path(@output_root, code)
35
+ count += 1 if write_atomic(path, script_payload(code, ranges))
36
+ end
37
+ count
38
+ end
39
+
40
+ private
41
+
42
+ def script_payload(code, ranges)
43
+ to_pretty_json(
44
+ "code" => code,
45
+ "name" => ranges.first&.name,
46
+ "range_first" => ranges.map(&:range_first).min,
47
+ "range_last" => ranges.map(&:range_last).max,
48
+ "codepoint_ids" => (@script_codepoint_ids[code] || []),
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Repo
5
+ # Per-concern writer classes, one per output file kind. Each
6
+ # conforms to the `#write → Integer` interface (returns the count
7
+ # of files written). Composed by AggregateWriter#flush — adding a
8
+ # new aggregate = one writer class + one line in AggregateWriter.
9
+ module Writers
10
+ autoload :PlanesWriter, "ucode/repo/writers/planes_writer"
11
+ autoload :BlocksWriter, "ucode/repo/writers/blocks_writer"
12
+ autoload :ScriptsWriter, "ucode/repo/writers/scripts_writer"
13
+ autoload :IndexesWriter, "ucode/repo/writers/indexes_writer"
14
+ autoload :RelationshipsWriter, "ucode/repo/writers/relationships_writer"
15
+ autoload :EnumsWriter, "ucode/repo/writers/enums_writer"
16
+ autoload :NamedSequencesWriter, "ucode/repo/writers/named_sequences_writer"
17
+ autoload :ManifestWriter, "ucode/repo/writers/manifest_writer"
18
+ end
19
+ end
20
+ end
data/lib/ucode/repo.rb CHANGED
@@ -18,5 +18,10 @@ module Ucode
18
18
  autoload :AtomicWrites, "ucode/repo/atomic_writes"
19
19
  autoload :CodepointWriter, "ucode/repo/codepoint_writer"
20
20
  autoload :AggregateWriter, "ucode/repo/aggregate_writer"
21
+ autoload :Writers, "ucode/repo/writers"
22
+ autoload :BuildReportAccumulator, "ucode/repo/build_report_accumulator"
23
+ autoload :BuildReportWriter, "ucode/repo/build_report_writer"
24
+ autoload :BuildValidator, "ucode/repo/build_validator"
25
+ autoload :BlockFeedEmitter, "ucode/repo/block_feed_emitter"
21
26
  end
22
27
  end
data/lib/ucode/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ucode
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,134 @@
1
+ # ucode block-feed output schema
2
+ #
3
+ # This is the canonical shape of ucode's `block-feed` output — a compact
4
+ # per-block Unicode data feed derived from ucode's canonical output tree.
5
+ #
6
+ # ucode emits three files under the target directory:
7
+ #
8
+ # unicode-blocks.json
9
+ # unicode-version.json
10
+ # unicode/blocks/<slug>.json
11
+ #
12
+ # Block slugs are derived from the Unicode block display name via the
13
+ # standard slug algorithm:
14
+ #
15
+ # name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
16
+ #
17
+ # Field names are short (cp, n, c, s, cc, bc, mir) because the per-block
18
+ # files inline every codepoint — byte size matters at the long tail
19
+ # (CJK Unified Ideographs alone is 20,992 chars in one file).
20
+ #
21
+ # This schema describes Unicode data only. It is not specific to any
22
+ # consumer; any tooling that wants a compact per-block view of Unicode
23
+ # can consume it.
24
+
25
+ $schema: "https://json-schema.org/draft/2020-12/schema"
26
+ title: UcodeBlockFeed
27
+ description: Canonical shape of ucode's compact per-block Unicode data feed
28
+
29
+ definitions:
30
+
31
+ codepoint:
32
+ description: A single Unicode codepoint's compact metadata.
33
+ type: object
34
+ additionalProperties: false
35
+ required: [cp]
36
+ properties:
37
+ cp:
38
+ type: integer
39
+ minimum: 0
40
+ maximum: 0x10FFFF
41
+ description: The Unicode scalar value (integer form of U+XXXX).
42
+ n:
43
+ type: string
44
+ minLength: 1
45
+ description: Codepoint name (e.g. "LATIN CAPITAL LETTER A"). Omitted when empty.
46
+ c:
47
+ type: string
48
+ minLength: 1
49
+ pattern: '^[A-Z][a-z]$'
50
+ description: General category short code (Lu, Ll, Mn, So, ...).
51
+ s:
52
+ type: string
53
+ minLength: 2
54
+ description: Script code (Latn, Grek, Hani, Zyyy, ...). 4-letter ISO 15924.
55
+ cc:
56
+ type: integer
57
+ minimum: 0
58
+ maximum: 255
59
+ description: Canonical combining class. Always present (0 for non-marks).
60
+ bc:
61
+ type: string
62
+ minLength: 1
63
+ description: Bidi class short code (L, R, AL, AN, NSM, BN, ...).
64
+ mir:
65
+ type: boolean
66
+ description: Bidi mirrored flag. Omitted when false.
67
+
68
+ block_summary:
69
+ description: One entry per Unicode block in unicode-blocks.json.
70
+ type: object
71
+ additionalProperties: false
72
+ required: [start, end, name, unicode_version]
73
+ properties:
74
+ start:
75
+ type: integer
76
+ minimum: 0
77
+ maximum: 0x10FFFF
78
+ description: First codepoint in the block (inclusive).
79
+ end:
80
+ type: integer
81
+ minimum: 0
82
+ maximum: 0x10FFFF
83
+ description: Last codepoint in the block (inclusive).
84
+ name:
85
+ type: string
86
+ minLength: 1
87
+ description: Unicode block display name (e.g. "Basic Latin", "Greek and Coptic").
88
+ unicode_version:
89
+ type: string
90
+ pattern: '^\d+\.\d+\.\d+$'
91
+ description: Earliest Unicode version of any codepoint in the block (e.g. "1.1", "17.0.0").
92
+
93
+ type: object
94
+ properties:
95
+
96
+ unicode-blocks.json:
97
+ type: array
98
+ description: Top-level index of all Unicode blocks (~346 entries for Unicode 17).
99
+ items:
100
+ $ref: '#/definitions/block_summary'
101
+
102
+ unicode-version.json:
103
+ type: object
104
+ additionalProperties: false
105
+ required: [version, blockCount, charCount, generatedAt]
106
+ properties:
107
+ version:
108
+ type: string
109
+ pattern: '^\d+\.\d+\.\d+$'
110
+ description: UCD version (e.g. "17.0.0").
111
+ blockCount:
112
+ type: integer
113
+ minimum: 1
114
+ description: Total number of blocks (must match length of unicode-blocks.json).
115
+ charCount:
116
+ type: integer
117
+ minimum: 1
118
+ description: Total assigned codepoints across all blocks.
119
+ generatedAt:
120
+ type: string
121
+ format: date-time
122
+ description: ISO 8601 timestamp of the emitter run.
123
+
124
+ unicode/blocks/<slug>.json:
125
+ type: object
126
+ additionalProperties: false
127
+ required: [chars]
128
+ properties:
129
+ chars:
130
+ type: array
131
+ minItems: 0
132
+ description: Codepoints in this block, ordered by cp ascending.
133
+ items:
134
+ $ref: '#/definitions/codepoint'