@keepsnappy/react-native 0.1.0-alpha.15 → 0.1.0-alpha.17
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/README.md +12 -0
- package/js/babel-transformer.js +31 -6
- package/js/component-contract.cjs +14 -0
- package/js/component-stamps.js +207 -0
- package/js/sdk/bytes.ts +20 -0
- package/js/sdk/core.ts +92 -22
- package/js/sdk/device-model.ts +10 -0
- package/js/sdk/fiber.ts +352 -28
- package/js/sdk/interactions.ts +38 -24
- package/js/sdk/place.ts +25 -0
- package/js/sdk/profile-export.ts +15 -0
- package/js/sdk/profile.ts +272 -18
- package/js/sdk/push-install.ts +2 -1
- package/js/sdk/push.ts +70 -46
- package/js/sdk/sampler.ts +1 -1
- package/js/sdk/screens.ts +91 -6
- package/js/sdk/stream.ts +6 -0
- package/metro.js +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,18 @@ const { withKeepSnappy } = require('@keepsnappy/react-native/metro');
|
|
|
19
19
|
module.exports = withKeepSnappy(config);
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Shared client packages can opt into component keys:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
module.exports = withKeepSnappy(config, { clientPackages: ['@my-app/ui'] });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
With the flag on, `.js/.jsx/.ts/.tsx` files inside the real project root, outside `node_modules`, and owned by the root package count as client code. Another package (including a workspace sibling or linked SDK) counts only when its name is in `clientPackages` (default `[]`). Package keys use `<package>/<path inside it>`, never a parent-relative path.
|
|
29
|
+
|
|
30
|
+
A plugin in the client's own Babel pipeline stamps only module-scope named functions, React component classes, function-valued constants and outer `memo`/`forwardRef` bindings. Nested declarations and anonymous default exports remain unstamped. An unsupported instrumentation path falls back to the client's transform. The key is `Name@file`; the file part uses `encodeURIComponent`, keeps `/` and `@` readable, and additionally encodes `~`. Names support uppercase/titlecase Unicode identifiers. Parsing accepts only the exact canonical encoding. `@keepsnappy/facts` owns the grammar and fragment schema. The SDK's small build-time encoder is pinned by a round-trip test through facts, without adding a private workspace dependency to the shipped SDK.
|
|
31
|
+
|
|
32
|
+
Workers write atomic, content-addressed fragments under `.keepsnappy/components/`, with source paths relative to the project root. Metro excludes that directory from its resolver. `keepsnappy sourcemaps upload` sends source maps first, then prepares sorted `.keepsnappy/components.json`; component-map errors only print a warning. Use `--project-root PATH` for a moved checkout. Each configuration has its own `.config.json`; when several exist, select its directory name with `--components-config ID`. Keep this build-output directory out of version control. The map's upload awaits API support.
|
|
33
|
+
|
|
22
34
|
`app.config.js` / `app.json`:
|
|
23
35
|
|
|
24
36
|
```js
|
package/js/babel-transformer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Wraps the app's Babel transformer (set by withKeepSnappy)
|
|
2
|
-
//
|
|
1
|
+
// Wraps the app's Babel transformer (set by withKeepSnappy), stamps client
|
|
2
|
+
// declarations and imports the keepsnappy boot module in the entry. That import puts the SDK
|
|
3
3
|
// in the graph; getModulesRunBeforeMainModule then runs it before the entry.
|
|
4
4
|
//
|
|
5
5
|
// babel-preset-expo inlines EXPO_PUBLIC_* only outside node_modules, and the
|
|
@@ -12,6 +12,9 @@ const upstreamPath = (process.env.KEEPSNAPPY_UPSTREAM_BABEL_TRANSFORMER);
|
|
|
12
12
|
const entryFile = (process.env.KEEPSNAPPY_ENTRY_FILE);
|
|
13
13
|
const bootFile = (process.env.KEEPSNAPPY_BOOT_FILE);
|
|
14
14
|
const sdkVersion = process.env.KEEPSNAPPY_SDK_VERSION;
|
|
15
|
+
const clientPackages = JSON.parse(process.env.KEEPSNAPPY_CLIENT_PACKAGES ?? '[]');
|
|
16
|
+
const projectRoot = process.env.KEEPSNAPPY_PROJECT_ROOT;
|
|
17
|
+
const componentCacheKey = process.env.KEEPSNAPPY_COMPONENT_CACHE_KEY ?? '';
|
|
15
18
|
|
|
16
19
|
if (!upstreamPath || !entryFile || !bootFile || !sdkVersion) {
|
|
17
20
|
throw new Error(
|
|
@@ -53,23 +56,45 @@ const absolute = (filename, options) =>
|
|
|
53
56
|
module.exports = {
|
|
54
57
|
...upstream,
|
|
55
58
|
transform(args) {
|
|
59
|
+
if (process.env.EXPO_PUBLIC_KEEPSNAPPY !== '1') return upstream.transform(args);
|
|
56
60
|
const file = absolute(args.filename, args.options);
|
|
61
|
+
const original = args;
|
|
57
62
|
if (file === entryFile) {
|
|
58
|
-
|
|
63
|
+
args = { ...args, src: bootImport() + args.src };
|
|
59
64
|
}
|
|
60
65
|
if (file.startsWith(packageRoot)) {
|
|
61
66
|
const src = file === path.join(packageRoot, 'js', 'sdk', 'push-install.ts')
|
|
62
67
|
? args.src.replace("require('../../package.json')", JSON.stringify({ version: sdkVersion }))
|
|
63
68
|
: args.src;
|
|
64
|
-
|
|
69
|
+
args = { ...args, src: inlineEnv(src) };
|
|
65
70
|
}
|
|
66
|
-
|
|
71
|
+
const root = args.options?.projectRoot ?? projectRoot ?? process.cwd();
|
|
72
|
+
// A client file built without its stamps keys its components to an ancestor's: never silently.
|
|
73
|
+
const skipped = error => console.warn(`KeepSnappy: component stamps skipped for ${path.relative(root, file)}: ${String(error)}`);
|
|
74
|
+
let stamps;
|
|
75
|
+
try {
|
|
76
|
+
stamps = require('./component-stamps.js').instrumentation(
|
|
77
|
+
original.src, file, root, clientPackages, file === entryFile ? 1 : 0,
|
|
78
|
+
);
|
|
79
|
+
} catch (error) { skipped(error); return upstream.transform(args); }
|
|
80
|
+
if (!stamps) return upstream.transform(args);
|
|
81
|
+
const complete = result => { stamps.complete(); return result; };
|
|
82
|
+
const passthrough = error => {
|
|
83
|
+
skipped(error);
|
|
84
|
+
stamps.complete(true);
|
|
85
|
+
return upstream.transform(args);
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const result = upstream.transform({ ...args, plugins: [stamps.plugin, ...(args.plugins ?? [])] });
|
|
89
|
+
return result && typeof result.then === 'function'
|
|
90
|
+
? result.then(complete, passthrough) : complete(result);
|
|
91
|
+
} catch (error) { return passthrough(error); }
|
|
67
92
|
},
|
|
68
93
|
getCacheKey(...args) {
|
|
69
94
|
const base =
|
|
70
95
|
typeof upstream.getCacheKey === 'function'
|
|
71
96
|
? upstream.getCacheKey(...args)
|
|
72
97
|
: '';
|
|
73
|
-
return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}`;
|
|
98
|
+
return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}:components-v2:${JSON.stringify(clientPackages)}:${componentCacheKey}`;
|
|
74
99
|
},
|
|
75
100
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Build-time copy of @keepsnappy/facts. The SDK ships without the
|
|
2
|
+
// private facts workspace; Metro's round-trip test pins this encoder to it.
|
|
3
|
+
const FRAGMENT_VERSION = 2;
|
|
4
|
+
const isComponentName = name => typeof name === 'string' && /^[\p{Lu}\p{Lt}][\p{ID_Continue}$\u200C\u200D]*$/u.test(name);
|
|
5
|
+
function componentKey(name, file) {
|
|
6
|
+
if ((name !== 'default' && !isComponentName(name)) || typeof file !== 'string' || !file
|
|
7
|
+
|| /^(?:\/|[A-Za-z]:)/.test(file) || file.includes('\\')
|
|
8
|
+
|| file.split('/').some(part => part === '..' || part === '.' || part === '')) {
|
|
9
|
+
throw new Error('invalid client component name or file');
|
|
10
|
+
}
|
|
11
|
+
const encoded = encodeURIComponent(file).replace(/%2F/g, '/').replace(/%40/g, '@').replace(/~/g, '%7E');
|
|
12
|
+
return `${name}@${encoded}`;
|
|
13
|
+
}
|
|
14
|
+
module.exports = { FRAGMENT_VERSION, componentKey, isComponentName };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { createHash, randomUUID } = require('crypto');
|
|
4
|
+
const { FRAGMENT_VERSION, componentKey, isComponentName } = require('./component-contract.cjs');
|
|
5
|
+
|
|
6
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const posix = value => value.replace(/\\/g, '/');
|
|
8
|
+
const realPath = file => {
|
|
9
|
+
try { return fs.realpathSync(file); }
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code !== 'ENOENT') throw error;
|
|
12
|
+
return path.resolve(file);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function atomicJson(file, value) {
|
|
17
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
18
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(temporary, JSON.stringify(value));
|
|
21
|
+
fs.renameSync(temporary, file);
|
|
22
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const configuration = clientPackages => hash(JSON.stringify([FRAGMENT_VERSION, clientPackages]));
|
|
26
|
+
const contexts = new Map();
|
|
27
|
+
function context(projectRoot) {
|
|
28
|
+
const absolute = path.resolve(projectRoot);
|
|
29
|
+
if (contexts.has(absolute)) return contexts.get(absolute);
|
|
30
|
+
const root = realPath(absolute);
|
|
31
|
+
const owners = new Map();
|
|
32
|
+
const owningPackage = directory => {
|
|
33
|
+
if (owners.has(directory)) return owners.get(directory);
|
|
34
|
+
let owner;
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
|
37
|
+
if (typeof pkg.name === 'string') owner = { name: pkg.name, directory };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
40
|
+
}
|
|
41
|
+
if (!owner) {
|
|
42
|
+
const parent = path.dirname(directory);
|
|
43
|
+
owner = parent === directory ? null : owningPackage(parent);
|
|
44
|
+
}
|
|
45
|
+
owners.set(directory, owner);
|
|
46
|
+
return owner;
|
|
47
|
+
};
|
|
48
|
+
const value = { root, owningPackage, projectPackage: owningPackage(root)?.name };
|
|
49
|
+
contexts.set(absolute, value);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function configure(projectRoot, clientPackages) {
|
|
54
|
+
context(projectRoot);
|
|
55
|
+
const directory = configuration(clientPackages);
|
|
56
|
+
const selected = path.join(projectRoot, '.keepsnappy', 'components', directory);
|
|
57
|
+
fs.mkdirSync(selected, { recursive: true });
|
|
58
|
+
const cacheFile = path.join(selected, '.cache-key');
|
|
59
|
+
try { fs.writeFileSync(cacheFile, randomUUID(), { flag: 'wx' }); }
|
|
60
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
61
|
+
atomicJson(path.join(selected, '.config.json'), { version: FRAGMENT_VERSION, directory });
|
|
62
|
+
return fs.readFileSync(cacheFile, 'utf8');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clientFile(filename, projectRoot, clientPackages) {
|
|
66
|
+
if (!/\.[jt]sx?$/.test(filename)) return null;
|
|
67
|
+
const source = realPath(filename);
|
|
68
|
+
if (source.split(path.sep).includes('node_modules') && clientPackages.length === 0) return null;
|
|
69
|
+
const { root, owningPackage, projectPackage } = context(projectRoot);
|
|
70
|
+
const pkg = owningPackage(path.dirname(source));
|
|
71
|
+
const relative = posix(path.relative(root, source));
|
|
72
|
+
if (pkg && clientPackages.includes(pkg.name)) {
|
|
73
|
+
return { source: relative, file: `${pkg.name}/${posix(path.relative(pkg.directory, source))}` };
|
|
74
|
+
}
|
|
75
|
+
if (relative.startsWith('../') || path.isAbsolute(relative) || source.split(path.sep).includes('node_modules')) return null;
|
|
76
|
+
if (pkg && pkg.name !== projectPackage) return null;
|
|
77
|
+
return { source: relative, file: relative };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reactMember = (node, names) => node?.type === 'Identifier'
|
|
81
|
+
? names.includes(node.name)
|
|
82
|
+
: node?.type === 'MemberExpression' && !node.computed && node.object.type === 'Identifier'
|
|
83
|
+
&& node.object.name === 'React' && names.includes(node.property.name);
|
|
84
|
+
const unwrap = node => {
|
|
85
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression', 'ParenthesizedExpression'].includes(node?.type)) node = node.expression;
|
|
86
|
+
return node;
|
|
87
|
+
};
|
|
88
|
+
const componentValue = node => {
|
|
89
|
+
node = unwrap(node);
|
|
90
|
+
return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression'
|
|
91
|
+
|| (node?.type === 'CallExpression' && reactMember(node.callee, ['memo', 'forwardRef']));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const jsxResult = node => {
|
|
95
|
+
node = unwrap(node);
|
|
96
|
+
if (!node) return false;
|
|
97
|
+
if (['JSXElement', 'JSXFragment'].includes(node.type)) return true;
|
|
98
|
+
if (node.type === 'ConditionalExpression') return jsxResult(node.consequent) || jsxResult(node.alternate);
|
|
99
|
+
if (node.type === 'LogicalExpression') return jsxResult(node.right) || (node.operator !== '&&' && jsxResult(node.left));
|
|
100
|
+
if (node.type === 'SequenceExpression') return jsxResult(node.expressions.at(-1));
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
const returnsJSX = (body, t) => {
|
|
104
|
+
if (body.type !== 'BlockStatement') return jsxResult(body);
|
|
105
|
+
const visit = node => {
|
|
106
|
+
if (!node || t.isFunction(node) || t.isClass(node)) return false;
|
|
107
|
+
if (node.type === 'ReturnStatement') return jsxResult(node.argument);
|
|
108
|
+
return (t.VISITOR_KEYS[node.type] ?? []).some(key => {
|
|
109
|
+
const child = node[key];
|
|
110
|
+
return Array.isArray(child) ? child.some(visit) : visit(child);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
return visit(body);
|
|
114
|
+
};
|
|
115
|
+
const anonymousRender = (node, t) => {
|
|
116
|
+
node = unwrap(node);
|
|
117
|
+
if (node?.type === 'CallExpression' && reactMember(node.callee, ['memo', 'forwardRef'])) return anonymousRender(node.arguments[0], t);
|
|
118
|
+
return ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node?.type)
|
|
119
|
+
&& !node.id && !node.async && !node.generator && returnsJSX(node.body, t);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// A plugin that ran first (React Compiler listed in the app's babel.config.js) may put back regenerated nodes without
|
|
123
|
+
// `loc`; the line then comes from the declaration in the source.
|
|
124
|
+
const declarationLine = (src, id, statement, lineOffset) => {
|
|
125
|
+
const line = id.loc?.start?.line ?? statement.loc?.start?.line;
|
|
126
|
+
if (typeof line === 'number') return Math.max(1, line - lineOffset);
|
|
127
|
+
const match = new RegExp(`^[ \\t]*(?:export[ \\t]+(?:default[ \\t]+)?)?(?:async[ \\t]+)?(?:function\\*?|class|const)[ \\t]+${id.name}\\b`, 'm').exec(src);
|
|
128
|
+
return match ? src.slice(0, match.index).split('\n').length : 1;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function instrumentation(src, filename, projectRoot, clientPackages, lineOffset = 0) {
|
|
132
|
+
const client = clientFile(filename, projectRoot, clientPackages);
|
|
133
|
+
if (!client) return null;
|
|
134
|
+
const components = [];
|
|
135
|
+
const contentHash = hash(src);
|
|
136
|
+
const target = path.join(projectRoot, '.keepsnappy', 'components', configuration(clientPackages), `${hash(client.source)}-${contentHash}.json`);
|
|
137
|
+
const plugin = ({ types: t }) => ({
|
|
138
|
+
name: 'keepsnappy-components',
|
|
139
|
+
visitor: {
|
|
140
|
+
Program(program) {
|
|
141
|
+
// Inspect the original module body once, before preset visitors lower declarations.
|
|
142
|
+
for (const statement of program.get('body')) {
|
|
143
|
+
const node = statement.node;
|
|
144
|
+
const declaration = ['ExportNamedDeclaration', 'ExportDefaultDeclaration'].includes(node.type) ? node.declaration : node;
|
|
145
|
+
if (!declaration || declaration.declare) continue;
|
|
146
|
+
const found = [];
|
|
147
|
+
if (declaration.type === 'FunctionDeclaration' && isComponentName(declaration.id?.name)) found.push(declaration.id);
|
|
148
|
+
else if (declaration.type === 'ClassDeclaration' && isComponentName(declaration.id?.name)
|
|
149
|
+
&& reactMember(declaration.superClass, ['Component', 'PureComponent'])) found.push(declaration.id);
|
|
150
|
+
else if (declaration.type === 'VariableDeclaration' && declaration.kind === 'const') {
|
|
151
|
+
for (const binding of declaration.declarations) {
|
|
152
|
+
if (binding.id.type === 'Identifier' && isComponentName(binding.id.name) && componentValue(binding.init)) found.push(binding.id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
let defaultId;
|
|
156
|
+
if (node.type === 'ExportDefaultDeclaration' && (
|
|
157
|
+
anonymousRender(declaration, t) || (declaration.type === 'ClassDeclaration' && !declaration.id
|
|
158
|
+
&& reactMember(declaration.superClass, ['Component', 'PureComponent']))
|
|
159
|
+
)) {
|
|
160
|
+
// Only real component defaults gain an uppercase binding.
|
|
161
|
+
let name = 'KeepSnappyDefault';
|
|
162
|
+
while (program.scope.hasBinding(name) || program.scope.hasGlobal(name) || program.scope.hasReference(name)) name += '_';
|
|
163
|
+
defaultId = t.identifier(name);
|
|
164
|
+
defaultId.loc = declaration.loc;
|
|
165
|
+
if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
|
|
166
|
+
// Keep declarations in place, including function hoisting through circular imports.
|
|
167
|
+
declaration.id = defaultId;
|
|
168
|
+
} else {
|
|
169
|
+
statement.insertBefore(t.variableDeclaration('const', [t.variableDeclarator(defaultId, declaration)]));
|
|
170
|
+
statement.get('declaration').replaceWith(t.cloneNode(defaultId));
|
|
171
|
+
}
|
|
172
|
+
found.push(defaultId);
|
|
173
|
+
program.scope.crawl();
|
|
174
|
+
}
|
|
175
|
+
const stamps = [];
|
|
176
|
+
for (const id of found) {
|
|
177
|
+
const componentName = id === defaultId ? 'default' : id.name;
|
|
178
|
+
// One declaration that cannot be stamped must not cost the file its other stamps.
|
|
179
|
+
try {
|
|
180
|
+
const key = componentKey(componentName, client.file);
|
|
181
|
+
const line = declarationLine(src, id, statement.node, lineOffset);
|
|
182
|
+
const name = () => t.identifier(id.name);
|
|
183
|
+
const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
|
|
184
|
+
stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
|
|
185
|
+
t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
|
|
186
|
+
t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
|
|
187
|
+
components.push({ key, name: componentName, file: client.file, line });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.warn(`KeepSnappy: could not stamp ${componentName} in ${client.file}: ${String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (stamps.length) statement.insertAfter(stamps);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
plugin,
|
|
199
|
+
complete(skipped = false) {
|
|
200
|
+
// Inventory output is optional; source compilation must remain usable on write failures.
|
|
201
|
+
try { atomicJson(target, { version: FRAGMENT_VERSION, source: client.source, hash: contentHash, components: skipped ? [] : components }); }
|
|
202
|
+
catch { /* The CLI reports an unavailable inventory without blocking source maps. */ }
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { configure, instrumentation };
|
package/js/sdk/bytes.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The push queue drops a window line over this many bytes (push.ts enqueue). The window summary budgets against half of it.
|
|
2
|
+
export const MAX_BATCH_BYTES = 256 * 1024;
|
|
3
|
+
|
|
4
|
+
export const utf8Length = (text: string) => {
|
|
5
|
+
let n = 0;
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const c = text.charCodeAt(i);
|
|
8
|
+
if (c < 0x80) {
|
|
9
|
+
n += 1;
|
|
10
|
+
} else if (c < 0x800) {
|
|
11
|
+
n += 2;
|
|
12
|
+
} else if (c >= 0xd800 && c <= 0xdbff) {
|
|
13
|
+
n += 4;
|
|
14
|
+
i++;
|
|
15
|
+
} else {
|
|
16
|
+
n += 3;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return n;
|
|
20
|
+
};
|