mappel 0.1.0 → 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.
- package/README.md +17 -5
- package/bin/mappel.mjs +130 -0
- package/package.json +2 -3
- package/src/index.mjs +107 -5
- package/bin/import-map.mjs +0 -92
package/README.md
CHANGED
|
@@ -8,16 +8,28 @@ It resolves each specifier through the package's own exports map — nested cond
|
|
|
8
8
|
|
|
9
9
|
A repo declares named layers in importmap.config.mjs, and a layer can exclude what another already resolves, so a page loads two maps and neither repeats the other. Layers can also drop a whole scope by prefix. A css specifier maps to a `.js` sibling that adds the file as a link, because a browser cannot import a stylesheet as a module. Pass `--css link` to get the specifier mapped away and the link tags printed instead.
|
|
10
10
|
|
|
11
|
+
Every map is written twice: `name.json` and `name.js`, which installs the map when a page loads it with a script tag. The script must come before the first module script in the page, or it is ignored. Inline maps are registered when their element is inserted, and multiple maps in one page require Chromium 133 or newer.
|
|
12
|
+
|
|
11
13
|
```sh
|
|
12
|
-
mappel
|
|
13
|
-
mappel --
|
|
14
|
-
mappel --layer
|
|
15
|
-
mappel --layer min --html
|
|
14
|
+
mappel # every layer, to the out in the config
|
|
15
|
+
mappel --no-js # the json only, without the script siblings
|
|
16
|
+
mappel --layer min # one layer, to stdout
|
|
17
|
+
mappel --layer min --html # ready to paste into a page
|
|
18
|
+
mappel --layer min --dist-tag alpha # follow a channel instead of pinning
|
|
19
|
+
mappel --config packages/cdn/mappel.config.mjs --out dist
|
|
16
20
|
```
|
|
17
21
|
|
|
22
|
+
An option given on the command line wins; the config supplies what the command line
|
|
23
|
+
does not. With no arguments it looks for `mappel.config.mjs` from the current
|
|
24
|
+
directory upwards, so a package's build script is just `mappel`.
|
|
25
|
+
|
|
18
26
|
```js
|
|
19
|
-
//
|
|
27
|
+
// mappel.config.mjs
|
|
20
28
|
export default {
|
|
29
|
+
out: 'packages/cdn/dist', // relative to this file
|
|
30
|
+
workspaces: ['packages'],
|
|
31
|
+
split: { components: 'components' }, // a file per package in that layer
|
|
32
|
+
js: false, // skip the script siblings
|
|
21
33
|
layers: {
|
|
22
34
|
base: { packages: ['@scope/core', '@scope/dom'] },
|
|
23
35
|
extra: { packages: ['@scope/extra'], excludes: ['base'] },
|
package/bin/mappel.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Write the import maps a workspace declares.
|
|
3
|
+
//
|
|
4
|
+
// mappel every layer in mappel.config.mjs, to its `out`
|
|
5
|
+
// mappel --layer min one layer, to stdout
|
|
6
|
+
// mappel --config packages/cdn/mappel.config.mjs --out dist
|
|
7
|
+
// mappel --layer min --dist-tag alpha --html
|
|
8
|
+
// mappel --no-js maps only, without their script siblings
|
|
9
|
+
//
|
|
10
|
+
// With no arguments it finds mappel.config.mjs by walking up from the current
|
|
11
|
+
// directory, so a package's build script is just `mappel`. Every map is written
|
|
12
|
+
// twice: `<name>.json` to read, and `<name>.js` to load from a page.
|
|
13
|
+
|
|
14
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { buildLayer, writeAll, writeMap, subpathsOf } from '../src/index.mjs';
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const flag = (name, fallback) => {
|
|
21
|
+
const i = argv.indexOf(`--${name}`);
|
|
22
|
+
return i === -1 ? fallback : argv[i + 1];
|
|
23
|
+
};
|
|
24
|
+
const has = (name) => argv.includes(`--${name}`);
|
|
25
|
+
|
|
26
|
+
const NAMES = ['mappel.config.mjs', 'mappel.config.js', 'importmap.config.mjs'];
|
|
27
|
+
|
|
28
|
+
function findConfig(from) {
|
|
29
|
+
let dir = resolvePath(from);
|
|
30
|
+
while (true) {
|
|
31
|
+
for (const name of NAMES) {
|
|
32
|
+
const candidate = join(dir, name);
|
|
33
|
+
if (existsSync(candidate)) return candidate;
|
|
34
|
+
}
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
if (parent === dir) return null;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const explicit = flag('config', null);
|
|
42
|
+
const configPath = explicit ? resolvePath(explicit) : findConfig(process.cwd());
|
|
43
|
+
if (!configPath) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`[mappel] no ${NAMES[0]} here or above ${process.cwd()}\n` +
|
|
46
|
+
` point at one with --config, or see https://github.com/bambiste/mappel\n`,
|
|
47
|
+
);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let config;
|
|
52
|
+
try {
|
|
53
|
+
config = (await import(pathToFileURL(configPath).href)).default;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
process.stderr.write(`[mappel] cannot read ${configPath}\n ${error.message}\n`);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The config lives at the root of what it describes unless it says otherwise, so
|
|
60
|
+
// a path in it reads relative to the file rather than to where this was run from.
|
|
61
|
+
const base = dirname(configPath);
|
|
62
|
+
const root = resolvePath(flag('root', config.root ? join(base, config.root) : base));
|
|
63
|
+
|
|
64
|
+
const options = {
|
|
65
|
+
root,
|
|
66
|
+
target: flag('target', config.target ?? 'cdn'),
|
|
67
|
+
cdn: flag('cdn', config.cdn ?? 'https://unpkg.com'),
|
|
68
|
+
css: flag('css', config.css ?? 'loader'),
|
|
69
|
+
distTag: flag('dist-tag', config.distTag ?? null),
|
|
70
|
+
workspaces: config.workspaces ?? ['packages'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const layerName = flag('layer', null);
|
|
74
|
+
const splitInto = flag('split', null);
|
|
75
|
+
|
|
76
|
+
// Each map gets a script sibling that installs it. `--no-js` writes only JSON.
|
|
77
|
+
const settings = { ...config, js: has('no-js') ? false : has('js') ? true : config.js };
|
|
78
|
+
|
|
79
|
+
if (!layerName && !splitInto) {
|
|
80
|
+
const out = resolvePath(flag('out', config.out ? join(base, config.out) : join(base, 'dist')));
|
|
81
|
+
const written = writeAll(settings, { ...options, out });
|
|
82
|
+
for (const { file, entries, split } of written) {
|
|
83
|
+
process.stderr.write(` ${file} — ${entries} ${split ? 'files' : 'entries'}\n`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (splitInto) {
|
|
89
|
+
const dir = resolvePath(splitInto);
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
const layer = config.layers[layerName];
|
|
92
|
+
const names =
|
|
93
|
+
typeof layer.packages === 'function'
|
|
94
|
+
? layer.packages({ subpathsOf: (n) => subpathsOf(root, n, options.workspaces), root })
|
|
95
|
+
: layer.packages;
|
|
96
|
+
let written = 0;
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const one = buildLayer(
|
|
99
|
+
layerName,
|
|
100
|
+
{ ...config, layers: { ...config.layers, [layerName]: { ...layer, packages: [name] } } },
|
|
101
|
+
options,
|
|
102
|
+
);
|
|
103
|
+
if (Object.keys(one.imports).length === 0) continue;
|
|
104
|
+
writeMap(join(dir, name.split('/').pop() + '.json'), one.imports, settings);
|
|
105
|
+
written += 1;
|
|
106
|
+
}
|
|
107
|
+
process.stderr.write(`wrote ${written} maps to ${dir}\n`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { imports, stylesheets, unresolved } = buildLayer(layerName, config, options);
|
|
112
|
+
const json = JSON.stringify({ imports }, null, 2);
|
|
113
|
+
const out = flag('out', null);
|
|
114
|
+
|
|
115
|
+
if (out) {
|
|
116
|
+
if (out.endsWith('.json')) writeMap(out, imports, settings);
|
|
117
|
+
else writeFileSync(out, json + '\n');
|
|
118
|
+
process.stderr.write(`wrote ${out} — ${Object.keys(imports).length} entries\n`);
|
|
119
|
+
} else if (has('html')) {
|
|
120
|
+
const links =
|
|
121
|
+
options.css === 'link' ? stylesheets.map((h) => `<link rel="stylesheet" href="${h}" />`).join('\n') : '';
|
|
122
|
+
process.stdout.write(`<script type="importmap">\n${json}\n</script>\n${links ? links + '\n' : ''}`);
|
|
123
|
+
} else {
|
|
124
|
+
process.stdout.write(json + '\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (unresolved.length) {
|
|
128
|
+
process.stderr.write('\nnot in the map (check they are installed):\n');
|
|
129
|
+
for (const u of unresolved.sort()) process.stderr.write(` ${u}\n`);
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mappel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "generate the import map a page needs from a workspace: resolves every transitive subpath through each package exports map, in layers that compose",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"mappel": "./bin/
|
|
9
|
-
"import-map": "./bin/import-map.mjs"
|
|
8
|
+
"mappel": "./bin/mappel.mjs"
|
|
10
9
|
},
|
|
11
10
|
"exports": {
|
|
12
11
|
".": "./src/index.mjs",
|
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, normalize, relative } from 'node:path';
|
|
3
3
|
import { manifest, packageDir, resolveExport, splitSpecifier, subpathsOf } from './resolve.mjs';
|
|
4
4
|
|
|
@@ -25,7 +25,18 @@ function scanImports(file) {
|
|
|
25
25
|
* mapped to a url a browser can fetch.
|
|
26
26
|
*/
|
|
27
27
|
export function collect(specifiers, options) {
|
|
28
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
root,
|
|
30
|
+
target = 'cdn',
|
|
31
|
+
cdn = 'https://unpkg.com',
|
|
32
|
+
css = 'loader',
|
|
33
|
+
workspaces,
|
|
34
|
+
// The version in the url. A manifest version pins the map to exactly what was
|
|
35
|
+
// installed when it was written, which is what a published map wants. A dist
|
|
36
|
+
// tag follows a channel instead, so a page tracks it without being rebuilt —
|
|
37
|
+
// at the cost of the page changing when the channel does.
|
|
38
|
+
distTag = null,
|
|
39
|
+
} = options;
|
|
29
40
|
const imports = {};
|
|
30
41
|
const stylesheets = new Set();
|
|
31
42
|
const unresolved = new Set();
|
|
@@ -33,9 +44,9 @@ export function collect(specifiers, options) {
|
|
|
33
44
|
|
|
34
45
|
const urlFor = (name, dir, file) => {
|
|
35
46
|
const clean = file.replace(/^\.\//, '');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
if (target === 'local') return '/' + relative(root, join(dir, clean)).split('\\').join('/');
|
|
48
|
+
const version = distTag === 'none' ? '' : '@' + (distTag || manifest(dir).version);
|
|
49
|
+
return `${cdn}/${name}${version}/${clean}`;
|
|
39
50
|
};
|
|
40
51
|
|
|
41
52
|
const add = (spec) => {
|
|
@@ -118,3 +129,94 @@ function helpers(options) {
|
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
export { subpathsOf, packageDir, resolveExport };
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The script sibling of a map. A page that cannot write JSON into its own
|
|
135
|
+
* markup — a docs site, a playground, anything served as a template — gets the
|
|
136
|
+
* map by loading one classic script instead.
|
|
137
|
+
*
|
|
138
|
+
* An inline map is registered when the element is inserted, so a second file
|
|
139
|
+
* cannot merge into the first one's element and each installs its own. Several
|
|
140
|
+
* maps in one page need Chromium 133 or newer; the combined file is the way to
|
|
141
|
+
* stay on one.
|
|
142
|
+
*/
|
|
143
|
+
function installer(imports) {
|
|
144
|
+
const map = JSON.stringify(JSON.stringify({ imports }));
|
|
145
|
+
return `(function(){var d=document,w=function(m){if(typeof console!=='undefined')console.warn('[mappel] '+m)};
|
|
146
|
+
if(d.querySelector('script[type="module"]'))w('the import map is being added after a module script and will not be used — load this file first');
|
|
147
|
+
var s=d.createElement('script');s.type='importmap';s.setAttribute('data-mappel','');s.textContent=${map};
|
|
148
|
+
s.addEventListener('error',function(){w('the browser rejected this import map — a page with several maps needs Chromium 133+, so use the combined map instead')});
|
|
149
|
+
(d.head||d.documentElement).appendChild(s);
|
|
150
|
+
(self.__mappel||(self.__mappel=[])).push(JSON.parse(s.textContent))})();
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function writeMap(file, imports, config = {}) {
|
|
155
|
+
writeFileSync(file, JSON.stringify({ imports }, null, 2) + '\n');
|
|
156
|
+
if (config.js !== false) writeFileSync(file.replace(/\.json$/, '.js'), installer(imports));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Write every layer a config declares, plus any per-item split it asks for.
|
|
161
|
+
* This is what `mappel` does with no arguments: the config says where the
|
|
162
|
+
* workspace is and where the files go, so a repo needs no script of its own.
|
|
163
|
+
*/
|
|
164
|
+
export function writeAll(config, options) {
|
|
165
|
+
const out = options.out;
|
|
166
|
+
mkdirSync(out, { recursive: true });
|
|
167
|
+
|
|
168
|
+
const written = [];
|
|
169
|
+
for (const name of Object.keys(config.layers)) {
|
|
170
|
+
const { imports } = buildLayer(name, config, options);
|
|
171
|
+
const file = join(out, `${name}.json`);
|
|
172
|
+
writeMap(file, imports, config);
|
|
173
|
+
written.push({ file, entries: Object.keys(imports).length });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A combined file, for engines that take only one import map. Several maps in
|
|
177
|
+
// one page need Chromium 133 or newer. A layer of the same name means the config
|
|
178
|
+
// already has one, and writing both would silently overwrite the layer's file.
|
|
179
|
+
const combined = config.full === true || config.full === undefined ? 'full' : config.full;
|
|
180
|
+
if (config.full !== false && config.layers[combined]) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`[mappel] a layer is named "${combined}" and the combined file would overwrite it. ` +
|
|
183
|
+
`Set full: false, or full: "<other-name>".`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (config.full !== false) {
|
|
187
|
+
const imports = {};
|
|
188
|
+
for (const name of Object.keys(config.layers)) {
|
|
189
|
+
Object.assign(imports, buildLayer(name, config, options).imports);
|
|
190
|
+
}
|
|
191
|
+
const file = join(out, `${combined}.json`);
|
|
192
|
+
writeMap(file, sorted(imports), config);
|
|
193
|
+
written.push({ file, entries: Object.keys(imports).length });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const [layerName, target] of Object.entries(config.split ?? {})) {
|
|
197
|
+
const dir = join(out, target);
|
|
198
|
+
mkdirSync(dir, { recursive: true });
|
|
199
|
+
const layer = config.layers[layerName];
|
|
200
|
+
const names =
|
|
201
|
+
typeof layer.packages === 'function'
|
|
202
|
+
? layer.packages({
|
|
203
|
+
subpathsOf: (n) => subpathsOf(options.root, n, options.workspaces),
|
|
204
|
+
root: options.root,
|
|
205
|
+
})
|
|
206
|
+
: layer.packages;
|
|
207
|
+
let count = 0;
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
const one = buildLayer(
|
|
210
|
+
layerName,
|
|
211
|
+
{ ...config, layers: { ...config.layers, [layerName]: { ...layer, packages: [name] } } },
|
|
212
|
+
options,
|
|
213
|
+
);
|
|
214
|
+
if (Object.keys(one.imports).length === 0) continue;
|
|
215
|
+
writeMap(join(dir, name.split('/').pop() + '.json'), one.imports, config);
|
|
216
|
+
count += 1;
|
|
217
|
+
}
|
|
218
|
+
written.push({ file: `${dir}/*.json`, entries: count, split: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return written;
|
|
222
|
+
}
|
package/bin/import-map.mjs
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Emit the import map a page needs, from the layers a repo defines.
|
|
3
|
-
//
|
|
4
|
-
// import-map --layer min
|
|
5
|
-
// import-map --layer components --split dist/components
|
|
6
|
-
// import-map --layer foundation --target local --html
|
|
7
|
-
//
|
|
8
|
-
// Layers come from importmap.config.mjs in the repo this runs in, so the tool
|
|
9
|
-
// stays generic and the policy stays where it belongs.
|
|
10
|
-
|
|
11
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
12
|
-
import { join, resolve as resolvePath } from 'node:path';
|
|
13
|
-
import { pathToFileURL } from 'node:url';
|
|
14
|
-
import { buildLayer, subpathsOf } from '../src/index.mjs';
|
|
15
|
-
|
|
16
|
-
const argv = process.argv.slice(2);
|
|
17
|
-
const flag = (name, fallback) => {
|
|
18
|
-
const i = argv.indexOf(`--${name}`);
|
|
19
|
-
return i === -1 ? fallback : argv[i + 1];
|
|
20
|
-
};
|
|
21
|
-
const has = (name) => argv.includes(`--${name}`);
|
|
22
|
-
|
|
23
|
-
// The repo being scanned, not where this tool is installed — it lives in
|
|
24
|
-
// node_modules, and every path it resolves hangs off this.
|
|
25
|
-
const root = resolvePath(flag('root', process.cwd()));
|
|
26
|
-
const configPath = resolvePath(flag('config', join(root, 'importmap.config.mjs')));
|
|
27
|
-
|
|
28
|
-
let config;
|
|
29
|
-
try {
|
|
30
|
-
config = (await import(pathToFileURL(configPath).href)).default;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
process.stderr.write(`[import-map] cannot read ${configPath}\n ${error.message}\n`);
|
|
33
|
-
process.exit(2);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const options = {
|
|
37
|
-
root,
|
|
38
|
-
target: flag('target', 'cdn'),
|
|
39
|
-
cdn: flag('cdn', 'https://unpkg.com'),
|
|
40
|
-
css: flag('css', 'loader'),
|
|
41
|
-
workspaces: config.workspaces ?? ['packages'],
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const layerName = flag('layer', Object.keys(config.layers)[0]);
|
|
45
|
-
const splitInto = flag('split', null);
|
|
46
|
-
|
|
47
|
-
if (splitInto) {
|
|
48
|
-
const layer = config.layers[layerName];
|
|
49
|
-
const names =
|
|
50
|
-
typeof layer.packages === 'function'
|
|
51
|
-
? layer.packages({ subpathsOf: (n) => subpathsOf(root, n, options.workspaces), root })
|
|
52
|
-
: layer.packages;
|
|
53
|
-
mkdirSync(splitInto, { recursive: true });
|
|
54
|
-
let written = 0;
|
|
55
|
-
for (const name of names) {
|
|
56
|
-
const one = buildLayer(
|
|
57
|
-
layerName,
|
|
58
|
-
{ ...config, layers: { ...config.layers, [layerName]: { ...layer, packages: [name] } } },
|
|
59
|
-
options,
|
|
60
|
-
);
|
|
61
|
-
if (Object.keys(one.imports).length === 0) continue;
|
|
62
|
-
writeFileSync(
|
|
63
|
-
join(splitInto, name.split('/').pop() + '.json'),
|
|
64
|
-
JSON.stringify({ imports: one.imports }, null, 2) + '\n',
|
|
65
|
-
);
|
|
66
|
-
written += 1;
|
|
67
|
-
}
|
|
68
|
-
process.stderr.write(`wrote ${written} maps to ${splitInto}\n`);
|
|
69
|
-
process.exit(0);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const { imports, stylesheets, unresolved } = buildLayer(layerName, config, options);
|
|
73
|
-
const json = JSON.stringify({ imports }, null, 2);
|
|
74
|
-
|
|
75
|
-
const out = flag('out', null);
|
|
76
|
-
if (out) {
|
|
77
|
-
writeFileSync(out, json + '\n');
|
|
78
|
-
process.stderr.write(`wrote ${out} — ${Object.keys(imports).length} entries\n`);
|
|
79
|
-
} else if (has('html')) {
|
|
80
|
-
const links =
|
|
81
|
-
options.css === 'link'
|
|
82
|
-
? stylesheets.map((href) => `<link rel="stylesheet" href="${href}" />`).join('\n')
|
|
83
|
-
: '';
|
|
84
|
-
process.stdout.write(`<script type="importmap">\n${json}\n</script>\n${links ? links + '\n' : ''}`);
|
|
85
|
-
} else {
|
|
86
|
-
process.stdout.write(json + '\n');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (unresolved.length) {
|
|
90
|
-
process.stderr.write('\nnot in the map (check they are installed):\n');
|
|
91
|
-
for (const u of unresolved.sort()) process.stderr.write(` ${u}\n`);
|
|
92
|
-
}
|