scratch-l10n 6.1.103 → 6.1.105

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-l10n",
3
- "version": "6.1.103",
3
+ "version": "6.1.105",
4
4
  "description": "Localization for the Scratch 3.0 components",
5
5
  "main": "./dist/l10n.js",
6
6
  "browser": "./src/index.mjs",
@@ -55,7 +55,7 @@
55
55
  "lodash.defaultsdeep": "4.6.1",
56
56
  "mkdirp": "3.0.1",
57
57
  "transifex": "1.6.6",
58
- "tsx": "4.23.4"
58
+ "tsx": "4.23.8"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/cli": "7.29.7",
@@ -76,7 +76,7 @@
76
76
  "eslint": "9.39.5",
77
77
  "eslint-config-scratch": "11.0.53",
78
78
  "format-message-cli": "6.2.4",
79
- "globals": "17.8.0",
79
+ "globals": "17.9.0",
80
80
  "husky": "8.0.3",
81
81
  "jshint": "2.13.6",
82
82
  "json": "9.0.6",
@@ -84,7 +84,7 @@
84
84
  "prettier": "3.9.6",
85
85
  "rimraf": "2.7.1",
86
86
  "scratch-semantic-release-config": "4.0.1",
87
- "semantic-release": "25.0.8",
87
+ "semantic-release": "25.0.9",
88
88
  "webpack": "4.47.0",
89
89
  "webpack-cli": "3.3.12"
90
90
  },
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file
3
+ * Emit GitHub Actions workflow annotations for problems found during a run, so they surface at the
4
+ * top of the run page (and inline in the log) instead of only in the raw output. A no-op outside
5
+ * GitHub Actions, so local runs stay quiet.
6
+ */
7
+
8
+ /** True when running inside a GitHub Actions runner, where `::error::`/`::warning::` are meaningful. */
9
+ const IN_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'
10
+
11
+ /**
12
+ * GitHub surfaces only about the first ten annotations of each level per step in the run UI. A
13
+ * first-time full sync can fail on many items; emitting hundreds would be truncated there anyway and
14
+ * just spams the log, so cap what we emit and note once when the cap is reached. The full, unbounded
15
+ * list still goes to stderr and the failure summary.
16
+ */
17
+ const MAX_ANNOTATIONS_PER_LEVEL = 10
18
+
19
+ type AnnotationLevel = 'error' | 'warning' | 'notice'
20
+
21
+ const emittedByLevel: Record<AnnotationLevel, number> = { error: 0, warning: 0, notice: 0 }
22
+
23
+ // In a workflow command's message, only `%`, CR and LF are special and must be encoded. The order
24
+ // matters: encode `%` first so the escapes we add are not themselves re-encoded.
25
+ const escapeData = (value: string): string => value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
26
+
27
+ // A property value (such as `title`) additionally encodes `:` and `,`.
28
+ const escapeProperty = (value: string): string => escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C')
29
+
30
+ /**
31
+ * Emit a single GitHub Actions annotation. No-op outside Actions and after the per-level display cap
32
+ * is reached (the full list still reaches stderr and the failure summary).
33
+ * @param annotation - the annotation to emit
34
+ * @param annotation.level - annotation severity (`error`, `warning`, or `notice`)
35
+ * @param annotation.message - the annotation body; may contain newlines and a URL
36
+ * @param annotation.title - optional short bold header (for example, the failing item)
37
+ */
38
+ export const emitAnnotation = (annotation: { level: AnnotationLevel; message: string; title?: string }): void => {
39
+ const { level, message, title } = annotation
40
+ if (!IN_GITHUB_ACTIONS) {
41
+ return
42
+ }
43
+ emittedByLevel[level]++
44
+ if (emittedByLevel[level] > MAX_ANNOTATIONS_PER_LEVEL) {
45
+ if (emittedByLevel[level] === MAX_ANNOTATIONS_PER_LEVEL + 1) {
46
+ // Say so once; the rest are in the log and the summary rather than the annotations UI.
47
+ console.log(
48
+ `Reached the GitHub annotation display limit for ${level}s; further ${level}s appear in the log only.`,
49
+ )
50
+ }
51
+ return
52
+ }
53
+ const titlePart = title ? ` title=${escapeProperty(title)}` : ''
54
+ process.stdout.write(`::${level}${titlePart}::${escapeData(message)}\n`)
55
+ }
@@ -10,3 +10,51 @@
10
10
  * @returns the error message, or a string representation of a non-Error throw
