polytypo 1.6.3 → 1.7.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -0
  3. data/lib/polytypo/data/.not-vendored +11 -0
  4. data/lib/polytypo/data/README.md +16 -11
  5. data/lib/polytypo/data/VERSION +1 -1
  6. data/lib/polytypo/data/fixtures/cs.json +1 -1
  7. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  8. data/lib/polytypo/data/fixtures/de-DE.json +14 -1
  9. data/lib/polytypo/data/fixtures/el.json +1 -1
  10. data/lib/polytypo/data/fixtures/en-GB.json +1 -1
  11. data/lib/polytypo/data/fixtures/en-US.json +143 -1
  12. data/lib/polytypo/data/fixtures/es.json +1 -1
  13. data/lib/polytypo/data/fixtures/fi.json +1 -1
  14. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  15. data/lib/polytypo/data/fixtures/fr.json +13 -1
  16. data/lib/polytypo/data/fixtures/it.json +1 -1
  17. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  18. data/lib/polytypo/data/fixtures/nl.json +1 -1
  19. data/lib/polytypo/data/fixtures/pl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  22. data/lib/polytypo/data/fixtures/ru.json +1 -1
  23. data/lib/polytypo/data/fixtures/sv.json +1 -1
  24. data/lib/polytypo/data/fixtures/tr.json +1 -1
  25. data/lib/polytypo/data/fixtures/uk.json +1 -1
  26. data/lib/polytypo/data/locales/cs.json +56 -37
  27. data/lib/polytypo/data/locales/de-CH.json +43 -34
  28. data/lib/polytypo/data/locales/de-DE.json +47 -32
  29. data/lib/polytypo/data/locales/el.json +51 -1
  30. data/lib/polytypo/data/locales/en-GB.json +56 -4
  31. data/lib/polytypo/data/locales/en-US.json +68 -4
  32. data/lib/polytypo/data/locales/es.json +66 -29
  33. data/lib/polytypo/data/locales/fi.json +77 -7
  34. data/lib/polytypo/data/locales/fr-CA.json +63 -36
  35. data/lib/polytypo/data/locales/fr.json +70 -41
  36. data/lib/polytypo/data/locales/it.json +65 -22
  37. data/lib/polytypo/data/locales/nl.json +61 -29
  38. data/lib/polytypo/data/locales/pl.json +61 -28
  39. data/lib/polytypo/data/locales/pt-BR.json +53 -28
  40. data/lib/polytypo/data/locales/pt-PT.json +55 -28
  41. data/lib/polytypo/data/locales/registry.json +1 -1
  42. data/lib/polytypo/data/locales/ru.json +45 -30
  43. data/lib/polytypo/data/locales/sv.json +63 -4
  44. data/lib/polytypo/data/locales/tr.json +72 -32
  45. data/lib/polytypo/data/locales/uk.json +55 -27
  46. data/lib/polytypo/data/rules/modes.md +243 -11
  47. data/lib/polytypo/data/rules/order.json +1 -1
  48. data/lib/polytypo/data/schema/fixtures.schema.json +12 -1
  49. data/lib/polytypo/engine/pipeline.rb +29 -0
  50. data/lib/polytypo/modes/markdown.rb +51 -0
  51. data/lib/polytypo/modes/runner.rb +33 -3
  52. data/lib/polytypo/version.rb +1 -1
  53. data/lib/polytypo.rb +27 -12
  54. metadata +2 -1
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://polytypo.org/schema/fixtures.schema.json",
4
4
  "title": "polytypo conformance fixtures",
5
- "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, rules, narrowNbsp) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
5
+ "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, frontmatterKeys, rules, narrowNbsp) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["spec", "locale", "cases"],
@@ -29,6 +29,11 @@
29
29
  "if": { "properties": { "mode": { "const": "yaml" } }, "required": ["mode"] },
30
30
  "then": { "required": ["keys"] },
31
31
  "else": { "not": { "required": ["keys"] } }
