prosebook 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +121 -0
- package/src/tools/config.ts +211 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook migrate`: a catalog and its translations rewritten from one syntax to the other. Each
|
|
3
|
+
* message is written in the target syntax, read back, and rendered both ways over sample values, so
|
|
4
|
+
* one that can't be written, or would come out different, is reported before anything changes.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { isMap, parseDocument, Scalar } from 'yaml';
|
|
10
|
+
|
|
11
|
+
import { createHelpers, flatten } from '#src/runtime/helpers.ts';
|
|
12
|
+
import type { CompiledMessages } from '#src/runtime/types.ts';
|
|
13
|
+
|
|
14
|
+
import { loadCatalog } from './catalog.ts';
|
|
15
|
+
import { compileLocale, localeExpression } from './compile.ts';
|
|
16
|
+
import type { ResolvedConfig } from './config.ts';
|
|
17
|
+
import { argsOf, tagsOf, type MessageFormatElement } from './icu.ts';
|
|
18
|
+
import { readMessage, writeMessage, type MessageFormatVersion } from './syntax.ts';
|
|
19
|
+
|
|
20
|
+
interface Located {
|
|
21
|
+
key: string;
|
|
22
|
+
locale: string;
|
|
23
|
+
/** The file, relative to the project root. */
|
|
24
|
+
file: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MigratePlan {
|
|
28
|
+
to: MessageFormatVersion;
|
|
29
|
+
rewrites: (Located & { before: string; after: string })[];
|
|
30
|
+
/** Messages the target syntax can't say, and why. Nothing is written while there are any. */
|
|
31
|
+
blocked: (Located & { reason: string })[];
|
|
32
|
+
/** Messages that would render differently, with the first values that show it. */
|
|
33
|
+
changed: (Located & { values: string; before: string; after: string })[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): MigratePlan {
|
|
37
|
+
const catalog = loadCatalog(config);
|
|
38
|
+
const plan: MigratePlan = { to, rewrites: [], blocked: [], changed: [] };
|
|
39
|
+
for (const m of catalog.messages) {
|
|
40
|
+
const versions = [
|
|
41
|
+
{ locale: config.source, text: m.text, ast: m.ast, file: m.file },
|
|
42
|
+
...[...m.translations].map(([locale, t]) => ({ locale, text: t.text, ast: t.ast, file: t.file })),
|
|
43
|
+
];
|
|
44
|
+
for (const { locale, text, ast, file } of versions) {
|
|
45
|
+
const at = { key: m.key, locale, file };
|
|
46
|
+
let after: string;
|
|
47
|
+
let back: MessageFormatElement[];
|
|
48
|
+
try {
|
|
49
|
+
after = writeMessage(to, ast, locale);
|
|
50
|
+
back = readMessage(to, after, locale);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
plan.blocked.push({ ...at, reason: (e as Error).message });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const difference = firstDifference(ast, back, locale);
|
|
56
|
+
if (difference !== undefined) plan.changed.push({ ...at, ...difference });
|
|
57
|
+
if (after !== text) plan.rewrites.push({ ...at, before: text, after });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return plan;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Writes the plan's rewrites into the YAML files, keeping their comments and layout. Returns the files written. */
|
|
64
|
+
export function applyMigrate(config: ResolvedConfig, plan: MigratePlan): string[] {
|
|
65
|
+
if (plan.blocked.length > 0) throw new Error('prosebook: some messages have no equivalent in the target syntax; nothing was written');
|
|
66
|
+
const byFile = Map.groupBy(plan.rewrites, (r) => r.file);
|
|
67
|
+
for (const [file, rewrites] of byFile) {
|
|
68
|
+
const path = join(config.root, file);
|
|
69
|
+
// As YAML 1.1, so a new value that 1.1 would read differently, like `n`, is quoted.
|
|
70
|
+
const doc = parseDocument(readFileSync(path, 'utf8'), { version: '1.1' });
|
|
71
|
+
for (const r of rewrites) {
|
|
72
|
+
const value = doc.createNode(r.after);
|
|
73
|
+
if (r.after.includes('\n')) value.type = Scalar.BLOCK_LITERAL;
|
|
74
|
+
const entry = doc.get(r.key, true);
|
|
75
|
+
if (isMap(entry)) entry.set(config.source, value);
|
|
76
|
+
else doc.set(r.key, value);
|
|
77
|
+
}
|
|
78
|
+
writeFileSync(path, doc.toString({ lineWidth: 0 }));
|
|
79
|
+
}
|
|
80
|
+
return [...byFile.keys()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NUMBERS = [0, 1, 2, 3, 5, 11, 21, 101, 1.5, 1234.5];
|
|
84
|
+
const DATE = new Date(Date.UTC(2026, 0, 5, 14, 30, 15));
|
|
85
|
+
|
|
86
|
+
function samplesOf(type: string): unknown[] {
|
|
87
|
+
if (type === 'number') return NUMBERS;
|
|
88
|
+
if (type === 'Date | number') return [DATE];
|
|
89
|
+
const named = [...type.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string);
|
|
90
|
+
return named.length > 0 ? [...named, 'zzz'] : ['Ada'];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every combination of sample values, or each argument varied alone where there are too many. */
|
|
94
|
+
function* samples(a: MessageFormatElement[], b: MessageFormatElement[]): Generator<Record<string, unknown>> {
|
|
95
|
+
const types = new Map<string, Set<unknown>>();
|
|
96
|
+
for (const [name, type] of [...argsOf(a), ...argsOf(b)]) {
|
|
97
|
+
const set = types.get(name) ?? new Set();
|
|
98
|
+
for (const value of samplesOf(type)) set.add(value);
|
|
99
|
+
types.set(name, set);
|
|
100
|
+
}
|
|
101
|
+
const tags = Object.fromEntries(
|
|
102
|
+
[...new Set([...tagsOf(a), ...tagsOf(b)])].map((t) => [t, (c: unknown[]) => `<${t}>${c.join('')}</${t}>`]),
|
|
103
|
+
);
|
|
104
|
+
const names = [...types.keys()];
|
|
105
|
+
const lists = names.map((n) => [...types.get(n)!]);
|
|
106
|
+
if (lists.reduce((n, l) => n * l.length, 1) <= 1000) {
|
|
107
|
+
let combos: unknown[][] = [[]];
|
|
108
|
+
for (const list of lists) combos = combos.flatMap((c) => list.map((v) => [...c, v]));
|
|
109
|
+
for (const combo of combos) yield { ...tags, ...Object.fromEntries(names.map((n, i) => [n, combo[i]])) };
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const base = Object.fromEntries(names.map((n, i) => [n, lists[i]![0]]));
|
|
113
|
+
for (const [i, n] of names.entries()) for (const value of lists[i]!) yield { ...tags, ...base, [n]: value };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderer(ast: MessageFormatElement[], locale: string): (values: Record<string, unknown>) => string {
|
|
117
|
+
// oxlint-disable-next-line no-new-func, typescript/no-implied-eval -- evaluating our own compiled message, as the bundle would
|
|
118
|
+
const messages = new Function(`return ${localeExpression(compileLocale([['m', ast]]))};`)() as CompiledMessages;
|
|
119
|
+
const message = messages.m!;
|
|
120
|
+
const h = createHelpers(locale);
|
|
121
|
+
return (values) => (typeof message === 'string' ? message : flatten(message(values, h)).join(''));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function firstDifference(a: MessageFormatElement[], b: MessageFormatElement[], locale: string) {
|
|
125
|
+
const before = renderer(a, locale);
|
|
126
|
+
const after = renderer(b, locale);
|
|
127
|
+
for (const values of samples(a, b)) {
|
|
128
|
+
const x = before(values);
|
|
129
|
+
const y = after(values);
|
|
130
|
+
if (x !== y) {
|
|
131
|
+
const shown = Object.entries(values)
|
|
132
|
+
.filter(([, v]) => typeof v !== 'function')
|
|
133
|
+
.map(([k, v]) => `${k}: ${v instanceof Date ? v.toISOString() : JSON.stringify(v)}`);
|
|
134
|
+
return { values: `{ ${shown.join(', ')} }`, before: x, after: y };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The defaults: four kinds of string, the surfaces they sit on, and the word lists of the strict lint preset.
|
|
3
|
+
*
|
|
4
|
+
* The kinds are the four any UI writing guide comes down to. A name identifies, an action says what
|
|
5
|
+
* pressing it does, a description adds what the name cannot carry, a state reports what is true.
|
|
6
|
+
* Each carries a budget and a grammar, which is what makes a mechanical check of the words possible.
|
|
7
|
+
*/
|
|
8
|
+
export type Severity = 'error' | 'warn' | 'off';
|
|
9
|
+
|
|
10
|
+
export interface KindRule {
|
|
11
|
+
/** What the kind is for, shown when an editor completes it. */
|
|
12
|
+
doc: string;
|
|
13
|
+
/** The most words it may have. Articles, short prepositions and asides in parentheses are free. */
|
|
14
|
+
maxWords?: number;
|
|
15
|
+
/** The most sentences it may have, and per surface where that differs. */
|
|
16
|
+
maxSentences?: number;
|
|
17
|
+
maxSentencesOn?: Record<string, number>;
|
|
18
|
+
/** Whether it ends with a period: always, never, or only on the surfaces listed. */
|
|
19
|
+
period?: 'always' | 'never' | string[];
|
|
20
|
+
/** Capitalizes only its first word and proper nouns. */
|
|
21
|
+
sentenceCase?: boolean;
|
|
22
|
+
/** Carries no explanation after a dash or colon: `Quick capture`, not `Quick capture: choose an area`. */
|
|
23
|
+
bare?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_KINDS: Record<string, KindRule> = {
|
|
27
|
+
name: {
|
|
28
|
+
doc: 'Identifies a thing so it can be found again. One to four words, a noun phrase, no period.',
|
|
29
|
+
maxWords: 4,
|
|
30
|
+
period: 'never',
|
|
31
|
+
sentenceCase: true,
|
|
32
|
+
bare: true,
|
|
33
|
+
},
|
|
34
|
+
action: {
|
|
35
|
+
doc: 'Says what pressing it does. One to three words, verb first, no period.',
|
|
36
|
+
maxWords: 3,
|
|
37
|
+
period: 'never',
|
|
38
|
+
sentenceCase: true,
|
|
39
|
+
},
|
|
40
|
+
description: {
|
|
41
|
+
doc: 'Adds only what the name cannot carry. One sentence, two at most, with a period.',
|
|
42
|
+
maxSentences: 2,
|
|
43
|
+
period: 'always',
|
|
44
|
+
},
|
|
45
|
+
state: {
|
|
46
|
+
doc: 'Reports what is true right now. One sentence, present tense; on an error or empty state, one more saying what to do.',
|
|
47
|
+
maxSentences: 1,
|
|
48
|
+
maxSentencesOn: { error: 2, empty: 2, hint: 2, status: 2 },
|
|
49
|
+
period: ['hint', 'status', 'error', 'empty', 'confirm'],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_SURFACES: Record<string, string> = {
|
|
54
|
+
action: 'The text of a button or a menu entry.',
|
|
55
|
+
aria: 'Reached only by a screen reader, so nothing on screen carries it.',
|
|
56
|
+
choice: 'One option among a set offered together.',
|
|
57
|
+
confirm: 'The question of a confirmation, or the verb that answers it.',
|
|
58
|
+
empty: 'Fills a list or a panel that has nothing in it.',
|
|
59
|
+
error: 'Says what failed.',
|
|
60
|
+
heading: 'Heads a section, group, dialog or list.',
|
|
61
|
+
hint: 'A subordinate line under the thing it qualifies.',
|
|
62
|
+
label: 'Names a control, a field, a section or a destination.',
|
|
63
|
+
placeholder: 'Sits inside an empty field, and goes when typing starts.',
|
|
64
|
+
status: 'Reports what is true right now, on a badge or beside a row.',
|
|
65
|
+
title: 'The title of a page, window or document.',
|
|
66
|
+
toast: 'A notification that comes and goes.',
|
|
67
|
+
tooltip: 'Appears on hover.',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The word lists of the strict preset: filler, marketing and reassurance, and American spelling. */
|
|
71
|
+
export const STRICT_LISTS = {
|
|
72
|
+
banned: [
|
|
73
|
+
{
|
|
74
|
+
words: ['seamless', 'seamlessly', 'effortless', 'effortlessly', 'hassle-free', 'frictionless'],
|
|
75
|
+
why: 'promises a feeling rather than saying what happens',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
words: ['leverage', 'leverages', 'leveraging', 'utilize', 'utilizes', 'utilizing', 'harness', 'take advantage of'],
|
|
79
|
+
why: 'is a long way to say use',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
words: [
|
|
83
|
+
'delve',
|
|
84
|
+
'dive into',
|
|
85
|
+
'deep dive',
|
|
86
|
+
'embark',
|
|
87
|
+
'journey',
|
|
88
|
+
'tapestry',
|
|
89
|
+
'realm',
|
|
90
|
+
'landscape',
|
|
91
|
+
'testament',
|
|
92
|
+
'synergy',
|
|
93
|
+
'paradigm',
|
|
94
|
+
'holistic',
|
|
95
|
+
],
|
|
96
|
+
why: 'is filler, not a thing on the screen',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
words: [
|
|
100
|
+
'robust',
|
|
101
|
+
'powerful',
|
|
102
|
+
'cutting-edge',
|
|
103
|
+
'state-of-the-art',
|
|
104
|
+
'best-in-class',
|
|
105
|
+
'world-class',
|
|
106
|
+
'next-generation',
|
|
107
|
+
'revolutionary',
|
|
108
|
+
'game-changing',
|
|
109
|
+
],
|
|
110
|
+
why: 'is marketing: say what it does',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
words: ['streamline', 'streamlined', 'empower', 'unlock', 'unleash', 'elevate', 'supercharge', 'boost', 'enhance', 'enhanced'],
|
|
114
|
+
why: 'claims an improvement without naming it',
|
|
115
|
+
},
|
|
116
|
+
{ words: ['meticulous', 'crucial', 'pivotal', 'vital', 'paramount'], why: 'tells the reader how to feel instead of what it is' },
|
|
117
|
+
{
|
|
118
|
+
words: ['comprehensive', 'myriad', 'plethora', 'a variety of', 'a wide range of'],
|
|
119
|
+
why: 'is padding: name the things, or say how many',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
words: ['furthermore', 'moreover', 'additionally', 'in order to', 'please note', 'note that', 'keep in mind', 'rest assured'],
|
|
123
|
+
why: 'is a connective a short string has no room for',
|
|
124
|
+
},
|
|
125
|
+
{ words: ['intuitive', 'sleek', 'stunning', 'beautiful', 'delightful', 'magical'], why: 'grades the product instead of describing it' },
|
|
126
|
+
{ words: ['simply', 'just', 'easily', 'easy', 'quickly'], why: 'says it is easy, which helps nobody it is not easy for' },
|
|
127
|
+
{ words: ['so you can', 'allows you to', 'lets you', 'enables you to', "don't worry"], why: 'justifies or reassures: state the fact' },
|
|
128
|
+
{ words: ['please', 'oops', 'whoops', 'sorry'], why: 'pleads or apologizes: say what failed and what to do' },
|
|
129
|
+
{ words: ['etc', 'and more', 'and so on'], why: 'trails off: name the things or stop' },
|
|
130
|
+
],
|
|
131
|
+
spelling: {
|
|
132
|
+
colour: 'color',
|
|
133
|
+
colours: 'colors',
|
|
134
|
+
behaviour: 'behavior',
|
|
135
|
+
favourite: 'favorite',
|
|
136
|
+
cancelled: 'canceled',
|
|
137
|
+
cancelling: 'canceling',
|
|
138
|
+
labelled: 'labeled',
|
|
139
|
+
centre: 'center',
|
|
140
|
+
licence: 'license',
|
|
141
|
+
grey: 'gray',
|
|
142
|
+
organise: 'organize',
|
|
143
|
+
recognise: 'recognize',
|
|
144
|
+
customise: 'customize',
|
|
145
|
+
optimise: 'optimize',
|
|
146
|
+
minimise: 'minimize',
|
|
147
|
+
maximise: 'maximize',
|
|
148
|
+
synchronise: 'synchronize',
|
|
149
|
+
authorise: 'authorize',
|
|
150
|
+
initialise: 'initialize',
|
|
151
|
+
analyse: 'analyze',
|
|
152
|
+
catalogue: 'catalog',
|
|
153
|
+
dialogue: 'dialog',
|
|
154
|
+
artefact: 'artifact',
|
|
155
|
+
judgement: 'judgment',
|
|
156
|
+
whilst: 'while',
|
|
157
|
+
amongst: 'among',
|
|
158
|
+
} as Record<string, string>,
|
|
159
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pseudo-localization: the source with every letter accented, vowels doubled for the length a
|
|
3
|
+
* translation usually adds, and brackets around the whole. A screen in the pseudo-locale shows at a
|
|
4
|
+
* glance which text is hardcoded (it is plain), which is clipped (a bracket is missing), and which
|
|
5
|
+
* splices fragments together (brackets inside brackets).
|
|
6
|
+
*/
|
|
7
|
+
import { TYPE, type MessageFormatElement } from './icu.ts';
|
|
8
|
+
|
|
9
|
+
const PLAIN = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
10
|
+
const ACCENTED = Array.from('áƀçðéƒĝĥíĵķļɱñóþǫŕšţúṽŵẋýžÁƁÇÐÉƑĜĤÍĴĶĻṀÑÓÞǪŔŠŢÚṼŴẊÝŽ');
|
|
11
|
+
|
|
12
|
+
export function pseudoText(text: string): string {
|
|
13
|
+
return text.replace(/[A-Za-z]/g, (c) => {
|
|
14
|
+
const accented = ACCENTED[PLAIN.indexOf(c)]!;
|
|
15
|
+
return /[aeiou]/i.test(c) ? accented.repeat(2) : accented;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function transform(elements: MessageFormatElement[]): MessageFormatElement[] {
|
|
20
|
+
return elements.map((el) => {
|
|
21
|
+
if (el.type === TYPE.literal) return { ...el, value: pseudoText(el.value) };
|
|
22
|
+
if (el.type === TYPE.tag) return { ...el, children: transform(el.children) };
|
|
23
|
+
if (el.type === TYPE.select || el.type === TYPE.plural) {
|
|
24
|
+
const options = Object.fromEntries(Object.entries(el.options).map(([arm, o]) => [arm, { ...o, value: transform(o.value) }]));
|
|
25
|
+
return { ...el, options };
|
|
26
|
+
}
|
|
27
|
+
return el;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pseudoAst(ast: MessageFormatElement[]): MessageFormatElement[] {
|
|
32
|
+
const out: MessageFormatElement[] = [];
|
|
33
|
+
// Adjacent literals are joined, so a message with no placeholders still compiles to a plain string.
|
|
34
|
+
for (const el of [{ type: TYPE.literal, value: '⟦' }, ...transform(ast), { type: TYPE.literal, value: '⟧' }] as MessageFormatElement[]) {
|
|
35
|
+
const last = out.at(-1);
|
|
36
|
+
if (el.type === TYPE.literal && last?.type === TYPE.literal) out[out.length - 1] = { ...last, value: last.value + el.value };
|
|
37
|
+
else out.push(el);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings as SARIF 2.1.0, the static-analysis format code scanning reads (GitHub's among them), so
|
|
3
|
+
* `lint`, `scan`, `status` and `check` can annotate a pull request on the line the string is written.
|
|
4
|
+
*
|
|
5
|
+
* Paths are relative to the project root under `%SRCROOT%`, which the log maps to the root itself:
|
|
6
|
+
* a project that isn't at the top of its repository still resolves to the right files.
|
|
7
|
+
*/
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export interface SarifResult {
|
|
11
|
+
/** A rule the report describes, or one of a lint plugin's. */
|
|
12
|
+
rule: string;
|
|
13
|
+
level: 'error' | 'warning';
|
|
14
|
+
message: string;
|
|
15
|
+
/** Relative to the project root, with forward slashes; none for a problem that isn't in a file. */
|
|
16
|
+
file?: string;
|
|
17
|
+
/** From 1; none for the file as a whole. */
|
|
18
|
+
line?: number;
|
|
19
|
+
column?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SarifTool {
|
|
23
|
+
name: string;
|
|
24
|
+
version: string;
|
|
25
|
+
informationUri: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Collects what a command found, then writes it as one SARIF run. */
|
|
29
|
+
export class SarifReport {
|
|
30
|
+
readonly results: SarifResult[] = [];
|
|
31
|
+
private readonly rules = new Map<string, string | undefined>();
|
|
32
|
+
|
|
33
|
+
/** Describes a rule in one sentence; the first description given for an id stands. */
|
|
34
|
+
rule(id: string, description?: string): void {
|
|
35
|
+
if (this.rules.get(id) === undefined) this.rules.set(id, description);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
add(result: SarifResult): void {
|
|
39
|
+
if (!this.rules.has(result.rule)) this.rules.set(result.rule, undefined);
|
|
40
|
+
this.results.push(result);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
toJSON(root: string, tool: SarifTool): object {
|
|
44
|
+
const ids = [...this.rules.keys()];
|
|
45
|
+
return {
|
|
46
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
47
|
+
version: '2.1.0',
|
|
48
|
+
runs: [
|
|
49
|
+
{
|
|
50
|
+
tool: {
|
|
51
|
+
driver: {
|
|
52
|
+
...tool,
|
|
53
|
+
rules: ids.map((id) => {
|
|
54
|
+
const description = this.rules.get(id);
|
|
55
|
+
return description === undefined ? { id } : { id, shortDescription: { text: description } };
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
originalUriBaseIds: { '%SRCROOT%': { uri: pathToFileURL(root).href.replace(/\/?$/, '/') } },
|
|
60
|
+
results: this.results.map((r) => ({
|
|
61
|
+
ruleId: r.rule,
|
|
62
|
+
ruleIndex: ids.indexOf(r.rule),
|
|
63
|
+
level: r.level,
|
|
64
|
+
message: { text: r.message },
|
|
65
|
+
locations: r.file === undefined ? [] : [location(r.file, r.line, r.column)],
|
|
66
|
+
})),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function location(file: string, line: number | undefined, column: number | undefined): object {
|
|
74
|
+
return {
|
|
75
|
+
physicalLocation: {
|
|
76
|
+
artifactLocation: { uri: encodeURI(file), uriBaseId: '%SRCROOT%' },
|
|
77
|
+
...(line === undefined ? {} : { region: { startLine: line, startColumn: column ?? 1 } }),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|