11
11
  */
12
12
  export const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
13
+
14
+ /**
15
+ * Describe where a `JSON.parse` failed, for a diagnostic message. V8 reports the byte offset (and, on
16
+ * newer Node, the line and column) in the error message; combined with the original text this pins the
17
+ * problem to a snippet and, for a flat `{"key": "value"}` file, the key whose value is malformed.
18
+ * Best-effort: returns undefined when the error is not a positioned JSON parse error or the text can't
19
+ * be read, so callers fall back to the plain message.
20
+ * @param error - the caught value (expected to be a `SyntaxError` from `JSON.parse`)
21
+ * @param text - the exact string that was parsed
22
+ * @returns a one-line description (location, nearby key, snippet), or undefined
23
+ */
24
+ export const describeJsonParseError = (error: unknown, text: string): string | undefined => {
25
+ if (!(error instanceof SyntaxError) || typeof text !== 'string') {
26
+ return undefined
27
+ }
28
+ const posMatch = /in JSON at position (\d+)/.exec(error.message)
29
+ if (!posMatch) {
30
+ return undefined
31
+ }
32
+ const pos = Number(posMatch[1])
33
+ if (!Number.isFinite(pos)) {
34
+ return undefined
35
+ }
36
+ const lineColMatch = /\(line (\d+) column (\d+)\)/.exec(error.message)
37
+ const where = lineColMatch ? `line ${lineColMatch[1]}, column ${lineColMatch[2]}` : `position ${pos}`
38
+ // Recover the enclosing key of a flat KEYVALUEJSON object: the value that failed to parse is
39
+ // preceded by `"<key>":`, so scan back from the error position to that key. Heuristic (a colon or
40
+ // quote inside an earlier string can fool it), so it is reported as "near key", not an exact locator.
41
+ let nearKey: string | undefined
42
+ const before = text.slice(0, pos)
43
+ const colon = before.lastIndexOf(':')
44
+ if (colon > 0) {
45
+ const keyClose = before.lastIndexOf('"', colon)
46
+ if (keyClose > 0) {
47
+ const keyOpen = before.lastIndexOf('"', keyClose - 1)
48
+ if (keyOpen >= 0) {
49
+ nearKey = before.slice(keyOpen + 1, keyClose)
50
+ }
51
+ }
52
+ }
53
+ // A short, whitespace-collapsed window around the failure so the operator can eyeball the bad text.
54
+ const snippet = text
55
+ .slice(Math.max(0, pos - 30), pos + 30)
56
+ .replace(/\s+/g, ' ')
57
+ .trim()
58
+ const keyPart = nearKey ? ` near key "${nearKey}"` : ''
59
+ return `malformed JSON at ${where}${keyPart}: …${snippet}…`
60
+ }
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import { promises as fsPromises } from 'fs'
6
6
  import { mkdirp } from 'mkdirp'
