@zerolith/i18n 0.1.0-alpha.1 → 0.1.0-alpha.10

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/src/compile.ts CHANGED
@@ -25,6 +25,7 @@ export interface CompileOptions
25
25
  /** The result of compiling one catalog. */
26
26
  export interface CompiledCatalog
27
27
  {
28
+ /** The generated module's source, TypeScript or JavaScript as the `language` option chose; the `zerolith-i18n` CLI writes it to `<locale>.ts` or `<locale>.js`. */
28
29
  code: string;
29
30
  /** The message keys as function names, in source order. */
30
31
  names: string[];
@@ -414,6 +415,40 @@ const NUMBER_OPTIONS: Record<string, string> = {
414
415
  numberingSystem: "numberingSystem"
415
416
  };
416
417
 
418
+ /** Names a module cannot bind; a message named so is exported under its name from a binding of its own. */
419
+ const RESERVED: ReadonlySet<string> = new Set(["arguments", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "eval", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield"]);
420
+
421
+ /** Characters an HTML parser reads inside an inline script, and the line separators older engines refused in string literals. */
422
+ const SCRIPT_UNSAFE = new RegExp(`[<>${String.fromCodePoint(0x2028, 0x2029)}]`, "g");
423
+
424
+ /** The line breaks of a message's source, each starting a new line of its doc comment. */
425
+ const SOURCE_LINES = /\r\n|[\n\r\u2028\u2029]/;
426
+
427
+ /**
428
+ * A value written as JavaScript source: its JSON, with `<`, `>`, U+2028, and
429
+ * U+2029 escaped, so generated code stays inert even when a page inlines
430
+ * it in a script element.
431
+ */
432
+ function jsLiteral(value: unknown): string
433
+ {
434
+ return JSON.stringify(value).replace(SCRIPT_UNSAFE, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
435
+ }
436
+
437
+ /** Text for a `//` comment, kept on one line and with `<` written as `&lt;`, so it can end neither the comment nor an inline script. */
438
+ function commentText(text: string): string
439
+ {
440
+ return text.split(SOURCE_LINES).join(" ").replaceAll("<", "&lt;");
441
+ }
442
+
443
+ /** An argument or local name JavaScript can write as it is; MessageFormat also allows `-` and `.`, as in `$first-name`. */
444
+ const PLAIN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
445
+
446
+ /** A property name as code: as it is when plain, quoted otherwise. */
447
+ function property(name: string): string
448
+ {
449
+ return PLAIN_NAME.test(name) ? name : jsLiteral(name);
450
+ }
451
+
417
452
  function identifier(key: string): string
418
453
  {
419
454
  const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
@@ -428,11 +463,29 @@ class Emitter
428
463
  readonly prelude: string[] = [];
429
464
  private readonly key: string;
430
465
  private readonly message: Message;
466
+ private readonly formatters: Map<string, string>;
467
+ private readonly used: Set<Helper>;
468
+ private reads = 0;
431
469
 
432
- constructor(key: string, message: Message)
470
+ constructor(key: string, message: Message, formatters: Map<string, string>, used: Set<Helper>)
433
471
  {
434
472
  this.key = key;
435
473
  this.message = message;
474
+ this.formatters = formatters;
475
+ this.used = used;
476
+ }
477
+
478
+ /** The module-level binding of a formatter whose options are known at compile time, created on first use and shared by every message that creates it the same way. */
479
+ private shared(create: string): string
480
+ {
481
+ this.used.add("once");
482
+ let binding = this.formatters.get(create);
483
+ if (binding === undefined)
484
+ {
485
+ binding = `$f${this.formatters.size}`;
486
+ this.formatters.set(create, binding);
487
+ }
488
+ return binding;
436
489
  }
437
490
 
438
491
  private fail(text: string): never
@@ -442,6 +495,7 @@ class Emitter
442
495
 
443
496
  private argument(name: string, type: ArgumentType): string
444
497
  {
498
+ this.reads++;
445
499
  const local = this.locals.get(name);
446
500
  if (local !== undefined)
447
501
  {
@@ -456,7 +510,7 @@ class Emitter
456
510
  {
457
511
  this.fail(`$${name} is used both as ${existing} and as ${type}`);
458
512
  }
459
- return `args.${name}`;
513
+ return PLAIN_NAME.test(name) ? `args.${name}` : `args[${jsLiteral(name)}]`;
460
514
  }
461
515
 
462
516
  private optionValue(value: Literal | Variable | undefined): string | undefined
@@ -465,7 +519,7 @@ class Emitter
465
519
  {
466
520
  return undefined;
467
521
  }
468
- return value.kind === "literal" ? JSON.stringify(value.value) : `String(${this.argument(value.name, "string")})`;
522
+ return value.kind === "literal" ? jsLiteral(value.value) : `String(${this.argument(value.name, "string")})`;
469
523
  }
470
524
 
471
525
  private numberOptions(expression: Expression): string
@@ -500,14 +554,14 @@ class Emitter
500
554
  }
501
555
  else if (option === "useGrouping" && value.kind === "literal")
502
556
  {
503
- entries.push(`useGrouping: ${value.value === "never" ? "false" : value.value === "always" ? "true" : JSON.stringify(value.value)}`);
557
+ entries.push(`useGrouping: ${value.value === "never" ? "false" : value.value === "always" ? "true" : jsLiteral(value.value)}`);
504
558
  }
505
559
  else if (option === "fractionDigits" && value.kind === "literal" && /^[0-9]+$/.test(value.value))
506
560
  {
507
561
  entries.push(`minimumFractionDigits: ${value.value}`, `maximumFractionDigits: ${value.value}`);
508
562
  }
509
563
  }
510
- return `{ ${entries.join(", ")} }`;
564
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
511
565
  }
512
566
 
513
567
  private dateOptions(expression: Expression): string
@@ -522,17 +576,17 @@ class Emitter
522
576
  const length = literal(fn === "datetime" ? "dateLength" : "length") ?? (fn === "time" ? undefined : "medium");
523
577
  if (length !== undefined && fn !== "time")
524
578
  {
525
- entries.push(`dateStyle: ${JSON.stringify(length)}`);
579
+ entries.push(`dateStyle: ${jsLiteral(length)}`);
526
580
  }
527
581
  const precision = literal(fn === "datetime" ? "timePrecision" : "precision") ?? (fn === "date" ? undefined : "minute");
528
582
  if (precision !== undefined && fn !== "date")
529
583
  {
530
- entries.push(`timeStyle: ${JSON.stringify(precision === "second" ? "medium" : "short")}`);
584
+ entries.push(`timeStyle: ${jsLiteral(precision === "second" ? "medium" : "short")}`);
531
585
  }
532
586
  const zone = literal("timeZone");
533
587
  if (zone !== undefined && zone !== "input")
534
588
  {
535
- entries.push(`timeZone: ${JSON.stringify(zone)}`);
589
+ entries.push(`timeZone: ${jsLiteral(zone)}`);
536
590
  }
537
591
  const hour12 = literal("hour12");
538
592
  if (hour12 !== undefined)
@@ -542,9 +596,9 @@ class Emitter
542
596
  const style = literal("timeZoneStyle");
543
597
  if (style !== undefined)
544
598
  {
545
- entries.push(`timeZoneName: ${JSON.stringify(style)}`);
599
+ entries.push(`timeZoneName: ${jsLiteral(style)}`);
546
600
  }
547
- return `{ ${entries.join(", ")} }`;
601
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
548
602
  }
549
603
 
550
604
  /** The JavaScript for an expression's numeric value, before formatting. */
@@ -577,13 +631,13 @@ class Emitter
577
631
  return value;
578
632
  }
