@_linked/translation 0.2.4 → 0.3.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.
@@ -13,7 +13,11 @@ import { canonicalLanguageTag } from '../key-version.js';
13
13
 
14
14
  const textDecoder = new TextDecoder('utf-8', { fatal: true });
15
15
  const textEncoder = new TextEncoder();
16
- const SIDECAR_PATH = 'create-now.exchange.json';
16
+ const SIDECAR_PATH = 'linked.exchange.json';
17
+ /** Pre-0.3.0 sidecar path and format id — read, never written. */
18
+ const LEGACY_SIDECAR_PATH = 'create-now.exchange.json';
19
+ const SIDECAR_FORMAT = 'linked-i18next-zip';
20
+ const LEGACY_SIDECAR_FORMAT = 'create-now-i18next-zip';
17
21
  const ZIP_LOCAL_FILE_SIGNATURE = 0x04034b50;
18
22
  const ZIP_EMPTY_SIGNATURE = 0x06054b50;
19
23
  const ZIP_SPANNED_SIGNATURE = 0x08074b50;
@@ -55,9 +59,9 @@ export interface TranslationJsonSerializeOptions {
55
59
  limits?: Partial<TranslationJsonArchiveLimits>;
56
60
  }
57
61
 
58
- interface CreateNowJsonSidecar {
62
+ interface TranslationJsonSidecar {
59
63
  schemaVersion: 1;
60
- format: 'create-now-i18next-zip';
64
+ format: typeof SIDECAR_FORMAT | typeof LEGACY_SIDECAR_FORMAT;
61
65
  exchange: TranslationExchangeDocument;
62
66
  files: Record<string, string>;
63
67
  }
@@ -356,21 +360,24 @@ function parseStandalone(
356
360
  });
357
361
  }
358
362
 