32
+ },
33
+ {
34
+ "if": { "properties": { "mode": { "const": "markdown" } }, "required": ["mode"] },
35
+ "then": true,
36
+ "else": { "not": { "required": ["frontmatterKeys"] } }
32
37
  }
33
38
  ],
34
39
  "oneOf": [{ "required": ["out"] }, { "required": ["throws"] }],
@@ -62,6 +67,12 @@
62
67
  "items": { "type": "string" },
63
68
  "description": "Required when mode is \"yaml\" — spec/rules/modes.md §3.8.2 gives the option no default, so a fixture must name the processable keys exactly as a caller would. An empty array is legal and processes nothing. Meaningless for the other modes."
64
69
  },
70
+ "frontmatterKeys": {
71
+ "type": "array",
72
+ "items": { "type": "string" },
73
+ "description": "Optional, spec 1.7.0, and only meaningful when mode is \"markdown\" — spec/rules/modes.md §3.7.4's opt-out from the frontmatter skip. Absent means the block is skipped whole, exactly as before 1.7.0; an empty array is legal and yields no spans. A runner passes it through to transform() as it passes dialect.",
74
+ "$comment": "The block is its own text unit (§3.7.4), so this option cannot change a byte of the body — every case released before 1.7.0 keeps its recorded output whatever it is set to."
75
+ },
65
76
  "in": { "type": "string" },
66
77
  "out": { "type": "string" },