579
633
 
580
- /** Resolves a plain `{$x}` to its declaration when one exists. */
581
- private resolve(expression: Expression): Expression
634
+ /** Resolves a plain `{$x}` to its `.input` declaration when one exists, or with `locals` to its `.local` one too, so a selector on a local selects as its expression does. */
635
+ private resolve(expression: Expression, locals = false): Expression
582
636
  {
583
637
  if (expression.fn === undefined && expression.operand?.kind === "variable")
584
638
  {
585
639
  const declared = this.message.declarations.get(expression.operand.name);
586
- if (declared !== undefined && !declared.local)
640
+ if (declared !== undefined && (locals || !declared.local))
587
641
  {
588
642
  return declared.expression;
589
643
  }
@@ -603,11 +657,19 @@ class Emitter
603
657
  {
604
658
  this.fail(":string needs an operand");
605
659
  }
606
- return operand.kind === "literal" ? JSON.stringify(operand.value) : `String(${this.argument(operand.name, "string")})`;
660
+ return operand.kind === "literal" ? jsLiteral(operand.value) : `String(${this.argument(operand.name, "string")})`;
607
661
  }
608
662
  if (NUMBER_FUNCTIONS.has(fn))
609
663
  {
610
- return `number(${this.numeric(expression)}, ${this.numberOptions(expression)})`;
664
+ const value = this.numeric(expression);
665
+ const reads = this.reads;
666
+ const options = this.numberOptions(expression);
667
+ if (this.reads === reads)
668
+ {
669
+ return `${this.shared(`new Intl.NumberFormat(locale, ${options})`)}().format(${value})`;
670
+ }
671
+ this.used.add("number");
672
+ return `$number(${value}, ${options})`;
611
673
  }
612
674
  if (DATE_FUNCTIONS.has(fn))
613
675
  {
@@ -616,8 +678,8 @@ class Emitter
616
678
  {
617
679
  this.fail(`:${fn} needs an operand`);
618
680
  }
619
- const value = operand.kind === "literal" ? `new Date(${JSON.stringify(operand.value)})` : this.argument(operand.name, "date");
620
- return `date(${value}, ${this.dateOptions(expression)})`;
681
+ const value = operand.kind === "literal" ? `new Date(${jsLiteral(operand.value)})` : this.argument(operand.name, "date");
682
+ return `${this.shared(`new Intl.DateTimeFormat(locale, ${this.dateOptions(expression)})`)}().format(${value})`;
621
683
  }
622
684
  this.fail(`unknown function :${fn}`);
623
685
  }
