@zenit-hosting/zenit-ui 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.
- package/README.md +400 -0
- package/fesm2022/zenit-hosting-zenit-ui.mjs +11289 -0
- package/fesm2022/zenit-hosting-zenit-ui.mjs.map +1 -0
- package/llms-full.txt +5120 -0
- package/llms.txt +179 -0
- package/package.json +44 -0
- package/schematics/collection.json +15 -0
- package/schematics/migrate-material/component.js +241 -0
- package/schematics/migrate-material/index.js +463 -0
- package/schematics/migrate-material/report.js +125 -0
- package/schematics/migrate-material/schema.js +2 -0
- package/schematics/migrate-material/schema.json +32 -0
- package/schematics/migrate-material/tables.js +138 -0
- package/schematics/migrate-material/template.js +871 -0
- package/schematics/ng-add/html.js +151 -0
- package/schematics/ng-add/index.js +609 -0
- package/schematics/ng-add/init-script.js +108 -0
- package/schematics/ng-add/schema.js +2 -0
- package/schematics/ng-add/schema.json +34 -0
- package/schematics/package.json +3 -0
- package/styles/_daten.css +138 -0
- package/styles/_formulare.css +97 -0
- package/styles/_grundlage.css +424 -0
- package/styles/_konfigurator.css +216 -0
- package/styles/_navigation.css +179 -0
- package/styles/_overlays.css +86 -0
- package/styles/_rueckmeldung.css +100 -0
- package/styles/_werkzeuge.css +119 -0
- package/styles/themes/accents.css +91 -0
- package/styles/themes/base.css +26 -0
- package/styles/themes/contrast.css +67 -0
- package/styles/themes/light.css +74 -0
- package/styles/themes.css +21 -0
- package/styles/tokens.css +76 -0
- package/styles/zenit-ui.css +12 -0
- package/types/zenit-hosting-zenit-ui.d.ts +5957 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Group = void 0;
|
|
4
|
+
exports.acceptGroups = acceptGroups;
|
|
5
|
+
exports.sortEdits = sortEdits;
|
|
6
|
+
exports.migrateTemplate = migrateTemplate;
|
|
7
|
+
/**
|
|
8
|
+
* The template half of `migrate-material`: parses one Angular template with
|
|
9
|
+
* `@angular/compiler`, plans every conversion as a group of edits against the
|
|
10
|
+
* original text and reports what it left alone.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here writes a file. `migrateTemplate()` returns edits by source span,
|
|
13
|
+
* so the caller applies them in one pass, to an `.html` file or to the inside of
|
|
14
|
+
* an inline template.
|
|
15
|
+
*/
|
|
16
|
+
const compiler_1 = require("@angular/compiler");
|
|
17
|
+
const tables_1 = require("./tables");
|
|
18
|
+
/**
|
|
19
|
+
* All edits of one converted spot. A group is applied completely or not at all,
|
|
20
|
+
* so an element is never left half converted.
|
|
21
|
+
*/
|
|
22
|
+
class Group {
|
|
23
|
+
rule;
|
|
24
|
+
offset;
|
|
25
|
+
source;
|
|
26
|
+
edits = [];
|
|
27
|
+
findings = [];
|
|
28
|
+
/** Material names this conversion removes from the element. */
|
|
29
|
+
consumed = [];
|
|
30
|
+
unsafe = '';
|
|
31
|
+
primary = false;
|
|
32
|
+
/** `false` for an edit that belongs to other spots, like the wrapper of chips. */
|
|
33
|
+
counted = true;
|
|
34
|
+
constructor(rule, offset, source) {
|
|
35
|
+
this.rule = rule;
|
|
36
|
+
this.offset = offset;
|
|
37
|
+
this.source = source;
|
|
38
|
+
}
|
|
39
|
+
/** `expect` is what the span must hold in the original text, otherwise the group is unsafe. */
|
|
40
|
+
replace(start, end, text, expect) {
|
|
41
|
+
if (expect !== undefined && this.source.slice(start, end) !== expect) {
|
|
42
|
+
this.unsafe = `expected "${expect}" at offset ${start}`;
|
|
43
|
+
}
|
|
44
|
+
this.edits.push({ start, end, text });
|
|
45
|
+
}
|
|
46
|
+
/** Removes an attribute together with the whitespace in front of it. */
|
|
47
|
+
drop(attr) {
|
|
48
|
+
let start = attr.start;
|
|
49
|
+
while (start > 0 && /\s/.test(this.source[start - 1])) {
|
|
50
|
+
start -= 1;
|
|
51
|
+
}
|
|
52
|
+
this.replace(start, attr.end, '');
|
|
53
|
+
this.consumed.push(attr.key);
|
|
54
|
+
}
|
|
55
|
+
note(kind, code, reason, fix, offset = this.offset) {
|
|
56
|
+
this.findings.push({ offset, rule: this.rule, kind, code, reason, fix });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.Group = Group;
|
|
60
|
+
/**
|
|
61
|
+
* Keeps the groups whose edits are in bounds, match the text they expect and do
|
|
62
|
+
* not overlap an edit that was accepted before. A rejected group becomes a
|
|
63
|
+
* `manual` finding, the others are still applied.
|
|
64
|
+
*/
|
|
65
|
+
function acceptGroups(groups, length) {
|
|
66
|
+
const accepted = [];
|
|
67
|
+
const rejected = [];
|
|
68
|
+
const taken = [];
|
|
69
|
+
for (const group of groups) {
|
|
70
|
+
const broken = group.unsafe ||
|
|
71
|
+
(group.edits.some(({ start, end }) => start < 0 || end < start || end > length) &&
|
|
72
|
+
'an edit lies outside the text') ||
|
|
73
|
+
(group.edits.some((edit) => taken.some((other) => overlaps(edit, other))) &&
|
|
74
|
+
'an edit overlaps another conversion');
|
|
75
|
+
if (broken) {
|
|
76
|
+
rejected.push({
|
|
77
|
+
offset: group.offset,
|
|
78
|
+
rule: group.rule,
|
|
79
|
+
kind: 'manual',
|
|
80
|
+
code: 'unsafe-edit',
|
|
81
|
+
reason: `The conversion could not be applied safely (${broken}) and was skipped.`,
|
|
82
|
+
fix: 'Convert this element by hand.',
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
taken.push(...group.edits);
|
|
87
|
+
accepted.push(group);
|
|
88
|
+
}
|
|
89
|
+
return { accepted, rejected };
|
|
90
|
+
}
|
|
91
|
+
/** Two insertions at the same offset do not overlap, nor does an insertion at the edge of a span. */
|
|
92
|
+
function overlaps(a, b) {
|
|
93
|
+
return a.start < b.end && b.start < a.end;
|
|
94
|
+
}
|
|
95
|
+
/** Sorted so that an insertion comes before a replacement that starts at the same offset. */
|
|
96
|
+
function sortEdits(edits) {
|
|
97
|
+
return [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
98
|
+
}
|
|
99
|
+
const LIGATURE = /^[a-z0-9_]+$/;
|
|
100
|
+
const COLOUR_FIX = 'Colour comes from the context in zenit-ui (text-muted; coloured only in the active sidebar ' +
|
|
101
|
+
'entry and in an alert). Remove the emphasis or express it with a z-badge/z-alert status.';
|
|
102
|
+
const FIELD_SLOTS = /^mat(Icon|Text)?(Prefix|Suffix)$/;
|
|
103
|
+
const LABELS = ['aria-label', 'attr.aria-label', 'aria-labelledby', 'attr.aria-labelledby'];
|
|
104
|
+
function migrateTemplate(source, options) {
|
|
105
|
+
const result = {
|
|
106
|
+
edits: [],
|
|
107
|
+
findings: [],
|
|
108
|
+
converted: { icon: 0, button: 0, tooltip: 0, spinner: 0, chip: 0 },
|
|
109
|
+
remaining: {},
|
|
110
|
+
zenit: [],
|
|
111
|
+
};
|
|
112
|
+
let nodes;
|
|
113
|
+
try {
|
|
114
|
+
// The parser has no switch that skips i18n; these keep it from touching text or spans.
|
|
115
|
+
const parsed = (0, compiler_1.parseTemplate)(source, 'template.html', {
|
|
116
|
+
preserveWhitespaces: true,
|
|
117
|
+
preserveLineEndings: true,
|
|
118
|
+
leadingTriviaChars: [],
|
|
119
|
+
enableI18nLegacyMessageIdFormat: false,
|
|
120
|
+
i18nNormalizeLineEndingsInICUs: false,
|
|
121
|
+
});
|
|
122
|
+
if (parsed.errors?.length) {
|
|
123
|
+
const first = parsed.errors[0];
|
|
124
|
+
result.error = `${first.msg.split('\n')[0]} (line ${first.span.start.line + 1})`;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
nodes = parsed.nodes;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
const planner = new Planner(source, options);
|
|
134
|
+
(0, compiler_1.tmplAstVisitAll)(planner, nodes);
|
|
135
|
+
const { accepted, rejected } = acceptGroups(planner.groups, source.length);
|
|
136
|
+
const consumed = new Set();
|
|
137
|
+
const zenit = new Set();
|
|
138
|
+
const needs = {
|
|
139
|
+
icon: 'ZIcon',
|
|
140
|
+
button: 'ZButton',
|
|
141
|
+
tooltip: 'ZTooltip',
|
|
142
|
+
spinner: 'ZSpinner',
|
|
143
|
+
chip: 'ZBadge',
|
|
144
|
+
};
|
|
145
|
+
for (const group of accepted) {
|
|
146
|
+
result.edits.push(...group.edits);
|
|
147
|
+
result.findings.push(...group.findings);
|
|
148
|
+
result.converted[group.rule] += group.counted ? 1 : 0;
|
|
149
|
+
zenit.add(needs[group.rule]);
|
|
150
|
+
group.consumed.forEach((name) => consumed.add(`${group.offset}:${name}`));
|
|
151
|
+
}
|
|
152
|
+
result.findings.push(...planner.findings, ...rejected);
|
|
153
|
+
// "At most one primary button per screen height" is an acceptance rule of the design system.
|
|
154
|
+
const primaries = [
|
|
155
|
+
...accepted.filter((group) => group.primary).map((group) => group.offset),
|
|
156
|
+
...planner.primaries,
|
|
157
|
+
].sort((a, b) => a - b);
|
|
158
|
+
if (primaries.length > 1 && accepted.some((group) => group.primary)) {
|
|
159
|
+
result.findings.push({
|
|
160
|
+
offset: primaries[1],
|
|
161
|
+
rule: 'button',
|
|
162
|
+
kind: 'review',
|
|
163
|
+
code: 'several-primaries',
|
|
164
|
+
reason: `This template now has ${primaries.length} buttons with zBtn="primary". The design ` +
|
|
165
|
+
'system allows at most one primary per screen height.',
|
|
166
|
+
fix: 'Keep the one action the screen exists for as primary and make the others zBtn (secondary) or zBtn="ghost".',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const usage = new Usage(consumed, source);
|
|
170
|
+
(0, compiler_1.tmplAstVisitAll)(usage, nodes);
|
|
171
|
+
result.remaining = usage.material;
|
|
172
|
+
usage.zenit.forEach((symbol) => zenit.add(symbol));
|
|
173
|
+
result.zenit = [...zenit].sort();
|
|
174
|
+
result.edits = sortEdits(result.edits);
|
|
175
|
+
result.findings.sort((a, b) => a.offset - b.offset || a.code.localeCompare(b.code));
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/** Plans the conversions. Reads the AST only, the edits refer to the original text. */
|
|
179
|
+
class Planner extends compiler_1.TmplAstRecursiveVisitor {
|
|
180
|
+
source;
|
|
181
|
+
groups = [];
|
|
182
|
+
findings = [];
|
|
183
|
+
/** Offsets of buttons that already carry a static `zBtn="primary"`. */
|
|
184
|
+
primaries = [];
|
|
185
|
+
elements = [];
|
|
186
|
+
/** `*ngIf` and `@if` conditions around the current node; `null` marks an element. */
|
|
187
|
+
conditions = [];
|
|
188
|
+
rules;
|
|
189
|
+
quote;
|
|
190
|
+
plainString;
|
|
191
|
+
/** Memo of the wrappers whose chips all become badges. */
|
|
192
|
+
wrappers = new Map();
|
|
193
|
+
constructor(source, options) {
|
|
194
|
+
super();
|
|
195
|
+
this.source = source;
|
|
196
|
+
this.rules = options.rules;
|
|
197
|
+
this.quote = options.quote ?? '"';
|
|
198
|
+
this.plainString = options.plainString ?? false;
|
|
199
|
+
}
|
|
200
|
+
visitElement(element) {
|
|
201
|
+
const attrs = this.attrsOf(element);
|
|
202
|
+
if (this.rules.has('tooltip')) {
|
|
203
|
+
this.tooltip(element, attrs);
|
|
204
|
+
}
|
|
205
|
+
if (this.rules.has('icon') && element.name === 'mat-icon') {
|
|
206
|
+
this.icon(element, attrs);
|
|
207
|
+
}
|
|
208
|
+
if (this.rules.has('button') && (element.name === 'button' || element.name === 'a')) {
|
|
209
|
+
this.button(element, attrs);
|
|
210
|
+
}
|
|
211
|
+
if (this.rules.has('spinner') &&
|
|
212
|
+
(element.name === 'mat-spinner' || element.name === 'mat-progress-spinner')) {
|
|
213
|
+
this.spinner(element, attrs);
|
|
214
|
+
}
|
|
215
|
+
if (this.rules.has('chip')) {
|
|
216
|
+
if (element.name === 'mat-chip') {
|
|
217
|
+
this.chip(element, attrs);
|
|
218
|
+
}
|
|
219
|
+
else if (element.name === 'mat-chip-set' || element.name === 'mat-chip-listbox') {
|
|
220
|
+
this.chipWrapper(element, attrs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (attrs.some((attr) => attr.key === 'zBtn' && attr.value === 'primary')) {
|
|
224
|
+
this.primaries.push(offsetOf(element));
|
|
225
|
+
}
|
|
226
|
+
this.elements.push(element);
|
|
227
|
+
this.conditions.push(null);
|
|
228
|
+
super.visitElement(element);
|
|
229
|
+
this.conditions.pop();
|
|
230
|
+
this.elements.pop();
|
|
231
|
+
}
|
|
232
|
+
visitTemplate(template) {
|
|
233
|
+
const condition = template.templateAttrs.find((attr) => attr.name === 'ngIf');
|
|
234
|
+
this.conditions.push(condition?.valueSpan
|
|
235
|
+
? this.source.slice(condition.valueSpan.start.offset, condition.valueSpan.end.offset)
|
|
236
|
+
: null);
|
|
237
|
+
super.visitTemplate(template);
|
|
238
|
+
this.conditions.pop();
|
|
239
|
+
}
|
|
240
|
+
visitIfBlockBranch(branch) {
|
|
241
|
+
const expression = branch.expression;
|
|
242
|
+
this.conditions.push(expression?.source?.trim() || null);
|
|
243
|
+
super.visitIfBlockBranch(branch);
|
|
244
|
+
this.conditions.pop();
|
|
245
|
+
}
|
|
246
|
+
// ------------------------------------------------------------------------
|
|
247
|
+
// icon
|
|
248
|
+
// ------------------------------------------------------------------------
|
|
249
|
+
icon(element, attrs) {
|
|
250
|
+
const group = this.groupFor('icon', element);
|
|
251
|
+
const blockers = [];
|
|
252
|
+
let fontIcon;
|
|
253
|
+
for (const attr of attrs) {
|
|
254
|
+
const { key } = attr;
|
|
255
|
+
if (key.startsWith('matTooltip')) {
|
|
256
|
+
continue; // the tooltip rule owns these
|
|
257
|
+
}
|
|
258
|
+
if (key === 'color') {
|
|
259
|
+
this.dropColour(group, attr, 'icon');
|
|
260
|
+
}
|
|
261
|
+
else if (key === 'inline') {
|
|
262
|
+
group.drop(attr);
|
|
263
|
+
group.note('review', 'icon-inline', 'The "inline" attribute was dropped: the icon took its size from the surrounding text, z-icon is 20px.', 'Use size="sm" (16px) where the icon sits in small text, otherwise keep the default.');
|
|
264
|
+
}
|
|
265
|
+
else if (key === 'aria-hidden' || key === 'attr.aria-hidden') {
|
|
266
|
+
if (/^'?true'?$/.test(attr.value?.trim() ?? '') && !attr.interpolated) {
|
|
267
|
+
group.drop(attr);
|
|
268
|
+
group.note('info', 'icon-aria-hidden', 'aria-hidden was dropped, z-icon sets it itself.', 'Nothing to do.');
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
blockers.push([
|
|
272
|
+
'icon-exposed',
|
|
273
|
+
`The icon is exposed to assistive technology (${this.text(attr)}), z-icon is always aria-hidden.`,
|
|
274
|
+
'Give the surrounding control an aria-label or put the meaning into visible text, then use <z-icon name="…" />.',
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else if (LABELS.includes(key) || key === 'role' || key === 'attr.role') {
|
|
279
|
+
blockers.push([
|
|
280
|
+
'icon-labelled',
|
|
281
|
+
`The icon carries ${this.text(attr)}, z-icon is decorative by design and always aria-hidden.`,
|
|
282
|
+
'Move the text to an aria-label of the surrounding control or to visible text, then use <z-icon name="…" />.',
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
else if (key === 'svgIcon' || key === 'fontSet') {
|
|
286
|
+
blockers.push([
|
|
287
|
+
'icon-registry',
|
|
288
|
+
`${this.text(attr)}: z-icon only renders Material Icons ligatures.`,
|
|
289
|
+
'Find a ligature with the same meaning, or keep the SVG as an inline <svg aria-hidden="true">.',
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
else if (key === 'fontIcon' && this.translated(element, key)) {
|
|
293
|
+
blockers.push(this.translatedBlocker(key));
|
|
294
|
+
}
|
|
295
|
+
else if (key === 'fontIcon') {
|
|
296
|
+
fontIcon = attr;
|
|
297
|
+
}
|
|
298
|
+
else if (FIELD_SLOTS.test(key)) {
|
|
299
|
+
blockers.push([
|
|
300
|
+
'icon-in-field',
|
|
301
|
+
`The icon is projected into a mat-form-field through ${key}.`,
|
|
302
|
+
'Migrate the field to z-field and use the icon of z-input-group.',
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
else if (tables_1.MATERIAL_NAME.test(key) || key === 'iconPositionEnd') {
|
|
306
|
+
blockers.push([
|
|
307
|
+
'icon-slot',
|
|
308
|
+
`${key} places the icon in a slot of its Material parent.`,
|
|
309
|
+
'Migrate the parent first, then use <z-icon name="…" /> where the new component takes an icon.',
|
|
310
|
+
]);
|
|
311
|
+
}
|
|
312
|
+
else if (attr.kind === 'ref' && attr.value) {
|
|
313
|
+
blockers.push([
|
|
314
|
+
'icon-reference',
|
|
315
|
+
`${this.text(attr)} reads the MatIcon instance.`,
|
|
316
|
+
'Remove the template reference and what uses it, then use <z-icon name="…" />.',
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
else if (key === 'style' || key.startsWith('style.') || key === 'ngStyle') {
|
|
320
|
+
group.note('review', 'icon-style', `The inline style was kept (${this.text(attr)}). Sizes and colours of an icon come from the tokens.`, 'Remove it; use size="sm" for the 16px icon.');
|
|
321
|
+
}
|
|
322
|
+
else if (key === 'class' && /\bmaterial-(icons|symbols)-/.test(attr.value ?? '')) {
|
|
323
|
+
group.note('review', 'icon-font-class', `${this.text(attr)} selects another icon font. It was kept, next to the "material-icons" class z-icon sets itself.`, 'zenit-ui ships the filled Material Icons only: remove the class, or load that font and check which one wins.');
|
|
324
|
+
}
|
|
325
|
+
else if (attr.kind === 'output' && key === 'click') {
|
|
326
|
+
group.note('review', 'icon-clickable', 'A clickable icon has no role, no focus and no keyboard handling.', 'Wrap it: <button zBtn="ghost" iconOnly type="button" aria-label="…" (click)="…"><z-icon name="…" /></button>.');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const content = this.iconContent(element);
|
|
330
|
+
let nameAttr = '';
|
|
331
|
+
if (typeof content === 'object') {
|
|
332
|
+
blockers.push(content.blocker);
|
|
333
|
+
}
|
|
334
|
+
else if (content && fontIcon) {
|
|
335
|
+
blockers.push([
|
|
336
|
+
'icon-content',
|
|
337
|
+
'The icon has both fontIcon and content.',
|
|
338
|
+
'Decide which one names the ligature and write <z-icon name="…" /> by hand.',
|
|
339
|
+
]);
|
|
340
|
+
}
|
|
341
|
+
else if (content) {
|
|
342
|
+
nameAttr = content;
|
|
343
|
+
}
|
|
344
|
+
else if (!fontIcon && blockers.length === 0) {
|
|
345
|
+
// (with svgIcon or fontSet the missing name is expected and already reported)
|
|
346
|
+
blockers.push([
|
|
347
|
+
'icon-content',
|
|
348
|
+
'The icon has no ligature name.',
|
|
349
|
+
'Write <z-icon name="…" /> by hand or remove the element.',
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
if (blockers.length > 0) {
|
|
353
|
+
for (const [code, reason, fix] of blockers) {
|
|
354
|
+
this.manual('icon', element, code, reason, fix);
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const parent = this.elements.at(-1);
|
|
359
|
+
if (parent && this.attrsOf(parent).some((attr) => attr.key === 'mat-menu-item')) {
|
|
360
|
+
group.note('review', 'icon-in-menu-item', 'The icon sits in a mat-menu-item, which projects <mat-icon> into its own slot.', 'When the menu is migrated the icon becomes the "icon" input of button[zMenuItem].');
|
|
361
|
+
}
|
|
362
|
+
if (fontIcon) {
|
|
363
|
+
group.replace(fontIcon.keyStart, fontIcon.keyEnd, 'name', 'fontIcon');
|
|
364
|
+
group.consumed.push('fontIcon');
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const at = offsetOf(element) + '<mat-icon'.length;
|
|
368
|
+
group.replace(at, at, nameAttr);
|
|
369
|
+
}
|
|
370
|
+
this.renameVoid(group, element, 'z-icon');
|
|
371
|
+
this.groups.push(group);
|
|
372
|
+
}
|
|
373
|
+
/** The ` name="…"` attribute for the content, `''` for no content, or the reason it cannot be converted. */
|
|
374
|
+
iconContent(element) {
|
|
375
|
+
const inner = this.inner(element).trim();
|
|
376
|
+
const children = element.children;
|
|
377
|
+
if (children.length === 0 && inner === '') {
|
|
378
|
+
return '';
|
|
379
|
+
}
|
|
380
|
+
const only = children.length === 1 ? children[0] : undefined;
|
|
381
|
+
if (only instanceof compiler_1.TmplAstText && only.value.trim() === inner && LIGATURE.test(inner)) {
|
|
382
|
+
return ` name=${this.quote}${inner}${this.quote}`;
|
|
383
|
+
}
|
|
384
|
+
const bound = only instanceof compiler_1.TmplAstBoundText ? only.value : undefined;
|
|
385
|
+
const ast = bound && 'ast' in bound ? bound.ast : undefined;
|
|
386
|
+
const match = /^\{\{([\s\S]+)\}\}$/.exec(inner);
|
|
387
|
+
if (ast instanceof compiler_1.Interpolation &&
|
|
388
|
+
ast.expressions.length === 1 &&
|
|
389
|
+
ast.strings.every((part) => part.trim() === '') &&
|
|
390
|
+
match) {
|
|
391
|
+
const quote = this.quoteFor(match[1]);
|
|
392
|
+
if (quote) {
|
|
393
|
+
return ` [name]=${quote}${match[1].trim()}${quote}`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
blocker: [
|
|
398
|
+
'icon-content',
|
|
399
|
+
`The content is not a single ligature name or a single interpolation: ${short(inner)}`,
|
|
400
|
+
'Write <z-icon name="…" /> or <z-icon [name]="…" /> by hand; text next to the icon belongs outside of it.',
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// ------------------------------------------------------------------------
|
|
405
|
+
// button
|
|
406
|
+
// ------------------------------------------------------------------------
|
|
407
|
+
button(element, attrs) {
|
|
408
|
+
const variants = attrs.filter((attr) => Object.hasOwn(tables_1.BUTTON_VARIANTS, attr.key));
|
|
409
|
+
if (variants.length === 0) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const [attr] = variants;
|
|
413
|
+
const target = tables_1.BUTTON_VARIANTS[attr.key];
|
|
414
|
+
const reason = variants.length > 1
|
|
415
|
+
? `The element carries several Material button attributes (${variants.map((v) => v.key).join(', ')}).`
|
|
416
|
+
: attr.kind !== 'text'
|
|
417
|
+
? `${this.text(attr)} is a binding.`
|
|
418
|
+
: attrs.some((other) => other.key === 'zBtn')
|
|
419
|
+
? 'The element already carries zBtn.'
|
|
420
|
+
: /fab/i.test(attr.key)
|
|
421
|
+
? `${attr.key}: a floating action button has no counterpart in zenit-ui.`
|
|
422
|
+
: !target
|
|
423
|
+
? `${attr.key} is the attribute API of Angular Material 20, its appearance value is not mapped.`
|
|
424
|
+
: '';
|
|
425
|
+
if (reason || !target) {
|
|
426
|
+
this.manual('button', element, /fab/i.test(attr.key) ? 'button-fab' : 'button-unmapped', reason, /fab/i.test(attr.key)
|
|
427
|
+
? 'Place the action in the page flow as button[zBtn], usually in the PageHeader or the panel actions.'
|
|
428
|
+
: 'Write zBtn="primary", zBtn (secondary), zBtn="ghost" or zBtn="danger" by hand.');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const group = this.groupFor('button', element);
|
|
432
|
+
const q = this.quote;
|
|
433
|
+
group.replace(attr.start, attr.end, (target.variant ? `zBtn=${q}${target.variant}${q}` : 'zBtn') +
|
|
434
|
+
(target.iconOnly ? ' iconOnly' : ''));
|
|
435
|
+
group.consumed.push(attr.key);
|
|
436
|
+
group.primary = target.variant === 'primary';
|
|
437
|
+
for (const other of attrs) {
|
|
438
|
+
if (other.key === 'color') {
|
|
439
|
+
this.dropColour(group, other, 'button');
|
|
440
|
+
}
|
|
441
|
+
else if (other.key === 'disableRipple') {
|
|
442
|
+
group.drop(other);
|
|
443
|
+
group.note('info', 'button-ripple', 'disableRipple was dropped, zBtn has no ripple.', 'Nothing to do.');
|
|
444
|
+
}
|
|
445
|
+
else if (other.key === 'disabledInteractive') {
|
|
446
|
+
group.drop(other);
|
|
447
|
+
group.note('review', 'button-disabled-interactive', `${this.text(other)} was dropped: zBtn has no such input.`, 'A disabled button that must stay focusable (for a tooltip) takes a static aria-disabled="true" instead of disabled.');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (element.name === 'button' &&
|
|
451
|
+
!attrs.some((a) => a.key === 'type' || a.key === 'attr.type')) {
|
|
452
|
+
group.note('review', 'button-type', 'The button has no type attribute, so inside a <form> it submits. It was not added, because that would change what the button does.', 'Add type="button", or type="submit" where the button is meant to submit.');
|
|
453
|
+
}
|
|
454
|
+
if (target.iconOnly && !attrs.some((a) => LABELS.includes(a.key))) {
|
|
455
|
+
const tooltip = attrs.find((a) => a.key === 'matTooltip');
|
|
456
|
+
group.note('review', 'button-no-name', 'The icon-only button has no accessible name: z-icon is aria-hidden and a tooltip is a description, not a label.', tooltip?.value
|
|
457
|
+
? `Add an aria-label, usually the tooltip text: ${tooltip.kind === 'text' ? `aria-label="${tooltip.value}"` : `[attr.aria-label]="${tooltip.value}"`}.`
|
|
458
|
+
: 'Add aria-label="…" that names the action.');
|
|
459
|
+
}
|
|
460
|
+
this.groups.push(group);
|
|
461
|
+
}
|
|
462
|
+
// ------------------------------------------------------------------------
|
|
463
|
+
// tooltip
|
|
464
|
+
// ------------------------------------------------------------------------
|
|
465
|
+
tooltip(element, attrs) {
|
|
466
|
+
const tip = attrs.find((attr) => attr.key === 'matTooltip');
|
|
467
|
+
if (!tip) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const exported = attrs.find((attr) => attr.kind === 'ref' && attr.value === 'matTooltip');
|
|
471
|
+
const reason = attrs.some((attr) => attr.key === 'zTooltip')
|
|
472
|
+
? 'The element already carries zTooltip.'
|
|
473
|
+
: this.translated(element, 'matTooltip')
|
|
474
|
+
? 'The tooltip is translated through i18n-matTooltip, which the template AST does not expose.'
|
|
475
|
+
: exported
|
|
476
|
+
? `${this.text(exported)} reads the MatTooltip instance (show(), hide(), toggle()).`
|
|
477
|
+
: tip.kind !== 'text' && tip.kind !== 'input'
|
|
478
|
+
? `${this.text(tip)} is not a plain attribute or property binding.`
|
|
479
|
+
: '';
|
|
480
|
+
if (reason) {
|
|
481
|
+
this.manual('tooltip', element, 'tooltip-unmapped', reason, 'Rename matTooltip to zTooltip by hand (and i18n-matTooltip to i18n-zTooltip). zTooltip opens on hover and focus only, it has no programmatic API.');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const group = this.groupFor('tooltip', element);
|
|
485
|
+
const disabled = attrs.find((attr) => attr.key === 'matTooltipDisabled');
|
|
486
|
+
if (disabled) {
|
|
487
|
+
const merged = this.mergeDisabled(tip, disabled);
|
|
488
|
+
if (!merged) {
|
|
489
|
+
this.manual('tooltip', element, 'tooltip-disabled', `${this.text(disabled)} could not be merged into the tooltip text mechanically.`, 'zTooltip shows nothing for an empty text: write [zTooltip]="condition ? \'\' : text" by hand and remove matTooltipDisabled.');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
group.replace(tip.start, tip.end, merged);
|
|
493
|
+
group.drop(disabled);
|
|
494
|
+
group.note('review', 'tooltip-disabled-merged', `${this.text(disabled)} was merged into the text: an empty zTooltip shows nothing.`, 'Check the expression.');
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
group.replace(tip.keyStart, tip.keyEnd, 'zTooltip', 'matTooltip');
|
|
498
|
+
}
|
|
499
|
+
group.consumed.push('matTooltip');
|
|
500
|
+
for (const option of attrs.filter((attr) => tables_1.TOOLTIP_OPTIONS.includes(attr.key))) {
|
|
501
|
+
group.drop(option);
|
|
502
|
+
group.note('info', 'tooltip-option', `${this.text(option)} was dropped: zTooltip has no position, delay, class or touch options, the overlay places it above the trigger with below as fallback.`, 'Nothing to do, unless the page relied on the option.');
|
|
503
|
+
}
|
|
504
|
+
this.groups.push(group);
|
|
505
|
+
}
|
|
506
|
+
/** `[zTooltip]="cond ? '' : (text)"`, or `undefined` when that cannot be written safely. */
|
|
507
|
+
mergeDisabled(tip, disabled) {
|
|
508
|
+
const condition = disabled.value?.trim();
|
|
509
|
+
if (this.plainString ||
|
|
510
|
+
disabled.kind !== 'input' ||
|
|
511
|
+
disabled.interpolated ||
|
|
512
|
+
tip.interpolated ||
|
|
513
|
+
!condition ||
|
|
514
|
+
tip.value === undefined ||
|
|
515
|
+
/["\r\n]|(^|[^|])\|([^|]|$)/.test(condition)) {
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
let text;
|
|
519
|
+
if (tip.kind === 'text') {
|
|
520
|
+
// The value is still HTML-encoded here; an encoded apostrophe would end the literal.
|
|
521
|
+
if (/[\r\n]|&(#0*39|#x0*27|apos);/i.test(tip.value)) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
text = `'${tip.value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '"')}'`;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
if (/["\r\n]/.test(tip.value)) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
text = `(${tip.value.trim()})`;
|
|
531
|
+
}
|
|
532
|
+
const simple = /^!?[\w$.]+(\(\))?$/.test(condition);
|
|
533
|
+
const merged = `[zTooltip]="${simple ? condition : `(${condition})`} ? '' : ${text}"`;
|
|
534
|
+
// The expression is new text, so it has to survive the real parser.
|
|
535
|
+
const check = (0, compiler_1.parseTemplate)(`<i ${merged}></i>`, 'check.html');
|
|
536
|
+
return check.errors?.length ? undefined : merged;
|
|
537
|
+
}
|
|
538
|
+
// ------------------------------------------------------------------------
|
|
539
|
+
// spinner
|
|
540
|
+
// ------------------------------------------------------------------------
|
|
541
|
+
spinner(element, attrs) {
|
|
542
|
+
const button = this.elements.find((parent) => parent.name === 'button' || parent.name === 'a');
|
|
543
|
+
if (button) {
|
|
544
|
+
const condition = this.conditions.at(-1);
|
|
545
|
+
this.manual('spinner', element, 'spinner-in-button', `The spinner sits inside a <${button.name}> and was left alone: zBtn shows its own spinner.`, `Replace it with [loading] on the button${condition ? `: [loading]="${condition}"` : ''}, and remove the spinner and the label swap around it. loading also locks the button.`);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const mode = attrs.find((attr) => attr.key === 'mode');
|
|
549
|
+
const determinate = attrs.some((attr) => attr.key === 'value') ||
|
|
550
|
+
(element.name === 'mat-progress-spinner' &&
|
|
551
|
+
!(mode?.kind === 'text' && mode.value === 'indeterminate'));
|
|
552
|
+
const blocker = determinate
|
|
553
|
+
? 'The spinner is determinate (mat-progress-spinner without mode="indeterminate", or with a value); z-spinner has no progress.'
|
|
554
|
+
: this.inner(element).trim() !== ''
|
|
555
|
+
? 'The spinner has content.'
|
|
556
|
+
: attrs.find((attr) => (attr.kind === 'ref' && attr.value) ||
|
|
557
|
+
(attr.key.endsWith('aria-label') && this.translated(element, 'aria-label')) ||
|
|
558
|
+
(tables_1.MATERIAL_NAME.test(attr.key) && !attr.key.startsWith('matTooltip')) ||
|
|
559
|
+
attr.key.endsWith('aria-labelledby'))
|
|
560
|
+
? 'The spinner carries an attribute that z-spinner cannot take (template reference to the instance, Material directive, aria-labelledby or a translated i18n-* attribute).'
|
|
561
|
+
: '';
|
|
562
|
+
if (blocker) {
|
|
563
|
+
this.manual('spinner', element, determinate ? 'spinner-determinate' : 'spinner-unmapped', blocker, determinate
|
|
564
|
+
? 'Show progress as z-metric with [percent], and the value as a word next to it.'
|
|
565
|
+
: 'Write <z-spinner label="…" /> by hand.');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const group = this.groupFor('spinner', element);
|
|
569
|
+
let labelled = false;
|
|
570
|
+
for (const attr of attrs) {
|
|
571
|
+
if (['diameter', 'strokeWidth', 'mode'].includes(attr.key)) {
|
|
572
|
+
group.drop(attr);
|
|
573
|
+
group.note('info', 'spinner-option', `${this.text(attr)} was dropped: z-spinner has one size and one stroke, set by the tokens.`, 'Nothing to do.');
|
|
574
|
+
}
|
|
575
|
+
else if (attr.key === 'color') {
|
|
576
|
+
this.dropColour(group, attr, 'spinner');
|
|
577
|
+
}
|
|
578
|
+
else if (attr.key === 'aria-label' || attr.key === 'attr.aria-label') {
|
|
579
|
+
// Left as aria-label the host binding of z-spinner would remove it.
|
|
580
|
+
group.replace(attr.keyStart, attr.keyEnd, 'label', attr.key);
|
|
581
|
+
labelled = true;
|
|
582
|
+
}
|
|
583
|
+
else if (attr.key === 'style' || attr.key.startsWith('style.') || attr.key === 'ngStyle') {
|
|
584
|
+
group.note('review', 'spinner-style', `The inline style was kept (${this.text(attr)}). The size of z-spinner comes from the tokens.`, 'Remove it.');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!labelled) {
|
|
588
|
+
group.note('review', 'spinner-no-label', 'The spinner has no accessible label; without one z-spinner is decorative and aria-hidden.', 'Add label="Wird geladen" (or what is loading) for a standalone loading state; leave it out only where text next to it says the same. A list that loads shows z-skeleton rows instead of a spinner.');
|
|
589
|
+
}
|
|
590
|
+
this.renameVoid(group, element, 'z-spinner');
|
|
591
|
+
this.groups.push(group);
|
|
592
|
+
}
|
|
593
|
+
// ------------------------------------------------------------------------
|
|
594
|
+
// chip
|
|
595
|
+
// ------------------------------------------------------------------------
|
|
596
|
+
chip(element, attrs) {
|
|
597
|
+
const wrapper = [...this.elements]
|
|
598
|
+
.reverse()
|
|
599
|
+
.find((parent) => parent.name.startsWith('mat-chip-'));
|
|
600
|
+
const reason = this.chipBlocker(element, attrs) ||
|
|
601
|
+
(wrapper?.name === 'mat-chip-grid' &&
|
|
602
|
+
'The chip sits in a mat-chip-grid, which is an input for chips.') ||
|
|
603
|
+
(wrapper?.name === 'mat-chip-listbox' &&
|
|
604
|
+
!this.wrapperConverts(wrapper) &&
|
|
605
|
+
'The chip sits in a mat-chip-listbox that stays (selection, bindings or other content), and a badge is not an option of a listbox.') ||
|
|
606
|
+
'';
|
|
607
|
+
if (reason) {
|
|
608
|
+
this.manual('chip', element, 'chip-interactive', reason, 'A z-badge is never interactive. A removable or selectable chip has no counterpart: use z-checkbox or z-segment for a choice, a row with a ghost button for a removable entry.');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const group = this.groupFor('chip', element);
|
|
612
|
+
const start = offsetOf(element);
|
|
613
|
+
group.replace(start + 1, start + 1 + 'mat-chip'.length, 'z-badge', 'mat-chip');
|
|
614
|
+
const end = element.endSourceSpan.start.offset;
|
|
615
|
+
group.replace(end + 2, end + 2 + 'mat-chip'.length, 'z-badge', 'mat-chip');
|
|
616
|
+
group.consumed.push('mat-chip');
|
|
617
|
+
for (const attr of attrs) {
|
|
618
|
+
if (attr.key === 'color') {
|
|
619
|
+
this.dropColour(group, attr, 'chip');
|
|
620
|
+
}
|
|
621
|
+
else if (attr.key === 'disableRipple') {
|
|
622
|
+
group.drop(attr);
|
|
623
|
+
group.note('info', 'chip-ripple', 'disableRipple was dropped, z-badge has no ripple.', 'Nothing to do.');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
group.note('review', 'chip-status', 'The chip became a neutral z-badge.', 'Where it shows a state, set status="success|warning|danger|info" and dot, and keep the state as a word.');
|
|
627
|
+
this.groups.push(group);
|
|
628
|
+
}
|
|
629
|
+
/** Why a chip cannot become a badge, looking at the chip alone. */
|
|
630
|
+
chipBlocker(element, attrs) {
|
|
631
|
+
const allowed = /^(class(\..+)?|ngClass|style(\..+)?|ngStyle|id|title|color|disableRipple|data-.+|matTooltip.*)$/;
|
|
632
|
+
const bad = attrs.find((attr) => attr.kind === 'output' || !allowed.test(attr.key));
|
|
633
|
+
if (bad) {
|
|
634
|
+
return `${this.text(bad)} makes the chip interactive or stateful.`;
|
|
635
|
+
}
|
|
636
|
+
if (!element.endSourceSpan || element.endSourceSpan.start.offset === offsetOf(element)) {
|
|
637
|
+
return 'The chip is self-closing and has no label.';
|
|
638
|
+
}
|
|
639
|
+
const inside = new Collector(true);
|
|
640
|
+
(0, compiler_1.tmplAstVisitAll)(inside, element.children);
|
|
641
|
+
const slot = inside.elements
|
|
642
|
+
.flatMap((child) => this.attrsOf(child))
|
|
643
|
+
.find((attr) => /^matChip/.test(attr.key));
|
|
644
|
+
return slot ? `The chip contains ${slot.key}.` : '';
|
|
645
|
+
}
|
|
646
|
+
wrapperConverts(wrapper) {
|
|
647
|
+
let converts = this.wrappers.get(wrapper);
|
|
648
|
+
if (converts === undefined) {
|
|
649
|
+
const attrs = this.attrsOf(wrapper);
|
|
650
|
+
const content = new Collector(false);
|
|
651
|
+
(0, compiler_1.tmplAstVisitAll)(content, wrapper.children);
|
|
652
|
+
converts =
|
|
653
|
+
attrs.every((attr) => attr.key === 'class' && attr.kind === 'text') &&
|
|
654
|
+
!content.text &&
|
|
655
|
+
content.elements.length > 0 &&
|
|
656
|
+
!!wrapper.endSourceSpan &&
|
|
657
|
+
content.elements.every((child) => child.name === 'z-badge' ||
|
|
658
|
+
(child.name === 'mat-chip' && !this.chipBlocker(child, this.attrsOf(child))));
|
|
659
|
+
this.wrappers.set(wrapper, converts);
|
|
660
|
+
}
|
|
661
|
+
return converts;
|
|
662
|
+
}
|
|
663
|
+
chipWrapper(element, attrs) {
|
|
664
|
+
if (!this.wrapperConverts(element)) {
|
|
665
|
+
this.manual('chip', element, 'chip-wrapper', `<${element.name}> was left alone: it has bindings or ARIA of its own, or content other than static chips.`, 'Static badges sit in <span class="z-cluster">. Give a group that needs a name a visible heading.');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const group = this.groupFor('chip', element);
|
|
669
|
+
group.counted = false;
|
|
670
|
+
const start = offsetOf(element);
|
|
671
|
+
const name = element.name;
|
|
672
|
+
const classAttr = attrs.find((attr) => attr.key === 'class');
|
|
673
|
+
group.consumed.push(name);
|
|
674
|
+
if (classAttr?.value !== undefined) {
|
|
675
|
+
group.replace(start + 1, start + 1 + name.length, 'span', name);
|
|
676
|
+
const at = classAttr.start +
|
|
677
|
+
this.text(classAttr).lastIndexOf(classAttr.value) +
|
|
678
|
+
classAttr.value.length;
|
|
679
|
+
group.replace(at, at, `${/\S$/.test(classAttr.value) ? ' ' : ''}z-cluster`);
|
|
680
|
+
}
|
|
681
|
+
else if (classAttr) {
|
|
682
|
+
// `class` without a value: replace it as a whole.
|
|
683
|
+
group.replace(start + 1, start + 1 + name.length, 'span', name);
|
|
684
|
+
group.replace(classAttr.start, classAttr.end, `class=${this.quote}z-cluster${this.quote}`);
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
group.replace(start + 1, start + 1 + name.length, `span class=${this.quote}z-cluster${this.quote}`, name);
|
|
688
|
+
}
|
|
689
|
+
const end = element.endSourceSpan.start.offset;
|
|
690
|
+
group.replace(end + 2, end + 2 + name.length, 'span', name);
|
|
691
|
+
this.groups.push(group);
|
|
692
|
+
}
|
|
693
|
+
// ------------------------------------------------------------------------
|
|
694
|
+
// shared
|
|
695
|
+
// ------------------------------------------------------------------------
|
|
696
|
+
groupFor(rule, element) {
|
|
697
|
+
return new Group(rule, offsetOf(element), this.source);
|
|
698
|
+
}
|
|
699
|
+
manual(rule, element, code, reason, fix) {
|
|
700
|
+
this.findings.push({ offset: offsetOf(element), rule, kind: 'manual', code, reason, fix });
|
|
701
|
+
}
|
|
702
|
+
dropColour(group, attr, what) {
|
|
703
|
+
group.drop(attr);
|
|
704
|
+
const harmless = attr.kind === 'text' && /^(primary|accent|)$/.test(attr.value ?? '');
|
|
705
|
+
const danger = what === 'button' && attr.kind === 'text' && attr.value === 'warn';
|
|
706
|
+
group.note(harmless ? 'info' : 'review', danger ? 'button-warn' : `${what}-colour`, `${this.text(attr)} was dropped: zenit-ui has no colour input, colour must come from the context.`, danger
|
|
707
|
+
? 'Candidate for zBtn="danger": use it only where the action destroys something, and confirm it with a dialog.'
|
|
708
|
+
: COLOUR_FIX);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* `i18n-<key>` on the element. The i18n pass of the parser removes these
|
|
712
|
+
* attributes from the AST, so a renamed attribute would silently lose its
|
|
713
|
+
* translation; the start tag is read as text instead.
|
|
714
|
+
*/
|
|
715
|
+
translated(element, key) {
|
|
716
|
+
const startTag = this.source.slice(offsetOf(element), element.startSourceSpan.end.offset);
|
|
717
|
+
return startTag.includes(`i18n-${key}`);
|
|
718
|
+
}
|
|
719
|
+
translatedBlocker(key) {
|
|
720
|
+
return [
|
|
721
|
+
'icon-translated',
|
|
722
|
+
`The attribute is translated through i18n-${key}, which the template AST does not expose.`,
|
|
723
|
+
`Rename ${key} and i18n-${key} together by hand.`,
|
|
724
|
+
];
|
|
725
|
+
}
|
|
726
|
+
/** Renames an element that has no content in zenit-ui and writes it self-closing. */
|
|
727
|
+
renameVoid(group, element, to) {
|
|
728
|
+
const start = offsetOf(element);
|
|
729
|
+
const open = element.startSourceSpan.end.offset;
|
|
730
|
+
group.replace(start + 1, start + 1 + element.name.length, to, element.name);
|
|
731
|
+
group.consumed.push(element.name);
|
|
732
|
+
if (element.endSourceSpan && element.endSourceSpan.start.offset !== start) {
|
|
733
|
+
const gap = /\s/.test(this.source[open - 2]) ? '' : ' ';
|
|
734
|
+
group.replace(open - 1, element.sourceSpan.end.offset, `${gap}/>`);
|
|
735
|
+
if (this.source[open - 1] !== '>') {
|
|
736
|
+
group.unsafe = 'the start tag does not end in ">"';
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** The source between the start and the end tag. */
|
|
741
|
+
inner(element) {
|
|
742
|
+
const end = element.endSourceSpan?.start.offset;
|
|
743
|
+
return end === undefined || end === offsetOf(element)
|
|
744
|
+
? ''
|
|
745
|
+
: this.source.slice(element.startSourceSpan.end.offset, end);
|
|
746
|
+
}
|
|
747
|
+
text(attr) {
|
|
748
|
+
return short(this.source.slice(attr.start, attr.end));
|
|
749
|
+
}
|
|
750
|
+
/** The quote an expression can be wrapped in, `undefined` when neither is possible. */
|
|
751
|
+
quoteFor(expression) {
|
|
752
|
+
const other = this.quote === '"' ? "'" : '"';
|
|
753
|
+
if (!expression.includes(this.quote)) {
|
|
754
|
+
return this.quote;
|
|
755
|
+
}
|
|
756
|
+
return !this.plainString && !expression.includes(other) ? other : undefined;
|
|
757
|
+
}
|
|
758
|
+
attrsOf(element) {
|
|
759
|
+
return attributesOf(element, this.source);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** Counts what is left after the accepted conversions. */
|
|
763
|
+
class Usage extends compiler_1.TmplAstRecursiveVisitor {
|
|
764
|
+
consumed;
|
|
765
|
+
source;
|
|
766
|
+
material = {};
|
|
767
|
+
zenit = new Set();
|
|
768
|
+
constructor(consumed, source) {
|
|
769
|
+
super();
|
|
770
|
+
this.consumed = consumed;
|
|
771
|
+
this.source = source;
|
|
772
|
+
}
|
|
773
|
+
visitElement(element) {
|
|
774
|
+
const offset = offsetOf(element);
|
|
775
|
+
const names = [element.name, ...attributesOf(element, this.source).map((attr) => attr.key)];
|
|
776
|
+
const classes = attributesOf(element, this.source).find((attr) => attr.key === 'class' && attr.kind === 'text');
|
|
777
|
+
for (const token of classes?.value?.split(/\s+/) ?? []) {
|
|
778
|
+
if (/^(mat|mdc)-/.test(token)) {
|
|
779
|
+
names.push(`.${token}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const name of new Set(names)) {
|
|
783
|
+
if (this.consumed.has(`${offset}:${name}`)) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (tables_1.MATERIAL_NAME.test(name) || name.startsWith('.')) {
|
|
787
|
+
this.material[name] = (this.material[name] ?? 0) + 1;
|
|
788
|
+
}
|
|
789
|
+
else if (Object.hasOwn(tables_1.ZENIT_SYMBOLS, name)) {
|
|
790
|
+
this.zenit.add(tables_1.ZENIT_SYMBOLS[name]);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
super.visitElement(element);
|
|
794
|
+
}
|
|
795
|
+
visitTemplate(template) {
|
|
796
|
+
// On `<div *matCellDef>` the other attributes belong to the element and are counted there.
|
|
797
|
+
const own = template.tagName === 'ng-template'
|
|
798
|
+
? attributesOf(template, this.source).map((attr) => attr.key)
|
|
799
|
+
: [];
|
|
800
|
+
// Of `*matRowDef="let row; columns: c"` only the first name is a directive, the rest is microsyntax.
|
|
801
|
+
for (const name of new Set([
|
|
802
|
+
...own,
|
|
803
|
+
...template.templateAttrs.slice(0, 1).map((attr) => attr.name),
|
|
804
|
+
])) {
|
|
805
|
+
if (tables_1.MATERIAL_NAME.test(name)) {
|
|
806
|
+
this.material[name] = (this.material[name] ?? 0) + 1;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
super.visitTemplate(template);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Elements below a node: all of them (`deep`), or only the first level through blocks and templates. */
|
|
813
|
+
class Collector extends compiler_1.TmplAstRecursiveVisitor {
|
|
814
|
+
deep;
|
|
815
|
+
elements = [];
|
|
816
|
+
/** Text or an interpolation on the first level. */
|
|
817
|
+
text = false;
|
|
818
|
+
constructor(deep) {
|
|
819
|
+
super();
|
|
820
|
+
this.deep = deep;
|
|
821
|
+
}
|
|
822
|
+
visitElement(element) {
|
|
823
|
+
this.elements.push(element);
|
|
824
|
+
if (this.deep) {
|
|
825
|
+
super.visitElement(element);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
visitText(text) {
|
|
829
|
+
this.text ||= text.value.trim() !== '';
|
|
830
|
+
}
|
|
831
|
+
visitBoundText() {
|
|
832
|
+
this.text = true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function offsetOf(element) {
|
|
836
|
+
return element.startSourceSpan.start.offset;
|
|
837
|
+
}
|
|
838
|
+
function attributesOf(element, source) {
|
|
839
|
+
const make = (node, kind) => {
|
|
840
|
+
const start = node.sourceSpan.start.offset;
|
|
841
|
+
const end = node.sourceSpan.end.offset;
|
|
842
|
+
const keyStart = node.keySpan?.start.offset ?? start;
|
|
843
|
+
const keyEnd = node.keySpan?.end.offset ?? end;
|
|
844
|
+
const value = 'valueSpan' in node && node.valueSpan
|
|
845
|
+
? source.slice(node.valueSpan.start.offset, node.valueSpan.end.offset)
|
|
846
|
+
: undefined;
|
|
847
|
+
const ast = node instanceof compiler_1.TmplAstBoundAttribute && 'ast' in node.value
|
|
848
|
+
? node.value.ast
|
|
849
|
+
: undefined;
|
|
850
|
+
return {
|
|
851
|
+
key: source.slice(keyStart, keyEnd),
|
|
852
|
+
kind,
|
|
853
|
+
value,
|
|
854
|
+
start,
|
|
855
|
+
end,
|
|
856
|
+
keyStart,
|
|
857
|
+
keyEnd,
|
|
858
|
+
interpolated: ast instanceof compiler_1.Interpolation,
|
|
859
|
+
};
|
|
860
|
+
};
|
|
861
|
+
return [
|
|
862
|
+
...element.attributes.map((node) => make(node, 'text')),
|
|
863
|
+
...element.inputs.map((node) => make(node, 'input')),
|
|
864
|
+
...element.outputs.map((node) => make(node, 'output')),
|
|
865
|
+
...element.references.map((node) => make(node, 'ref')),
|
|
866
|
+
].sort((a, b) => a.start - b.start);
|
|
867
|
+
}
|
|
868
|
+
function short(text) {
|
|
869
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
870
|
+
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
|
871
|
+
}
|