7
- import { messageOf } from './errors.mts'
7
+ import { emitAnnotation } from './annotations.mts'
8
+ import { describeJsonParseError, messageOf } from './errors.mts'
8
9
  import FreshdeskApi, {
9
10
  FreshdeskArticleCreate,
10
11
  FreshdeskArticleStatus,
@@ -14,7 +15,15 @@ import FreshdeskApi, {
14
15
  import { loadBaseline, saveBaseline, SyncBaseline } from './sync-baseline.mts'
15
16
  import { TransifexStringKeyValueJson, TransifexStringsKeyValueJson, TransifexStrings } from './transifex-formats.mts'
16
17
  import { TransifexResourceObject } from './transifex-objects.mts'
17
- import { txPull, txResourcesObjects, txAvailableLanguages, txResourceLanguageStats } from './transifex.mts'
18
+ import {
19
+ txPull,
20
+ txResourcesObjects,
21
+ txAvailableLanguages,
22
+ txResourceLanguageStats,
23
+ transifexEditorLink,
24
+ txPullErrorCause,
25
+ SOURCE_LOCALE,
26
+ } from './transifex.mts'
18
27
  import { emitWarning } from './warnings.mts'
19
28
 
20
29
  const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN ?? '')
@@ -25,12 +34,30 @@ const TX_PROJECT = 'scratch-help'
25
34
  // transparently by the client (paced requests, `Retry-After`-aware retries), so anything that reaches
26
35
  // here is a genuine problem worth a human's attention. `reportFailures` prints a consolidated summary
27
36
  // and fails the run at the end, so real errors are surfaced rather than lost in the log.
28
- const failures: { context: string; message: string }[] = []
29
- const recordFailure = (context: string, error: unknown): void => {
37
+ const failures: { context: string; message: string; link?: string }[] = []
38
+ const recordFailure = (context: string, error: unknown, link?: string): void => {
30
39
  const message = messageOf(error)
40
+ // A txPull error carries the failing (resource, locale) — and, for a parse failure, the raw text —
41
+ // on its `cause`, so we can build a Transifex link and describe malformed JSON without the call site
42
+ // passing anything. An explicit link still wins: some callers know the fix lives on the source (`en`)
43
+ // rather than the translation locale the pull used.
44
+ const cause = txPullErrorCause(error)
45
+ const resolvedLink =
46
+ link ??
47
+ (cause
48
+ ? transifexEditorLink({ project: cause.project, resource: cause.resource, lang: cause.locale })
49
+ : undefined)
50
+ const detail = cause?.buffer != null ? describeJsonParseError(error, cause.buffer) : undefined
51
+ const fullMessage = detail ? `${message} — ${detail}` : message
31
52
  // Genuine failures go to stderr (like the consolidated summary), so they stand apart from normal output.
32
- process.stderr.write(`Error: ${context}: ${message}\n`)
33
- failures.push({ context, message })
53
+ process.stderr.write(`Error: ${context}: ${fullMessage}${resolvedLink ? ` (${resolvedLink})` : ''}\n`)
54
+ failures.push({ context, message: fullMessage, link: resolvedLink })
55
+ // Surface it on the run page too (no-op off CI). The full list still lives in stderr and the summary.
56
+ emitAnnotation({
57
+ level: 'error',
58
+ title: context,
59
+ message: resolvedLink ? `${fullMessage}\n${resolvedLink}` : fullMessage,
60
+ })
34
61
  }
35
62
 
36
63
  /**
@@ -43,7 +70,7 @@ export const reportFailures = (): void => {
43
70
  }
44
71
  console.error(`\n${failures.length} item(s) failed to sync to Freshdesk:`)
45
72
  for (const failure of failures) {
46
- console.error(` - ${failure.context}: ${failure.message}`)
73
+ console.error(` - ${failure.context}: ${failure.message}${failure.link ? ` (${failure.link})` : ''}`)
47
74
  }
48
75
  process.exitCode = 1
49
76
  }
@@ -386,6 +413,8 @@ const serializeNameSave = async (
386
413
  warnedKeys.add(warnedKey)
387
414
  emitWarning(
388
415
  `Warning: key "${key}" in Transifex resource "${resource.attributes.name}" refers to Freshdesk id ${id} which no longer exists. Remove this key from the Transifex resource.`,
416
+ // The key is removed from the source, so point at the source language, not a translation.
417
+ transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: SOURCE_LOCALE }),
389
418
  )
390
419
  }
391
420
  continue
@@ -409,7 +438,11 @@ const serializeNameSave = async (
409
438
  } catch (error) {
410
439
  // Record the failure so the job still reports a non-zero exit at the end, then move on to the
411
440
  // next entry.
412
- recordFailure(`${resource.attributes.name} entry "${key}" for ${locale}`, error)
441
+ recordFailure(
442
+ `${resource.attributes.name} entry "${key}" for ${locale}`,
443
+ error,
444
+ transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: locale }),
445
+ )
413
446
  allOk = false
414
447
  }
415
448
  }
@@ -430,11 +463,13 @@ interface FreshdeskFolderInTransifex {
430
463
  * Internal function serialize Freshdesk requests to avoid getting rate limited
431
464
  * @param json object with keys corresponding to article ids
432
465
  * @param locale language code
466
+ * @param resourceSlug Transifex resource slug for these articles, used to build editor links in warnings and failures
433
467
  * @returns true if every article saved without a failure
434
468
  */
435
469
  const serializeFolderSave = async (
436
470
  json: TransifexStrings<FreshdeskFolderInTransifex>,
437
471
  locale: string,
472
+ resourceSlug: string,
438
473
  ): Promise<boolean> => {
439
474
  let allOk = true
440
475
  for (const [idString, value] of Object.entries(json)) {
@@ -457,6 +492,7 @@ const serializeFolderSave = async (
457
492
  `(${locale}); shorten them in Transifex: ${droppedTags
458
493
  .map(tag => `"${tag}" (${tag.length} chars)`)
459
494
  .join(', ')}.`,
495
+ transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }),
460
496
  )
461
497
  }
462
498
  body.tags = validTags
@@ -470,7 +506,11 @@ const serializeFolderSave = async (
470
506
  } catch (error) {
471
507
  // Record the failure so the job still reports a non-zero exit at the end, then move on to the
472
508
  // next article.
473
- recordFailure(`article ${idString} for ${locale}`, error)
509
+ recordFailure(
510
+ `article ${idString} for ${locale}`,
511
+ error,
512
+ transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }),
513
+ )
474
514
  allOk = false