@@ -625,7 +687,7 @@ class Emitter
625
687
  /** The JavaScript for a selector: its comparable value and the categories it matches. */
626
688
  selector(raw: Expression): string
627
689
  {
628
- const expression = this.resolve(raw);
690
+ const expression = this.resolve(raw, true);
629
691
  const fn = expression.fn ?? "string";
630
692
  if (NUMBER_FUNCTIONS.has(fn))
631
693
  {
@@ -636,7 +698,15 @@ class Emitter
636
698
  {
637
699
  return `{ value: String(${value}), categories: [] }`;
638
700
  }
639
- return `{ value: String(${value}), categories: [plural(${value}, ${JSON.stringify(mode === "ordinal" ? "ordinal" : "cardinal")})] }`;
701
+ const type = jsLiteral(mode === "ordinal" ? "ordinal" : "cardinal");
702
+ const reads = this.reads;
703
+ const options = this.numberOptions(expression);
704
+ if (this.reads === reads)
705
+ {
706
+ return `{ value: String(${value}), categories: [${this.shared(`new Intl.PluralRules(locale, { ...new Intl.NumberFormat(locale, ${options}).resolvedOptions(), type: ${type} })`)}().select(${value})] }`;
707
+ }
708
+ this.used.add("plural");
709
+ return `{ value: String(${value}), categories: [$plural(${value}, ${type}, ${options})] }`;
640
710
  }
641
711
  if (fn === "string")
642
712
  {
@@ -645,7 +715,7 @@ class Emitter
645
715
  {
646
716
  this.fail("a selector needs an operand");
647
717
  }
648
- const value = operand.kind === "literal" ? JSON.stringify(operand.value) : `String(${this.argument(operand.name, "string")})`;
718
+ const value = operand.kind === "literal" ? jsLiteral(operand.value) : `String(${this.argument(operand.name, "string")})`;
649
719
  return `{ value: ${value}, categories: [] }`;
650
720
  }
651
721
  this.fail(`:${fn} cannot select`);
@@ -657,7 +727,7 @@ class Emitter
657
727
  {
658
728
  return "\"\"";
659
729
  }
660
- return parts.map((part) => (typeof part === "string" ? JSON.stringify(part) : this.format(part))).join(" + ");
730
+ return parts.map((part) => (typeof part === "string" ? jsLiteral(part) : this.format(part))).join(" + ");
661
731
  }
