ucode 0.1.1 → 0.2.1
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 +4 -4
- data/TODO.extract-code-chart/01-pdf-fetch-validation.md +80 -0
- data/TODO.extract-code-chart/02-block-name-resolver.md +68 -0
- data/TODO.extract-code-chart/03-codechart-namespace.md +82 -0
- data/TODO.extract-code-chart/04-codechart-extractor.md +154 -0
- data/TODO.extract-code-chart/05-provenance-and-sidecar.md +147 -0
- data/TODO.extract-code-chart/06-codechart-writer.md +134 -0
- data/TODO.extract-code-chart/07-codechart-cli.md +135 -0
- data/TODO.extract-code-chart/08-specs.md +87 -0
- data/lib/ucode/audit/reference_factory.rb +66 -0
- data/lib/ucode/audit.rb +1 -0
- data/lib/ucode/cli.rb +134 -16
- data/lib/ucode/code_chart/extractor.rb +122 -0
- data/lib/ucode/code_chart/provenance.rb +81 -0
- data/lib/ucode/code_chart/sidecar.rb +52 -0
- data/lib/ucode/code_chart/writer.rb +128 -0
- data/lib/ucode/code_chart.rb +39 -0
- data/lib/ucode/commands/audit.rb +0 -1
- data/lib/ucode/commands/build.rb +4 -0
- data/lib/ucode/commands/canonical_build.rb +2 -3
- data/lib/ucode/commands/fetch.rb +12 -14
- data/lib/ucode/commands/glyphs.rb +25 -67
- data/lib/ucode/commands/lookup.rb +11 -11
- data/lib/ucode/commands/parse.rb +7 -5
- data/lib/ucode/commands/release.rb +0 -1
- data/lib/ucode/commands/universal_set.rb +10 -14
- data/lib/ucode/coordinator/indices.rb +38 -2
- data/lib/ucode/error.rb +11 -0
- data/lib/ucode/fetch/code_charts.rb +1 -1
- data/lib/ucode/fetch/http.rb +84 -14
- data/lib/ucode/glyphs/pipeline.rb +106 -0
- data/lib/ucode/glyphs.rb +1 -0
- data/lib/ucode/parsers/blocks.rb +34 -0
- data/lib/ucode/repo/aggregate_writer.rb +60 -298
- data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
- data/lib/ucode/repo/writers/enums_writer.rb +38 -0
- data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
- data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
- data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
- data/lib/ucode/repo/writers/planes_writer.rb +82 -0
- data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
- data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
- data/lib/ucode/repo/writers.rb +20 -0
- data/lib/ucode/repo.rb +1 -0
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +3 -0
- data/ucode.gemspec +56 -0
- metadata +31 -5
- data/Gemfile.lock +0 -406
- data/lib/ucode/commands/audit/reference_builder.rb +0 -64
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
-
require "json"
|
|
5
|
-
require "time"
|
|
6
4
|
|
|
7
|
-
require "ucode/
|
|
8
|
-
require "ucode/repo/
|
|
9
|
-
require "ucode/repo/
|
|
5
|
+
require "ucode/repo/writers/planes_writer"
|
|
6
|
+
require "ucode/repo/writers/blocks_writer"
|
|
7
|
+
require "ucode/repo/writers/scripts_writer"
|
|
8
|
+
require "ucode/repo/writers/indexes_writer"
|
|
9
|
+
require "ucode/repo/writers/relationships_writer"
|
|
10
|
+
require "ucode/repo/writers/enums_writer"
|
|
11
|
+
require "ucode/repo/writers/named_sequences_writer"
|
|
12
|
+
require "ucode/repo/writers/manifest_writer"
|
|
10
13
|
|
|
11
14
|
module Ucode
|
|
12
15
|
module Repo
|
|
@@ -14,67 +17,30 @@ module Ucode
|
|
|
14
17
|
#
|
|
15
18
|
# output/planes/<n>.json
|
|
16
19
|
# output/blocks/<ID>.json
|
|
17
|
-
# output/blocks/index.json
|
|
20
|
+
# output/blocks/index.json
|
|
18
21
|
# output/scripts/<code>.json
|
|
19
|
-
# output/index/names.json
|
|
20
|
-
# output/index/labels.json
|
|
21
|
-
# output/index/codepoint_to_block.json
|
|
22
|
-
# output/relationships/*.json
|
|
23
|
-
# output/enums.json
|
|
22
|
+
# output/index/names.json
|
|
23
|
+
# output/index/labels.json
|
|
24
|
+
# output/index/codepoint_to_block.json
|
|
25
|
+
# output/relationships/*.json
|
|
26
|
+
# output/enums.json
|
|
24
27
|
# output/named_sequences/<slug>.json
|
|
25
28
|
# output/manifest.json
|
|
26
29
|
#
|
|
27
|
-
# **Single pass**: callers feed one CodePoint at a time via `#add
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
+
# **Single pass**: callers feed one CodePoint at a time via `#add`,
|
|
31
|
+
# which folds into the streaming accumulators. `#flush` then
|
|
32
|
+
# composes eight per-concern writer classes (one per output kind)
|
|
33
|
+
# and runs them in order. Adding a new aggregate = adding one
|
|
34
|
+
# writer class + one line here. See Candidate 5 of the 2026-06-29
|
|
35
|
+
# architecture review.
|
|
30
36
|
#
|
|
31
37
|
# **MECE**:
|
|
32
38
|
# - paths: `Repo::Paths`
|
|
33
39
|
# - atomic writes: `Repo::AtomicWrites`
|
|
34
|
-
# - stream aggregation: this class
|
|
40
|
+
# - stream aggregation: this class (the `#add` half)
|
|
41
|
+
# - per-concern writers: `Repo::Writers::*`
|
|
35
42
|
# - serialization: lutaml-model `to_yaml_hash` / `to_json`
|
|
36
43
|
class AggregateWriter
|
|
37
|
-
include AtomicWrites
|
|
38
|
-
|
|
39
|
-
# Static metadata for the 17 Unicode planes. Planes 4–13 are
|
|
40
|
-
# unassigned in Unicode 17; their entries use placeholder names.
|
|
41
|
-
PLANE_TABLE = {
|
|
42
|
-
0 => ["Basic Multilingual Plane", "BMP"],
|
|
43
|
-
1 => ["Supplementary Multilingual Plane", "SMP"],
|
|
44
|
-
2 => ["Supplementary Ideographic Plane", "SIP"],
|
|
45
|
-
3 => ["Tertiary Ideographic Plane", "TIP"],
|
|
46
|
-
4 => ["Unassigned Plane 4", "—"],
|
|
47
|
-
5 => ["Unassigned Plane 5", "—"],
|
|
48
|
-
6 => ["Unassigned Plane 6", "—"],
|
|
49
|
-
7 => ["Unassigned Plane 7", "—"],
|
|
50
|
-
8 => ["Unassigned Plane 8", "—"],
|
|
51
|
-
9 => ["Unassigned Plane 9", "—"],
|
|
52
|
-
10 => ["Unassigned Plane 10", "—"],
|
|
53
|
-
11 => ["Unassigned Plane 11", "—"],
|
|
54
|
-
12 => ["Unassigned Plane 12", "—"],
|
|
55
|
-
13 => ["Unassigned Plane 13", "—"],
|
|
56
|
-
14 => ["Supplementary Special-purpose Plane", "SSP"],
|
|
57
|
-
15 => ["Supplementary Private Use Area-A", "SPUA-A"],
|
|
58
|
-
16 => ["Supplementary Private Use Area-B", "SPUA-B"],
|
|
59
|
-
}.freeze
|
|
60
|
-
private_constant :PLANE_TABLE
|
|
61
|
-
|
|
62
|
-
# Coordinator::Indices fields paired with the file slug used
|
|
63
|
-
# under `output/relationships/`. Each field is a Hash<Integer,
|
|
64
|
-
# Record> or Hash<Integer, Array<Record>>.
|
|
65
|
-
RELATIONSHIP_SOURCES = {
|
|
66
|
-
special_casing: "special_casing",
|
|
67
|
-
case_folding: "case_folding",
|
|
68
|
-
bidi_mirroring: "bidi_mirroring",
|
|
69
|
-
bidi_brackets: "bidi_brackets",
|
|
70
|
-
cjk_radicals: "cjk_radicals",
|
|
71
|
-
standardized_variants: "standardized_variants",
|
|
72
|
-
name_aliases: "name_aliases",
|
|
73
|
-
}.freeze
|
|
74
|
-
private_constant :RELATIONSHIP_SOURCES
|
|
75
|
-
|
|
76
|
-
attr_reader :codepoint_count
|
|
77
|
-
|
|
78
44
|
# @param output_root [String, Pathname]
|
|
79
45
|
def initialize(output_root)
|
|
80
46
|
@output_root = Pathname.new(output_root)
|
|
@@ -87,6 +53,8 @@ module Ucode
|
|
|
87
53
|
@codepoint_count = 0
|
|
88
54
|
end
|
|
89
55
|
|
|
56
|
+
attr_reader :codepoint_count
|
|
57
|
+
|
|
90
58
|
# Fold one CodePoint into the stream accumulators. No-ops if the
|
|
91
59
|
# cp has no block_id (it has no home in the output tree).
|
|
92
60
|
# @param cp [Ucode::Models::CodePoint]
|
|
@@ -107,10 +75,8 @@ module Ucode
|
|
|
107
75
|
@codepoint_count += 1
|
|
108
76
|
end
|
|
109
77
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# `sc` subset of PropertyValueAliases; the full alias tables and
|
|
113
|
-
# the named sequences are passed through from the CLI/parsers).
|
|
78
|
+
# Compose the eight per-concern writers, run them in order, and
|
|
79
|
+
# return the total number of files written.
|
|
114
80
|
#
|
|
115
81
|
# @param ucd_version [String]
|
|
116
82
|
# @param indices [Ucode::Coordinator::Indices]
|
|
@@ -118,19 +84,40 @@ module Ucode
|
|
|
118
84
|
# @param property_value_aliases [Array<Ucode::Models::PropertyValueAlias>]
|
|
119
85
|
# @param named_sequences [Array<Ucode::Models::NamedSequence>]
|
|
120
86
|
# @param glyph_count [Integer]
|
|
121
|
-
# @return [Integer]
|
|
87
|
+
# @return [Integer]
|
|
122
88
|
def flush(ucd_version:, indices:, property_aliases: [],
|
|
123
89
|
property_value_aliases: [], named_sequences: [], glyph_count: 0)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
90
|
+
writers(ucd_version, indices, property_aliases, property_value_aliases,
|
|
91
|
+
named_sequences, glyph_count).sum(&:write)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api private — exposed for testing.
|
|
95
|
+
def writers(ucd_version, indices, property_aliases,
|
|
96
|
+
property_value_aliases, named_sequences, glyph_count)
|
|
97
|
+
[
|
|
98
|
+
Writers::PlanesWriter.new(output_root: @output_root, blocks: indices.blocks),
|
|
99
|
+
Writers::BlocksWriter.new(output_root: @output_root,
|
|
100
|
+
blocks: indices.blocks,
|
|
101
|
+
block_codepoint_ids: @block_codepoint_ids,
|
|
102
|
+
block_ages: @block_ages),
|
|
103
|
+
Writers::ScriptsWriter.new(output_root: @output_root,
|
|
104
|
+
scripts: indices.scripts,
|
|
105
|
+
script_codepoint_ids: @script_codepoint_ids),
|
|
106
|
+
Writers::IndexesWriter.new(output_root: @output_root,
|
|
107
|
+
names: @names_index,
|
|
108
|
+
labels: @labels_index,
|
|
109
|
+
cp_to_block: @cp_to_block),
|
|
110
|
+
Writers::RelationshipsWriter.new(output_root: @output_root, indices: indices),
|
|
111
|
+
Writers::EnumsWriter.new(output_root: @output_root,
|
|
112
|
+
property_aliases: property_aliases,
|
|
113
|
+
property_value_aliases: property_value_aliases),
|
|
114
|
+
Writers::NamedSequencesWriter.new(output_root: @output_root,
|
|
115
|
+
named_sequences: named_sequences),
|
|
116
|
+
Writers::ManifestWriter.new(output_root: @output_root,
|
|
117
|
+
ucd_version: ucd_version,
|
|
118
|
+
codepoint_count: @codepoint_count,
|
|
119
|
+
glyph_count: glyph_count),
|
|
120
|
+
]
|
|
134
121
|
end
|
|
135
122
|
|
|
136
123
|
private
|
|
@@ -151,9 +138,7 @@ module Ucode
|
|
|
151
138
|
|
|
152
139
|
# Per-block `age` is the earliest DerivedAge of any codepoint in
|
|
153
140
|
# the block, compared as a Gem::Version. Stored as the original
|
|
154
|
-
# string (e.g. "1.1", "17.0.0").
|
|
155
|
-
# block has an age (rare — only happens for entirely-reserved
|
|
156
|
-
# blocks, which the parser excludes anyway).
|
|
141
|
+
# string (e.g. "1.1", "17.0.0").
|
|
157
142
|
def track_block_age(cp)
|
|
158
143
|
return if cp.age.nil? || cp.age.empty?
|
|
159
144
|
|
|
@@ -168,229 +153,6 @@ module Ucode
|
|
|
168
153
|
def min_age(a, b)
|
|
169
154
|
Gem::Version.new(a) < Gem::Version.new(b) ? a : b
|
|
170
155
|
end
|
|
171
|
-
|
|
172
|
-
# ---- Plane files -------------------------------------------------
|
|
173
|
-
|
|
174
|
-
def write_planes(blocks)
|
|
175
|
-
plane_block_ids = group_block_ids_by_plane(blocks)
|
|
176
|
-
count = 0
|
|
177
|
-
(0..16).each do |n|
|
|
178
|
-
path = Paths.plane_metadata_path(@output_root, n)
|
|
179
|
-
count += 1 if write_atomic(path, plane_payload(n, plane_block_ids[n] || []))
|
|
180
|
-
end
|
|
181
|
-
count
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def group_block_ids_by_plane(blocks)
|
|
185
|
-
blocks.each_with_object(Hash.new { |h, k| h[k] = [] }) do |block, h|
|
|
186
|
-
h[block.plane_number] << block.id
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def plane_payload(plane_number, block_ids)
|
|
191
|
-
name, abbrev = PLANE_TABLE.fetch(plane_number)
|
|
192
|
-
range_first = plane_number * 0x10000
|
|
193
|
-
range_last = range_first + 0xFFFF
|
|
194
|
-
to_pretty_json(
|
|
195
|
-
"number" => plane_number,
|
|
196
|
-
"name" => name,
|
|
197
|
-
"abbrev" => abbrev,
|
|
198
|
-
"range_first" => range_first,
|
|
199
|
-
"range_last" => range_last,
|
|
200
|
-
"block_ids" => block_ids,
|
|
201
|
-
)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
# ---- Block files -------------------------------------------------
|
|
205
|
-
|
|
206
|
-
def write_blocks(blocks)
|
|
207
|
-
count = blocks.sum do |block|
|
|
208
|
-
block.age = @block_ages[block.id]
|
|
209
|
-
path = Paths.block_metadata_path(@output_root, block.id)
|
|
210
|
-
write_atomic(path, block_payload(block)) ? 1 : 0
|
|
211
|
-
end
|
|
212
|
-
count + write_blocks_index(blocks)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def write_blocks_index(blocks)
|
|
216
|
-
path = Paths.blocks_index_path(@output_root)
|
|
217
|
-
summary = blocks.map do |block|
|
|
218
|
-
{
|
|
219
|
-
"id" => block.id,
|
|
220
|
-
"name" => block.name,
|
|
221
|
-
"first_cp" => block.range_first,
|
|
222
|
-
"last_cp" => block.range_last,
|
|
223
|
-
"plane_number" => block.plane_number,
|
|
224
|
-
"age" => @block_ages[block.id],
|
|
225
|
-
}
|
|
226
|
-
end
|
|
227
|
-
write_atomic(path, to_pretty_json(summary)) ? 1 : 0
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def block_payload(block)
|
|
231
|
-
to_pretty_json(
|
|
232
|
-
"id" => block.id,
|
|
233
|
-
"name" => block.name,
|
|
234
|
-
"range_first" => block.range_first,
|
|
235
|
-
"range_last" => block.range_last,
|
|
236
|
-
"plane_number" => block.plane_number,
|
|
237
|
-
"age" => @block_ages[block.id],
|
|
238
|
-
"codepoint_ids" => (@block_codepoint_ids[block.id] || []),
|
|
239
|
-
)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# ---- Script files ------------------------------------------------
|
|
243
|
-
|
|
244
|
-
def write_scripts(scripts)
|
|
245
|
-
count = 0
|
|
246
|
-
scripts.group_by(&:code).each do |code, ranges|
|
|
247
|
-
next if code.nil? || code.empty?
|
|
248
|
-
|
|
249
|
-
path = Paths.script_metadata_path(@output_root, code)
|
|
250
|
-
count += 1 if write_atomic(path, script_payload(code, ranges))
|
|
251
|
-
end
|
|
252
|
-
count
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def script_payload(code, ranges)
|
|
256
|
-
to_pretty_json(
|
|
257
|
-
"code" => code,
|
|
258
|
-
"name" => ranges.first&.name,
|
|
259
|
-
"range_first" => ranges.map(&:range_first).min,
|
|
260
|
-
"range_last" => ranges.map(&:range_last).max,
|
|
261
|
-
"codepoint_ids" => (@script_codepoint_ids[code] || []),
|
|
262
|
-
)
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
# ---- Lookup indexes ---------------------------------------------
|
|
266
|
-
|
|
267
|
-
def write_indexes
|
|
268
|
-
count = 0
|
|
269
|
-
count += 1 if write_atomic(Paths.names_index_path(@output_root), to_pretty_json(@names_index))
|
|
270
|
-
count += 1 if write_atomic(Paths.labels_index_path(@output_root), to_pretty_json(@labels_index))
|
|
271
|
-
count += 1 if write_atomic(codepoint_to_block_path, to_pretty_json(@cp_to_block))
|
|
272
|
-
count
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def codepoint_to_block_path
|
|
276
|
-
Pathname(@output_root).join("index", "codepoint_to_block.json")
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# ---- Relationships ----------------------------------------------
|
|
280
|
-
|
|
281
|
-
def write_relationships(indices)
|
|
282
|
-
RELATIONSHIP_SOURCES.sum do |field, slug|
|
|
283
|
-
records = indices.public_send(field)
|
|
284
|
-
write_relationship_file(slug, records)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def write_relationship_file(slug, records)
|
|
289
|
-
return 0 if records.nil? || records.empty?
|
|
290
|
-
|
|
291
|
-
path = Pathname(@output_root).join("relationships", "#{slug}.json")
|
|
292
|
-
write_atomic(path, relationship_payload(records)) ? 1 : 0
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# records is Hash<Integer, Record>, Hash<Integer, Array<Record>>,
|
|
296
|
-
# Hash<String, Record>, or Hash<String, Array<Record>>.
|
|
297
|
-
# Output: { "U+XXXX" => record.to_yaml_hash, ... } or
|
|
298
|
-
# { "U+XXXX" => [record.to_yaml_hash, ...], ... }
|
|
299
|
-
def relationship_payload(records)
|
|
300
|
-
payload = records.each_with_object({}) do |(key, value), h|
|
|
301
|
-
h[key_to_cp_id(key)] = serialize_value(value)
|
|
302
|
-
end
|
|
303
|
-
to_pretty_json(payload)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Indices that are keyed by Integer codepoint (most of them) get
|
|
307
|
-
# formatted into "U+XXXX". Indices keyed by string ids already
|
|
308
|
-
# (cjk_radicals by ideograph_id, standardized_variants by base_id)
|
|
309
|
-
# are passed through verbatim.
|
|
310
|
-
def key_to_cp_id(key)
|
|
311
|
-
key.is_a?(Integer) ? Paths.cp_id(key) : key
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def serialize_value(value)
|
|
315
|
-
return value.map { |v| serialize_one(v) } if value.is_a?(Array)
|
|
316
|
-
|
|
317
|
-
serialize_one(value)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def serialize_one(record)
|
|
321
|
-
record.to_yaml_hash
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# ---- Enums -------------------------------------------------------
|
|
325
|
-
|
|
326
|
-
def write_enums(property_aliases, property_value_aliases)
|
|
327
|
-
path = Pathname(@output_root).join("enums.json")
|
|
328
|
-
payload = {
|
|
329
|
-
"properties" => property_aliases.map(&:to_yaml_hash),
|
|
330
|
-
"property_values" => property_value_aliases.map(&:to_yaml_hash),
|
|
331
|
-
}
|
|
332
|
-
write_atomic(path, to_pretty_json(payload)) ? 1 : 0
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
# ---- Named sequences --------------------------------------------
|
|
336
|
-
|
|
337
|
-
def write_named_sequences(named_sequences)
|
|
338
|
-
return 0 if named_sequences.nil? || named_sequences.empty?
|
|
339
|
-
|
|
340
|
-
dir = Pathname(@output_root).join("named_sequences")
|
|
341
|
-
named_sequences.sum do |ns|
|
|
342
|
-
path = dir.join("#{slug_for(ns)}.json")
|
|
343
|
-
write_atomic(path, ns.to_json(pretty: true)) ? 1 : 0
|
|
344
|
-
end
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
# Slug derived from the name: downcase, non-alphanumerics → "_".
|
|
348
|
-
def slug_for(named_sequence)
|
|
349
|
-
named_sequence.name
|
|
350
|
-
.downcase
|
|
351
|
-
.gsub(/[^a-z0-9]+/, "_")
|
|
352
|
-
.gsub(/^_+|_+$/, "")
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# ---- Manifest ---------------------------------------------------
|
|
356
|
-
|
|
357
|
-
# Fields that define the manifest's semantic content. When these
|
|
358
|
-
# match the existing manifest on disk, we preserve the old
|
|
359
|
-
# `generated_at` so that re-runs are byte-idempotent (no rewrite
|
|
360
|
-
# unless something actually changed).
|
|
361
|
-
MANIFEST_CONTENT_KEYS = %w[
|
|
362
|
-
ucd_version codepoint_count glyph_count schema_version
|
|
363
|
-
].freeze
|
|
364
|
-
private_constant :MANIFEST_CONTENT_KEYS
|
|
365
|
-
|
|
366
|
-
def write_manifest(ucd_version:, glyph_count:)
|
|
367
|
-
path = Paths.manifest_path(@output_root)
|
|
368
|
-
content = {
|
|
369
|
-
"ucd_version" => ucd_version,
|
|
370
|
-
"codepoint_count" => @codepoint_count,
|
|
371
|
-
"glyph_count" => glyph_count,
|
|
372
|
-
"schema_version" => "1",
|
|
373
|
-
}
|
|
374
|
-
ts = preserved_or_new_timestamp(path, content)
|
|
375
|
-
payload = content.merge("generated_at" => ts)
|
|
376
|
-
write_atomic(path, to_pretty_json(payload)) ? 1 : 0
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def preserved_or_new_timestamp(path, content)
|
|
380
|
-
existing = read_manifest(path)
|
|
381
|
-
return Time.now.utc.iso8601 unless existing
|
|
382
|
-
|
|
383
|
-
unchanged = MANIFEST_CONTENT_KEYS.all? { |k| existing[k] == content[k] }
|
|
384
|
-
unchanged ? existing["generated_at"] : Time.now.utc.iso8601
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def read_manifest(path)
|
|
388
|
-
return nil unless path.exist?
|
|
389
|
-
|
|
390
|
-
JSON.parse(path.read)
|
|
391
|
-
rescue JSON::ParserError
|
|
392
|
-
nil
|
|
393
|
-
end
|
|
394
156
|
end
|
|
395
157
|
end
|
|
396
|
-
end
|
|
158
|
+
end
|
|
@@ -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
|