359
- function sidecarFromBytes(input: Uint8Array): CreateNowJsonSidecar {
360
- const value = parseJsonObject(input, SIDECAR_PATH) as unknown as Partial<
361
- CreateNowJsonSidecar
363
+ function sidecarFromBytes(
364
+ input: Uint8Array,
365
+ path: string,
366
+ ): TranslationJsonSidecar {
367
+ const value = parseJsonObject(input, path) as unknown as Partial<
368
+ TranslationJsonSidecar
362
369
  >;
363
370
  if (
364
371
  value.schemaVersion !== 1 ||
365
- value.format !== 'create-now-i18next-zip' ||
372
+ (value.format !== SIDECAR_FORMAT && value.format !== LEGACY_SIDECAR_FORMAT) ||
366
373
  !value.exchange ||
367
374
  !value.files ||
368
375
  typeof value.files !== 'object' ||
369
376
  Array.isArray(value.files)
370
377
  ) {
371
- throw new Error('Create Now JSON ZIP sidecar is invalid.');
378
+ throw new Error('Translation JSON ZIP sidecar is invalid.');
372
379
  }
373
- return value as CreateNowJsonSidecar;
380
+ return value as TranslationJsonSidecar;
374
381
  }
375
382
 
376
383
  function assertMapsEqual(
@@ -384,12 +391,12 @@ function assertMapsEqual(
384
391
  actualKeys.length !== expectedKeys.length ||
385
392
  actualKeys.some((key, index) => key !== expectedKeys[index])
386
393
  ) {
387
- throw new Error(`${label} keys do not match the Create Now sidecar.`);
394
+ throw new Error(`${label} keys do not match the exchange sidecar.`);
388
395
  }
389
396
  for (const key of expectedKeys) {
390
397
  if (actual[key] !== expected[key]) {
391
398
  throw new Error(
392
- `${label} value for "${key}" does not match the Create Now sidecar.`,
399
+ `${label} value for "${key}" does not match the exchange sidecar.`,
393
400
  );
394
401
  }
395
402
  }
@@ -400,11 +407,13 @@ function parseArchive(
400
407
  options: TranslationJsonParseOptions,
401
408
  ): ParsedTranslationExchangeDocument {
402
409
  const files = safeUnzipTranslationZip(input, options.limits);
403
- const sidecarBytes = files[SIDECAR_PATH];
410
+ const sidecarPath =
411
+ files[SIDECAR_PATH] !== undefined ? SIDECAR_PATH : LEGACY_SIDECAR_PATH;
412
+ const sidecarBytes = files[sidecarPath];
404
413
  if (!sidecarBytes) {
405
414
  return parseTolgeeArchive(files, options);
406
415
  }
407
- const sidecar = sidecarFromBytes(sidecarBytes);
416
+ const sidecar = sidecarFromBytes(sidecarBytes, sidecarPath);
408
417
  const exchange = validateTranslationExchangeDocument(sidecar.exchange);
409
418
  const expectedLanguages = [
410
419
  exchange.sourceLanguage,
@@ -418,22 +427,22 @@ function parseArchive(
418
427
  .sort()
419
428
  .some((language, index) => language !== fileLanguages[index])
420
429
  ) {
421
- throw new Error('Create Now JSON ZIP language manifest is incomplete.');
430
+ throw new Error('Translation JSON ZIP language manifest is incomplete.');
422
431
  }
423
432
  const usedPaths = new Set<string>();
424
433
  for (const language of expectedLanguages) {
425
434
  const path = sidecar.files[language];
426
435
  if (typeof path !== 'string') {
427
- throw new Error(`Create Now JSON ZIP has no file for ${language}.`);
436
+ throw new Error(`Translation JSON ZIP has no file for ${language}.`);
428
437
  }
429
438
  assertSafeArchivePath(path);
430
439
  if (usedPaths.has(path)) {
431
- throw new Error(`Create Now JSON ZIP reuses language file "${path}".`);
440
+ throw new Error(`Translation JSON ZIP reuses language file "${path}".`);
432
441
  }
433
442
  usedPaths.add(path);
434
443
  const bytes = files[path];
435
444
  if (!bytes) {
436
- throw new Error(`Create Now JSON ZIP is missing language file "${path}".`);
445
+ throw new Error(`Translation JSON ZIP is missing language file "${path}".`);
437
446
  }
438
447
  const actual = flattenJsonObject(parseJsonObject(bytes, path));
439
448
  const expected = languageValues(exchange, language);
@@ -534,9 +543,9 @@ export function serializeI18nextJsonZip(
534
543
  languageFiles[language] = path;
535
544
  files[path] = serializeLanguage(exchange, language, layout);
536
545
  }
537
- const sidecar: CreateNowJsonSidecar = {
546
+ const sidecar: TranslationJsonSidecar = {
538
547
  schemaVersion: 1,
539
- format: 'create-now-i18next-zip',
548
+ format: SIDECAR_FORMAT,
540
549
  exchange,
541
550
  files: languageFiles,
542
551
  };
@@ -10,7 +10,27 @@ import {
10
10
  import type { TranslationState } from '../records.js';
11
11
 
12
12
  const UTF8 = new TextEncoder();
13
- const CN_NAMESPACE = 'https://create-now.app/ns/translation-exchange/1';
13
+ /**
14
+ * Our own metadata namespace, a sibling of the translation vocabulary
15
+ * (`https://id.linked.cm/translation/vocab#`). Files exported before 0.3.0
16
+ * used a Create Now URI and a `cn:` prefix; the reader below still accepts
17
+ * both, so those files keep importing.
18
+ */
19
+ const EXCHANGE_NAMESPACE = 'https://id.linked.cm/translation/exchange/1';
20
+ const PREFIX = 'lt';
21
+ const LEGACY_PREFIX = 'cn';
22
+ /** `from`/`category` on a `<note>`, in current and pre-0.3.0 spelling. */
23
+ const NOTE_ROLES = {
24
+ description: ['linked-description', 'create-now-description'],
25
+ target: ['linked-target', 'create-now-target'],
26
+ } as const;
27
+
28
+ function isNoteRole(
29
+ value: string | undefined,
30
+ role: keyof typeof NOTE_ROLES,
31
+ ): boolean {
32
+ return value !== undefined && (NOTE_ROLES[role] as readonly string[]).includes(value);
33
+ }
14
34
  const XLIFF_12_NAMESPACE = 'urn:oasis:names:tc:xliff:document:1.2';
15
35
  const XLIFF_20_NAMESPACE = 'urn:oasis:names:tc:xliff:document:2.0';
16
36
  const XML_MEDIA_TYPE = 'application/xliff+xml';
@@ -301,7 +321,11 @@ function plainText(node: XmlNode | undefined): string | undefined {
301
321
  }
302
322
 
303
323
  function metadata(node: XmlNode, name: string): string | undefined {
304
- return node.attributes[`cn:${name}`] ?? attribute(node, name);
324
+ return (
325
+ node.attributes[`${PREFIX}:${name}`] ??
326
+ node.attributes[`${LEGACY_PREFIX}:${name}`] ??
327
+ attribute(node, name)
328
+ );
305
329
  }
306
330
 
307
331
  function recognizedState(value: string | undefined): TranslationState | undefined {
@@ -419,12 +443,12 @@ function parse12(
419
443
  if (language) targetLanguages.add(language);
420
444
  const notes = children(unit, 'note');
421
445
  const description = plainText(
422
- notes.find((note) => attribute(note, 'from') === 'create-now-description'),
446
+ notes.find((note) => isNoteRole(attribute(note, 'from'), 'description')),
423
447
  );
424
448
  const targetNote = plainText(
425
449
  notes.find(
426
450
  (note) =>
427
- attribute(note, 'from') === 'create-now-target' &&
451
+ isNoteRole(attribute(note, 'from'), 'target') &&
428
452
  (!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
429
453
  ),
430
454
  );
@@ -475,13 +499,13 @@ function parse20(
475
499
  const notes = notesContainer ? children(notesContainer, 'note') : [];
476
500
  const description = plainText(
477
501
  notes.find(
478
- (note) => attribute(note, 'category') === 'create-now-description',
502
+ (note) => isNoteRole(attribute(note, 'category'), 'description'),
479
503
  ),
480
504
  );
481
505
  const targetNote = plainText(
482
506
  notes.find(
483
507
  (note) =>
484
- attribute(note, 'category') === 'create-now-target' &&
508
+ isNoteRole(attribute(note, 'category'), 'target') &&
485
509
  (!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
486
510
  ),
487
511
  );
@@ -545,14 +569,14 @@ function state20(state: TranslationState | undefined): string {
545
569
 
546
570
  function metadataAttributes(entry: TranslationExchangeEntry): string {
547
571
  return [
548
- ['cn:key', entry.key],
549
- ['cn:namespace', entry.namespace],
550
- ['cn:kind', entry.kind],
551
- ['cn:format', entry.format],
552
- ['cn:key-version-id', entry.keyVersionId],
553
- ['cn:source-hash', entry.sourceHash],
554
- ['cn:contract-hash', entry.contractHash],
555
- ['cn:argument-signature', entry.argumentSignature],
572
+ [`${PREFIX}:key`, entry.key],
573
+ [`${PREFIX}:namespace`, entry.namespace],
574
+ [`${PREFIX}:kind`, entry.kind],
575
+ [`${PREFIX}:format`, entry.format],
576
+ [`${PREFIX}:key-version-id`, entry.keyVersionId],
577
+ [`${PREFIX}:source-hash`, entry.sourceHash],
578
+ [`${PREFIX}:contract-hash`, entry.contractHash],
579
+ [`${PREFIX}:argument-signature`, entry.argumentSignature],
556
580
  ]
557
581
  .filter((pair): pair is [string, string] => pair[1] !== undefined)
558
582
  .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
@@ -561,12 +585,12 @@ function metadataAttributes(entry: TranslationExchangeEntry): string {
561
585
 
562
586
  function rootMetadata(document: TranslationExchangeDocument): string {
563
587
  return [
564
- ['cn:schema-version', String(document.schemaVersion)],
565
- ['cn:app-id', document.appId],
566
- ['cn:branch-id', document.branchId],
567
- ['cn:exported-at', document.exportedAt],
568
- ['cn:revision-watermark', document.revisionWatermark],
569
- ['cn:contract-set-hash', document.contractSetHash],
588
+ [`${PREFIX}:schema-version`, String(document.schemaVersion)],
589
+ [`${PREFIX}:app-id`, document.appId],
590
+ [`${PREFIX}:branch-id`, document.branchId],
591
+ [`${PREFIX}:exported-at`, document.exportedAt],
592
+ [`${PREFIX}:revision-watermark`, document.revisionWatermark],
593
+ [`${PREFIX}:contract-set-hash`, document.contractSetHash],
570
594
  ]
571
595
  .filter((pair): pair is [string, string] => pair[1] !== undefined)
572
596
  .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
@@ -611,25 +635,25 @@ function serialize12(document: TranslationExchangeDocument): string {
611
635
  const id = deterministicId(entry, index);
612
636
  const notes = [
613
637
  entry.description
614
- ? `<note from="create-now-description">${escapeText(entry.description)}</note>`
638
+ ? `<note from="${NOTE_ROLES.description[0]}">${escapeText(entry.description)}</note>`
615
639
  : '',
616
640
  target?.note
617
- ? `<note from="create-now-target" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
641
+ ? `<note from="${NOTE_ROLES.target[0]}" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
618
642
  : '',
619
643
  ].join('');
620
644
  return `<trans-unit id="${escapeAttribute(id)}" resname="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}><source>${validatedMixedContent(entry.sourceText, INLINE_12)}</source>${
621
645
  language && target
622
- ? `<target xml:lang="${escapeAttribute(language)}" state="${state12(target?.state)}" cn:state="${escapeAttribute(target?.state ?? 'untranslated')}">${validatedMixedContent(target?.text ?? '', INLINE_12)}</target>`
646
+ ? `<target xml:lang="${escapeAttribute(language)}" state="${state12(target?.state)}" ${PREFIX}:state="${escapeAttribute(target?.state ?? 'untranslated')}">${validatedMixedContent(target?.text ?? '', INLINE_12)}</target>`
623
647
  : ''
624
648
  }${notes}</trans-unit>`;
625
649
  })
626
650
  .join('');
627
- return `<file id="f${fileIndex + 1}" original="create-now" datatype="plaintext" source-language="${escapeAttribute(document.sourceLanguage)}"${
651
+ return `<file id="f${fileIndex + 1}" original="linked" datatype="plaintext" source-language="${escapeAttribute(document.sourceLanguage)}"${
628
652
  language ? ` target-language="${escapeAttribute(language)}"` : ''
629
653
  }><body>${units}</body></file>`;
630
654
  })
631
655
  .join('');
632
- return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_12_NAMESPACE}" xmlns:cn="${CN_NAMESPACE}" version="1.2"${rootMetadata(document)}>${files}</xliff>\n`;
656
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_12_NAMESPACE}" xmlns:${PREFIX}="${EXCHANGE_NAMESPACE}" version="1.2"${rootMetadata(document)}>${files}</xliff>\n`;
633
657
  }
634
658
 
635
659
  function serialize20(document: TranslationExchangeDocument): string {
@@ -643,15 +667,15 @@ function serialize20(document: TranslationExchangeDocument): string {
643
667
  entry.description || target?.note
644
668
  ? `<notes>${
645
669
  entry.description
646
- ? `<note category="create-now-description">${escapeText(entry.description)}</note>`
670
+ ? `<note category="${NOTE_ROLES.description[0]}">${escapeText(entry.description)}</note>`
647
671
  : ''
648
672
  }${
649
673
  target?.note
650
- ? `<note category="create-now-target" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
674
+ ? `<note category="${NOTE_ROLES.target[0]}" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
651
675
  : ''
652
676
  }</notes>`
653
677
  : '';
654
- return `<unit id="${escapeAttribute(id)}" name="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}>${notes}<segment id="s1" state="${state20(target?.state)}" cn:state="${escapeAttribute(target?.state ?? 'untranslated')}"><source>${validatedMixedContent(entry.sourceText, INLINE_20)}</source>${
678
+ return `<unit id="${escapeAttribute(id)}" name="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}>${notes}<segment id="s1" state="${state20(target?.state)}" ${PREFIX}:state="${escapeAttribute(target?.state ?? 'untranslated')}"><source>${validatedMixedContent(entry.sourceText, INLINE_20)}</source>${
655
679
  language && target
656
680
  ? `<target xml:lang="${escapeAttribute(language)}">${validatedMixedContent(target?.text ?? '', INLINE_20)}</target>`
657
681
  : ''
@@ -659,7 +683,7 @@ function serialize20(document: TranslationExchangeDocument): string {
659
683
  })
660
684
  .join('');
661
685
  return `<file id="f${fileIndex + 1}"${
662
- language ? ` cn:target-language="${escapeAttribute(language)}"` : ''
686
+ language ? ` ${PREFIX}:target-language="${escapeAttribute(language)}"` : ''
663
687
  }>${units}</file>`;
664
688
  })
665
689
  .join('');
@@ -667,7 +691,7 @@ function serialize20(document: TranslationExchangeDocument): string {
667
691
  document.targetLanguages.length === 1
668
692
  ? ` trgLang="${escapeAttribute(document.targetLanguages[0])}"`
669
693
  : '';
670
- return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_20_NAMESPACE}" xmlns:cn="${CN_NAMESPACE}" version="2.0" srcLang="${escapeAttribute(document.sourceLanguage)}"${singleTarget}${rootMetadata(document)}>${files}</xliff>\n`;
694
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_20_NAMESPACE}" xmlns:${PREFIX}="${EXCHANGE_NAMESPACE}" version="2.0" srcLang="${escapeAttribute(document.sourceLanguage)}"${singleTarget}${rootMetadata(document)}>${files}</xliff>\n`;
671
695
  }
672
696
 
673
697
  function sniffVersion(
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Isomorphic barrel (Plan 014 AD-G): side-effect imports register the ontology
2
2
  // + shapes into the LINKED tree. Safe on client and server — no backend/Node
3
3
  // code here (that lives in ./backend.ts). Ontology FIRST, then shapes.
4
- import './ontologies/translation.js';
4
+ import './ontologies/translation.register.js';
5
5
  import './shapes/TranslationKey.js';
6
6
  import './shapes/TranslationKeyVersion.js';
7
7
  import './shapes/TranslationRelease.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Registers this ontology.
3
+ *
4
+ * Kept out of `translation.ts` because registration needs that module's whole export
5
+ * namespace, and a module cannot import itself once a bundler is involved: Rollup
6
+ * treats a static self-reference as a circular import and elides it, so the binding is
7
+ * undefined at runtime and the consuming app dies at boot with `_this is not
8
+ * defined`. `tsc` preserves it, which is why the pattern survived for as long as
9
+ * packages were built with `tsc` alone.
10
+ *
11
+ * From a sibling module the same import is ordinary and survives.
12
+ */
13
+ import * as terms from './translation.js';
14
+ import {loadData, ns} from './translation.js';
15
+ import {linkedOntology} from '../package.js';
16
+
17
+ linkedOntology(terms, ns, 'translation', loadData, '../data/translation.json');
@@ -1,6 +1,4 @@
1
1
  import { createNameSpace } from '@_linked/core/utils/NameSpace';
2
- import { linkedOntology } from '../package.js';
3
- import * as _this from './translation.js';
4
2
 
5
3
  /** Lazy-load the (currently empty) serialized ontology graph. */
6
4
  export var loadData = () => {
@@ -231,4 +229,3 @@ export const tr = {
231
229
  inventoryObservedAt,
232
230
  };
233
231
 
234
- linkedOntology(_this, ns, 'translation', loadData, '../data/translation.json');
@@ -1426,7 +1426,7 @@ export class TranslationProvider extends ShapeProvider {
1426
1426
 
1427
1427
  /**
1428
1428
  * Authoring gate. Reads `linkedAuth` off the request the LINKED server already
1429
- * populates — framework-level, so this stays portable (no CN/create-now-js
1429
+ * populates — framework-level, so this stays portable (no Create Now
1430
1430
  * import, AD-G). Anonymous callers can no longer read or write an app's
1431
1431
  * translation content via `/call`. Fine-grained "is translation enabled for
1432
1432
  * this app + is the caller a project member" gating is a Create Now concern and