@widgetic/design-system 0.5.15 → 0.5.16
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 +167 -0
- package/dist/components/CopyableHeader.svelte +25 -11
- package/dist/components/CopyableHeader.svelte.d.ts +4 -0
- package/dist/components/EmptyState.svelte +1 -1
- package/dist/components/PasswordInput.svelte +2 -2
- package/dist/components/WScrollArea.svelte +2 -2
- package/dist/components/ic/browser/BrowserInputController.svelte +2 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/modals/ActionModal.svelte +3 -0
- package/dist/components/modals/ActionModal.svelte.d.ts +1 -0
- package/dist/components/modals/BaseModal.svelte +4 -2
- package/dist/components/modals/BaseModal.svelte.d.ts +1 -0
- package/dist/components/modals/NewItemDialog.svelte +48 -30
- package/dist/components/modals/NewItemDialog.svelte.d.ts +3 -0
- package/dist/components/modals/Portal.svelte +9 -1
- package/dist/components/modals/README.md +34 -1
- package/dist/components/modals/RenameDialog.svelte +29 -9
- package/dist/components/modals/RenameDialog.svelte.d.ts +3 -0
- package/dist/components/modals/messages.d.ts +39 -0
- package/dist/components/modals/messages.js +10 -0
- package/dist/components/navigation/Breadcrumbs.svelte +30 -17
- package/dist/components/navigation/Breadcrumbs.svelte.d.ts +4 -0
- package/dist/components/navigation/types.d.ts +8 -0
- package/dist/components/pdf/PDFViewer.svelte +17 -8
- package/dist/components/pdf/PDFViewer.svelte.d.ts +5 -0
- package/dist/components/search-input/SearchInput.svelte +3 -3
- package/dist/components/shad-editor/colorpicker.svelte +4 -1
- package/dist/components/shad-editor/custom/Extentions/ImageExtention.d.ts +20 -1
- package/dist/components/shad-editor/custom/Extentions/ImageExtention.js +4 -1
- package/dist/components/shad-editor/custom/code-extended.svelte +4 -2
- package/dist/components/shad-editor/custom/image-extended-component.svelte +54 -32
- package/dist/components/shad-editor/editor.css +5 -5
- package/dist/components/shad-editor/i18n/context.d.ts +6 -0
- package/dist/components/shad-editor/i18n/context.js +11 -0
- package/dist/components/shad-editor/i18n/index.d.ts +8 -0
- package/dist/components/shad-editor/i18n/index.js +11 -0
- package/dist/components/shad-editor/i18n/messages.d.ts +109 -0
- package/dist/components/shad-editor/i18n/messages.js +116 -0
- package/dist/components/shad-editor/icons/block-quote.svelte +3 -1
- package/dist/components/shad-editor/icons/bold.svelte +3 -1
- package/dist/components/shad-editor/icons/buttle-list.svelte +3 -1
- package/dist/components/shad-editor/icons/code.svelte +3 -1
- package/dist/components/shad-editor/icons/highlighter.svelte +5 -3
- package/dist/components/shad-editor/icons/image.svelte +7 -5
- package/dist/components/shad-editor/icons/italic.svelte +3 -1
- package/dist/components/shad-editor/icons/link.svelte +8 -6
- package/dist/components/shad-editor/icons/ordered-list.svelte +3 -1
- package/dist/components/shad-editor/icons/quickcolor.svelte +24 -22
- package/dist/components/shad-editor/icons/redo.svelte +3 -1
- package/dist/components/shad-editor/icons/search-replace.svelte +16 -14
- package/dist/components/shad-editor/icons/strikethrough.svelte +3 -1
- package/dist/components/shad-editor/icons/subscript.svelte +3 -1
- package/dist/components/shad-editor/icons/superscript.svelte +3 -1
- package/dist/components/shad-editor/icons/table.svelte +21 -19
- package/dist/components/shad-editor/icons/task-list.svelte +3 -1
- package/dist/components/shad-editor/icons/text.svelte +13 -11
- package/dist/components/shad-editor/icons/textalign.svelte +11 -9
- package/dist/components/shad-editor/icons/textcolor.svelte +5 -3
- package/dist/components/shad-editor/icons/underline.svelte +3 -1
- package/dist/components/shad-editor/icons/undo.svelte +3 -1
- package/dist/components/shad-editor/index.d.ts +1 -0
- package/dist/components/shad-editor/index.js +2 -0
- package/dist/components/shad-editor/shad-editor.svelte +32 -3
- package/dist/components/shad-editor/shad-editor.svelte.d.ts +12 -0
- package/dist/components/textDirection.d.ts +91 -0
- package/dist/components/textDirection.js +99 -0
- package/dist/components/toast/ToasterModal.svelte +49 -14
- package/dist/components/toast/ToasterModal.svelte.d.ts +9 -2
- package/dist/components/toast/toasterRoots.d.ts +23 -0
- package/dist/components/toast/toasterRoots.js +36 -0
- package/dist/components/ui/alert/index.d.ts +2 -2
- package/dist/components/ui/alert/index.js +1 -1
- package/dist/components/ui/breadcrumb/breadcrumb-separator.svelte +1 -1
- package/dist/components/ui/calendar/calendar-next-button.svelte +21 -4
- package/dist/components/ui/calendar/calendar-next-button.svelte.d.ts +9 -2
- package/dist/components/ui/calendar/calendar-prev-button.svelte +21 -4
- package/dist/components/ui/calendar/calendar-prev-button.svelte.d.ts +9 -2
- package/dist/components/ui/calendar/calendar.svelte +18 -6
- package/dist/components/ui/calendar/calendar.svelte.d.ts +6 -1
- package/dist/components/ui/command/command-input.svelte +1 -1
- package/dist/components/ui/command/command-shortcut.svelte +1 -1
- package/dist/components/ui/dialog/dialog-content.svelte +5 -2
- package/dist/components/ui/dialog/dialog-content.svelte.d.ts +1 -0
- package/dist/components/ui/dialog/dialog-header.svelte +1 -1
- package/dist/components/ui/drawer/drawer-header.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +2 -2
- package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +6 -0
- package/dist/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-item.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-label.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +2 -2
- package/dist/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +7 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +2 -2
- package/dist/components/ui/hover-card/hover-card-content.svelte +6 -0
- package/dist/components/ui/input-otp/input-otp-slot.svelte +1 -1
- package/dist/components/ui/popover/popover-content.svelte +6 -0
- package/dist/components/ui/progress/progress.svelte +2 -2
- package/dist/components/ui/range-calendar/range-calendar-cell.svelte +1 -1
- package/dist/components/ui/range-calendar/range-calendar-next-button.svelte +21 -4
- package/dist/components/ui/range-calendar/range-calendar-next-button.svelte.d.ts +9 -2
- package/dist/components/ui/range-calendar/range-calendar-prev-button.svelte +21 -4
- package/dist/components/ui/range-calendar/range-calendar-prev-button.svelte.d.ts +9 -2
- package/dist/components/ui/range-calendar/range-calendar.svelte +18 -6
- package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +7 -2
- package/dist/components/ui/scroll-area/scroll-area-scrollbar.svelte +1 -1
- package/dist/components/ui/scroll-area/scroll-area.svelte +6 -1
- package/dist/components/ui/select/select-content.svelte +6 -0
- package/dist/components/ui/select/select-group-heading.svelte +1 -1
- package/dist/components/ui/select/select-item.svelte +2 -2
- package/dist/components/ui/slider/slider.svelte +6 -0
- package/dist/components/ui/switch/switch.svelte +1 -1
- package/dist/components/ui/table/table-cell.svelte +1 -1
- package/dist/components/ui/table/table-head.svelte +1 -1
- package/dist/components/ui/tooltip/tooltip-content.svelte +6 -0
- package/dist/i18n/audit/catalogUsage.d.ts +36 -0
- package/dist/i18n/audit/catalogUsage.js +58 -0
- package/dist/i18n/audit/designSystemWords.d.ts +47 -0
- package/dist/i18n/audit/designSystemWords.js +137 -0
- package/dist/i18n/audit/hardCodedText.d.ts +40 -0
- package/dist/i18n/audit/hardCodedText.js +27 -0
- package/dist/i18n/audit/index.d.ts +19 -0
- package/dist/i18n/audit/index.js +19 -0
- package/dist/i18n/audit/messageCalls.d.ts +34 -0
- package/dist/i18n/audit/messageCalls.js +89 -0
- package/dist/i18n/audit/syntaxTree.d.ts +33 -0
- package/dist/i18n/audit/syntaxTree.js +90 -0
- package/dist/i18n/audit/visibleText.d.ts +57 -0
- package/dist/i18n/audit/visibleText.js +252 -0
- package/dist/i18n/createI18n.d.ts +63 -0
- package/dist/i18n/createI18n.js +72 -0
- package/dist/i18n/direction.d.ts +27 -0
- package/dist/i18n/direction.js +89 -0
- package/dist/i18n/index.d.ts +17 -0
- package/dist/i18n/index.js +16 -0
- package/dist/i18n/localeDate.d.ts +34 -0
- package/dist/i18n/localeDate.js +104 -0
- package/dist/i18n/splitMessages.d.ts +21 -0
- package/dist/i18n/splitMessages.js +33 -0
- package/dist/i18n/translator.d.ts +61 -0
- package/dist/i18n/translator.js +337 -0
- package/dist/rtl/audit/auditPhysicalPlacement.d.ts +36 -0
- package/dist/rtl/audit/auditPhysicalPlacement.js +58 -0
- package/dist/rtl/audit/index.d.ts +14 -0
- package/dist/rtl/audit/index.js +14 -0
- package/dist/rtl/audit/physicalPlacement.d.ts +64 -0
- package/dist/rtl/audit/physicalPlacement.js +444 -0
- package/dist/shad-editor/index.d.ts +1 -1
- package/dist/shad-editor/index.js +1 -1
- package/dist/styles/global.css +4 -4
- package/dist/utils/fillMessage.d.ts +8 -0
- package/dist/utils/fillMessage.js +10 -0
- package/dist/utils/itemName.d.ts +17 -0
- package/dist/utils/itemName.js +41 -0
- package/package.json +18 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words of a package, as a function. One translator serves every Widgetic package that has words of its own (the File
|
|
3
|
+
* Browser, the editor, the canvas, the chat, the widget creator): each brings its English catalog and gets the same rules.
|
|
4
|
+
*
|
|
5
|
+
* A message is a template in a flat catalog: `'upload.progress': '{done} of {total} uploaded'`. A page can replace any
|
|
6
|
+
* of them (the `messages` option); a key it leaves out keeps the English text, so a partly translated catalog never
|
|
7
|
+
* shows a hole.
|
|
8
|
+
*
|
|
9
|
+
* A template is plain text with two kinds of `{...}`:
|
|
10
|
+
*
|
|
11
|
+
* {name} the value called `name`; a number is written the way the locale
|
|
12
|
+
* writes numbers (1,234 or 1.234)
|
|
13
|
+
* {count, plural, =0 {none} one {# file} other {# files}}
|
|
14
|
+
* one branch, chosen by the locale's plural rules (`Intl.PluralRules`),
|
|
15
|
+
* so a language with more than two forms (Polish, Arabic) can say so.
|
|
16
|
+
* `=0` matches exactly 0; `other` is required; `#` is the number
|
|
17
|
+
*
|
|
18
|
+
* `\{`, `\}`, `\#` and `\\` write the character itself. An apostrophe is just an apostrophe: `Delete '{name}'?` works.
|
|
19
|
+
*
|
|
20
|
+
* In a language written right to left a text value (a file name, a person's name) is put between Unicode isolate marks
|
|
21
|
+
* (U+2068 and U+2069), so that the sentence around it cannot turn it around: `حذف '2024-report.pdf'؟` keeps the name
|
|
22
|
+
* whole, with its quotes and its leading digits where they belong. The marks cannot be seen. A number is never marked.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here knows about Svelte or the DOM: the same code runs in a unit test.
|
|
25
|
+
*/
|
|
26
|
+
import { resolveTextDirection } from "./direction.js";
|
|
27
|
+
/** One line a developer can read in the console about a {@link MessageProblem}. */
|
|
28
|
+
export function describeMessageProblem(problem) {
|
|
29
|
+
switch (problem.kind) {
|
|
30
|
+
case 'unknown-message':
|
|
31
|
+
return problem.suggestion
|
|
32
|
+
? `There is no message called "${problem.key}". Did you mean "${problem.suggestion}"? It is ignored.`
|
|
33
|
+
: `There is no message called "${problem.key}". It is ignored.`;
|
|
34
|
+
case 'not-text':
|
|
35
|
+
return `The message "${problem.key}" must be a string. The English text is used.`;
|
|
36
|
+
case 'broken-message':
|
|
37
|
+
return `The message "${problem.key}" cannot be used (${problem.reason}). The English text is used.`;
|
|
38
|
+
case 'unknown-value':
|
|
39
|
+
return `The message "${problem.key}" asks for {${problem.name}}, which this message is never given. It would show as written.`;
|
|
40
|
+
case 'unknown-locale':
|
|
41
|
+
return `"${problem.locale}" is not a language tag (like "de" or "pt-BR"). Numbers and dates use English.`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
class TemplateSyntaxError extends Error {
|
|
45
|
+
}
|
|
46
|
+
/** The names of the values a template uses, for checking a replacement against the English one. */
|
|
47
|
+
function valueNamesOf(parts, names = new Set()) {
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (part.kind === 'value')
|
|
50
|
+
names.add(part.name);
|
|
51
|
+
if (part.kind === 'plural') {
|
|
52
|
+
names.add(part.name);
|
|
53
|
+
for (const branch of part.branches.values())
|
|
54
|
+
valueNamesOf(branch, names);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
const NAME_START = /[A-Za-z_]/;
|
|
60
|
+
const NAME_CHARACTER = /[A-Za-z0-9_]/;
|
|
61
|
+
/** Starts a stretch of text that reads in the direction of its own first letter, and cannot be turned around by what is around it. */
|
|
62
|
+
const FIRST_STRONG_ISOLATE = '\u2068';
|
|
63
|
+
/** Ends the stretch that {@link FIRST_STRONG_ISOLATE} started. */
|
|
64
|
+
const POP_DIRECTIONAL_ISOLATE = '\u2069';
|
|
65
|
+
/** Reads a template. Throws a {@link TemplateSyntaxError} that says what is wrong, for the console. */
|
|
66
|
+
function parseTemplate(source) {
|
|
67
|
+
let position = 0;
|
|
68
|
+
function skipSpaces() {
|
|
69
|
+
while (position < source.length && /\s/.test(source[position]))
|
|
70
|
+
position += 1;
|
|
71
|
+
}
|
|
72
|
+
function readName() {
|
|
73
|
+
skipSpaces();
|
|
74
|
+
const begin = position;
|
|
75
|
+
if (position >= source.length || !NAME_START.test(source[position])) {
|
|
76
|
+
throw new TemplateSyntaxError(`a value name was expected at character ${position + 1}`);
|
|
77
|
+
}
|
|
78
|
+
while (position < source.length && NAME_CHARACTER.test(source[position]))
|
|
79
|
+
position += 1;
|
|
80
|
+
const name = source.slice(begin, position);
|
|
81
|
+
skipSpaces();
|
|
82
|
+
return name;
|
|
83
|
+
}
|
|
84
|
+
/** Reads parts until the end of the text, or (inside a branch) until the `}` that ends it, which it does not consume. */
|
|
85
|
+
function readParts(insideBranch) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
let text = '';
|
|
88
|
+
const flushText = () => {
|
|
89
|
+
if (text !== '')
|
|
90
|
+
parts.push({ kind: 'text', text });
|
|
91
|
+
text = '';
|
|
92
|
+
};
|
|
93
|
+
while (position < source.length) {
|
|
94
|
+
const character = source[position];
|
|
95
|
+
if (character === '\\') {
|
|
96
|
+
const escaped = source[position + 1];
|
|
97
|
+
if (escaped === undefined)
|
|
98
|
+
throw new TemplateSyntaxError('a backslash at the end');
|
|
99
|
+
text += escaped;
|
|
100
|
+
position += 2;
|
|
101
|
+
}
|
|
102
|
+
else if (character === '{') {
|
|
103
|
+
flushText();
|
|
104
|
+
position += 1;
|
|
105
|
+
parts.push(readArgument());
|
|
106
|
+
}
|
|
107
|
+
else if (character === '}') {
|
|
108
|
+
if (insideBranch)
|
|
109
|
+
break;
|
|
110
|
+
throw new TemplateSyntaxError(`a } at character ${position + 1} closes nothing (write \\} for the character)`);
|
|
111
|
+
}
|
|
112
|
+
else if (character === '#' && insideBranch) {
|
|
113
|
+
flushText();
|
|
114
|
+
parts.push({ kind: 'number' });
|
|
115
|
+
position += 1;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
text += character;
|
|
119
|
+
position += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
flushText();
|
|
123
|
+
return parts;
|
|
124
|
+
}
|
|
125
|
+
/** After the `{`: `name}` or `name, plural, selector {…} … }`. */
|
|
126
|
+
function readArgument() {
|
|
127
|
+
const name = readName();
|
|
128
|
+
if (source[position] === '}') {
|
|
129
|
+
position += 1;
|
|
130
|
+
return { kind: 'value', name };
|
|
131
|
+
}
|
|
132
|
+
if (source[position] !== ',') {
|
|
133
|
+
throw new TemplateSyntaxError(`a } or a comma was expected after {${name} at character ${position + 1}`);
|
|
134
|
+
}
|
|
135
|
+
position += 1;
|
|
136
|
+
skipSpaces();
|
|
137
|
+
const typeBegin = position;
|
|
138
|
+
while (position < source.length && /[a-z]/.test(source[position]))
|
|
139
|
+
position += 1;
|
|
140
|
+
const type = source.slice(typeBegin, position);
|
|
141
|
+
if (type !== 'plural') {
|
|
142
|
+
throw new TemplateSyntaxError(`{${name}, ${type || '…'}} is not supported: only plural is`);
|
|
143
|
+
}
|
|
144
|
+
skipSpaces();
|
|
145
|
+
if (source[position] !== ',')
|
|
146
|
+
throw new TemplateSyntaxError(`a comma was expected after {${name}, plural`);
|
|
147
|
+
position += 1;
|
|
148
|
+
const branches = new Map();
|
|
149
|
+
for (;;) {
|
|
150
|
+
skipSpaces();
|
|
151
|
+
if (position >= source.length)
|
|
152
|
+
throw new TemplateSyntaxError(`{${name}, plural, … is never closed`);
|
|
153
|
+
if (source[position] === '}') {
|
|
154
|
+
position += 1;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const selectorBegin = position;
|
|
158
|
+
while (position < source.length && !/[\s{}]/.test(source[position]))
|
|
159
|
+
position += 1;
|
|
160
|
+
const selector = source.slice(selectorBegin, position);
|
|
161
|
+
if (selector === '' || !/^(=\d+|zero|one|two|few|many|other)$/.test(selector)) {
|
|
162
|
+
throw new TemplateSyntaxError(`"${selector}" is not a plural form (use =0, zero, one, two, few, many or other)`);
|
|
163
|
+
}
|
|
164
|
+
skipSpaces();
|
|
165
|
+
if (source[position] !== '{')
|
|
166
|
+
throw new TemplateSyntaxError(`a { was expected after "${selector}"`);
|
|
167
|
+
position += 1;
|
|
168
|
+
if (branches.has(selector))
|
|
169
|
+
throw new TemplateSyntaxError(`"${selector}" is given twice`);
|
|
170
|
+
const branch = readParts(true);
|
|
171
|
+
if (source[position] !== '}')
|
|
172
|
+
throw new TemplateSyntaxError(`the text of "${selector}" is never closed with }`);
|
|
173
|
+
position += 1;
|
|
174
|
+
branches.set(selector, branch);
|
|
175
|
+
}
|
|
176
|
+
if (!branches.has('other'))
|
|
177
|
+
throw new TemplateSyntaxError(`{${name}, plural, …} needs an "other" form`);
|
|
178
|
+
return { kind: 'plural', name, branches };
|
|
179
|
+
}
|
|
180
|
+
return readParts(false);
|
|
181
|
+
}
|
|
182
|
+
/** The catalog key nearest to `key` by spelling, when it is near enough to be a typo. */
|
|
183
|
+
function nearestKey(key, keys) {
|
|
184
|
+
let best;
|
|
185
|
+
// A key that is up to three edits away is taken for a typo.
|
|
186
|
+
let bestDistance = 4;
|
|
187
|
+
for (const candidate of keys) {
|
|
188
|
+
if (Math.abs(candidate.length - key.length) >= bestDistance)
|
|
189
|
+
continue;
|
|
190
|
+
let previous = Array.from({ length: candidate.length + 1 }, (_, index) => index);
|
|
191
|
+
for (let row = 1; row <= key.length; row += 1) {
|
|
192
|
+
const current = [row];
|
|
193
|
+
for (let column = 1; column <= candidate.length; column += 1) {
|
|
194
|
+
current[column] = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + (key[row - 1] === candidate[column - 1] ? 0 : 1));
|
|
195
|
+
}
|
|
196
|
+
previous = current;
|
|
197
|
+
}
|
|
198
|
+
const distance = previous[candidate.length];
|
|
199
|
+
if (distance < bestDistance) {
|
|
200
|
+
best = candidate;
|
|
201
|
+
bestDistance = distance;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return best;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Makes the function that turns keys into text. It never throws for what the page supplied: an unusable message is
|
|
208
|
+
* reported through `onProblem` (once) and the English one is used in its place.
|
|
209
|
+
*/
|
|
210
|
+
export function createTranslator(options) {
|
|
211
|
+
const { catalog, onProblem } = options;
|
|
212
|
+
const reported = new Set();
|
|
213
|
+
const report = (problem) => {
|
|
214
|
+
const signature = JSON.stringify(problem);
|
|
215
|
+
if (reported.has(signature))
|
|
216
|
+
return;
|
|
217
|
+
reported.add(signature);
|
|
218
|
+
onProblem?.(problem);
|
|
219
|
+
};
|
|
220
|
+
// The locale: a tag this browser has number and plural rules for, or English. A tag that Intl accepts but has no data
|
|
221
|
+
// for would silently use the visitor's own language, which would mix two languages on one screen.
|
|
222
|
+
let locale = 'en';
|
|
223
|
+
if (options.locale !== undefined && options.locale !== null && options.locale !== '') {
|
|
224
|
+
try {
|
|
225
|
+
const canonical = Intl.getCanonicalLocales(options.locale)[0];
|
|
226
|
+
if (canonical !== undefined &&
|
|
227
|
+
Intl.NumberFormat.supportedLocalesOf([canonical]).length > 0 &&
|
|
228
|
+
Intl.PluralRules.supportedLocalesOf([canonical]).length > 0) {
|
|
229
|
+
locale = canonical;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
report({ kind: 'unknown-locale', locale: String(options.locale) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
report({ kind: 'unknown-locale', locale: String(options.locale) });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const numberFormat = new Intl.NumberFormat(locale);
|
|
240
|
+
const pluralRules = new Intl.PluralRules(locale);
|
|
241
|
+
const isolateValues = options.isolateValues ?? resolveTextDirection(locale) === 'rtl';
|
|
242
|
+
const catalogKeys = Object.keys(catalog);
|
|
243
|
+
const englishParts = new Map();
|
|
244
|
+
const englishPartsOf = (key) => {
|
|
245
|
+
let parts = englishParts.get(key);
|
|
246
|
+
if (parts === undefined && Object.prototype.hasOwnProperty.call(catalog, key)) {
|
|
247
|
+
// The built-in messages are ours; a syntax error in one is a bug the tests catch, so it is allowed to throw.
|
|
248
|
+
parts = parseTemplate(catalog[key]);
|
|
249
|
+
englishParts.set(key, parts);
|
|
250
|
+
}
|
|
251
|
+
return parts;
|
|
252
|
+
};
|
|
253
|
+
// The page's messages, checked once, here: a typo in a key, a value that is not text, a template that does not parse,
|
|
254
|
+
// or one that asks for a value the English message is never given.
|
|
255
|
+
const replacements = new Map();
|
|
256
|
+
const supplied = options.messages;
|
|
257
|
+
if (supplied !== undefined && supplied !== null && typeof supplied === 'object') {
|
|
258
|
+
for (const key of Object.keys(supplied)) {
|
|
259
|
+
if (!Object.prototype.hasOwnProperty.call(catalog, key)) {
|
|
260
|
+
report({ kind: 'unknown-message', key, suggestion: nearestKey(key, catalogKeys) });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const text = supplied[key];
|
|
264
|
+
if (typeof text !== 'string') {
|
|
265
|
+
report({ kind: 'not-text', key });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const parts = parseTemplate(text);
|
|
270
|
+
const allowedNames = valueNamesOf(englishPartsOf(key) ?? []);
|
|
271
|
+
for (const name of valueNamesOf(parts)) {
|
|
272
|
+
if (!allowedNames.has(name))
|
|
273
|
+
report({ kind: 'unknown-value', key, name });
|
|
274
|
+
}
|
|
275
|
+
replacements.set(key, parts);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!(error instanceof TemplateSyntaxError))
|
|
279
|
+
throw error;
|
|
280
|
+
report({ kind: 'broken-message', key, reason: error.message });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function write(parts, values, pluralNumber) {
|
|
285
|
+
let output = '';
|
|
286
|
+
for (const part of parts) {
|
|
287
|
+
if (part.kind === 'text') {
|
|
288
|
+
output += part.text;
|
|
289
|
+
}
|
|
290
|
+
else if (part.kind === 'number') {
|
|
291
|
+
output += pluralNumber === null ? '#' : numberFormat.format(pluralNumber);
|
|
292
|
+
}
|
|
293
|
+
else if (part.kind === 'value') {
|
|
294
|
+
const value = values[part.name];
|
|
295
|
+
if (value === undefined) {
|
|
296
|
+
output += `{${part.name}}`;
|
|
297
|
+
}
|
|
298
|
+
else if (typeof value === 'number') {
|
|
299
|
+
output += numberFormat.format(value);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const text = String(value);
|
|
303
|
+
output += isolateValues && text !== '' ? `${FIRST_STRONG_ISOLATE}${text}${POP_DIRECTIONAL_ISOLATE}` : text;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const raw = values[part.name];
|
|
308
|
+
const count = typeof raw === 'number' ? raw : Number(raw);
|
|
309
|
+
const form = Number.isFinite(count) ? (part.branches.get(`=${count}`) ?? part.branches.get(pluralRules.select(count))) : undefined;
|
|
310
|
+
output += write(form ?? part.branches.get('other'), values, Number.isFinite(count) ? count : null);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return output;
|
|
314
|
+
}
|
|
315
|
+
const t = (key, values = {}) => {
|
|
316
|
+
const parts = replacements.get(key) ?? englishPartsOf(key);
|
|
317
|
+
if (parts === undefined) {
|
|
318
|
+
report({ kind: 'unknown-message', key, suggestion: nearestKey(key, catalogKeys) });
|
|
319
|
+
return key;
|
|
320
|
+
}
|
|
321
|
+
return write(parts, values, null);
|
|
322
|
+
};
|
|
323
|
+
return { t, locale };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The names of the values a message asks for: each `{name}` in it, and the number a plural looks at, sorted. For a test
|
|
327
|
+
* or a tool that checks the code gives a message everything it needs. Throws when the template does not parse.
|
|
328
|
+
*/
|
|
329
|
+
export function valueNamesOfTemplate(template) {
|
|
330
|
+
return [...valueNamesOf(parseTemplate(template))].sort();
|
|
331
|
+
}
|
|
332
|
+
/** Checks `messages` against a catalog without making a translator: what a test, or a build step, can call. */
|
|
333
|
+
export function findMessageProblems(catalog, messages) {
|
|
334
|
+
const problems = [];
|
|
335
|
+
createTranslator({ catalog, messages, onProblem: (problem) => problems.push(problem) });
|
|
336
|
+
return problems;
|
|
337
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PhysicalPlacement } from './physicalPlacement.ts';
|
|
2
|
+
export interface PhysicalPlacementOptions {
|
|
3
|
+
/** The folder to read, with everything under it. */
|
|
4
|
+
folder: string;
|
|
5
|
+
/**
|
|
6
|
+
* Parts of the tree that stay as they are, each with the reason. `path` is a piece of a path relative to `folder`
|
|
7
|
+
* (for example `components/widget-system/`); every file whose path contains it is left out.
|
|
8
|
+
*/
|
|
9
|
+
skipped?: Array<{
|
|
10
|
+
path: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** The file endings to read; the default is templates, scripts and stylesheets. */
|
|
14
|
+
extensions?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface PhysicalPlacementReport {
|
|
17
|
+
/** How many files were read, so a test can tell that it looked at something. */
|
|
18
|
+
filesRead: number;
|
|
19
|
+
/** Every physical placement that is not let through by a note, with the file it is in (relative to the folder). */
|
|
20
|
+
found: Array<{
|
|
21
|
+
file: string;
|
|
22
|
+
placement: PhysicalPlacement;
|
|
23
|
+
}>;
|
|
24
|
+
/** The same as one line each, ready to show when an expectation fails. */
|
|
25
|
+
lines: string[];
|
|
26
|
+
/** Entries of `skipped` that no file matched: the part they named is gone, so they should go too. */
|
|
27
|
+
unusedSkips: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reads every source file under a folder and reports the physical placement in it. A package runs it in its own tests:
|
|
31
|
+
*
|
|
32
|
+
* const report = auditPhysicalPlacement({ folder: SOURCE_FOLDER, skipped: [{ path: 'lib/legacy/', reason: '...' }] });
|
|
33
|
+
* expect(report.lines).toEqual([]);
|
|
34
|
+
* expect(report.unusedSkips).toEqual([]);
|
|
35
|
+
*/
|
|
36
|
+
export declare function auditPhysicalPlacement({ folder, skipped, extensions }: PhysicalPlacementOptions): PhysicalPlacementReport;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describePhysicalPlacement, findPhysicalPlacement } from "./physicalPlacement.js";
|
|
4
|
+
const DEFAULT_EXTENSIONS = ['.svelte', '.ts', '.js', '.css'];
|
|
5
|
+
const NEVER_READ_FOLDERS = new Set(['node_modules', 'dist', '.svelte-kit', '.git']);
|
|
6
|
+
/** The kind of source a file is, by its ending: a stylesheet reads every line as a style, a script only a style written in a string. */
|
|
7
|
+
function languageOfFile(file) {
|
|
8
|
+
if (file.endsWith('.css'))
|
|
9
|
+
return 'stylesheet';
|
|
10
|
+
if (/\.[cm]?[jt]s$/.test(file))
|
|
11
|
+
return 'script';
|
|
12
|
+
return 'markup';
|
|
13
|
+
}
|
|
14
|
+
/** Every file under `folder` that ends with one of `extensions`, as paths relative to `folder` with forward slashes. */
|
|
15
|
+
function listFiles(folder, extensions, relativeFolder = '') {
|
|
16
|
+
const files = [];
|
|
17
|
+
for (const entry of fs.readdirSync(path.join(folder, relativeFolder), { withFileTypes: true })) {
|
|
18
|
+
const relative = relativeFolder ? `${relativeFolder}/${entry.name}` : entry.name;
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
if (!NEVER_READ_FOLDERS.has(entry.name))
|
|
21
|
+
files.push(...listFiles(folder, extensions, relative));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const isDeclaration = entry.name.endsWith('.d.ts');
|
|
25
|
+
const isTest = /\.(?:test|spec)\.[cm]?[jt]s$/.test(entry.name);
|
|
26
|
+
if (!isDeclaration && !isTest && extensions.some((extension) => entry.name.endsWith(extension)))
|
|
27
|
+
files.push(relative);
|
|
28
|
+
}
|
|
29
|
+
return files.sort();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads every source file under a folder and reports the physical placement in it. A package runs it in its own tests:
|
|
33
|
+
*
|
|
34
|
+
* const report = auditPhysicalPlacement({ folder: SOURCE_FOLDER, skipped: [{ path: 'lib/legacy/', reason: '...' }] });
|
|
35
|
+
* expect(report.lines).toEqual([]);
|
|
36
|
+
* expect(report.unusedSkips).toEqual([]);
|
|
37
|
+
*/
|
|
38
|
+
export function auditPhysicalPlacement({ folder, skipped = [], extensions = DEFAULT_EXTENSIONS }) {
|
|
39
|
+
const usedSkips = new Set();
|
|
40
|
+
const found = [];
|
|
41
|
+
let filesRead = 0;
|
|
42
|
+
for (const file of listFiles(folder, extensions)) {
|
|
43
|
+
const skip = skipped.find((candidate) => file.includes(candidate.path));
|
|
44
|
+
if (skip) {
|
|
45
|
+
usedSkips.add(skip.path);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
filesRead += 1;
|
|
49
|
+
for (const placement of findPhysicalPlacement(fs.readFileSync(path.join(folder, file), 'utf8'), { language: languageOfFile(file) }))
|
|
50
|
+
found.push({ file, placement });
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
filesRead,
|
|
54
|
+
found,
|
|
55
|
+
lines: found.map(({ file, placement }) => describePhysicalPlacement(file, placement)),
|
|
56
|
+
unusedSkips: skipped.map((skip) => skip.path).filter((skipPath) => !usedSkips.has(skipPath))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test tooling for the direction of a Widgetic package: an audit a package runs in its own tests, so that a screen
|
|
3
|
+
* converted to start and end is not put back to left and right by a later change.
|
|
4
|
+
*
|
|
5
|
+
* import { auditPhysicalPlacement } from '@widgetic/design-system/rtl/audit';
|
|
6
|
+
*
|
|
7
|
+
* const report = auditPhysicalPlacement({ folder: SOURCE_FOLDER });
|
|
8
|
+
* expect(report.lines).toEqual([]);
|
|
9
|
+
*
|
|
10
|
+
* A physical side that is on purpose stays when its line carries the note `rtl-ok` with the reason. It reads source text
|
|
11
|
+
* only, so it runs in Node (a test, a build step) and a page never loads it. Nothing here changes what a visitor sees.
|
|
12
|
+
*/
|
|
13
|
+
export { auditPhysicalPlacement, type PhysicalPlacementOptions, type PhysicalPlacementReport } from './auditPhysicalPlacement.ts';
|
|
14
|
+
export { ALLOWED_PLACEMENT_NOTE, blankComments, describePhysicalPlacement, findPhysicalPlacement, type FindPlacementOptions, type PhysicalPlacement, type PlacementKind, type SourceLanguage } from './physicalPlacement.ts';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test tooling for the direction of a Widgetic package: an audit a package runs in its own tests, so that a screen
|
|
3
|
+
* converted to start and end is not put back to left and right by a later change.
|
|
4
|
+
*
|
|
5
|
+
* import { auditPhysicalPlacement } from '@widgetic/design-system/rtl/audit';
|
|
6
|
+
*
|
|
7
|
+
* const report = auditPhysicalPlacement({ folder: SOURCE_FOLDER });
|
|
8
|
+
* expect(report.lines).toEqual([]);
|
|
9
|
+
*
|
|
10
|
+
* A physical side that is on purpose stays when its line carries the note `rtl-ok` with the reason. It reads source text
|
|
11
|
+
* only, so it runs in Node (a test, a build step) and a page never loads it. Nothing here changes what a visitor sees.
|
|
12
|
+
*/
|
|
13
|
+
export { auditPhysicalPlacement } from "./auditPhysicalPlacement.js";
|
|
14
|
+
export { ALLOWED_PLACEMENT_NOTE, blankComments, describePhysicalPlacement, findPhysicalPlacement } from "./physicalPlacement.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the places where a screen is put in place with a physical side ("left", "right") instead of a logical one
|
|
3
|
+
* ("start", "end"), because a physical side does not follow the direction of the text: on a right-to-left screen a
|
|
4
|
+
* `ml-2` still opens a gap at the left, where the logical `ms-2` opens it at the right.
|
|
5
|
+
*
|
|
6
|
+
* It reads source text only (a class in a template, a style in a `<style>` block or a style attribute), so it runs in a
|
|
7
|
+
* test and a page never loads it. Nothing here changes what a visitor sees.
|
|
8
|
+
*
|
|
9
|
+
* A box put in place from the left or the right edge (`left: 8px`, `style:right={x}`, `element.style.left = ...`) is found
|
|
10
|
+
* in a stylesheet, in a `<style>` block, in a style attribute and in an assignment to `style.left` or `style.right`; the
|
|
11
|
+
* code around it (`rect.left`, `{ left: 3 }` in a script) is not a style and is left alone. A box that is stretched from
|
|
12
|
+
* both edges (`left: 0; right: 0`), centred (`left: 50%`) or given back to the browser (`left: auto`) is the same in both
|
|
13
|
+
* directions and is never reported.
|
|
14
|
+
*
|
|
15
|
+
* A shorthand that names the four sides by their place (`padding: 0 8px 0 4px`, `inset: 0 2rem 0 0`, `border-radius: 4px 0 0 4px`)
|
|
16
|
+
* is found when the length at the left differs from the one at the right (`padding: 4px 8px` and `border-radius: 4px` are the
|
|
17
|
+
* same on both sides and are left alone).
|
|
18
|
+
*
|
|
19
|
+
* Placement that is physical on purpose stays, when the line says why:
|
|
20
|
+
*
|
|
21
|
+
* <!-- rtl-ok: the timeline is a left-to-right island -->
|
|
22
|
+
* <div class="left-0 right-0">
|
|
23
|
+
*
|
|
24
|
+
* The note `rtl-ok` on the line itself or on the line above lets that line through. A few patterns are never reported
|
|
25
|
+
* because they cannot be wrong: centring (`left-1/2` with `-translate-x-1/2`), a class that already names its direction
|
|
26
|
+
* (`rtl:ml-2`, `ltr:ml-2`), and a class that follows the side a pop-up opens on (`data-[side=left]:...`).
|
|
27
|
+
*/
|
|
28
|
+
export type PlacementKind = 'class' | 'style' | 'attribute';
|
|
29
|
+
export interface PhysicalPlacement {
|
|
30
|
+
/** The line of the source the placement is on, counted from 1. */
|
|
31
|
+
line: number;
|
|
32
|
+
/** What was found: a class such as `ml-2`, a style such as `margin-left`, or an attribute such as `side="left"`. */
|
|
33
|
+
text: string;
|
|
34
|
+
kind: PlacementKind;
|
|
35
|
+
/** What to write instead, as a short sentence a developer can act on. */
|
|
36
|
+
suggestion: string;
|
|
37
|
+
}
|
|
38
|
+
/** The note that lets a physical placement through; it goes on the line itself or on the line above, with the reason. */
|
|
39
|
+
export declare const ALLOWED_PLACEMENT_NOTE = "rtl-ok";
|
|
40
|
+
/**
|
|
41
|
+
* What kind of source is read. `markup` is a Svelte or HTML file (a `<style>` block is a stylesheet), `stylesheet` is a
|
|
42
|
+
* `.css` file (every line is a style), `script` is a `.ts` or `.js` file (only a style attribute written in a string, and
|
|
43
|
+
* an assignment to `style.left`, are styles).
|
|
44
|
+
*/
|
|
45
|
+
export type SourceLanguage = 'markup' | 'stylesheet' | 'script';
|
|
46
|
+
export interface FindPlacementOptions {
|
|
47
|
+
/** The kind of source; `markup` when left out. */
|
|
48
|
+
language?: SourceLanguage;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The source with every comment blanked out (a space for each letter, new lines kept), so the lines still count the same
|
|
52
|
+
* and the words in a comment are never taken for a class.
|
|
53
|
+
*
|
|
54
|
+
* What counts as a comment depends on where it is written, because the same characters are text somewhere else: in a
|
|
55
|
+
* template an HTML comment, a block comment and a line comment are comments; in a `<script>` block a block comment and a
|
|
56
|
+
* line comment are, and `<!--` is not (a regular expression or a string may hold it, and it must not hide the lines up to
|
|
57
|
+
* the next `-->`); in a `<style>` block and in a stylesheet only a block comment is. A script file has block and line
|
|
58
|
+
* comments.
|
|
59
|
+
*/
|
|
60
|
+
export declare function blankComments(source: string, language?: SourceLanguage): string;
|
|
61
|
+
/** Every physical placement in `source`, in the order the source has them; each with its line and what to write instead. */
|
|
62
|
+
export declare function findPhysicalPlacement(source: string, { language }?: FindPlacementOptions): PhysicalPlacement[];
|
|
63
|
+
/** A placement as one line of a report: where it is, what it is and what to do. */
|
|
64
|
+
export declare function describePhysicalPlacement(file: string, placement: PhysicalPlacement): string;
|