662
732
 
663
733
  body(): string
@@ -667,8 +737,9 @@ class Emitter
667
737
  if (declaration.local)
668
738
  {
669
739
  const value = this.format(declaration.expression);
670
- this.locals.set(name, `local_${name}`);
671
- this.prelude.push(`const local_${name} = ${value};`);
740
+ const binding = PLAIN_NAME.test(name) ? `local_${name}` : `local_${this.locals.size}`;
741
+ this.locals.set(name, binding);
742
+ this.prelude.push(`const ${binding} = ${value};`);
672
743
  }
673
744
  }
674
745
  if (this.message.pattern !== undefined)
@@ -676,36 +747,77 @@ class Emitter
676
747
  return this.pattern(this.message.pattern);
677
748
  }
678
749
  const selectors = this.message.selectors.map((selector) => this.selector(selector));
679
- const variants = this.message.variants.map((variant) => `{ keys: ${JSON.stringify(variant.keys)}, pattern: () => ${this.pattern(variant.pattern)} }`);
680
- return `select([${selectors.join(", ")}], [${variants.join(", ")}])`;
750
+ const variants = this.message.variants.map((variant) => `{ keys: ${jsLiteral(variant.keys)}, pattern: () => ${this.pattern(variant.pattern)} }`);
751
+ this.used.add("select");
752
+ return `$select([${selectors.join(", ")}], [${variants.join(", ")}])`;
681
753
  }
682
754
  }
683
755
 
