qss 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +26 -0
- data/MIT-LICENSE +20 -0
- data/QSS-MANIFESTO.md +61 -0
- data/README.md +159 -0
- data/app/assets/javascripts/qss/qss-engine.js +1054 -0
- data/app/assets/stylesheets/qss/_qss_defaults.scss +74 -0
- data/app/assets/stylesheets/qss/qss_core.css +205042 -0
- data/app/assets/stylesheets/qss/qss_core.scss +614 -0
- data/config/grammar.yml +265 -0
- data/config/qss-objects.json +3 -0
- data/exe/qss-core-render-check +104 -0
- data/exe/qss-sync +205 -0
- data/lib/qss/blueprints.rb +106 -0
- data/lib/qss/configuration.rb +82 -0
- data/lib/qss/engine.rb +9 -0
- data/lib/qss/qss_auditor.rb +412 -0
- data/lib/qss/qss_native_auditor.rb +908 -0
- data/lib/qss/version.rb +10 -0
- data/lib/qss.rb +26 -0
- metadata +154 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
require 'nokogiri'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
|
|
4
|
+
module QSS
|
|
5
|
+
class QssNativeAuditor
|
|
6
|
+
# rubocop:disable all -- generated by exe/qss-sync; its layout is the generator's, not the house style
|
|
7
|
+
# QSS-GENERATED-BEGIN (exe/qss-sync rewrites everything below, up to the closing marker)
|
|
8
|
+
# GENERATED FROM grammar.yml - DO NOT EDIT
|
|
9
|
+
PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
|
|
10
|
+
|
|
11
|
+
AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "portal_root"=>""}.freeze
|
|
12
|
+
# QSS-GENERATED-END
|
|
13
|
+
# rubocop:enable all
|
|
14
|
+
|
|
15
|
+
# The effective settings: the core defaults above (AUDITOR_CONFIG, generated from grammar.yml alone) with the
|
|
16
|
+
# consuming project's config over them, read at runtime (QSS.configure, lib/qss/configuration.rb).
|
|
17
|
+
def self.settings
|
|
18
|
+
QSS.configuration.auditor_settings(AUDITOR_CONFIG)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The project's interim id prefix ("" = no prefix convention).
|
|
22
|
+
def self.id_prefix(settings = self.settings)
|
|
23
|
+
settings['id_prefix'].to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Narrow list: keywords that MANDATE 'contain: layout'
|
|
27
|
+
def self.narrow_structural_pattern(settings = self.settings)
|
|
28
|
+
/\b(#{settings['narrow_words'].map { |w| Regexp.escape(w) }.join('|')})\b/i
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.portal_label(settings = self.settings)
|
|
32
|
+
settings['portal_root'].to_s.empty? ? 'the overlay portal root' : settings['portal_root']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# INTERIM (decision 2, part 3): an id carrying the project's own prefix is structural, exactly as before item B.
|
|
36
|
+
# Only reached for ids the id check exempts; everything else is decided by IdRules.
|
|
37
|
+
def self.structural_id?(id, prefix = id_prefix)
|
|
38
|
+
!prefix.empty? && id.start_with?(prefix)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Escape hatches
|
|
42
|
+
ESCAPE_HATCHES = [ 'foreign', 'qss-payload-override', 'qss-content-driven' ].freeze
|
|
43
|
+
|
|
44
|
+
# Name rules for the OPEN vocabularies (qualifiers, qss-function values, custom type names):
|
|
45
|
+
# a closed alphabet plus drift detection. Pure functions: nothing here reads a file or the DOM.
|
|
46
|
+
# Design: architecture-decisions-2026-09-16.md section 11 and the item B decisions in the roadmap.
|
|
47
|
+
module NameRules
|
|
48
|
+
SHAPE = /\A[a-z]+(?:-[a-z]+)*\z/
|
|
49
|
+
SHAPE_WITH_DIGITS = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
50
|
+
ANCHOR = 'QSSObject'.freeze
|
|
51
|
+
SUFFIXES = %w[ing es ed s].freeze
|
|
52
|
+
INVISIBLE = /[\u00AD\u200B-\u200F\u2060\uFEFF]/
|
|
53
|
+
LEET = { '4' => %w[a], '3' => %w[e], '1' => %w[i l], '0' => %w[o], '5' => %w[s],
|
|
54
|
+
'7' => %w[t], '8' => %w[b], '@' => %w[a], '$' => %w[s] }.freeze
|
|
55
|
+
# Best-effort Cyrillic/Greek lookalikes for the letters of the anchor. The real defence against
|
|
56
|
+
# homoglyphs is the closed alphabet (anything non-ASCII is rejected outright); this only lets the
|
|
57
|
+
# anchor near-miss check give a specific message.
|
|
58
|
+
CONFUSABLES = { "\u0430" => 'a', "\u0435" => 'e', "\u043E" => 'o', "\u0441" => 'c', "\u0455" => 's',
|
|
59
|
+
"\u0456" => 'i', "\u0458" => 'j', "\u051B" => 'q', "\u0405" => 'S', "\u041E" => 'O',
|
|
60
|
+
"\u03BF" => 'o', "\u039F" => 'O' }.freeze
|
|
61
|
+
|
|
62
|
+
# nil when the value is acceptable, otherwise a symbol saying why not.
|
|
63
|
+
def self.rejection_reason(value, allow_digits: false)
|
|
64
|
+
return :empty unless value.is_a?(String) && !value.empty?
|
|
65
|
+
return :invisible if value.match?(INVISIBLE)
|
|
66
|
+
return :non_ascii if value.match?(/[^\x00-\x7F]/)
|
|
67
|
+
return :uppercase if value.match?(/[A-Z]/)
|
|
68
|
+
return :underscore if value.include?('_')
|
|
69
|
+
return :digit if !allow_digits && value.match?(/\d/)
|
|
70
|
+
return :hyphen_placement if value.match?(/\A-|-\z|--/)
|
|
71
|
+
return :too_short if value.delete('-').length < 3
|
|
72
|
+
(allow_digits ? SHAPE_WITH_DIGITS : SHAPE).match?(value) ? nil : :invalid_character
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.valid?(value, allow_digits: false)
|
|
76
|
+
rejection_reason(value, allow_digits: allow_digits).nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Every reading of a leetspeak string (1 can be i or l), capped so it cannot blow up.
|
|
80
|
+
def self.leet_variants(value)
|
|
81
|
+
variants = [ '' ]
|
|
82
|
+
value.each_char do |ch|
|
|
83
|
+
options = LEET[ch] || [ ch ]
|
|
84
|
+
variants = variants.flat_map { |v| options.map { |o| v + o } }.first(16)
|
|
85
|
+
end
|
|
86
|
+
variants.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Registered names the candidate is suspiciously close to, as [name, reason] pairs. An exact match
|
|
90
|
+
# is not "similar" (it is accepted), and nothing here ever merges or rewrites a name.
|
|
91
|
+
def self.similar(candidate, existing_names, allow_digits: false)
|
|
92
|
+
existing_names.filter_map do |name|
|
|
93
|
+
next if name == candidate
|
|
94
|
+
reason = similarity_reason(candidate, name, allow_digits: allow_digits)
|
|
95
|
+
[ name, reason ] if reason
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.similarity_reason(candidate, name, allow_digits:)
|
|
100
|
+
if allow_digits && candidate.match?(/\d/)
|
|
101
|
+
return :numeronym if numeronym?(candidate, name)
|
|
102
|
+
variants = leet_variants(candidate)
|
|
103
|
+
return :leetspeak if variants.include?(name)
|
|
104
|
+
# After folding, only the strong rules apply: the weak ones (subsequence...) on folded text just make noise.
|
|
105
|
+
variants.each { |v| reason = plain_reason(v, name, strong_only: true); return reason if reason }
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
plain_reason(candidate, name)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# First match wins, and only sets the label in the message. Order: a one-or-two-letter slip first, then the
|
|
112
|
+
# composed canonical form (word order, endings and dropped vowels all at once), then the loose abbreviation
|
|
113
|
+
# checks (the weakest, skipped after leetspeak folding).
|
|
114
|
+
def self.plain_reason(a, b, strong_only: false)
|
|
115
|
+
return :edit_distance if osa_distance(a, b) <= ([ a.length, b.length ].max <= 5 ? 1 : 2)
|
|
116
|
+
return :canonical_form if canonical(a).length >= 2 && canonical(a) == canonical(b)
|
|
117
|
+
return :initialism if !strong_only && (initialism?(a, b) || initialism?(b, a))
|
|
118
|
+
return :subsequence if !strong_only && (subsequence?(a, b) || subsequence?(b, a))
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.words(name)
|
|
123
|
+
name.split('-')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# One form for comparing names that differ by word order, common endings or dropped vowels:
|
|
127
|
+
# stem each word, drop its vowels after the first letter (collapsing doubled letters), sort the words.
|
|
128
|
+
# save-file, file-save and sv-fl all become fl-sv; deleting, delete and dlt all become dlt.
|
|
129
|
+
def self.canonical(name)
|
|
130
|
+
words(name).map { |w| strip_vowels(stem_word(w)) }.sort.join('-')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.strip_vowels(word)
|
|
134
|
+
word[0].to_s + word[1..].to_s.delete('aeiou').squeeze
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Strip one common ending (and a silent final e): delete/deleting/deleted -> delet.
|
|
138
|
+
def self.stem_word(word)
|
|
139
|
+
suffix = SUFFIXES.find { |x| word.end_with?(x) && word.length - x.length >= 3 }
|
|
140
|
+
(suffix ? word[0...-suffix.length] : word).chomp('e')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# cta == call-to-action: the short form is the first letters of the long name's words.
|
|
144
|
+
def self.initialism?(short, long)
|
|
145
|
+
parts = words(long)
|
|
146
|
+
parts.size > 1 && short == parts.map { |w| w[0] }.join
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# i18n, a11y, k8s: first letter + count of the letters between + last letter.
|
|
150
|
+
def self.numeronym?(candidate, name)
|
|
151
|
+
m = candidate.match(/\A([a-z])(\d+)([a-z])\z/)
|
|
152
|
+
m && name.match?(/\A[a-z]+\z/) && name[0] == m[1] && name[-1] == m[3] && name.length == m[2].to_i + 2
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Weaker check for abbreviations that lose consonants too: cfg is inside config.
|
|
156
|
+
def self.subsequence?(short, long)
|
|
157
|
+
return false unless short.length >= 3 && short.length < long.length && short[0] == long[0]
|
|
158
|
+
rest = long.each_char
|
|
159
|
+
short.each_char.all? { |c| loop { return false unless (l = rest.next rescue nil); break if l == c }; true }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Edit distance where swapping two neighbouring letters counts as one edit (form/from).
|
|
163
|
+
def self.osa_distance(a, b)
|
|
164
|
+
rows = Array.new(a.length + 1) { |i| [ i ] + [ 0 ] * b.length }
|
|
165
|
+
(0..b.length).each { |j| rows[0][j] = j }
|
|
166
|
+
(1..a.length).each do |i|
|
|
167
|
+
(1..b.length).each do |j|
|
|
168
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
169
|
+
rows[i][j] = [ rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost ].min
|
|
170
|
+
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]
|
|
171
|
+
rows[i][j] = [ rows[i][j], rows[i - 2][j - 2] + 1 ].min
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
rows[a.length][b.length]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Case, leetspeak and lookalike-character folding, for the anchor near-miss check.
|
|
179
|
+
def self.fold_lookalikes(str)
|
|
180
|
+
s = str.gsub(INVISIBLE, '')
|
|
181
|
+
s = s.unicode_normalize(:nfkc) rescue s
|
|
182
|
+
s.each_char.map { |c| CONFUSABLES[c] || c }.join.downcase
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# True when a segment of the id is NOT the exact anchor but reads like it once case, leetspeak and
|
|
186
|
+
# lookalikes are folded (or is one edit away): QssObject, QSSObjct, QSS0bject, a Cyrillic o...
|
|
187
|
+
def self.anchor_near_miss?(id)
|
|
188
|
+
return false unless id.is_a?(String)
|
|
189
|
+
segments = id.split('-')
|
|
190
|
+
candidates = segments + (segments.size >= 2 ? [ segments.last(2).join ] : [])
|
|
191
|
+
candidates.any? do |cand|
|
|
192
|
+
next false if cand == ANCHOR
|
|
193
|
+
leet_variants(fold_lookalikes(cand)).any? { |v| osa_distance(v, ANCHOR.downcase) <= 1 }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
# The closed vocabulary of native base objects (all HTML element names, plus the svg/math roots).
|
|
198
|
+
# Convention, fixed by the W3C spec and NOT configurable (architecture doc 11.6). Also the closed list a
|
|
199
|
+
# `qss-relation` value is checked against. The base object of a native id is its last hyphen segment.
|
|
200
|
+
NATIVE_TAGS = %w[
|
|
201
|
+
a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite
|
|
202
|
+
code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure
|
|
203
|
+
footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link
|
|
204
|
+
main map mark math menu meta meter nav noscript object ol optgroup option output p picture pre progress q
|
|
205
|
+
rp rt ruby s samp script search section select selectedcontent slot small source span strong style sub summary sup svg
|
|
206
|
+
table tbody td template textarea tfoot th thead time title tr track u ul var video wbr
|
|
207
|
+
].freeze
|
|
208
|
+
|
|
209
|
+
# The native tags that are structural frames by convention (decision 2, part 1). To be confirmed by Kraig.
|
|
210
|
+
STRUCTURAL_NATIVE_TAGS = %w[header footer nav aside main section article dialog form].freeze
|
|
211
|
+
|
|
212
|
+
# The CSS length units, from MDN's <length> reference (49). Percentages, times, angles, flex fractions and
|
|
213
|
+
# resolutions are NOT lengths and are deliberately absent. Used to recognise a raw CSS length in a value.
|
|
214
|
+
LENGTH_UNITS = %w[cap ch em ex ic lh rcap rch rem rex ric rlh vh svh lvh dvh vw svw lvw dvw vmax svmax lvmax dvmax
|
|
215
|
+
vmin svmin lvmin dvmin vb svb lvb dvb vi svi lvi dvi cqw cqh cqi cqb cqmin cqmax px cm mm q in pc pt].freeze
|
|
216
|
+
|
|
217
|
+
# Essential dimensional facts (architecture doc 11.7) are exact and mathematical, written in QSS's OWN units,
|
|
218
|
+
# never in raw CSS units: a whole number of QSS units (1..1000, the w[N] / h[N] scale), a percentage in 0.1
|
|
219
|
+
# steps (the w-pct[N] scale, up to 100%), or, for aspect-ratio, a ratio of two whole numbers.
|
|
220
|
+
module Dimensions
|
|
221
|
+
PERCENT = /\A(\d{1,3})(?:\.(\d))?%\z/
|
|
222
|
+
RATIO = /\A(\d+)\s*\/\s*(\d+)\z/
|
|
223
|
+
|
|
224
|
+
# Returns [normalized, nil] or [nil, "what is wrong"].
|
|
225
|
+
def self.parse(key, raw)
|
|
226
|
+
return parse_ratio(raw) if key == 'aspect-ratio'
|
|
227
|
+
return parse_units(raw) if raw.is_a?(Integer)
|
|
228
|
+
return [ nil, "must be a whole number of QSS units or a percentage, not #{raw.inspect}" ] unless raw.is_a?(String)
|
|
229
|
+
text = raw.strip
|
|
230
|
+
if (m = text.match(PERCENT))
|
|
231
|
+
pct = "#{m[1]}.#{m[2] || 0}".to_f
|
|
232
|
+
return [ { kind: :percent, value: pct }, nil ] if pct.positive? && pct <= 100
|
|
233
|
+
return [ nil, 'a percentage must be above 0 and at most 100' ]
|
|
234
|
+
end
|
|
235
|
+
unit = LENGTH_UNITS.find { |u| text.downcase.match?(/\A[\d.]+#{u}\z/) }
|
|
236
|
+
return [ nil, "#{text.inspect} uses the CSS unit #{unit}; declare QSS units (a whole number) or a percentage" ] if unit
|
|
237
|
+
return [ nil, "#{text.inspect} is qualitative; an essential dimension must be an exact number" ] if text.match?(/\A[a-z-]+\z/i)
|
|
238
|
+
[ nil, "must be a whole number of QSS units or a percentage, not #{raw.inspect}" ]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.parse_units(int)
|
|
242
|
+
int.between?(1, 1000) ? [ { kind: :units, value: int }, nil ] : [ nil, "QSS units run from 1 to 1000, not #{int}" ]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.parse_ratio(raw)
|
|
246
|
+
m = raw.is_a?(String) && raw.match(RATIO)
|
|
247
|
+
return [ nil, "must be a ratio of two whole numbers like \"16 / 9\", not #{raw.inspect}" ] unless m && m[1].to_i.positive? && m[2].to_i.positive?
|
|
248
|
+
[ { kind: :ratio, value: [ m[1].to_i, m[2].to_i ] }, nil ]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ---- enforcement: an element of a type with declared dimensions must not set a conflicting value ----
|
|
252
|
+
|
|
253
|
+
SIZE_CLASS = /\A(w|h)\[(\d+)\]\z/
|
|
254
|
+
PCT_CLASS = /\A(w|h)-pct\[(\d+)\]\z/
|
|
255
|
+
FULL_CLASS = /\A(w|h)-(?:full|f)\z/
|
|
256
|
+
|
|
257
|
+
# The QSS size utilities on an element's classes, in the declaration's own units:
|
|
258
|
+
# w[N] / h[N] are QSS units, w-pct[N] is N/10 percent, w-full and w-f are 100%. Responsive variants
|
|
259
|
+
# (md:w[..]) and anything else are ignored. Returns { 'width' => [facts], 'height' => [facts] }.
|
|
260
|
+
def self.from_classes(classes)
|
|
261
|
+
found = Hash.new { |h, k| h[k] = [] }
|
|
262
|
+
classes.each do |cls|
|
|
263
|
+
base = cls.sub(/\Aqss-/, '')
|
|
264
|
+
axis = ->(letter) { letter == 'w' ? 'width' : 'height' }
|
|
265
|
+
if (m = base.match(SIZE_CLASS))
|
|
266
|
+
found[axis.(m[1])] << { kind: :units, value: m[2].to_i, from: cls }
|
|
267
|
+
elsif (m = base.match(PCT_CLASS))
|
|
268
|
+
found[axis.(m[1])] << { kind: :percent, value: m[2].to_i / 10.0, from: cls }
|
|
269
|
+
elsif (m = base.match(FULL_CLASS))
|
|
270
|
+
found[axis.(m[1])] << { kind: :percent, value: 100.0, from: cls }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
found
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The axes an element's classes leave undeclared, width before height (empty when both are declared).
|
|
277
|
+
def self.missing_axes(classes)
|
|
278
|
+
found = from_classes(classes)
|
|
279
|
+
%w[width height].reject { |axis| found.key?(axis) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def self.describe(fact)
|
|
283
|
+
case fact[:kind]
|
|
284
|
+
when :units then "#{fact[:value]} QSS units"
|
|
285
|
+
when :percent then "#{fact[:value] == fact[:value].to_i ? fact[:value].to_i : fact[:value]}%"
|
|
286
|
+
when :ratio then fact[:value].join(' / ')
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Messages for every way the element's classes contradict the type's declared dimensions.
|
|
291
|
+
def self.conflicts(declared, classes, type_name)
|
|
292
|
+
found = from_classes(classes)
|
|
293
|
+
messages = []
|
|
294
|
+
%w[width height].each do |key|
|
|
295
|
+
want = declared[key]
|
|
296
|
+
next unless want
|
|
297
|
+
found[key].each do |have|
|
|
298
|
+
same = have[:kind] == want[:kind] && (have[:value] - want[:value]).abs < 1e-9
|
|
299
|
+
next if same
|
|
300
|
+
messages << "#{type_name.inspect} declares #{key} #{describe(want)}, but this element sets #{describe(have)} (#{have[:from]})"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
ratio = declared['aspect-ratio']
|
|
304
|
+
if ratio && found['width'].size == 1 && found['height'].size == 1
|
|
305
|
+
w, h = found['width'].first, found['height'].first
|
|
306
|
+
if w[:kind] == :units && h[:kind] == :units && w[:value] * ratio[:value][1] != h[:value] * ratio[:value][0]
|
|
307
|
+
messages << "#{type_name.inspect} declares aspect-ratio #{describe(ratio)}, but #{w[:from]} with #{h[:from]} is #{w[:value]} / #{h[:value]}"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
messages
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Foreign content (third-party or framework markup inside a QSS page: blog text, Tailwind, ...) is exempt from
|
|
315
|
+
# the frame rules but not from this one: any RAW CSS LENGTH in an arbitrary value or an inline style must be in
|
|
316
|
+
# rem. Percentages are allowed; every other length unit (the closed MDN list minus rem) is a violation.
|
|
317
|
+
# Named framework utilities (text-lg, p-4) carry no unit in their name and are the framework's business.
|
|
318
|
+
# Time, angle, flex and resolution values (300ms, 17deg, 1fr) are not lengths and pass.
|
|
319
|
+
module Units
|
|
320
|
+
NON_REM = (LENGTH_UNITS - [ 'rem' ]).freeze
|
|
321
|
+
# A number that is a whole token: not part of a word or a hex colour (the lookbehind), and the unit ends it.
|
|
322
|
+
LENGTH = /(?<![A-Za-z0-9#.])(\d*\.?\d+)(#{NON_REM.join('|')})(?![A-Za-z0-9])/i
|
|
323
|
+
|
|
324
|
+
# Quoted strings and url(...) contents are text, not values.
|
|
325
|
+
def self.strip_literals(text)
|
|
326
|
+
text.to_s.gsub(/url\([^)]{0,300}\)/i, 'url()').gsub(/'[^']{0,300}'/, "''").gsub(/"[^"]{0,300}"/, '""')
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The raw non-rem lengths in a class token or a style attribute, as "11px" strings.
|
|
330
|
+
def self.violations(text)
|
|
331
|
+
strip_literals(text).scan(LENGTH).map { |number, unit| "#{number}#{unit}" }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
EXACT_PX = { 'px' => 1.0, 'pt' => 96.0 / 72, 'pc' => 16.0, 'in' => 96.0, 'cm' => 96.0 / 2.54, 'mm' => 96.0 / 25.4, 'q' => 96.0 / 101.6 }.freeze
|
|
335
|
+
FONT_RELATIVE = %w[em ex ch cap ic lh rcap rch rex ric rlh].freeze
|
|
336
|
+
CONTAINER = %w[cqw cqh cqi cqb cqmin cqmax].freeze
|
|
337
|
+
|
|
338
|
+
# What to write instead, for the message. Absolute units convert exactly (at the default 16px root font
|
|
339
|
+
# size); font-relative ones depend on the element's own font; viewport and container units are relative to
|
|
340
|
+
# something rem and % cannot name.
|
|
341
|
+
def self.hint(value)
|
|
342
|
+
m = value.to_s.match(/\A(-?\d*\.?\d+)([a-z]+)\z/i)
|
|
343
|
+
return nil unless m
|
|
344
|
+
number, unit = m[1].to_f, m[2].downcase
|
|
345
|
+
if EXACT_PX.key?(unit)
|
|
346
|
+
rem = (number * EXACT_PX[unit] / 16.0).round(4)
|
|
347
|
+
"#{value} = #{rem == rem.to_i ? rem.to_i : rem}rem at the default 16px root size"
|
|
348
|
+
elsif FONT_RELATIVE.include?(unit)
|
|
349
|
+
"#{unit} depends on the element's own font, so it has no exact rem equivalent; choose a rem value"
|
|
350
|
+
elsif CONTAINER.include?(unit)
|
|
351
|
+
"#{unit} is relative to a container, which rem and % cannot express"
|
|
352
|
+
else
|
|
353
|
+
"#{unit} is relative to the viewport, which rem and % cannot express (a whole-viewport size has a named utility such as h-dvh or min-h-svh)"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# On QSS STRUCTURE (a QSS actor outside foreign content) the rule is stricter: ANY raw CSS length, rem
|
|
358
|
+
# included, is a second unit system next to QSS's own. Structure is sized in QSS units (calc(N * var(--qnt)),
|
|
359
|
+
# w[N], h[N]) or in the canvas grid (w-pct[N], h-pct[N]). Percentages and non-lengths still pass.
|
|
360
|
+
LENGTH_ANY = /(?<![A-Za-z0-9#.])(\d*\.?\d+)(#{LENGTH_UNITS.join('|')})(?![A-Za-z0-9])/i
|
|
361
|
+
VIEWPORT = %w[vh svh lvh dvh vw svw lvw dvw vmin svmin lvmin dvmin vmax svmax lvmax dvmax vb svb lvb dvb vi svi lvi dvi].freeze
|
|
362
|
+
|
|
363
|
+
def self.violations_any(text)
|
|
364
|
+
strip_literals(text).scan(LENGTH_ANY).map { |number, unit| "#{number}#{unit}" }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def self.qss_hint(value)
|
|
368
|
+
m = value.to_s.match(/\A(-?\d*\.?\d+)([a-z]+)\z/i)
|
|
369
|
+
return nil unless m
|
|
370
|
+
number, unit = m[1], m[2].downcase
|
|
371
|
+
if VIEWPORT.include?(unit)
|
|
372
|
+
"viewport units never appear in QSS structure; use the canvas grid (h-pct[N] or w-pct[N], N in 0.1% steps)"
|
|
373
|
+
elsif unit == 'px'
|
|
374
|
+
"QSS structure is sized in QSS units, and 1 unit is 1/1000 of the canvas width, so it scales with the canvas: #{number}px is not simply #{number}; pick the units for the intended proportion (calc(N * var(--qnt)), w[N], h[N])"
|
|
375
|
+
else
|
|
376
|
+
"#{unit} is not a QSS unit; use QSS units (calc(N * var(--qnt)), w[N], h[N]) or a named utility"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# A consumer's vocabulary, read at runtime from a YAML file (decision 3):
|
|
383
|
+
#
|
|
384
|
+
# allow_digits: false # optional, per-project opt-in for digits in open names
|
|
385
|
+
# functions: [save, delete] # the open qss-function vocabulary
|
|
386
|
+
# types: # entries record ONLY what a type ADDS (deltas, decision 4b)
|
|
387
|
+
# divider-hr: # native extension: {qualifier}-{nativeTag}
|
|
388
|
+
# dimensions: # exact facts in QSS units (whole number), a percentage, or a ratio
|
|
389
|
+
# height: 1
|
|
390
|
+
# workspace-QSSObject: # custom type: {qualifier}-QSSObject
|
|
391
|
+
# structural: true
|
|
392
|
+
#
|
|
393
|
+
# (qss-function and qss-relation are ELEMENT attributes, not type facts, so they are not declared here.)
|
|
394
|
+
#
|
|
395
|
+
# A missing file is an empty registry. A malformed one is an empty registry that carries an error, so the
|
|
396
|
+
# audit can report it rather than crash. Everything here is a plain value: no side effects but the cache.
|
|
397
|
+
class Registry
|
|
398
|
+
TOP_LEVEL_KEYS = %w[allow_digits functions types].freeze
|
|
399
|
+
ENTRY_KEYS = %w[extends structural dimensions notes].freeze
|
|
400
|
+
DIMENSION_KEYS = %w[width height aspect-ratio].freeze
|
|
401
|
+
# Named only so the error can say WHY: these are accidental (presentational/positional), never essential.
|
|
402
|
+
PRESENTATIONAL_KEYS = %w[color background background-color border border-radius padding margin font font-size
|
|
403
|
+
font-weight position top left right bottom z-index opacity shadow box-shadow display
|
|
404
|
+
gap radius].freeze
|
|
405
|
+
CACHE = {}
|
|
406
|
+
|
|
407
|
+
attr_reader :allow_digits, :functions, :types, :errors, :warnings
|
|
408
|
+
|
|
409
|
+
def self.load(path)
|
|
410
|
+
path = path.to_s
|
|
411
|
+
return new({}) if path.empty? || !File.exist?(path)
|
|
412
|
+
mtime = File.mtime(path)
|
|
413
|
+
cached = CACHE[path]
|
|
414
|
+
return cached.last if cached && cached.first == mtime
|
|
415
|
+
registry = begin
|
|
416
|
+
new(YAML.safe_load(File.read(path), permitted_classes: [], aliases: false))
|
|
417
|
+
rescue Psych::Exception => e
|
|
418
|
+
new({}, load_error: "#{File.basename(path)} is not valid YAML: #{e.message.lines.first.to_s.strip}")
|
|
419
|
+
end
|
|
420
|
+
CACHE[path] = [ mtime, registry ]
|
|
421
|
+
registry
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def initialize(data, load_error: nil)
|
|
425
|
+
@errors = []
|
|
426
|
+
@warnings = []
|
|
427
|
+
@types = {}
|
|
428
|
+
@functions = []
|
|
429
|
+
@allow_digits = false
|
|
430
|
+
return add_error(:malformed, load_error) if load_error
|
|
431
|
+
data = {} if data.nil?
|
|
432
|
+
return add_error(:malformed, 'the registry must be a mapping with allow_digits, functions and types') unless data.is_a?(Hash)
|
|
433
|
+
(data.keys.map(&:to_s) - TOP_LEVEL_KEYS).each do |key|
|
|
434
|
+
add_error(:unknown_key, "unknown top-level key #{key.inspect} (allowed: #{TOP_LEVEL_KEYS.join(', ')})")
|
|
435
|
+
end
|
|
436
|
+
read_allow_digits(data['allow_digits'])
|
|
437
|
+
read_functions(data['functions'])
|
|
438
|
+
read_types(data['types'])
|
|
439
|
+
check_extends
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def empty?
|
|
443
|
+
@types.empty? && @functions.empty?
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def function?(name)
|
|
447
|
+
@functions.include?(name)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def type?(name)
|
|
451
|
+
@types.key?(name)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Registered type names by kind, so an id is only compared against names of its own kind.
|
|
455
|
+
def custom_type_names
|
|
456
|
+
@types.keys.select { |n| base_of(n) == :qss_object }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def native_type_names
|
|
460
|
+
@types.keys.reject { |n| base_of(n) == :qss_object }
|
|
461
|
+
end
|
|
462
|
+
# Is this registered type a structural frame? Its own declaration wins, then what it extends; a native
|
|
463
|
+
# extension falls back to whether its base tag is structural by convention.
|
|
464
|
+
def structural?(name, seen = [])
|
|
465
|
+
entry = @types[name]
|
|
466
|
+
return false if entry.nil? || seen.include?(name)
|
|
467
|
+
return entry['structural'] if entry.key?('structural')
|
|
468
|
+
parent = entry['extends']
|
|
469
|
+
return structural?(parent, seen + [ name ]) if parent && @types.key?(parent)
|
|
470
|
+
STRUCTURAL_NATIVE_TAGS.include?(base_of(name))
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# The exact dimensional facts a type has, its own declarations over whatever it extends:
|
|
474
|
+
# { 'height' => { kind: :units, value: 1 } }. Only valid declarations are returned.
|
|
475
|
+
def dimensions_of(name, seen = [])
|
|
476
|
+
entry = @types[name]
|
|
477
|
+
return {} if entry.nil? || seen.include?(name)
|
|
478
|
+
parent = entry['extends']
|
|
479
|
+
inherited = parent && @types.key?(parent) ? dimensions_of(parent, seen + [ name ]) : {}
|
|
480
|
+
own = (entry['dimensions'].is_a?(Hash) ? entry['dimensions'] : {}).each_with_object({}) do |(key, raw), out|
|
|
481
|
+
parsed, problem = Dimensions.parse(key.to_s, raw)
|
|
482
|
+
out[key.to_s] = parsed if parsed && !problem
|
|
483
|
+
end
|
|
484
|
+
inherited.merge(own)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# The native tag a type ultimately builds on, or :qss_object for a custom type.
|
|
488
|
+
def base_of(name)
|
|
489
|
+
return :qss_object if name.to_s.end_with?("-#{NameRules::ANCHOR}")
|
|
490
|
+
name.to_s.split('-').last
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
private
|
|
494
|
+
|
|
495
|
+
def add_error(code, message)
|
|
496
|
+
@errors << { code: code, message: message }
|
|
497
|
+
nil
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def add_warning(code, message)
|
|
501
|
+
@warnings << { code: code, message: message }
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def read_allow_digits(value)
|
|
505
|
+
return if value.nil?
|
|
506
|
+
return @allow_digits = value if value == true || value == false
|
|
507
|
+
add_error(:bad_allow_digits, "allow_digits must be true or false, not #{value.inspect}")
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def read_functions(list)
|
|
511
|
+
return if list.nil?
|
|
512
|
+
return add_error(:bad_functions, 'functions must be a list of names') unless list.is_a?(Array)
|
|
513
|
+
list.each do |name|
|
|
514
|
+
reason = NameRules.rejection_reason(name, allow_digits: @allow_digits)
|
|
515
|
+
next add_error(:invalid_name, "function #{name.inspect} is not an acceptable name (#{reason})") if reason
|
|
516
|
+
next add_error(:duplicate, "function #{name.inspect} is listed twice") if @functions.include?(name)
|
|
517
|
+
NameRules.similar(name, @functions, allow_digits: @allow_digits).each do |other, why|
|
|
518
|
+
add_warning(:similar_registered, "function #{name.inspect} looks like #{other.inspect} (#{why}); confirm they are different")
|
|
519
|
+
end
|
|
520
|
+
@functions << name
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def read_types(map)
|
|
525
|
+
return if map.nil?
|
|
526
|
+
return add_error(:bad_types, 'types must be a mapping of type name to what it adds') unless map.is_a?(Hash)
|
|
527
|
+
map.each do |name, entry|
|
|
528
|
+
name = name.to_s
|
|
529
|
+
problem = type_name_problem(name)
|
|
530
|
+
next add_error(:invalid_type_name, "type #{name.inspect}: #{problem}") if problem
|
|
531
|
+
entry = {} if entry.nil?
|
|
532
|
+
next add_error(:bad_entry, "type #{name.inspect} must be a mapping (or empty)") unless entry.is_a?(Hash)
|
|
533
|
+
@types[name] = read_entry(name, entry.transform_keys(&:to_s))
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def read_entry(name, entry)
|
|
538
|
+
entry.each_key do |key|
|
|
539
|
+
if DIMENSION_KEYS.include?(key)
|
|
540
|
+
add_error(:dimension_outside_dimensions, "type #{name.inspect}: declare #{key.inspect} under dimensions:, in QSS units")
|
|
541
|
+
elsif PRESENTATIONAL_KEYS.include?(key)
|
|
542
|
+
add_error(:presentational_fact, "type #{name.inspect}: #{key.inspect} is presentational or positional, so it is never an essential fact (use a class)")
|
|
543
|
+
elsif !ENTRY_KEYS.include?(key)
|
|
544
|
+
add_error(:unknown_type_key, "type #{name.inspect}: unknown key #{key.inspect} (allowed: #{ENTRY_KEYS.join(', ')})")
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
if entry.key?('structural') && ![ true, false ].include?(entry['structural'])
|
|
548
|
+
add_error(:bad_structural, "type #{name.inspect}: structural must be true or false")
|
|
549
|
+
end
|
|
550
|
+
read_dimensions(name, entry['dimensions']) if entry.key?('dimensions')
|
|
551
|
+
entry
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def read_dimensions(name, value)
|
|
555
|
+
return add_error(:bad_dimensions, "type #{name.inspect}: dimensions must be a mapping of #{DIMENSION_KEYS.join(', ')}") unless value.is_a?(Hash)
|
|
556
|
+
value.each do |key, raw|
|
|
557
|
+
key = key.to_s
|
|
558
|
+
next add_error(:unknown_dimension, "type #{name.inspect}: #{key.inspect} is not a declarable dimension (allowed: #{DIMENSION_KEYS.join(', ')})") unless DIMENSION_KEYS.include?(key)
|
|
559
|
+
_parsed, problem = Dimensions.parse(key, raw)
|
|
560
|
+
add_error(:bad_dimension, "type #{name.inspect}: #{key}: #{problem}") if problem
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# nil when the name is well formed: {qualifier}-QSSObject or {qualifier}-{nativeTag}.
|
|
565
|
+
def type_name_problem(name)
|
|
566
|
+
if name.include?(NameRules::ANCHOR)
|
|
567
|
+
return "the anchor #{NameRules::ANCHOR} must appear once, as the last segment" unless name.end_with?("-#{NameRules::ANCHOR}") && name.scan(NameRules::ANCHOR).size == 1
|
|
568
|
+
qualifier = name.delete_suffix("-#{NameRules::ANCHOR}")
|
|
569
|
+
else
|
|
570
|
+
return 'looks like a broken QSSObject anchor' if NameRules.anchor_near_miss?(name)
|
|
571
|
+
qualifier, _, tag = name.rpartition('-')
|
|
572
|
+
return "must be {qualifier}-{nativeTag} or {qualifier}-#{NameRules::ANCHOR}; #{tag.inspect} is not a native tag" unless NATIVE_TAGS.include?(tag)
|
|
573
|
+
return 'a qualifier is required (a bare native tag needs no registration)' if qualifier.empty?
|
|
574
|
+
end
|
|
575
|
+
reason = NameRules.rejection_reason(qualifier, allow_digits: @allow_digits)
|
|
576
|
+
reason ? "the qualifier #{qualifier.inspect} is not acceptable (#{reason})" : nil
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def check_extends
|
|
580
|
+
@types.each do |name, entry|
|
|
581
|
+
parent = entry['extends']
|
|
582
|
+
next if parent.nil?
|
|
583
|
+
unless parent.is_a?(String) && (NATIVE_TAGS.include?(parent) || @types.key?(parent))
|
|
584
|
+
next add_error(:unknown_extends, "type #{name.inspect} extends #{parent.inspect}, which is neither a native tag nor a registered type")
|
|
585
|
+
end
|
|
586
|
+
unless base_of(parent) == base_of(name)
|
|
587
|
+
add_error(:mismatched_base, "type #{name.inspect} cannot extend #{parent.inspect}: they build on different base objects")
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
@types.each_key do |name|
|
|
591
|
+
chain = [ name ]
|
|
592
|
+
cursor = @types[name]['extends']
|
|
593
|
+
while cursor && @types.key?(cursor)
|
|
594
|
+
if chain.include?(cursor)
|
|
595
|
+
add_error(:extends_cycle, "types #{(chain + [ cursor ]).join(' -> ')} extend each other in a loop")
|
|
596
|
+
break
|
|
597
|
+
end
|
|
598
|
+
chain << cursor
|
|
599
|
+
cursor = @types[cursor]['extends']
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
# The id check (decisions 2 and 4b), a pure function over one id. It BIFURCATES on a single boolean:
|
|
605
|
+
# does the id contain the literal anchor QSSObject?
|
|
606
|
+
# yes -> custom-type branch, committed: exact shape {qualifier}-QSSObject, or a red error (never falls through)
|
|
607
|
+
# no -> native branch: an id with the project's interim prefix (its id_prefix) or the framework prefix (qss-) is
|
|
608
|
+
# exempt (the caller applies its interim rules); otherwise the last hyphen segment must be a native tag
|
|
609
|
+
# Severities: RED means a rule is violated (look closer: fix the markup, or amend the theory);
|
|
610
|
+
# ORANGE means a judgment call the rules cannot settle (a look-alike name, a probable broken anchor).
|
|
611
|
+
module IdRules
|
|
612
|
+
FRAMEWORK_PREFIX = 'qss-'.freeze
|
|
613
|
+
Result = Struct.new(:branch, :type_name, :qualifier, :base, :structural, :findings, keyword_init: true)
|
|
614
|
+
|
|
615
|
+
def self.check(id, registry:, exempt_prefixes: [])
|
|
616
|
+
id = id.to_s
|
|
617
|
+
return build(:none) if id.empty?
|
|
618
|
+
id.include?(NameRules::ANCHOR) ? check_custom(id, registry) : check_native(id, registry, exempt_prefixes)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def self.build(branch, **fields)
|
|
622
|
+
Result.new(branch: branch, findings: [], **fields)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def self.finding(code, severity, message)
|
|
626
|
+
{ code: code, severity: severity, message: message }
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def self.check_custom(id, registry)
|
|
630
|
+
result = build(:custom, type_name: id, base: :qss_object)
|
|
631
|
+
unless id.end_with?("-#{NameRules::ANCHOR}") && id.scan(NameRules::ANCHOR).size == 1
|
|
632
|
+
result.findings << finding(:malformed_anchor, 'red', "#{id.inspect}: the anchor #{NameRules::ANCHOR} must appear once, as the last segment ({qualifier}-#{NameRules::ANCHOR})")
|
|
633
|
+
return result
|
|
634
|
+
end
|
|
635
|
+
result.qualifier = id.delete_suffix("-#{NameRules::ANCHOR}")
|
|
636
|
+
reason = NameRules.rejection_reason(result.qualifier, allow_digits: registry.allow_digits)
|
|
637
|
+
return with(result, finding(:invalid_qualifier, 'red', "#{id.inspect}: the qualifier #{result.qualifier.inspect} is not acceptable (#{reason})")) if reason
|
|
638
|
+
if registry.type?(id)
|
|
639
|
+
result.structural = registry.structural?(id)
|
|
640
|
+
else
|
|
641
|
+
result.structural = false
|
|
642
|
+
register_or_similar(result, id, registry.custom_type_names, registry)
|
|
643
|
+
end
|
|
644
|
+
result
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def self.check_native(id, registry, exempt_prefixes)
|
|
648
|
+
return build(:exempt, type_name: id) if exempt_prefixes.any? { |p| !p.to_s.empty? && id.start_with?(p) }
|
|
649
|
+
if NameRules.anchor_near_miss?(id)
|
|
650
|
+
return with(build(:native, type_name: id), finding(:broken_anchor, 'orange', "#{id.inspect} looks like a broken #{NameRules::ANCHOR} anchor; write it exactly, or drop it"))
|
|
651
|
+
end
|
|
652
|
+
qualifier, _, base = id.rpartition('-')
|
|
653
|
+
result = build(:native, type_name: id, qualifier: qualifier, base: base)
|
|
654
|
+
unless NATIVE_TAGS.include?(base)
|
|
655
|
+
return with(result, finding(:unknown_base_object, 'red', "#{id.inspect}: #{base.inspect} is not a native base object; an id reads {qualifier}-{nativeTag} or {qualifier}-#{NameRules::ANCHOR}"))
|
|
656
|
+
end
|
|
657
|
+
result.structural = STRUCTURAL_NATIVE_TAGS.include?(base)
|
|
658
|
+
return with(result, finding(:missing_qualifier, 'red', "#{id.inspect} is a bare native tag; say which one ({qualifier}-#{base})")) if qualifier.empty?
|
|
659
|
+
reason = NameRules.rejection_reason(qualifier, allow_digits: registry.allow_digits)
|
|
660
|
+
return with(result, finding(:invalid_qualifier, 'red', "#{id.inspect}: the qualifier #{qualifier.inspect} is not acceptable (#{reason})")) if reason
|
|
661
|
+
if registry.type?(id)
|
|
662
|
+
result.structural = registry.structural?(id)
|
|
663
|
+
else
|
|
664
|
+
register_or_similar(result, id, registry.native_type_names, registry)
|
|
665
|
+
end
|
|
666
|
+
result
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# An unregistered type is an empty identity claim: point at the near match if there is one.
|
|
670
|
+
def self.register_or_similar(result, id, known, registry)
|
|
671
|
+
near = NameRules.similar(id, known, allow_digits: registry.allow_digits)
|
|
672
|
+
if near.empty?
|
|
673
|
+
kind = result.branch == :custom ? 'declare it as a type' : "register what #{result.qualifier.inspect} adds to #{result.base.inspect}, or drop the qualifier"
|
|
674
|
+
result.findings << finding(result.branch == :custom ? :undeclared_type : :unregistered_qualifier, 'red', "#{id.inspect} is not in the registry: #{kind}")
|
|
675
|
+
else
|
|
676
|
+
list = near.map { |name, why| "#{name.inspect} (#{why})" }.join(', ')
|
|
677
|
+
result.findings << finding(:similar_type, 'orange', "#{id.inspect} is not in the registry but looks like #{list}; confirm they differ, or fix the name")
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def self.with(result, finding)
|
|
682
|
+
result.findings << finding
|
|
683
|
+
result
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
# The relational and functional facts of an element are plain attributes, not part of its id (decision 4b):
|
|
687
|
+
# qss-relation="form" names the larger structure it belongs to, qss-function="delete" what it does.
|
|
688
|
+
# Both are optional; only QSS actors are checked. Severity follows the scheme: red = a rule is violated,
|
|
689
|
+
# orange = a judgment call (a look-alike of a registered function).
|
|
690
|
+
module AttributeRules
|
|
691
|
+
def self.finding(code, severity, message)
|
|
692
|
+
{ code: code, severity: severity, message: message }
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def self.check(function:, relation:, registry:)
|
|
696
|
+
findings = []
|
|
697
|
+
findings.concat(check_function(function, registry)) if function
|
|
698
|
+
findings.concat(check_relation(relation)) if relation
|
|
699
|
+
findings
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# The open vocabulary: closed alphabet, then the registry, then look-alikes of registered names.
|
|
703
|
+
def self.check_function(value, registry)
|
|
704
|
+
reason = NameRules.rejection_reason(value, allow_digits: registry.allow_digits)
|
|
705
|
+
return [ finding(:invalid_function, 'red', "qss-function #{value.inspect} is not an acceptable name (#{reason})") ] if reason
|
|
706
|
+
return [] if registry.function?(value)
|
|
707
|
+
near = NameRules.similar(value, registry.functions, allow_digits: registry.allow_digits)
|
|
708
|
+
if near.empty?
|
|
709
|
+
[ finding(:unregistered_function, 'red', "qss-function #{value.inspect} is not in the registry: register it, or use a registered function") ]
|
|
710
|
+
else
|
|
711
|
+
list = near.map { |name, why| "#{name.inspect} (#{why})" }.join(', ')
|
|
712
|
+
[ finding(:similar_function, 'orange', "qss-function #{value.inspect} is not in the registry but looks like #{list}; confirm they differ, or use the registered name") ]
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# The closed vocabulary: the same native base-object list an id is checked against.
|
|
717
|
+
def self.check_relation(value)
|
|
718
|
+
return [] if NATIVE_TAGS.include?(value)
|
|
719
|
+
hint = NameRules.similar(value.to_s, NATIVE_TAGS).first(2).map(&:first)
|
|
720
|
+
[ finding(:bad_relation, 'red', "qss-relation #{value.inspect} is not a native base object#{hint.empty? ? '' : " (did you mean #{hint.map(&:inspect).join(' or ')}?)"}") ]
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
def self.audit_file(file_path)
|
|
724
|
+
return { status: 'grey', issues: [] } unless File.exist?(file_path)
|
|
725
|
+
raw_content = File.read(file_path).force_encoding('UTF-8').scrub rescue ""
|
|
726
|
+
return { status: 'grey', issues: [] } if raw_content.strip.empty?
|
|
727
|
+
|
|
728
|
+
issues = []
|
|
729
|
+
|
|
730
|
+
# Global checks
|
|
731
|
+
if raw_content.include?('!important')
|
|
732
|
+
issues << { type: 'protocol', message: "Cheat detected: '!important' is forbidden", severity: 'red' }
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Template parsing
|
|
736
|
+
doc_content = raw_content.gsub(/<%.*?%>/m, '<!--ERB-->')
|
|
737
|
+
# A fragment parse silently drops <html>, <head> and <body> (and their attributes), so a full document, such as
|
|
738
|
+
# a layout, is parsed as a document; anything else stays a fragment.
|
|
739
|
+
doc = doc_content.match?(/<(?:html|head|body)\b/i) ? Nokogiri::HTML(doc_content) : Nokogiri::HTML.fragment(doc_content)
|
|
740
|
+
|
|
741
|
+
# Item B: the consumer's registry (empty when none is configured) and the id rules.
|
|
742
|
+
settings = self.settings
|
|
743
|
+
prefix = id_prefix(settings)
|
|
744
|
+
narrow_pattern = narrow_structural_pattern(settings)
|
|
745
|
+
portal_label = portal_label(settings)
|
|
746
|
+
registry = Registry.load(settings['registry_path'])
|
|
747
|
+
exempt_prefixes = [ prefix, IdRules::FRAMEWORK_PREFIX ]
|
|
748
|
+
seen_ids = {}
|
|
749
|
+
seen_attributes = {}
|
|
750
|
+
unless registry.errors.empty?
|
|
751
|
+
issues << { type: 'identity', code: :registry_problem, message: "Registry problem (#{registry.errors.size}): #{registry.errors.first[:message]}", severity: 'red' }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
doc.traverse do |node|
|
|
755
|
+
next unless node.element?
|
|
756
|
+
|
|
757
|
+
id = node['id'] || ""
|
|
758
|
+
classes = node['class']&.split(/\s+/) || []
|
|
759
|
+
style = node['style'] || ""
|
|
760
|
+
|
|
761
|
+
# The id rules apply to QSS actors (class `qss`); ids on other elements (third-party markup, framework or
|
|
762
|
+
# form-helper ids) are not ours to police. Ids with an exempt prefix still take the interim rules.
|
|
763
|
+
qss_actor = classes.include?('qss')
|
|
764
|
+
exempt_id = exempt_prefixes.any? { |p| !p.to_s.empty? && id.start_with?(p) }
|
|
765
|
+
identity = id.empty? || id.include?('<!--ERB-->') || !(qss_actor || exempt_id) ? nil : IdRules.check(id, registry: registry, exempt_prefixes: exempt_prefixes)
|
|
766
|
+
is_structural = identity.nil? ? false : (identity.branch == :exempt ? structural_id?(id, prefix) : identity.structural == true)
|
|
767
|
+
is_narrow_structural = is_structural && id.match?(narrow_pattern)
|
|
768
|
+
is_portal = id.include?('portal')
|
|
769
|
+
is_content_driven = classes.include?('qss-content-driven')
|
|
770
|
+
|
|
771
|
+
# Foreign context check
|
|
772
|
+
is_foreign = classes.include?('foreign') || classes.include?('qss-payload-override') ||
|
|
773
|
+
node.ancestors.any? { |a| (a['class']&.split(/\s+/) || []).intersect?([ 'foreign', 'qss-payload-override' ]) }
|
|
774
|
+
|
|
775
|
+
# Foreign hygiene (item B): raw CSS lengths in arbitrary values and inline styles must be rem.
|
|
776
|
+
if is_foreign
|
|
777
|
+
(classes.select { |c| c.include?('[') }.flat_map { |c| Units.violations(c).map { |v| [ v, c ] } } + Units.violations(style).map { |v| [ v, "style=\"#{style}\"" ] }).each do |value, where|
|
|
778
|
+
issues << { type: 'protocol', code: :foreign_unit, message: "Foreign Unit Violation: #{value} in #{where} is a raw CSS length; foreign content may use rem or % only (#{Units.hint(value)})", severity: 'red' }
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# QSS structure (item B): no raw CSS lengths at all; structure is sized in QSS units or the canvas grid.
|
|
783
|
+
if qss_actor && !is_foreign
|
|
784
|
+
(classes.select { |c| c.include?('[') }.flat_map { |c| Units.violations_any(c).map { |v| [ v, c ] } } + Units.violations_any(style).map { |v| [ v, "style=\"#{style[0, 60]}\"" ] }).each do |value, where|
|
|
785
|
+
issues << { type: 'protocol', code: :qss_unit, message: "QSS Unit Violation: #{value} in #{where} is a raw CSS length on QSS structure; #{Units.qss_hint(value)}", severity: 'red' }
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
next if is_foreign && !is_structural # Skip internal payload of foreign components
|
|
790
|
+
|
|
791
|
+
# Identity findings (item B), each id reported once per file.
|
|
792
|
+
if identity && !seen_ids[id]
|
|
793
|
+
seen_ids[id] = true
|
|
794
|
+
identity.findings.each { |f| issues << { type: 'identity', code: f[:code], message: f[:message], severity: f[:severity] } }
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Declared essential dimensions (item B): a registered type's exact facts must not be contradicted by the element.
|
|
798
|
+
if identity && registry.type?(id)
|
|
799
|
+
Dimensions.conflicts(registry.dimensions_of(id), classes, id).each do |message|
|
|
800
|
+
issues << { type: 'identity', code: :dimension_conflict, message: message, severity: 'red' }
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Relational and functional attributes (item B), on QSS actors only, each distinct pair once per file.
|
|
805
|
+
if qss_actor && (node['qss-function'] || node['qss-relation'])
|
|
806
|
+
pair = [ node['qss-function'], node['qss-relation'] ]
|
|
807
|
+
unless seen_attributes[pair]
|
|
808
|
+
seen_attributes[pair] = true
|
|
809
|
+
AttributeRules.check(function: pair[0], relation: pair[1], registry: registry).each do |f|
|
|
810
|
+
issues << { type: 'identity', code: f[:code], message: f[:message], severity: f[:severity] }
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Rule 5: Legacy Style Ban & Reactive Properties
|
|
816
|
+
if !style.empty?
|
|
817
|
+
# Split styles and check for static vs reactive
|
|
818
|
+
style_pairs = style.split(';').map(&:strip).reject(&:empty?)
|
|
819
|
+
style_pairs.each do |pair|
|
|
820
|
+
prop, val = pair.split(':', 2).map(&:strip)
|
|
821
|
+
next if prop.start_with?('--') # Reactive/Custom property allowed
|
|
822
|
+
|
|
823
|
+
# Special case: contain: layout in style is RED (Rule 1 / Rule 5)
|
|
824
|
+
if prop == 'contain' && val == 'layout'
|
|
825
|
+
issues << { type: 'protocol', message: "Legacy Style Violation: 'contain: layout' on ##{id} must be a class, not inline style.", severity: 'red' }
|
|
826
|
+
else
|
|
827
|
+
issues << { type: 'protocol', message: "Protocol Violation: Static inline style '#{prop}' forbidden on <#{node.name}#{id.empty? ? '' : '#' + id}>.", severity: 'red' }
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Explicit dimensions (every QSS actor): the core reset gives each actor `flex-shrink: 0; height: auto`, so an
|
|
833
|
+
# actor without a declared width and height takes its content's size and can overflow a bounded frame unseen.
|
|
834
|
+
# Declared = a core size class (Dimensions.from_classes); the opt-out is `qss-content-driven`. Class lists
|
|
835
|
+
# built in ERB are skipped: their classes cannot be known from the template.
|
|
836
|
+
actor_dimensions_checked = qss_actor && !is_foreign && !is_content_driven && !node['class'].include?('<!--ERB-->')
|
|
837
|
+
if actor_dimensions_checked
|
|
838
|
+
missing = Dimensions.missing_axes(classes)
|
|
839
|
+
unless missing.empty?
|
|
840
|
+
label = id.empty? ? "<#{node.name}>" : "##{id}"
|
|
841
|
+
issues << { type: 'protocol', code: :implicit_dimension, message: "Implicit Dimension Violation: QSS actor #{label} declares no #{missing.join(' and no ')}; declare it with a QSS size class (w[N]/h[N], w-pct[N]/h-pct[N], w-full/h-full) or opt out with qss-content-driven", severity: 'red' }
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
if is_structural
|
|
846
|
+
# Rule 1: Steel Frame Isolation (Narrow List)
|
|
847
|
+
if is_narrow_structural && !is_portal
|
|
848
|
+
unless classes.include?('contain-layout') || classes.include?('qss-contain-layout')
|
|
849
|
+
# We also check if it's currently in style (handled by Rule 5 above as RED)
|
|
850
|
+
# If it's missing entirely:
|
|
851
|
+
unless style.match?(/contain\s*:\s*layout/i)
|
|
852
|
+
issues << { type: 'protocol', message: "Steel Frame Violation: Structural element ##{id} must carry 'contain-layout' class.", severity: 'red' }
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Rule 2: Explicit Dimensions (structural elements the actor check above did not cover: non-actors, foreign or ERB-built)
|
|
858
|
+
unless is_content_driven || is_portal || actor_dimensions_checked
|
|
859
|
+
has_w = classes.any? { |c| c.match?(/^(w-|qss-w-)(full|half|\[|[\d.]+)/) || c.include?('var(--') }
|
|
860
|
+
has_h = classes.any? { |c| c.match?(/^(h-|qss-h-)(full|half|\[|[\d.]+)/) || c.include?('var(--') }
|
|
861
|
+
|
|
862
|
+
# Explicitly reject legacy bracket-DSL w[640] as invalid for Rule 2
|
|
863
|
+
# (The regex above allows w-[...] or w-full, but not w[...])
|
|
864
|
+
|
|
865
|
+
unless has_w && has_h
|
|
866
|
+
issues << { type: 'protocol', message: "Implicit Dimension Warning: Structural element ##{id} lacks explicit width/height classes.", severity: 'orange' }
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Rule 3: Portal Integrity (Extended)
|
|
872
|
+
has_z_class = classes.any? { |c| c.match?(/^(qss-)?z-\[?\d+\]?/) }
|
|
873
|
+
has_z_style = style.match?(/z-index\s*:/i)
|
|
874
|
+
|
|
875
|
+
if (has_z_class || has_z_style) && !is_portal && !is_foreign
|
|
876
|
+
issues << { type: 'protocol', message: "Portal Integrity Violation: z-index on <#{node.name}#{id.empty? ? '' : '#' + id}> outside #{portal_label}.", severity: 'red' }
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Rule 4: Payload Purity
|
|
880
|
+
if PAYLOAD_TAGS.include?(node.name) && !is_structural
|
|
881
|
+
style_pairs = style.split(';').map(&:strip).reject(&:empty?)
|
|
882
|
+
style_pairs.each do |pair|
|
|
883
|
+
prop, _ = pair.split(':', 2).map(&:strip)
|
|
884
|
+
if [ 'width', 'height', 'position' ].include?(prop.downcase)
|
|
885
|
+
issues << { type: 'protocol', message: "Payload Purity Violation: <#{node.name}> setting structural geometry '#{prop}' via style.", severity: 'red' }
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Also check for absolute/pos classes on payload
|
|
890
|
+
if classes.any? { |c| c.match?(/^(absolute|pos-|qss-absolute|qss-pos-)/) }
|
|
891
|
+
issues << { type: 'protocol', message: "Payload Purity Violation: <#{node.name}> carrying structural positioning classes.", severity: 'red' }
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
status = issues.any? { |i| i[:severity] == 'red' } ? 'red' : (issues.any? { |i| i[:severity] == 'orange' } ? 'orange' : 'green')
|
|
897
|
+
{ status: status, issues: issues }
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def self.report(file_path)
|
|
901
|
+
result = audit_file(file_path)
|
|
902
|
+
puts "Audit: #{file_path} [#{result[:status].upcase}]"
|
|
903
|
+
result[:issues].each do |issue|
|
|
904
|
+
puts " [#{issue[:severity].upcase}] #{issue[:type]}: #{issue[:message]}"
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
end
|