-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
extract.ts
269 lines (253 loc) · 6.98 KB
/
extract.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import {warn, getStdinAsString, debug, writeStdout} from './console_utils'
import {readFile, outputFile} from 'fs-extra'
import {
interpolateName,
Opts,
MessageDescriptor,
} from '@formatjs/ts-transformer'
import {resolveBuiltinFormatter, Formatter} from './formatters'
import stringify from 'json-stable-stringify'
import {parseFile} from './vue_extractor'
import {parseScript} from './parse_script'
import {printAST} from '@formatjs/icu-messageformat-parser/printer'
import {hoistSelectors} from '@formatjs/icu-messageformat-parser/manipulator'
import {parse} from '@formatjs/icu-messageformat-parser'
export interface ExtractionResult<M = Record<string, string>> {
/**
* List of extracted messages
*/
messages: MessageDescriptor[]
/**
* Metadata extracted w/ `pragma`
*/
meta?: M
}
export interface ExtractedMessageDescriptor extends MessageDescriptor {
/**
* Line number
*/
line?: number
/**
* Column number
*/
col?: number
/**
* Metadata extracted from pragma
*/
meta?: Record<string, string>
}
export type ExtractCLIOptions = Omit<
ExtractOpts,
'overrideIdFn' | 'onMsgExtracted' | 'onMetaExtracted'
> & {
/**
* Output File
*/
outFile?: string
/**
* Ignore file glob pattern
*/
ignore?: string[]
}
export type ExtractOpts = Opts & {
/**
* Whether to throw an error if we had any issues with
* 1 of the source files
*/
throws?: boolean
/**
* Message ID interpolation pattern
*/
idInterpolationPattern?: string
/**
* Whether we read from stdin instead of a file
*/
readFromStdin?: boolean
/**
* Path to a formatter file that controls the shape of JSON file from `outFile`.
*/
format?: string | Formatter
/**
* Whether to hoist selectors & flatten sentences
*/
flatten?: boolean
} & Pick<Opts, 'onMsgExtracted' | 'onMetaExtracted'>
function calculateLineColFromOffset(
text: string,
start?: number
): Pick<ExtractedMessageDescriptor, 'line' | 'col'> {
if (!start) {
return {line: 1, col: 1}
}
const chunk = text.slice(0, start)
const lines = chunk.split('\n')
const lastLine = lines[lines.length - 1]
return {line: lines.length, col: lastLine.length}
}
function processFile(
source: string,
fn: string,
{idInterpolationPattern, ...opts}: Opts & {idInterpolationPattern?: string}
) {
let messages: ExtractedMessageDescriptor[] = []
let meta: Record<string, string> | undefined
opts = {
...opts,
additionalComponentNames: [
'$formatMessage',
...(opts.additionalComponentNames || []),
],
onMsgExtracted(_, msgs) {
if (opts.extractSourceLocation) {
msgs = msgs.map(msg => ({
...msg,
...calculateLineColFromOffset(source, msg.start),
}))
}
messages = messages.concat(msgs)
},
onMetaExtracted(_, m) {
meta = m
},
}
if (!opts.overrideIdFn && idInterpolationPattern) {
opts = {
...opts,
overrideIdFn: (id, defaultMessage, description, fileName) =>
id ||
interpolateName(
{
resourcePath: fileName,
} as any,
idInterpolationPattern,
{
content: description
? `${defaultMessage}#${description}`
: defaultMessage,
}
),
}
}
debug('Processing opts for %s: %s', fn, opts)
const scriptParseFn = parseScript(opts, fn)
if (fn.endsWith('.vue')) {
debug('Processing %s using vue extractor', fn)
parseFile(source, fn, scriptParseFn)
} else {
debug('Processing %s using typescript extractor', fn)
scriptParseFn(source)
}
debug('Done extracting %s messages: %s', fn, messages)
if (meta) {
debug('Extracted meta:', meta)
messages.forEach(m => (m.meta = meta))
}
return {messages, meta}
}
/**
* Extract strings from source files
* @param files list of files
* @param extractOpts extract options
* @returns messages serialized as JSON string since key order
* matters for some `format`
*/
export async function extract(
files: readonly string[],
extractOpts: ExtractOpts
) {
const {throws, readFromStdin, flatten, ...opts} = extractOpts
let rawResults: Array<ExtractionResult | undefined>
if (readFromStdin) {
debug(`Reading input from stdin`)
// Read from stdin
if (process.stdin.isTTY) {
warn('Reading source file from TTY.')
}
const stdinSource = await getStdinAsString()
rawResults = [processFile(stdinSource, 'dummy', opts)]
} else {
rawResults = await Promise.all(
files.map(async fn => {
debug('Extracting file:', fn)
try {
const source = await readFile(fn, 'utf8')
return processFile(source, fn, opts)
} catch (e) {
if (throws) {
throw e
} else {
warn(String(e))
}
}
})
)
}
const formatter = await resolveBuiltinFormatter(opts.format)
const extractionResults = rawResults.filter((r): r is ExtractionResult => !!r)
const extractedMessages = new Map<string, MessageDescriptor>()
for (const {messages} of extractionResults) {
for (const message of messages) {
const {id, description, defaultMessage} = message
if (!id) {
const error = new Error(
`[FormatJS CLI] Missing message id for message:
${JSON.stringify(message, undefined, 2)}`
)
if (throws) {
throw error
} else {
warn(error.message)
}
continue
}
if (extractedMessages.has(id)) {
const existing = extractedMessages.get(id)!
if (
description !== existing.description ||
defaultMessage !== existing.defaultMessage
) {
const error = new Error(
`[FormatJS CLI] Duplicate message id: "${id}", ` +
'but the `description` and/or `defaultMessage` are different.'
)
if (throws) {
throw error
} else {
warn(error.message)
}
}
}
extractedMessages.set(id, message)
}
}
const results: Record<string, Omit<MessageDescriptor, 'id'>> = {}
const messages = Array.from(extractedMessages.values())
for (const {id, ...msg} of messages) {
if (flatten && msg.defaultMessage) {
msg.defaultMessage = printAST(hoistSelectors(parse(msg.defaultMessage)))
}
results[id] = msg
}
return stringify(formatter.format(results), {
space: 2,
cmp: formatter.compareMessages || undefined,
})
}
/**
* Extract strings from source files, also writes to a file.
* @param files list of files
* @param extractOpts extract options
* @returns A Promise that resolves if output file was written successfully
*/
export default async function extractAndWrite(
files: readonly string[],
extractOpts: ExtractCLIOptions
) {
const {outFile, ...opts} = extractOpts
const serializedResult = (await extract(files, opts)) + '\n'
if (outFile) {
debug('Writing output file:', outFile)
return outputFile(outFile, serializedResult)
}
await writeStdout(serializedResult)
}