684
- const HELPERS = `const formats = new Map();
685
- function cached(kind, options, create)
756
+ /** A group of helpers a compiled module shares; each is written only when one of the module's messages uses it. */
757
+ type Helper = "cache" | "once" | "number" | "plural" | "select";
758
+
759
+ /** The helpers in JavaScript and in TypeScript, in the order a module writes them. */
760
+ const HELPERS: Record<Helper, { js: string; ts: string }> = {
761
+ cache: {
762
+ js: `const $formats = new Map();
763
+ function $cached(kind, options, create)
686
764
  {
687
765
  const key = kind + JSON.stringify(options);
688
- let instance = formats.get(key);
766
+ let instance = $formats.get(key);
689
767
  if (instance === undefined)
690
768
  {
691
769
  instance = create();
692
- formats.set(key, instance);
770
+ $formats.set(key, instance);
693
771
  }
694
772
  return instance;
695
- }
696
- function number(value, options)
773
+ }`,
774
+ ts: `const $formats: Map<string, unknown> = new Map();
775
+ function $cached<T>(kind: string, options: object, create: () => T): T
697
776
  {
698
- return cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
699
- }
700
- function date(value, options)
777
+ const key = kind + JSON.stringify(options);
778
+ let instance = $formats.get(key) as T | undefined;
779
+ if (instance === undefined)
780
+ {
781
+ instance = create();
782
+ $formats.set(key, instance);
783
+ }
784
+ return instance;
785
+ }`
786
+ },
787
+ once: {
788
+ js: `function $once(create)
701
789
  {
702
- return cached("d", options, () => new Intl.DateTimeFormat(locale, options)).format(value);
703
- }
704
- function plural(value, type)
790
+ let instance;
791
+ return () => (instance ??= create());
792
+ }`,
793
+ ts: `function $once<T>(create: () => T): () => T
705
794
  {
706
- return cached("p", { type }, () => new Intl.PluralRules(locale, { type })).select(value);
707
- }
708
- function select(selectors, variants)
795
+ let instance: T | undefined;
796
+ return () => (instance ??= create());
797
+ }`
798
+ },
799
+ number: {
800
+ js: `function $number(value, options)
801
+ {
802
+ return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
803
+ }`,
804
+ ts: `function $number(value: number, options: Intl.NumberFormatOptions): string
805
+ {
806
+ return $cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
807
+ }`
808
+ },
809
+ plural: {
810
+ js: `function $plural(value, type, options)
811
+ {
812
+ return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
813
+ }`,
814
+ ts: `function $plural(value: number, type: "cardinal" | "ordinal", options: Intl.NumberFormatOptions): string
815
+ {
816
+ return $cached("p" + type, options, () => new Intl.PluralRules(locale, { ...$cached("n", options, () => new Intl.NumberFormat(locale, options)).resolvedOptions(), type })).select(value);
817
+ }`
818
+ },
819
+ select: {
820
+ js: `function $select(selectors, variants)
709
821
  {
710
822
  let best;
711
823
  let bestRank;
@@ -738,7 +850,7 @@ function select(selectors, variants)
738
850
  {
739
851
  continue;
740
852
  }
741
- if (matches && better(rank, bestRank))
853
+ if (matches && $better(rank, bestRank))
742
854
  {
743
855
  best = variant;
744
856
  bestRank = rank;
@@ -746,7 +858,7 @@ function select(selectors, variants)
746
858
  }
747
859
  return best === undefined ? "" : best.pattern();
748
860
  }
749
- function better(rank, current)
861
+ function $better(rank, current)
750
862
  {
751
863
  if (current === undefined)
752
864
  {
@@ -761,9 +873,8 @@ function better(rank, current)
761
873
  }
762
874
  }
763
875
  return false;
764
- }`;
765
-
766
- const HELPERS_TS = `interface Selector
876
+ }`,
877
+ ts: `interface Selector
767
878
  {
768
879
  value: string;
769
880
  categories: string[];
@@ -773,31 +884,7 @@ interface Variant
773
884
  keys: string[];
774
885
  pattern: () => string;
775
886
  }
776
- const formats: Map<string, unknown> = new Map();
777
- function cached<T>(kind: string, options: object, create: () => T): T
778
- {
779
- const key = kind + JSON.stringify(options);
780
- let instance = formats.get(key) as T | undefined;
781
- if (instance === undefined)
782
- {
783
- instance = create();
784
- formats.set(key, instance);
785
- }
786
- return instance;
787
- }
788
- function number(value: number, options: Intl.NumberFormatOptions): string
789
- {
790
- return cached("n", options, () => new Intl.NumberFormat(locale, options)).format(value);
791
- }
792
- function date(value: Date | number, options: Intl.DateTimeFormatOptions): string
793
- {
794
- return cached("d", options, () => new Intl.DateTimeFormat(locale, options)).format(value);
795
- }
796
- function plural(value: number, type: "cardinal" | "ordinal"): string
797
- {
798
- return cached("p", { type }, () => new Intl.PluralRules(locale, { type })).select(value);
799
- }
800
- function better(rank: number[], current: number[] | undefined): boolean
887
+ function $better(rank: number[], current: number[] | undefined): boolean
801
888
  {
802
889
  if (current === undefined)
803
890
  {
@@ -813,7 +900,7 @@ function better(rank: number[], current: number[] | undefined): boolean
813
900
  }
814
901
  return false;
815
902
  }
