@upstart.gg/vite-plugins 0.0.39 → 0.0.40

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.
Files changed (64) hide show
  1. package/dist/upstart-editor-api.d.ts +79 -0
  2. package/dist/upstart-editor-api.d.ts.map +1 -0
  3. package/dist/upstart-editor-api.js +208 -0
  4. package/dist/upstart-editor-api.js.map +1 -0
  5. package/dist/vite-plugin-upstart-attrs.d.ts +3 -3
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +227 -25
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-branding/plugin.d.ts +17 -0
  10. package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -0
  11. package/dist/vite-plugin-upstart-branding/plugin.js +41 -0
  12. package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -0
  13. package/dist/vite-plugin-upstart-branding/runtime.d.ts +10 -0
  14. package/dist/vite-plugin-upstart-branding/runtime.d.ts.map +1 -0
  15. package/dist/vite-plugin-upstart-branding/runtime.js +118 -0
  16. package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -0
  17. package/dist/vite-plugin-upstart-branding/types.d.ts +14 -0
  18. package/dist/vite-plugin-upstart-branding/types.d.ts.map +1 -0
  19. package/dist/vite-plugin-upstart-branding/types.js +1 -0
  20. package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
  21. package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/plugin.js +3 -16
  23. package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +25 -11
  25. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +5 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +16 -0
  29. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -0
  30. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  32. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +2 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  34. package/dist/vite-plugin-upstart-editor/runtime/index.js +42 -7
  35. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  36. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts +6 -1
  37. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  38. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +423 -129
  39. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  40. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +18 -10
  41. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  42. package/dist/vite-plugin-upstart-theme.d.ts +3 -3
  43. package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
  44. package/dist/vite-plugin-upstart-theme.js +1 -3
  45. package/dist/vite-plugin-upstart-theme.js.map +1 -1
  46. package/package.json +12 -4
  47. package/src/tests/upstart-editor-api.test.ts +98 -174
  48. package/src/tests/vite-plugin-upstart-attrs.test.ts +408 -105
  49. package/src/tests/vite-plugin-upstart-branding.test.ts +90 -0
  50. package/src/tests/vite-plugin-upstart-editor.test.ts +1 -2
  51. package/src/upstart-editor-api.ts +90 -29
  52. package/src/vite-plugin-upstart-attrs.ts +376 -38
  53. package/src/vite-plugin-upstart-branding/plugin.ts +59 -0
  54. package/src/vite-plugin-upstart-branding/runtime.ts +128 -0
  55. package/src/vite-plugin-upstart-branding/types.ts +10 -0
  56. package/src/vite-plugin-upstart-editor/plugin.ts +4 -19
  57. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +25 -12
  58. package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +12 -0
  59. package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +1 -1
  60. package/src/vite-plugin-upstart-editor/runtime/index.ts +39 -5
  61. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +518 -141
  62. package/src/vite-plugin-upstart-editor/runtime/types.ts +18 -4
  63. package/src/vite-plugin-upstart-theme.ts +0 -3
  64. package/src/vite-plugin-upstart-editor/PLAN.md +0 -1391
@@ -42,7 +42,6 @@ interface LoopContext {
42
42
  arrayExpr: string;
43
43
  }
44
44
 