475
515
  }
476
516
  }
@@ -491,7 +531,7 @@ export const localizeFolder = async (folderAttributes: TransifexResourceObject,
491
531
  locale,
492
532
  'default',
493
533
  )
494
- return await serializeFolderSave(data, locale)
534
+ return await serializeFolderSave(data, locale, folderAttributes.attributes.slug)
495
535
  } catch (e) {
496
536
  recordFailure(`${folderAttributes.attributes.slug} for ${locale}`, e)
497
537
  return false
@@ -579,7 +619,11 @@ export const saveItem = async (item: TransifexResourceObject, languages: string[
579
619
  markSynced(slug, l)
580
620
  }
581
621
  } catch (err) {
582
- recordFailure(`saving ${slug} for ${l}`, err)
622
+ recordFailure(
623
+ `saving ${slug} for ${l}`,
624
+ err,
625
+ transifexEditorLink({ project: TX_PROJECT, resource: slug, lang: l }),
626
+ )
583
627
  }
584
628
  }),
585
629
  )
@@ -12,7 +12,63 @@ import {
12
12
  } from './transifex-objects.mts'
13
13
 
14
14
  const ORG_NAME = 'llk'
15
- const SOURCE_LOCALE = 'en'
15
+ export const SOURCE_LOCALE = 'en'
16
+
17
+ /**
18
+ * Build a deep link to the Transifex online editor for a resource in one language, matching the URL
19
+ * the web app uses: `app.transifex.com/<org>/<project>/translate/#<lang>/<resource>[/<stringId>]`.
20
+ * @param params - link target
21
+ * @param params.project - project slug (for example, `scratch-help`)
22
+ * @param params.resource - resource slug (for example, `Accessibility_4000040849_json`)
23
+ * @param params.lang - language code: the source ({@link SOURCE_LOCALE}) for a source-side fix, or the
24
+ * translation locale for a bad translation
25
+ * @param params.stringId - optional Transifex numeric string id to focus a single string; omitted, the
26
+ * link lands on the resource (with its first string shown)
27
+ * @returns the editor URL
28
+ */
29
+ export const transifexEditorLink = (params: {
30
+ project: string
31
+ resource: string
32
+ lang: string
33
+ stringId?: string | number
34
+ }): string => {
35
+ const { project, resource, lang, stringId } = params
36
+ const base = `https://app.transifex.com/${ORG_NAME}/${project}/translate/#${lang}/${resource}`
37
+ return stringId == null ? base : `${base}/${stringId}`
38
+ }
39
+
40
+ /**
41
+ * Shape attached to a {@link txPull} error's `cause`, carrying the context needed to locate and link
42
+ * the failing (resource, locale) in Transifex (and, for a parse failure, the raw file that failed).
43
+ */
44
+ export interface TxPullErrorCause {
45
+ project: string
46
+ resource: string
47
+ locale: string
48
+ buffer: string | null
49
+ }
50
+
51
+ /**
52
+ * Read the {@link TxPullErrorCause} that {@link txPull} attaches to its errors, if present, so a
53
+ * failure handler can build a Transifex link (and read the raw buffer for a parse failure) without the
54
+ * call site threading that context through.
55
+ * @param error - the caught value
56
+ * @returns the cause, or undefined if the error did not come from txPull
57
+ */
58
+ export const txPullErrorCause = (error: unknown): TxPullErrorCause | undefined => {
59
+ const cause = (error as { cause?: unknown } | null)?.cause
60
+ if (
61
+ cause &&
62
+ typeof cause === 'object' &&
63
+ typeof (cause as TxPullErrorCause).project === 'string' &&
64
+ typeof (cause as TxPullErrorCause).resource === 'string' &&
65
+ typeof (cause as TxPullErrorCause).locale === 'string' &&
66
+ (typeof (cause as TxPullErrorCause).buffer === 'string' || (cause as TxPullErrorCause).buffer === null)
67
+ ) {
68
+ return cause as TxPullErrorCause
69
+ }
70
+ return undefined
71
+ }
16
72
 