816
- function select(selectors: Selector[], variants: Variant[]): string
903
+ function $select(selectors: Selector[], variants: Variant[]): string
817
904
  {
818
905
  let best: Variant | undefined;
819
906
  let bestRank: number[] | undefined;
@@ -842,14 +929,27 @@ function select(selectors: Selector[], variants: Variant[]): string
842
929
  break;
843
930
  }
844
931
  }
845
- if (matches && better(rank, bestRank))
932
+ if (matches && $better(rank, bestRank))
846
933
  {
847
934
  best = variant;
848
935
  bestRank = rank;
849
936
  }
850
937
  }
851
938
  return best === undefined ? "" : best.pattern();
852
- }`;
939
+ }`
940
+ }
941
+ };
942
+
943
+ /** The helpers a module's messages use, with the cache that `$number` and `$plural` share. */
944
+ function helpersFor(used: ReadonlySet<Helper>, javascript: boolean): string
945
+ {
946
+ const needed = new Set(used);
947
+ if (needed.has("number") || needed.has("plural"))
948
+ {
949
+ needed.add("cache");
950
+ }
951
+ return (Object.keys(HELPERS) as Helper[]).filter((name) => needed.has(name)).map((name) => HELPERS[name][javascript ? "js" : "ts"]).join("\n");
952
+ }
853
953
 
854
954
  function typeOf(type: ArgumentType): string