67
78
  "throws": {
@@ -59,6 +59,35 @@ module Polytypo
59
59
  value.to_set
60
60
  end
61
61
 
62
+ # modes.md 3.7.4: "markdown" mode's `frontmatter_keys` option (spec 1.7.0).
63
+ #
64
+ # Optional, unlike `keys` -- nil means the frontmatter block is skipped whole, which is every
65
+ # pre-1.7.0 document's behaviour -- and an empty Array is legal, exactly as it is for `keys`.
66
+ # Checked after dialect and before the parse, which is what decides that a document failing to
67
+ # parse in its dialect still reports the option error rather than CODE_MALFORMED_INPUT.
68
+ def self.resolve_frontmatter_keys(value)
69
+ return nil if value.nil?
70
+
71
+ unless value.is_a?(Array)
72
+ raise Polytypo::Error.new(
73
+ Polytypo::CODE_INVALID_OPTION,
74
+ '"frontmatter_keys" must be an Array of Strings when given (modes.md 3.7.4). ' \
75
+ "Received #{value.inspect}.",
76
+ )
77
+ end
78
+
79
+ value.each do |key|
80
+ next if key.is_a?(String)
81
+
82
+ raise Polytypo::Error.new(
83
+ Polytypo::CODE_INVALID_OPTION,
84
+ "\"frontmatter_keys\" must contain only Strings; received #{key.inspect}.",
85
+ )
86
+ end
87
+
88
+ value.to_set
89
+ end
90
+
62
91
  # Resolves the locale, builds the rule plan, and runs each enabled rule in
63
92
  # spec/rules/order.json order over a code-point array, applying its edits before the next
64
93
  # rule sees it. No module-level mutable state beyond the immutable, load-once-on-first-use
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "spans"
4
4
  require_relative "html"
5
+ require_relative "yaml"
5
6
  require_relative "parse_error"
6
7
  require_relative "../errors"
7
8
 
@@ -66,6 +67,56 @@ module Polytypo
66
67
  end
67
68
  private_class_method :detect_frontmatter_delimiter
68
69
 
70
+ # modes.md 3.7.4, spec 1.7.0. The frontmatter block's own spans, which form a SECOND TEXT
71
+ # UNIT: the pipeline runs over them separately from the body's, so an unbalanced mark in a
72
+ # metadata field can never pair with one in the first paragraph, and the option cannot
73
+ # change a byte outside the block.
74
+ #
75
+ # Spans come from the scan of modes.md 3.8 -- frontmatter IS YAML, and implementing that
76
+ # grammar twice is how two implementations of one spec drift -- with +keys+ as step 8's key
77
+ # predicate. The block is the construct markdown_spans skips (3.7.3, the :frontmatter node),
78
+ # so the option only ever adds spans where the skip removed them: no source position belongs
79
+ # to both units. A TOML block yields nothing, with the option or without it -- its quoting is
80
+ # a second grammar this scan does not claim (modes.md 7.13).
81
+ #
82
+ # The content range comes from the node rather than from a second scan of the text: comrak
83
+ # reports the block from its opening delimiter line through its closing one, so the content
84
+ # is every line between them, and both delimiters and every line terminator stay outside
85
+ # every span.
86
+ def self.frontmatter_spans(source, keys)
87
+ require "commonmarker"
88
+ return [] if keys.empty?
89
+ return [] unless detect_frontmatter_delimiter(source) == "---"
90
+
91
+ cp = Polytypo::Engine::Codepoints.to_codepoints(source)
92
+ chars = cp.map { |c| [c].pack("U") }
93
+ line_starts = build_line_starts(chars)
94
+
95
+ spans = []
96
+ ParseError.wrap do
97
+ doc = Commonmarker.parse(
98
+ source,
99
+ options: { parse: { sourcepos_chars: true },
100
+ extension: { front_matter_delimiter: "---" } },
101
+ )
102
+ doc.each do |node|
103
+ next unless node.type == :frontmatter
104
+
105
+ pos = node.source_position
106
+ content_start = line_starts[pos[:start_line]]
107
+ content_end = line_starts[pos[:end_line] - 1]
108
+ break if content_start.nil? || content_end.nil? || content_end <= content_start
109
+
110
+ content = chars[content_start...content_end].join
111
+ Yaml.yaml_spans(content, keys).each do |span|
112
+ spans << Spans::Span.new(span.start + content_start, span.end + content_start)
113
+ end
114
+ break
115
+ end
116
+ end
117
+ spans
118
+ end
119
+
69
120
  # Locates the processable spans of a Markdown document. dialect must already be validated
70
121
  # via resolve_dialect (== "commonmark"); this function does not re-check it.
71
122
  def self.markdown_spans(source)
@@ -34,17 +34,46 @@ module Polytypo
34
34
  # source_cp is the whole input already converted to code points; spans address it by
35
35
  # code-point index.
36
36
  def self.run_over_spans(source_cp, spans, plan, locale_data, ctx)
37
+ emit(source_cp, replacements_of_unit(source_cp, spans, plan, locale_data, ctx))
38
+ end
39
+
40
+ # modes.md 3.1 and 3.5 step 3 (spec 1.7.0). A document has one text unit, except in
41
+ # "markdown" with frontmatter_keys, where the frontmatter block's spans form a unit of their
42
+ # own. The pipeline runs once per unit and the two edit sets are disjoint, because no span of
43
+ # one unit lies inside the other -- which is what the body's walk skipping the block
44
+ # guarantees. Only step 5 is shared: the source is emitted once, in document order.
45
+ def self.run_over_units(source_cp, units, plan, locale_data, ctx)
46
+ replacements = units.flat_map do |spans|
47
+ replacements_of_unit(source_cp, spans, plan, locale_data, ctx)
48
+ end
49
+ emit(source_cp, replacements.sort_by { |span, _piece| span.start })
50
+ end
51
+
52
+ # analyze_over_spans per text unit (modes.md 3.1), reported in document order.
53
+ def self.analyze_over_units(source_cp, units, plan, locale_data, ctx)
54
+ units.flat_map { |spans| analyze_over_spans(source_cp, spans, plan, locale_data, ctx) }
55
+ .sort_by(&:start)
56
+ end
57
+
58
+ # One text unit: the marker-separated concatenation, the pipeline, and the pieces it
59
+ # produced, paired with the spans they replace.
60
+ def self.replacements_of_unit(source_cp, spans, plan, locale_data, ctx)
37
61
  normalized = Spans.normalize_spans(spans)
38
- return source_cp.pack("U*") if normalized.empty?
62
+ return [] if normalized.empty?
39
63
 
40
64
  concatenated = Spans.concatenate_spans(source_cp, normalized)
41
65
  transformed = run_rules_over_spans(concatenated, plan, locale_data, ctx)
42
66
  pieces = Spans.split_on_marker(transformed, normalized.length)
67
+ normalized.each_with_index.map { |span, i| [span, pieces[i]] }
68
+ end
69
+ private_class_method :replacements_of_unit
43
70
 
71
+ # modes.md 4: the source with disjoint replacements applied at recorded offsets, and nothing
72
+ # else changed.
73
+ def self.emit(source_cp, replacements)
44
74
  out = []
45
75
  cursor = 0
46
- normalized.each_with_index do |span, i|
47
- piece = pieces[i]
76
+ replacements.each do |span, piece|
48
77
  original = source_cp[span.start...span.end]
49
78
  out.concat(source_cp[cursor...span.start])
50
79
  out.concat(piece == original ? original : piece)
@@ -53,6 +82,7 @@ module Polytypo
53
82
  out.concat(source_cp[cursor..])
54
83
  out.pack("U*")
55
84
  end
85
+ private_class_method :emit
56
86
 
57
87
  # run_over_spans, reporting instead of applying (analyze.md section 1). The span table
58
88
  # supplies the origin map, so every change comes back in DOCUMENT coordinates -- analyze.md
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polytypo
4
- VERSION = "1.6.3"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/polytypo.rb CHANGED
@@ -32,7 +32,8 @@ module Polytypo
32
32
  #
33
33
  # Only `mode: "text"`/`"html"` ever touch this file's own requires; `mode: "markdown"` lazily
34
34
  # requires "commonmarker" from within Modes::Markdown, never at load time of this file.
35
- def self.transform(input, locale:, mode: "text", dialect: nil, keys: nil, rules: nil, narrow_nbsp: nil)
35
+ def self.transform(input, locale:, mode: "text", dialect: nil, keys: nil, rules: nil, narrow_nbsp: nil,
36
+ frontmatter_keys: nil)
36
37
  resolved_mode = resolve_mode(mode)
37
38
  narrow_target = Engine.resolve_narrow_target(narrow_nbsp)
38
39
 
@@ -53,7 +54,7 @@ module Polytypo
53
54
  end
54
55
  transform_yaml(input, locale, keys, rules, narrow_target)
55
56
  else # "markdown"
56
- transform_markdown(input, locale, dialect, rules, narrow_target)
57
+ transform_markdown(input, locale, dialect, rules, narrow_target, frontmatter_keys)
57
58
  end
58
59
  end
59
60
 
@@ -69,7 +70,8 @@ module Polytypo
69
70
  # for the text (analyze.md sections 4 and 5).
70
71
  #
71
72
  # Pure and thread-safe on the same terms as .transform.
72
- def self.analyze(input, locale:, mode: "text", dialect: nil, keys: nil, rules: nil, narrow_nbsp: nil)
73
+ def self.analyze(input, locale:, mode: "text", dialect: nil, keys: nil, rules: nil, narrow_nbsp: nil,
74
+ frontmatter_keys: nil)
73
75
  resolved_mode = resolve_mode(mode)
74
76
  narrow_target = Engine.resolve_narrow_target(narrow_nbsp)
75
77
 
@@ -90,7 +92,7 @@ module Polytypo
90
92
  end
91
93
  analyze_yaml(input, locale, keys, rules, narrow_target)
92
94
  else # "markdown"
93
- analyze_markdown(input, locale, dialect, rules, narrow_target)
95
+ analyze_markdown(input, locale, dialect, rules, narrow_target, frontmatter_keys)
94
96
  end
95
97
  end
96
98
 
@@ -143,18 +145,30 @@ module Polytypo
143
145
  end
144
146
  private_class_method :transform_yaml
145
147
 
146
- def self.transform_markdown(input, locale, dialect, rules, narrow_target)
148
+ def self.transform_markdown(input, locale, dialect, rules, narrow_target, frontmatter_keys = nil)
147
149
  # Validation order is public, tested behaviour, identical across every runtime: rules (an
148
- # unknown rule id), then locale (an unknown locale), then dialect/parsing.
150
+ # unknown rule id), then locale (an unknown locale), then dialect, then frontmatter_keys, then
151
+ # parsing (modes.md 3.7.4).
149
152
  resolved_locale, locale_data, plan = Engine::Pipeline.prepare(locale, rules)
150
153
  require_relative "polytypo/modes/markdown"
151
154
  Modes::Markdown.resolve_dialect(dialect)
152
- spans = Modes::Markdown.markdown_spans(input)
155
+ resolved_keys = Engine.resolve_frontmatter_keys(frontmatter_keys)
153
156
  ctx = Engine::RuleContext.new(mode: "markdown", dialect: dialect, locale: resolved_locale,
154
157
  narrow_target: narrow_target)
155
158
  cp = Engine::Codepoints.to_codepoints(input)
156
- Modes::Runner.run_over_spans(cp, spans, plan, locale_data, ctx)
159
+ Modes::Runner.run_over_units(cp, markdown_units(input, resolved_keys), plan, locale_data, ctx)
157
160
  end
161
+
162
+ # modes.md 3.7.4: the body, and -- only when the caller named frontmatter keys -- the
163
+ # frontmatter block as a second text unit. With frontmatter_keys nil this is exactly the single
164
+ # unit every document had before spec 1.7.0, which is why no released output can move.
165
+ def self.markdown_units(input, resolved_keys)
166
+ body = Modes::Markdown.markdown_spans(input)
167
+ return [body] if resolved_keys.nil?
168
+
169
+ [Modes::Markdown.frontmatter_spans(input, resolved_keys), body]
170
+ end
171
+ private_class_method :markdown_units
158
172
  private_class_method :transform_markdown
159
173
 
160
174
  def self.analyze_text(input, locale, rules, narrow_target)
@@ -187,16 +201,17 @@ module Polytypo
187
201
  end
188
202
  private_class_method :analyze_yaml
189
203
 
190
- def self.analyze_markdown(input, locale, dialect, rules, narrow_target)
204
+ def self.analyze_markdown(input, locale, dialect, rules, narrow_target, frontmatter_keys = nil)
191
205
  # Validation order is public, tested behaviour and is shared with .transform: rules, then
192
- # locale, then dialect/parsing (analyze.md section 4, A1).
206
+ # locale, then dialect, then frontmatter_keys, then parsing (analyze.md section 4, A1).
193
207
  resolved_locale, locale_data, plan = Engine::Pipeline.prepare(locale, rules)
194
208
  require_relative "polytypo/modes/markdown"
195
209
  Modes::Markdown.resolve_dialect(dialect)
196
- spans = Modes::Markdown.markdown_spans(input)
210
+ resolved_keys = Engine.resolve_frontmatter_keys(frontmatter_keys)
197
211
  ctx = Engine::RuleContext.new(mode: "markdown", dialect: dialect, locale: resolved_locale,
198
212
  narrow_target: narrow_target)
199
- Modes::Runner.analyze_over_spans(Engine::Codepoints.to_codepoints(input), spans, plan, locale_data, ctx)
213
+ Modes::Runner.analyze_over_units(Engine::Codepoints.to_codepoints(input),
214
+ markdown_units(input, resolved_keys), plan, locale_data, ctx)
200
215
  end
201
216
  private_class_method :analyze_markdown
202
217
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polytypo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.3
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Iurii Rogulia
@@ -36,6 +36,7 @@ files:
36
36
  - README.md
37
37
  - lib/polytypo.rb
38
38
  - lib/polytypo/data/.not-canonical
39
+ - lib/polytypo/data/.not-vendored
39
40
  - lib/polytypo/data/README.md
40
41
  - lib/polytypo/data/UNICODE
41
42
  - lib/polytypo/data/VERSION