@symbiote-native/svelte 0.2.0 → 0.2.1

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.
@@ -0,0 +1,10 @@
1
+ interface IMarkupInput {
2
+ readonly content: string;
3
+ readonly filename?: string;
4
+ }
5
+ export declare function collapseTextWhitespace(): {
6
+ markup(input: IMarkupInput): {
7
+ code: string;
8
+ };
9
+ };
10
+ export {};
@@ -0,0 +1,125 @@
1
+ // svelte-adapter-dom-shim skill §16: Svelte trims only the leading/trailing whitespace of a
2
+ // fragment's own children - never whitespace INSIDE a single Text node, and never a
3
+ // whitespace-only Text node BETWEEN two siblings. Vue's compiler (and every browser) collapses
4
+ // both; Svelte doesn't. Confirmed against the real compiler (2026-08-17): a Text node authored
5
+ // as `Hello world, this is a\n long sentence.` compiles verbatim into
6
+ // `$.text('Hello world, this is a\n long sentence.')` - the newline and indent ship into the
7
+ // native text content and render as a forced line break plus stray leading spaces on device.
8
+ // Reproduced live on an iOS simulator before this file existed; fixed with it registered.
9
+ //
10
+ // Two hazards:
11
+ // 1. Wrapped sentence - a Text node with real content plus an embedded whitespace run
12
+ // (usually a line-wrap for readability). Fixed by collapsing every whitespace run to a
13
+ // single space, matching HTML/Vue.
14
+ // 2. Stray sibling gap - a Text node that is entirely whitespace. Compiles to a real
15
+ // `$.text(' ')` -> ShimText -> RCTRawText engine node: invalid outside a Text-accepting
16
+ // parent, and a stray paint even inside one. Fixed by deleting the node outright, since
17
+ // collapsing alone leaves it there as a single space.
18
+ //
19
+ // Telling an accidental gap from an intentional inline space (`<Text>{a} {b}</Text>`) needs a
20
+ // signal, since both are whitespace-only nodes: did the original contain a newline? A same-line
21
+ // space is something the author typed on purpose; a run spanning multiple source lines has, in
22
+ // every case this codebase has produced, come from writing each sibling on its own line. So only
23
+ // a whitespace-only node with a newline gets deleted; a same-line run of 2+ spaces collapses to
24
+ // one and stays, matching HTML's own collapse of an intentional double space.
25
+ //
26
+ // Known gap: a whitespace-only run between two siblings on ONE line with no newline
27
+ // (`<View><A/> <B/></View>`) isn't caught - indistinguishable from the intentional-space case
28
+ // without knowing whether the parent renders raw text. Rare in practice (§16's own audit found
29
+ // only the wrapped-sentence and cross-line-gap shapes) and still caught by
30
+ // `scripts/audit-svelte-stray-whitespace.mjs`, which walks the compiled output independently of
31
+ // this heuristic.
32
+ //
33
+ // Registered in svelte.config.js's `preprocess`, same as forbid-web-only-constructs.ts and
34
+ // scoped-styles.ts, so it covers both svelte-check/the language server and Metro's build.
35
+ //
36
+ // Line numbers are not preserved past an edited node, unlike scoped-styles.ts's blankOut trick:
37
+ // blankOut hides removed characters behind an equal count of `\n` because that code is gone from
38
+ // the output entirely; here the newlines being removed are the bug, so re-inserting them
39
+ // anywhere would bring it back. A diagnostic below an edited node may point a few lines off - no
40
+ // source map, matching the other two preprocessors, and the one place that choice costs
41
+ // something.
42
+ import { parse } from 'svelte/compiler';
43
+ function isRecord(value) {
44
+ return typeof value === 'object' && value !== null;
45
+ }
46
+ function numberAt(node, key) {
47
+ const value = node[key];
48
+ return typeof value === 'number' ? value : undefined;
49
+ }
50
+ function stringAt(node, key) {
51
+ const value = node[key];
52
+ return typeof value === 'string' ? value : undefined;
53
+ }
54
+ function fragmentNodes(ast) {
55
+ if (!isRecord(ast))
56
+ return [];
57
+ const fragment = ast.fragment;
58
+ if (!isRecord(fragment))
59
+ return [];
60
+ return Array.isArray(fragment.nodes) ? fragment.nodes : [];
61
+ }
62
+ // Same walk shape as scoped-styles.ts's collectClassEdits: a Text node can sit inside an
63
+ // {#if}/{#each} branch, a snippet body, or an element's children, so this descends through every
64
+ // child value rather than one known key.
65
+ function nestedNodes(node) {
66
+ const nested = [];
67
+ for (const value of Object.values(node)) {
68
+ if (Array.isArray(value))
69
+ nested.push(...value);
70
+ else if (isRecord(value))
71
+ nested.push(value);
72
+ }
73
+ return nested;
74
+ }
75
+ const WHITESPACE_RUN = /[ \t\r\n]+/g;
76
+ function collectEdits(nodes, edits) {
77
+ for (const node of nodes) {
78
+ if (!isRecord(node))
79
+ continue;
80
+ if (node.type === 'Text') {
81
+ const start = numberAt(node, 'start');
82
+ const end = numberAt(node, 'end');
83
+ const data = stringAt(node, 'data');
84
+ if (start !== undefined && end !== undefined && data !== undefined) {
85
+ const edit = textEdit(start, end, data);
86
+ if (edit !== undefined)
87
+ edits.push(edit);
88
+ }
89
+ continue;
90
+ }
91
+ collectEdits(nestedNodes(node), edits);
92
+ }
93
+ }
94
+ function textEdit(start, end, data) {
95
+ if (!WHITESPACE_RUN.test(data))
96
+ return undefined; // fast path: nothing to collapse
97
+ WHITESPACE_RUN.lastIndex = 0;
98
+ const collapsed = data.replace(WHITESPACE_RUN, ' ');
99
+ if (collapsed.trim() === '') {
100
+ // Whitespace-only node: delete it only if the original spanned a line break, else leave it
101
+ // as the single collapsed space.
102
+ return data.includes('\n') ? { start, end, text: '' } : { start, end, text: collapsed };
103
+ }
104
+ if (collapsed === data)
105
+ return undefined;
106
+ return { start, end, text: collapsed };
107
+ }
108
+ // Applied back-to-front so an earlier edit's offsets stay valid, same as scoped-styles.ts.
109
+ function applyEdits(content, edits) {
110
+ return [...edits]
111
+ .sort((left, right) => right.start - left.start)
112
+ .reduce((source, edit) => source.slice(0, edit.start) + edit.text + source.slice(edit.end), content);
113
+ }
114
+ export function collapseTextWhitespace() {
115
+ return {
116
+ markup({ content, filename }) {
117
+ const ast = parse(content, { filename, modern: true });
118
+ const edits = [];
119
+ collectEdits(fragmentNodes(ast), edits);
120
+ if (edits.length === 0)
121
+ return { code: content };
122
+ return { code: applyEdits(content, edits) };
123
+ },
124
+ };
125
+ }
@@ -68,6 +68,18 @@ function scopedStylesPreprocessor() {
68
68
  );