45
-
46
45
  interface I18nKeyInfo {
47
46
  /** The resolved key with namespace, e.g. "dashboard:features.title" */
48
47
  fullKey: string;
@@ -50,6 +49,10 @@ interface I18nKeyInfo {
50
49
  key: string;
51
50
  /** The namespace, e.g. "dashboard" */
52
51
  namespace: string;
52
+ /** Source expression for i18nKey when dynamic (e.g. "stat.labelKey"), null when static */
53
+ keyExpr: string | null;
54
+ /** Source expression for namespace when dynamic, null when static */
55
+ nsExpr: string | null;
53
56
  }
54
57
 
55
58
  // Registry entry for editable elements (text and className)
@@ -87,6 +90,10 @@ interface TransformState {
87
90
  code: string;
88
91
  s: MagicString;
89
92
  loopStack: LoopContext[];
93
+ /** Tracks const varName = "literal" declarations for resolving dynamic i18nKey expressions */
94
+ constants: Map<string, string>;
95
+ /** Maps t-function variable names to their default namespace, e.g. { "t": "dashboard" } */
96
+ tFunctions: Map<string, string>;
90
97
  }
91
98
 
92
99
  export const upstartEditor = createUnplugin<Options>((options) => {
@@ -173,6 +180,8 @@ export function transformWithOxc(code: string, filePath: string) {
173
180
  code,
174
181
  s,
175
182
  loopStack: [],
183
+ constants: new Map(),
184
+ tFunctions: new Map(),
176
185
  };
177
186
 
178
187
  let modified = false;
@@ -180,9 +189,21 @@ export function transformWithOxc(code: string, filePath: string) {
180
189
  // Use zimmerframe to walk and transform the AST
181
190
  walk(ast.program as Program, state, {
182
191
  _(node: Node, { state, next }: { state: TransformState; next: () => void }) {
183
- // ENFORCE: Throw error if useTranslation is used for translations (destructuring `t`)
192
+ // Track useTranslation() calls to resolve t() function namespaces
184
193
  if (node.type === "VariableDeclaration") {
185
- checkForbiddenUseTranslation(node, state.filePath);
194
+ trackUseTranslation(node, state);
195
+
196
+ // Track const varName = "literal" for resolving dynamic i18nKey expressions
197
+ for (const decl of (node as any).declarations) {
198
+ if (
199
+ decl.type === "VariableDeclarator" &&
200
+ decl.id?.type === "Identifier" &&
201
+ decl.init?.type === "Literal" &&
202
+ typeof decl.init.value === "string"
203
+ ) {
204
+ state.constants.set(decl.id.name, decl.init.value);
205
+ }
206
+ }
186
207
  }
187
208
 
188
209
  // Handle .map() calls to track loops (must be before JSXElement)
@@ -207,6 +228,15 @@ export function transformWithOxc(code: string, filePath: string) {
207
228
  const jsxNode = node as JSXElement;
208
229
  const opening = jsxNode.openingElement;
209
230
  const tagName = getJSXElementName(opening);
231
+
232
+ // Skip ALL attribute injection for <Trans> elements.
233
+ // <Trans> renders to a text node at runtime, so DOM attributes are lost.
234
+ // The i18n attributes are promoted to the parent element instead.
235
+ if (tagName === "Trans") {
236
+ next();
237
+ return;
238
+ }
239
+
210
240
  const insertPos = getAttributeInsertPosition(opening, state.code);
211
241
 
212
242
  // Track attributes to inject
@@ -217,13 +247,52 @@ export function transformWithOxc(code: string, filePath: string) {
217
247
  if (hasRange(jsxNode)) {
218
248
  const elementSource = state.code.slice(jsxNode.start, jsxNode.end);
219
249
  const hash = hashContent(elementSource);
220
- attributes.push(`data-upstart-hash="${hash}"`);
250
+
251
+ // Collect index variables from all enclosing loops so elements
252
+ // inside .map() get a unique hash per iteration at runtime.
253
+ const loopIndices = state.loopStack.map((l) => l.indexName).filter((n): n is string => n !== null);
254
+
255
+ if (loopIndices.length > 0) {
256
+ const suffix = loopIndices.map((n) => `\${${n}}`).join("-");
257
+ attributes.push(`data-upstart-hash={\`${hash}-${suffix}\`}`);
258
+ } else {
259
+ attributes.push(`data-upstart-hash="${hash}"`);
260
+ }
221
261
  }
222
262
 
223
- // Check for text leaf elements (any element with only static text)
224
- if (isTextLeafElement(jsxNode)) {
263
+ // --- Editable text detection ---
264
+ // Priority: i18n (true) > non-i18n text (false) > no text (no attribute)
265
+
266
+ // Step 1: Check for Trans children AND t() calls (editable via i18n, gets "true")
267
+ const transChildren = findTransInChildren(jsxNode, state.code, state.constants);
268
+ const tCallChildren = findTCallsInChildren(jsxNode, state.code, state);
269
+ const allI18nKeys = [...transChildren, ...tCallChildren];
270
+ const hasI18n = allI18nKeys.length > 0;
271
+
272
+ if (hasI18n) {
225
273
  attributes.push('data-upstart-editable-text="true"');
226
274
 
275
+ const hasDynamic = allI18nKeys.some((t) => t.keyExpr || t.nsExpr);
276
+ if (hasDynamic) {
277
+ // Build a runtime JSX template expression for dynamic i18n keys
278
+ const parts = allI18nKeys.map((t) => {
279
+ const nsPart = t.nsExpr ? `\${${t.nsExpr}}` : t.namespace;
280
+ const keyPart = t.keyExpr ? `\${${t.keyExpr}}` : t.key;
281
+ return `${nsPart}:${keyPart}`;
282
+ });
283
+ attributes.push(`data-upstart-i18n={\`${parts.join(",")}\`}`);
284
+ } else {
285
+ const keys = allI18nKeys.map((t: I18nKeyInfo) => escapeProp(t.fullKey)).join(",");
286
+ attributes.push(`data-upstart-i18n="${keys}"`);
287
+ }
288
+ }
289
+
290
+ // Step 2: Text leaf elements — registry tracking + non-editable flag
291
+ if (isTextLeafElement(jsxNode)) {
292
+ if (!hasI18n) {
293
+ attributes.push('data-upstart-editable-text="false"');
294
+ }
295
+
227
296
  // Find the actual JSXText node to track its location
228
297
  const textChild = jsxNode.children.find((c) => c.type === "JSXText" && (c as any).value?.trim());
229
298
 
@@ -247,12 +316,9 @@ export function transformWithOxc(code: string, filePath: string) {
247
316
  attributes.push(`data-upstart-id="${id}"`);
248
317
  }
249
318
  }
250
-
251
- // Check if this element IS a <Trans> component - add i18n tracking attributes
252
- const transInfo = detectTransComponent(jsxNode, state.code);
253
- if (transInfo) {
254
- attributes.push('data-upstart-editable-text="true"');
255
- attributes.push(`data-i18n-key="${escapeProp(transInfo.fullKey)}"`);
319
+ // Step 3: Non-leaf elements with visible text content (expressions, mixed content)
320
+ else if (!hasI18n && hasVisibleTextContent(jsxNode)) {
321
+ attributes.push('data-upstart-editable-text="false"');
256
322
  }
257
323
 
258
324
  // Track className attribute if it's a string literal
@@ -542,15 +608,15 @@ function extractRecordId(expr: MemberExpression): string | undefined {
542
608
  const obj = expr.object;
543
609
 
544
610
  if (obj.type === "Identifier") {
545
- // Assume the ID field is `${object}.id`
546
- return `${obj.name}.id`;
611
+ // Assume the ID field is `${object}.$id`
612
+ return `${obj.name}.$id`;
547
613
  }
548
614
 
549
615
  if (obj.type === "MemberExpression") {
550
616
  // For nested objects like user.profile.id
551
617
  const datasource = extractDatasource(obj);
552
618
  if (datasource) {
553
- return `${datasource}.id`;
619
+ return `${datasource}.$id`;
554
620
  }
555
621
  }
556
622
 
@@ -611,9 +677,8 @@ function escapeProp(value: string): string {
611
677
  .replace(/>/g, "&gt;");
612
678
  }
613
679
 
614
- // Helper: ENFORCE forbidden useTranslation usage - throw error if `t` is destructured
615
- // Only `{ i18n }` is allowed (for language switching), not `{ t }` (use <Trans> instead)
616
- function checkForbiddenUseTranslation(node: Node, filePath: string): void {
680
+ // Helper: Track useTranslation() calls to map t-function variable names to their namespace
681
+ function trackUseTranslation(node: Node, state: TransformState): void {
617
682
  if (node.type !== "VariableDeclaration") return;
618
683
 
619
684
  const decl = node as any;
@@ -631,56 +696,295 @@ function checkForbiddenUseTranslation(node: Node, filePath: string): void {
631
696
  continue;
632
697
  }
633
698
 
634
- // Check if `t` is being destructured (forbidden)
699
+ const namespace = extractUseTranslationNamespace(callExpr);
700
+
635
701
  for (const prop of declarator.id.properties) {
636
702
  if (prop.type !== "Property") continue;
703
+ if (prop.key?.type !== "Identifier" || prop.key.name !== "t") continue;
637
704
 
638
- if (prop.key?.type === "Identifier" && prop.key.name === "t") {
639
- throw new Error(
640
- `[${filePath}] useTranslation hook is forbidden for translations. ` +
641
- `Use <Trans i18nKey="..." /> component instead. ` +
642
- `Only { i18n } destructuring is allowed (for language switching).`,
643
- );
644
- }
705
+ // Handle aliased destructuring: { t: translate } → maps "translate" to namespace
706
+ const localName = prop.value?.type === "Identifier" ? prop.value.name : "t";
707
+ state.tFunctions.set(localName, namespace);
708
+ }
709
+ }
710
+ }
711
+
712
+ // Helper: Extract namespace from useTranslation() call arguments
713
+ function extractUseTranslationNamespace(callExpr: any): string {
714
+ const firstArg = callExpr.arguments?.[0];
715
+ if (!firstArg) return "translation";
716
+
717
+ // String literal: useTranslation("dashboard")
718
+ if (firstArg.type === "Literal" && typeof firstArg.value === "string") {
719
+ return firstArg.value;
720
+ }
721
+
722
+ // Array expression: useTranslation(["dashboard", "common"])
723
+ if (firstArg.type === "ArrayExpression" && firstArg.elements?.length > 0) {
724
+ const first = firstArg.elements[0];
725
+ if (first?.type === "Literal" && typeof first.value === "string") {
726
+ return first.value;
727
+ }
728
+ }
729
+
730
+ return "translation";
731
+ }
732
+
733
+ // Helper: Resolve a JSX attribute value to a string.
734
+ // Handles string literals directly, and JSXExpressionContainer by resolving
735
+ // Identifier references via the constants map, or returning the expression source for dynamic values.
736
+ function resolveJSXAttrValue(
737
+ value: any,
738
+ code: string,
739
+ constants: Map<string, string>,
740
+ ): { value: string; expr: string | null } | null {
741
+ if (!value) return null;
742
+
743
+ if (value.type === "Literal" && typeof value.value === "string") {
744
+ return { value: value.value, expr: null };
745
+ }
746
+
747
+ if (value.type === "JSXExpressionContainer") {
748
+ const expression = value.expression;
749
+ if (!expression) return null;
750
+
751
+ // Resolve simple identifiers via constants map (e.g. i18nKey={labelKey} where const labelKey = "...")
752
+ if (expression.type === "Identifier" && constants.has(expression.name)) {
753
+ return { value: constants.get(expression.name)!, expr: null };
754
+ }
755
+
756
+ // Dynamic expression — return source text so caller can emit a runtime JSX expression
757
+ if (hasRange(expression)) {
758
+ const src = code.slice(expression.start, expression.end);
759
+ return { value: src, expr: src };
645
760
  }
646
761
  }
762
+
763
+ return null;
647
764
  }
648
765
 
649
766
  // Helper: Detect <Trans i18nKey="..." /> component and extract i18n key info
650
- function detectTransComponent(jsxElement: JSXElement, code: string): I18nKeyInfo | null {
767
+ function detectTransComponent(
768
+ jsxElement: JSXElement,
769
+ code: string,
770
+ constants: Map<string, string>,
771
+ ): I18nKeyInfo | null {
651
772
  const opening = jsxElement.openingElement;
652
773
  const tagName = getJSXElementName(opening);
653
774
 
654
775
  if (tagName !== "Trans") return null;
655
776
 
656
- let i18nKey: string | null = null;
657
- let namespace = "translation";
777
+ let keyResult: { value: string; expr: string | null } | null = null;
778
+ let nsResult: { value: string; expr: string | null } | null = null;
658
779
 
659
780
  for (const attr of opening.attributes) {
660
781
  if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") continue;
661
782
 
662
783
  const attrName = attr.name.name;
663
784
 
664
- // Extract i18nKey prop value
665
- if (attrName === "i18nKey" && attr.value?.type === "Literal") {
666
- i18nKey = (attr.value as any).value as string;
785
+ if (attrName === "i18nKey") {
786
+ keyResult = resolveJSXAttrValue(attr.value, code, constants);
667
787
  }
668
788
 
669
- // Extract ns prop value (optional namespace override)
670
- if (attrName === "ns" && attr.value?.type === "Literal") {
671
- namespace = (attr.value as any).value as string;
789
+ if (attrName === "ns") {
790
+ nsResult = resolveJSXAttrValue(attr.value, code, constants);
672
791
  }
673
792
  }
674
793
 
675
- if (!i18nKey) return null;
794
+ if (!keyResult) {
795
+ return null;
796
+ }
797
+
798
+ const key = keyResult.value;
799
+ const namespace = nsResult?.value ?? "translation";
800
+ const keyExpr = keyResult.expr;
801
+ const nsExpr = nsResult?.expr ?? null;
676
802
 
677
803
  return {
678
- fullKey: `${namespace}:${i18nKey}`,
679
- key: i18nKey,
804
+ fullKey: `${namespace}:${key}`,
805
+ key,
680
806
  namespace,
807
+ keyExpr,
808
+ nsExpr,
681
809
  };
682
810
  }
683
811
 
812
+ // Helper: Scan an element's direct children for <Trans> components and extract i18n key info.
813
+ // Handles direct <Trans> children and <Trans> inside JSXExpressionContainer (e.g. {show && <Trans>}).
814
+ function findTransInChildren(
815
+ jsxElement: JSXElement,
816
+ code: string,
817
+ constants: Map<string, string>,
818
+ ): I18nKeyInfo[] {
819
+ const results: I18nKeyInfo[] = [];
820
+
821
+ for (const child of jsxElement.children) {
822
+ if (child.type === "JSXElement") {
823
+ const info = detectTransComponent(child as JSXElement, code, constants);
824
+ if (info) results.push(info);
825
+ }
826
+
827
+ if (child.type === "JSXExpressionContainer") {
828
+ const expr = (child as any).expression;
829
+ if (expr && expr.type !== "JSXEmptyExpression") {
830
+ findTransInExpression(expr, code, results, constants);
831
+ }
832
+ }
833
+ }
834
+
835
+ return results;
836
+ }
837
+
838
+ // Helper: Recursively search an expression tree for <Trans> JSXElements.
839
+ // Handles LogicalExpression (&&, ||), ConditionalExpression (?:), CallExpression (.map()),
840
+ // ArrowFunctionExpression/FunctionExpression (callbacks), BlockStatement, and direct JSXElement.
841
+ function findTransInExpression(
842
+ expr: any,
843
+ code: string,
844
+ results: I18nKeyInfo[],
845
+ constants: Map<string, string>,
846
+ ): void {
847
+ if (!expr || !expr.type) return;
848
+
849
+ if (expr.type === "JSXElement") {
850
+ const info = detectTransComponent(expr as JSXElement, code, constants);
851
+ if (info) results.push(info);
852
+ return;
853
+ }
854
+
855
+ if (expr.type === "LogicalExpression") {
856
+ findTransInExpression(expr.left, code, results, constants);
857
+ findTransInExpression(expr.right, code, results, constants);
858
+ return;
859
+ }
860
+
861
+ if (expr.type === "ConditionalExpression") {
862
+ findTransInExpression(expr.consequent, code, results, constants);
863
+ findTransInExpression(expr.alternate, code, results, constants);
864
+ return;
865
+ }
866
+
867
+ // Handle .map() and other call expressions — recurse into arguments
868
+ if (expr.type === "CallExpression") {
869
+ for (const arg of expr.arguments) {
870
+ findTransInExpression(arg, code, results, constants);
871
+ }
872
+ return;
873
+ }
874
+
875
+ // Handle arrow/function expressions — recurse into body
876
+ if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") {
877
+ findTransInExpression(expr.body, code, results, constants);
878
+ return;
879
+ }
880
+
881
+ // Handle block bodies (arrow functions with braces)
882
+ if (expr.type === "BlockStatement") {
883
+ for (const stmt of expr.body) {
884
+ if (stmt.type === "ReturnStatement" && stmt.argument) {
885
+ findTransInExpression(stmt.argument, code, results, constants);
886
+ }
887
+ }
888
+ return;
889
+ }
890
+ }
891
+
892
+ // Helper: Scan an element's direct children for t() function calls and extract i18n key info.
893
+ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: TransformState): I18nKeyInfo[] {
894
+ if (state.tFunctions.size === 0) return [];
895
+
896
+ const results: I18nKeyInfo[] = [];
897
+
898
+ for (const child of jsxElement.children) {
899
+ if (child.type === "JSXExpressionContainer") {
900
+ const expr = (child as any).expression;
901
+ if (expr && expr.type !== "JSXEmptyExpression") {
902
+ findTCallInExpression(expr, code, results, state);
903
+ }
904
+ }
905
+ }
906
+
907
+ return results;
908
+ }
909
+
910
+ // Helper: Recursively search an expression tree for t() calls.
911
+ // Handles LogicalExpression (&&, ||), ConditionalExpression (?:), CallExpression (.map()),
912
+ // ArrowFunctionExpression/FunctionExpression (callbacks), BlockStatement, and direct CallExpression.
913
+ function findTCallInExpression(expr: any, code: string, results: I18nKeyInfo[], state: TransformState): void {
914
+ if (!expr || !expr.type) return;
915
+
916
+ if (expr.type === "CallExpression") {
917
+ const info = detectTCall(expr, code, state);
918
+ if (info) {
919
+ results.push(info);
920
+ return;
921
+ }
922
+ // Not a t() call — recurse into arguments (for .map() callbacks etc.)
923
+ for (const arg of expr.arguments) {
924
+ findTCallInExpression(arg, code, results, state);
925
+ }
926
+ return;
927
+ }
928
+
929
+ if (expr.type === "LogicalExpression") {
930
+ findTCallInExpression(expr.left, code, results, state);
931
+ findTCallInExpression(expr.right, code, results, state);
932
+ return;
933
+ }
934
+
935
+ if (expr.type === "ConditionalExpression") {
936
+ findTCallInExpression(expr.consequent, code, results, state);
937
+ findTCallInExpression(expr.alternate, code, results, state);
938
+ return;
939
+ }
940
+
941
+ if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") {
942
+ findTCallInExpression(expr.body, code, results, state);
943
+ return;
944
+ }
945
+
946
+ if (expr.type === "BlockStatement") {
947
+ for (const stmt of expr.body) {
948
+ if (stmt.type === "ReturnStatement" && stmt.argument) {
949
+ findTCallInExpression(stmt.argument, code, results, state);
950
+ }
951
+ }
952
+ return;
953
+ }
954
+ }
955
+
956
+ // Helper: Detect a t("key") call and extract i18n key info.
957
+ function detectTCall(callExpr: any, code: string, state: TransformState): I18nKeyInfo | null {
958
+ if (callExpr.callee?.type !== "Identifier") return null;
959
+
960
+ const calleeName = callExpr.callee.name;
961
+ const namespace = state.tFunctions.get(calleeName);
962
+ if (namespace === undefined) return null;
963
+
964
+ const firstArg = callExpr.arguments?.[0];
965
+ if (!firstArg) return null;
966
+
967
+ // Static string key: t("features.title")
968
+ if (firstArg.type === "Literal" && typeof firstArg.value === "string") {
969
+ const key = firstArg.value;
970
+ return { fullKey: `${namespace}:${key}`, key, namespace, keyExpr: null, nsExpr: null };
971
+ }
972
+
973
+ // Dynamic key — try constant resolution first, then fall back to expression
974
+ if (hasRange(firstArg)) {
975
+ const exprSrc = code.slice(firstArg.start, firstArg.end);
976
+
977
+ if (firstArg.type === "Identifier" && state.constants.has(firstArg.name)) {
978
+ const key = state.constants.get(firstArg.name)!;
979
+ return { fullKey: `${namespace}:${key}`, key, namespace, keyExpr: null, nsExpr: null };
980
+ }
981
+
982
+ return { fullKey: `${namespace}:${exprSrc}`, key: exprSrc, namespace, keyExpr: exprSrc, nsExpr: null };
983
+ }
984
+
985
+ return null;
986
+ }
987
+
684
988
  // Helper: Check if element is a "leaf" with only static text (no nested elements or expressions)
685
989
  function isTextLeafElement(jsxElement: JSXElement): boolean {
686
990
  let hasText = false;
@@ -705,4 +1009,38 @@ function isTextLeafElement(jsxElement: JSXElement): boolean {
705
1009
  return hasText;
706
1010
  }
707
1011
 
1012
+ // Helper: Check if element has any visible text content (static or dynamic).
1013
+ // Broader than isTextLeafElement — returns true even if the element has other child types.
1014
+ // Detects JSXText with non-whitespace content and expression containers with
1015
+ // text-producing expressions (Identifier, MemberExpression, Literal, TemplateLiteral).
1016
+ // Skips CallExpression, ConditionalExpression, LogicalExpression (these typically produce elements).
1017
+ function hasVisibleTextContent(jsxElement: JSXElement): boolean {
1018
+ for (const child of jsxElement.children) {
1019
+ if (child.type === "JSXText") {
1020
+ const textValue = (child as any).value;
1021
+ if (textValue?.trim()) {
1022
+ return true;
1023
+ }
1024
+ continue;
1025
+ }
1026
+
1027
+ if (child.type === "JSXExpressionContainer") {
1028
+ const expr = (child as any).expression;
1029
+ if (!expr || expr.type === "JSXEmptyExpression") {
1030
+ continue;
1031
+ }
1032
+ if (
1033
+ expr.type === "Identifier" ||
1034
+ expr.type === "MemberExpression" ||
1035
+ expr.type === "Literal" ||
1036
+ expr.type === "TemplateLiteral"
1037
+ ) {
1038
+ return true;
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ return false;
1044
+ }
1045
+
708
1046
  export default upstartEditor.vite;
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { createUnplugin } from "unplugin";
4
+ import type { UpstartBrandingPluginOptions } from "./types.js";
5
+
6
+ const DEFAULT_OPTIONS: Required<UpstartBrandingPluginOptions> = {
7
+ enabled: false,
8
+ };
9
+
10
+ /**
11
+ * Upstart Branding Vite plugin (build-time)
12
+ *
13
+ * Injects a "Made with Upstart" badge into the app entry during build.
14
+ * The badge appears at the bottom of the page, then fades out after a
15
+ * few seconds or when the user scrolls.
16
+ */
17
+ export const upstartBranding = createUnplugin<UpstartBrandingPluginOptions>((options = {}) => {
18
+ const { enabled } = { ...DEFAULT_OPTIONS, ...options };
19
+
20
+ if (!enabled) {
21
+ return { name: "upstart-branding-disabled" };
22
+ }
23
+
24
+ const runtimePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./runtime");
25
+
26
+ return {
27
+ name: "upstart-branding",
28
+ enforce: "pre",
29
+
30
+ transform(code, id) {
31
+ const [cleanId] = id.split("?");
32
+ if (!cleanId || cleanId.includes("node_modules")) {
33
+ return null;
34
+ }
35
+
36
+ if (!/\.(t|j)sx?$/.test(cleanId)) {
37
+ return null;
38
+ }
39
+
40
+ if (!cleanId.includes("entry.client.tsx")) {
41
+ return null;
42
+ }
43
+
44
+ if (code.includes("initUpstartBranding")) {
45
+ return null;
46
+ }
47
+
48
+ const imports = `import { initUpstartBranding } from ${JSON.stringify(runtimePath)};`;
49
+ const injection = "requestIdleCallback(initUpstartBranding);";
50
+
51
+ return {
52
+ code: `${imports}${code}${injection}`,
53
+ map: null,
54
+ };
55
+ },
56
+ };
57
+ });
58
+
59
+ export default upstartBranding.vite;