17
73
  if (!process.env.TX_TOKEN) {
18
74
  throw new Error('TX_TOKEN is not defined.')
@@ -3,24 +3,30 @@
3
3
  * Shared helper for reporting non-fatal problems during help sync.
4
4
  */
5
5
  import { appendFileSync } from 'fs'
6
+ import { emitAnnotation } from './annotations.mts'
6
7
  import { messageOf } from './errors.mts'
7
8
 
8
9
  /**
9
10
  * Log a warning to the console and, when the `WARNINGS_FILE` environment variable is set, append it
10
11
  * to that file. CI reads the file after the sync to surface warnings in the job summary and to send
11
12
  * a notification, so a warning is the right tool for a problem worth a human's attention that should
12
- * not fail the run (for example, a resource we deliberately skip).
13
+ * not fail the run (for example, a resource we deliberately skip). Also emits a GitHub Actions
14
+ * warning annotation (a no-op off CI) so the problem shows on the run page, not just in the log.
13
15
  * @param warning - the warning message; a trailing newline is added when written to the file
16
+ * @param link - optional convenience URL (for example, a Transifex editor deep link) appended to the
17
+ * warning so a reader can jump straight to where the fix is made
14
18
  */
15
- export const emitWarning = (warning: string): void => {
16
- console.warn(warning)
19
+ export const emitWarning = (warning: string, link?: string): void => {
20
+ const line = link ? `${warning} (${link})` : warning
21
+ console.warn(line)
17
22
  if (process.env.WARNINGS_FILE) {
18
23
  // The file write is best-effort: emitWarning is the non-fatal path (often called from a catch
19
24
  // block), so a failed append must not turn a warning into a crash.
20
25
  try {
21
- appendFileSync(process.env.WARNINGS_FILE, warning + '\n')
26
+ appendFileSync(process.env.WARNINGS_FILE, line + '\n')
22
27
  } catch (error) {
23
28
  console.warn(`Could not append to WARNINGS_FILE "${process.env.WARNINGS_FILE}": ${messageOf(error)}`)
24
29
  }
25
30
  }
31
+ emitAnnotation({ level: 'warning', message: line })
26
32
  }