69
69
  return scopedStylesPromise;
70
70
  }
71
+
72
+ // Collapses whitespace inside Text node content the same way a browser/Vue's template compiler
73
+ // would (svelte-adapter-dom-shim skill §16/§29) — without it, a sentence an author wrapped across
74
+ // source lines for readability ships a literal newline + indent into the native text content and
75
+ // renders as a forced line break on device. Loaded lazily for the same reason as the two above.
76
+ let collapseTextWhitespacePromise;
77
+ function collapseTextWhitespacePreprocessor() {
78
+ collapseTextWhitespacePromise ??= import('@symbiote-native/svelte/collapse-text-whitespace').then(
79
+ mod => mod.collapseTextWhitespace(),
80
+ );
81
+ return collapseTextWhitespacePromise;
82
+ }
71
83
  //
72
84
  // Svelte 5's compiler strips <script lang="ts"> types structurally, with no external type
73
85
  // resolution needed (unlike @vue/compiler-sfc's compileScript, which needs registerTS + a real
@@ -116,9 +128,12 @@ module.exports.transform = async function transform(params) {
116
128
  // Throws with a message naming the RN alternative. Deliberately BEFORE compile(), so the
117
129
  // author sees the real diagnosis rather than a downstream symptom.
118
130
  (await webOnlyConstructGuard()).markup({ content: params.src, filename: params.filename });
119
- const preprocessed = await (
131
+ const stylePreprocessed = await (
120
132
  await scopedStylesPreprocessor()
121
133
  ).markup({ content: params.src, filename: params.filename });
134
+ const preprocessed = await (
135
+ await collapseTextWhitespacePreprocessor()
136
+ ).markup({ content: stylePreprocessed.code, filename: params.filename });
122
137
  const code = compileSvelteFile(preprocessed.code, params.filename);
123
138
  // Re-label as .tsx so RN's transformer processes the module exactly like app source; Metro
124
139
  // tracks the real path separately. Matches metro-vue-transformer.cjs's identical trick.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/svelte",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SymbioteNative's Svelte adapter — a DOM shim over stock compiled Svelte output driving real native iOS/Android views through the same engine as every other adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,10 @@
38
38
  "types": "./build/preprocessor/scoped-styles.d.ts",
39
39
  "default": "./build/preprocessor/scoped-styles.js"
40
40
  },
41
+ "./collapse-text-whitespace": {
42
+ "types": "./build/preprocessor/collapse-text-whitespace.d.ts",
43
+ "default": "./build/preprocessor/collapse-text-whitespace.js"
44
+ },
41
45
  "./style-scope": {
42
46
  "types": "./build/style-scope.d.ts",
43
47
  "default": "./build/style-scope.js"
@@ -71,8 +75,8 @@
71
75
  "@vue/runtime-core": "^3.5.13",
72
76
  "react": "19.2.3",
73
77
  "svelte": "^5.56.0",
74
- "@symbiote-native/engine": "0.2.0",
75
78
  "@symbiote-native/react": "0.3.0",
79
+ "@symbiote-native/engine": "0.2.0",
76
80
  "@symbiote-native/test-utils": "0.1.6",
77
81
  "@symbiote-native/vue": "0.4.0"
78
82
  },