855
955
  {
@@ -860,21 +960,26 @@ function typeOf(type: ArgumentType): string
860
960
  * Compiles one locale's catalog into a module: `locale`, one exported
861
961
  * function per message named from its key, and the `Intl` helpers they
862
962
  * share. Each function takes an `args` object typed from the message's
863
- * placeholders, or nothing when the message has none.
963
+ * placeholders, or nothing when the message has none. A formatter whose
964
+ * options are all literals is a module-level binding created on first use
965
+ * and shared by every message that formats the same way; options that read
966
+ * an argument find their formatter in a cache keyed by the options. Only
967
+ * the helpers the messages use are written.
864
968
  */
865
969
  export function compileMessages(locale: string, catalog: Catalog, options: CompileOptions = {}): CompiledCatalog
866
970
  {
867
971
  const javascript = options.language === "js";
868
972
  const out: string[] = [];
869
- out.push(`// Generated by @zerolith/i18n from ${options.source ?? `the ${locale} catalog`}. Do not edit; regenerate instead.`);
973
+ out.push(`// Generated by @zerolith/i18n from ${commentText(options.source ?? `the ${locale} catalog`)}. Do not edit; regenerate instead.`);
870
974
  if (javascript)
871
975
  {
872
976
  out.push("// @ts-check");
873
977
  }
874
- out.push(`export const locale${javascript ? "" : ": string"} = ${JSON.stringify(locale)};`);
875
- out.push("");
876
- out.push(javascript ? HELPERS : HELPERS_TS);
978
+ out.push(`export const locale${javascript ? "" : ": string"} = ${jsLiteral(locale)};`);
877
979
  out.push("");
980
+ const head = out.length;
981
+ const formatters = new Map<string, string>();
982
+ const used = new Set<Helper>();
878
983
  const names: string[] = [];
879
984
  const seen = new Set<string>();
880
985
  for (const [key, source] of Object.entries(catalog))
@@ -884,23 +989,28 @@ export function compileMessages(locale: string, catalog: Catalog, options: Compi
884
989
  {
885
990
  throw new Error(`zerolith/i18n: keys "${key}" and another one both become ${name}`);
886
991
  }
992
+ if (name === "locale")
993
+ {
994
+ throw new Error(`zerolith/i18n: key "${key}" becomes locale, which every compiled module exports; rename the key`);
995
+ }
996
+ const binding = RESERVED.has(name) ? `message_${name}` : name;
887
997
  seen.add(name);
888
998
  names.push(name);
889
999
  const message = new Parser(source, key).parse();
890
- const emitter = new Emitter(key, message);
1000
+ const emitter = new Emitter(key, message, formatters, used);
891
1001
  const body = emitter.body();
892
1002
  const args = [...emitter.arguments.entries()].toSorted(([a], [b]) => a.localeCompare(b));
893
- const doc = source.replace(/\*\//g, "* /").split(/\r?\n/).map((line) => ` * ${line}`.trimEnd()).join("\n");
894
- const argumentType = `{ ${args.map(([argument, type]) => `${argument}: ${typeOf(type)}`).join("; ")} }`;
1003
+ const doc = source.replace(/\*\//g, "* /").replaceAll("<", "&lt;").split(SOURCE_LINES).map((line) => ` * ${line}`.trimEnd()).join("\n");
1004
+ const argumentType = `{ ${args.map(([argument, type]) => `${property(argument)}: ${typeOf(type)}`).join("; ")} }`;
895
1005
  if (javascript)
896
1006
  {
897
1007
  out.push(`/**\n${doc}${args.length > 0 ? `\n * @param {${argumentType}} args` : ""}\n * @returns {string}\n */`);
898
- out.push(`export function ${name}(${args.length > 0 ? "args" : ""})`);
1008
+ out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? "args" : ""})`);
899
1009
  }
900
1010
  else
901
1011
  {
902
1012
  out.push(`/**\n${doc}\n */`);
903
- out.push(`export function ${name}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
1013
+ out.push(`${binding === name ? "export " : ""}function ${binding}(${args.length > 0 ? `args: ${argumentType}` : ""}): string`);
904
1014
  }
905
1015
  out.push("{");
906
1016
  for (const line of emitter.prelude)
@@ -909,8 +1019,15 @@ export function compileMessages(locale: string, catalog: Catalog, options: Compi
909
1019
  }
910
1020
  out.push(` return ${body};`);
911
1021
  out.push("}");
1022
+ if (binding !== name)
1023
+ {
1024
+ out.push(`export { ${binding} as ${name} };`);
1025
+ }
912
1026
  out.push("");
913
1027
  }
1028
+ const helpers = helpersFor(used, javascript);
1029
+ const shared = Array.from(formatters, ([create, binding]) => `const ${binding} = $once(() => ${create});`);
1030
+ out.splice(head, 0, ...(helpers === "" ? [] : [helpers, ""]), ...(shared.length === 0 ? [] : [...shared, ""]));
914
1031
  return { code: out.join("\n"), names };
915
1032
  }
916
1033
 
@@ -935,14 +1052,14 @@ export function compileIndex(locales: readonly string[], options: CompileOptions
935
1052
  }
936
1053
  if (!javascript)
937
1054
  {
938
- lines.push(`import type * as first from "./${locales[0] ?? "en"}.${extension}";`);
1055
+ lines.push(`import type * as first from ${jsLiteral(`./${locales[0] ?? "en"}.${extension}`)};`);
939
1056
  lines.push("");
940
1057
  }
941
- lines.push(`export const locales${javascript ? "" : ": readonly string[]"} = ${JSON.stringify(locales)};`);
1058
+ lines.push(`export const locales${javascript ? "" : ": readonly string[]"} = ${jsLiteral(locales)};`);
942
1059
  lines.push("");
943
1060
  lines.push(`/** Loads one locale's messages; each locale is its own module. */`);
944
1061
  lines.push(javascript ? "export const loaders = {" : "export const loaders: Readonly<Record<string, () => Promise<typeof first>>> = {");
945
- lines.push(locales.map((locale) => ` ${JSON.stringify(locale)}: () => import("./${locale}.${extension}")`).join(",\n"));
1062
+ lines.push(locales.map((locale) => ` ${jsLiteral(locale)}: () => import(${jsLiteral(`./${locale}.${extension}`)})`).join(",\n"));
946
1063
  lines.push("};");
947
1064
  lines.push("");
948
1065
  return lines.join("\n");