@backstage/plugin-scaffolder-node 0.5.0-next.0 → 0.5.0-next.2

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/dist/index.cjs.js CHANGED
@@ -1,627 +1,27 @@
1
1
  'use strict';
2
2
 
3
- var zodToJsonSchema = require('zod-to-json-schema');
4
- var child_process = require('child_process');
5
- var stream = require('stream');
6
- var backendPluginApi = require('@backstage/backend-plugin-api');
7
- var errors = require('@backstage/errors');
8
- var fs = require('fs-extra');
9
- var path = require('path');
10
- var git = require('isomorphic-git');
11
- var http = require('isomorphic-git/http/node');
12
- var fs$1 = require('fs');
13
- var globby = require('globby');
14
- var limiterFactory = require('p-limit');
15
-
16
- function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
17
-
18
- var zodToJsonSchema__default = /*#__PURE__*/_interopDefaultCompat(zodToJsonSchema);
19
- var fs__default = /*#__PURE__*/_interopDefaultCompat(fs);
20
- var path__default = /*#__PURE__*/_interopDefaultCompat(path);
21
- var git__default = /*#__PURE__*/_interopDefaultCompat(git);
22
- var http__default = /*#__PURE__*/_interopDefaultCompat(http);
23
- var globby__default = /*#__PURE__*/_interopDefaultCompat(globby);
24
- var limiterFactory__default = /*#__PURE__*/_interopDefaultCompat(limiterFactory);
25
-
26
- const createTemplateAction = (action) => {
27
- const inputSchema = action.schema?.input && "safeParseAsync" in action.schema.input ? zodToJsonSchema__default.default(action.schema.input) : action.schema?.input;
28
- const outputSchema = action.schema?.output && "safeParseAsync" in action.schema.output ? zodToJsonSchema__default.default(action.schema.output) : action.schema?.output;
29
- return {
30
- ...action,
31
- schema: {
32
- ...action.schema,
33
- input: inputSchema,
34
- output: outputSchema
35
- }
36
- };
37
- };
38
-
39
- async function executeShellCommand(options) {
40
- const {
41
- command,
42
- args,
43
- options: spawnOptions,
44
- logStream = new stream.PassThrough()
45
- } = options;
46
- await new Promise((resolve, reject) => {
47
- const process = child_process.spawn(command, args, spawnOptions);
48
- process.stdout.on("data", (stream) => {
49
- logStream.write(stream);
50
- });
51
- process.stderr.on("data", (stream) => {
52
- logStream.write(stream);
53
- });
54
- process.on("error", (error) => {
55
- return reject(error);
56
- });
57
- process.on("close", (code) => {
58
- if (code !== 0) {
59
- return reject(
60
- new Error(`Command ${command} failed, exit code: ${code}`)
61
- );
62
- }
63
- return resolve();
64
- });
65
- });
66
- }
67
-
68
- async function fetchContents(options) {
69
- const {
70
- reader,
71
- integrations,
72
- baseUrl,
73
- fetchUrl = ".",
74
- outputPath,
75
- token
76
- } = options;
77
- const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
78
- if (!fetchUrlIsAbsolute && baseUrl?.startsWith("file://")) {
79
- const basePath = baseUrl.slice("file://".length);
80
- const srcDir = backendPluginApi.resolveSafeChildPath(path__default.default.dirname(basePath), fetchUrl);
81
- await fs__default.default.copy(srcDir, outputPath);
82
- } else {
83
- const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
84
- const res = await reader.readTree(readUrl, { token });
85
- await fs__default.default.ensureDir(outputPath);
86
- await res.dir({ targetDir: outputPath });
87
- }
88
- }
89
- async function fetchFile(options) {
90
- const {
91
- reader,
92
- integrations,
93
- baseUrl,
94
- fetchUrl = ".",
95
- outputPath,
96
- token
97
- } = options;
98
- const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
99
- if (!fetchUrlIsAbsolute && baseUrl?.startsWith("file://")) {
100
- const basePath = baseUrl.slice("file://".length);
101
- const src = backendPluginApi.resolveSafeChildPath(path__default.default.dirname(basePath), fetchUrl);
102
- await fs__default.default.copyFile(src, outputPath);
103
- } else {
104
- const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
105
- const res = await reader.readUrl(readUrl, { token });
106
- await fs__default.default.ensureDir(path__default.default.dirname(outputPath));
107
- const buffer = await res.buffer();
108
- await fs__default.default.outputFile(outputPath, buffer);
109
- }
110
- }
111
- function isFetchUrlAbsolute(fetchUrl) {
112
- let fetchUrlIsAbsolute = false;
113
- try {
114
- new URL(fetchUrl);
115
- fetchUrlIsAbsolute = true;
116
- } catch {
117
- }
118
- return fetchUrlIsAbsolute;
119
- }
120
- function getReadUrl(fetchUrl, baseUrl, integrations) {
121
- if (isFetchUrlAbsolute(fetchUrl)) {
122
- return fetchUrl;
123
- } else if (baseUrl) {
124
- const integration = integrations.byUrl(baseUrl);
125
- if (!integration) {
126
- throw new errors.InputError(`No integration found for location ${baseUrl}`);
127
- }
128
- return integration.resolveUrl({
129
- url: fetchUrl,
130
- base: baseUrl
131
- });
132
- }
133
- throw new errors.InputError(
134
- `Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`
135
- );
136
- }
137
-
138
- function isAuthCallbackOptions(options) {
139
- return "onAuth" in options;
140
- }
141
- class Git {
142
- constructor(config) {
143
- this.config = config;
144
- this.onAuth = config.onAuth;
145
- this.headers = {
146
- "user-agent": "git/@isomorphic-git",
147
- ...config.token ? { Authorization: `Bearer ${config.token}` } : {}
148
- };
149
- }
150
- headers;
151
- async add(options) {
152
- const { dir, filepath } = options;
153
- this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`);
154
- return git__default.default.add({ fs: fs__default.default, dir, filepath });
155
- }
156
- async addRemote(options) {
157
- const { dir, url, remote, force } = options;
158
- this.config.logger?.info(
159
- `Creating new remote {dir=${dir},remote=${remote},url=${url}}`
160
- );
161
- return git__default.default.addRemote({ fs: fs__default.default, dir, remote, url, force });
162
- }
163
- async deleteRemote(options) {
164
- const { dir, remote } = options;
165
- this.config.logger?.info(`Deleting remote {dir=${dir},remote=${remote}}`);
166
- return git__default.default.deleteRemote({ fs: fs__default.default, dir, remote });
167
- }
168
- async checkout(options) {
169
- const { dir, ref } = options;
170
- this.config.logger?.info(`Checking out branch {dir=${dir},ref=${ref}}`);
171
- return git__default.default.checkout({ fs: fs__default.default, dir, ref });
172
- }
173
- async branch(options) {
174
- const { dir, ref } = options;
175
- this.config.logger?.info(`Creating branch {dir=${dir},ref=${ref}`);
176
- return git__default.default.branch({ fs: fs__default.default, dir, ref });
177
- }
178
- async commit(options) {
179
- const { dir, message, author, committer } = options;
180
- this.config.logger?.info(
181
- `Committing file to repo {dir=${dir},message=${message}}`
182
- );
183
- return git__default.default.commit({ fs: fs__default.default, dir, message, author, committer });
184
- }
185
- /** https://isomorphic-git.org/docs/en/clone */
186
- async clone(options) {
187
- const { url, dir, ref, depth, noCheckout } = options;
188
- this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
189
- try {
190
- return await git__default.default.clone({
191
- fs: fs__default.default,
192
- http: http__default.default,
193
- url,
194
- dir,
195
- ref,
196
- singleBranch: true,
197
- depth: depth ?? 1,
198
- noCheckout,
199
- onProgress: this.onProgressHandler(),
200
- headers: this.headers,
201
- onAuth: this.onAuth
202
- });
203
- } catch (ex) {
204
- this.config.logger?.error(`Failed to clone repo {dir=${dir},url=${url}}`);
205
- if (ex.data) {
206
- throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
207
- }
208
- throw ex;
209
- }
210
- }
211
- /** https://isomorphic-git.org/docs/en/currentBranch */
212
- async currentBranch(options) {
213
- const { dir, fullName = false } = options;
214
- return git__default.default.currentBranch({ fs: fs__default.default, dir, fullname: fullName });
215
- }
216
- /** https://isomorphic-git.org/docs/en/fetch */
217
- async fetch(options) {
218
- const { dir, remote = "origin", tags = false } = options;
219
- this.config.logger?.info(
220
- `Fetching remote=${remote} for repository {dir=${dir}}`
221
- );
222
- try {
223
- await git__default.default.fetch({
224
- fs: fs__default.default,
225
- http: http__default.default,
226
- dir,
227
- remote,
228
- tags,
229
- onProgress: this.onProgressHandler(),
230
- headers: this.headers,
231
- onAuth: this.onAuth
232
- });
233
- } catch (ex) {
234
- this.config.logger?.error(
235
- `Failed to fetch repo {dir=${dir},remote=${remote}}`
236
- );
237
- if (ex.data) {
238
- throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
239
- }
240
- throw ex;
241
- }
242
- }
243
- async init(options) {
244
- const { dir, defaultBranch = "master" } = options;
245
- this.config.logger?.info(`Init git repository {dir=${dir}}`);
246
- return git__default.default.init({
247
- fs: fs__default.default,
248
- dir,
249
- defaultBranch
250
- });
251
- }
252
- /** https://isomorphic-git.org/docs/en/merge */
253
- async merge(options) {
254
- const { dir, theirs, ours, author, committer } = options;
255
- this.config.logger?.info(
256
- `Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`
257
- );
258
- return git__default.default.merge({
259
- fs: fs__default.default,
260
- dir,
261
- ours,
262
- theirs,
263
- author,
264
- committer
265
- });
266
- }
267
- async push(options) {
268
- const { dir, remote, remoteRef, force } = options;
269
- this.config.logger?.info(
270
- `Pushing directory to remote {dir=${dir},remote=${remote}}`
271
- );
272
- try {
273
- return await git__default.default.push({
274
- fs: fs__default.default,
275
- dir,
276
- http: http__default.default,
277
- onProgress: this.onProgressHandler(),
278
- remoteRef,
279
- force,
280
- headers: this.headers,
281
- remote,
282
- onAuth: this.onAuth
283
- });
284
- } catch (ex) {
285
- this.config.logger?.error(
286
- `Failed to push to repo {dir=${dir}, remote=${remote}}`
287
- );
288
- if (ex.data) {
289
- throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
290
- }
291
- throw ex;
292
- }
293
- }
294
- /** https://isomorphic-git.org/docs/en/readCommit */
295
- async readCommit(options) {
296
- const { dir, sha } = options;
297
- return git__default.default.readCommit({ fs: fs__default.default, dir, oid: sha });
298
- }
299
- /** https://isomorphic-git.org/docs/en/remove */
300
- async remove(options) {
301
- const { dir, filepath } = options;
302
- this.config.logger?.info(
303
- `Removing file from git index {dir=${dir},filepath=${filepath}}`
304
- );
305
- return git__default.default.remove({ fs: fs__default.default, dir, filepath });
306
- }
307
- /** https://isomorphic-git.org/docs/en/resolveRef */
308
- async resolveRef(options) {
309
- const { dir, ref } = options;
310
- return git__default.default.resolveRef({ fs: fs__default.default, dir, ref });
311
- }
312
- /** https://isomorphic-git.org/docs/en/log */
313
- async log(options) {
314
- const { dir, ref } = options;
315
- return git__default.default.log({
316
- fs: fs__default.default,
317
- dir,
318
- ref: ref ?? "HEAD"
319
- });
320
- }
321
- onAuth;
322
- onProgressHandler = () => {
323
- let currentPhase = "";
324
- return (event) => {
325
- if (currentPhase !== event.phase) {
326
- currentPhase = event.phase;
327
- this.config.logger?.info(event.phase);
328
- }
329
- const total = event.total ? `${Math.round(event.loaded / event.total * 100)}%` : event.loaded;
330
- this.config.logger?.debug(`status={${event.phase},total={${total}}}`);
331
- };
332
- };
333
- static fromAuth = (options) => {
334
- if (isAuthCallbackOptions(options)) {
335
- const { onAuth, logger: logger2 } = options;
336
- return new Git({ onAuth, logger: logger2 });
337
- }
338
- const { username, password, token, logger } = options;
339
- return new Git({ onAuth: () => ({ username, password }), token, logger });
340
- };
341
- }
342
-
343
- async function initRepoAndPush(input) {
344
- const {
345
- dir,
346
- remoteUrl,
347
- auth,
348
- logger,
349
- defaultBranch = "master",
350
- commitMessage = "Initial commit",
351
- gitAuthorInfo
352
- } = input;
353
- const git = Git.fromAuth({
354
- ...auth,
355
- logger
356
- });
357
- await git.init({
358
- dir,
359
- defaultBranch
360
- });
361
- await git.add({ dir, filepath: "." });
362
- const authorInfo = {
363
- name: gitAuthorInfo?.name ?? "Scaffolder",
364
- email: gitAuthorInfo?.email ?? "scaffolder@backstage.io"
365
- };
366
- const commitHash = await git.commit({
367
- dir,
368
- message: commitMessage,
369
- author: authorInfo,
370
- committer: authorInfo
371
- });
372
- await git.addRemote({
373
- dir,
374
- url: remoteUrl,
375
- remote: "origin"
376
- });
377
- await git.push({
378
- dir,
379
- remote: "origin"
380
- });
381
- return { commitHash };
382
- }
383
- async function commitAndPushRepo(input) {
384
- const {
385
- dir,
386
- auth,
387
- logger,
388
- commitMessage,
389
- gitAuthorInfo,
390
- branch = "master",
391
- remoteRef
392
- } = input;
393
- const git = Git.fromAuth({
394
- ...auth,
395
- logger
396
- });
397
- await git.fetch({ dir });
398
- await git.checkout({ dir, ref: branch });
399
- await git.add({ dir, filepath: "." });
400
- const authorInfo = {
401
- name: gitAuthorInfo?.name ?? "Scaffolder",
402
- email: gitAuthorInfo?.email ?? "scaffolder@backstage.io"
403
- };
404
- const commitHash = await git.commit({
405
- dir,
406
- message: commitMessage,
407
- author: authorInfo,
408
- committer: authorInfo
409
- });
410
- await git.push({
411
- dir,
412
- remote: "origin",
413
- remoteRef: remoteRef ?? `refs/heads/${branch}`
414
- });
415
- return { commitHash };
416
- }
417
- async function cloneRepo(options) {
418
- const { url, dir, auth, logger, ref, depth, noCheckout } = options;
419
- const git = Git.fromAuth({
420
- ...auth,
421
- logger
422
- });
423
- await git.clone({ url, dir, ref, depth, noCheckout });
424
- }
425
- async function createBranch(options) {
426
- const { dir, ref, auth, logger } = options;
427
- const git = Git.fromAuth({
428
- ...auth,
429
- logger
430
- });
431
- await git.checkout({ dir, ref });
432
- }
433
- async function addFiles(options) {
434
- const { dir, filepath, auth, logger } = options;
435
- const git = Git.fromAuth({
436
- ...auth,
437
- logger
438
- });
439
- await git.add({ dir, filepath });
440
- }
441
- async function commitAndPushBranch(options) {
442
- const {
443
- dir,
444
- auth,
445
- logger,
446
- commitMessage,
447
- gitAuthorInfo,
448
- branch = "master",
449
- remoteRef,
450
- remote = "origin"
451
- } = options;
452
- const git = Git.fromAuth({
453
- ...auth,
454
- logger
455
- });
456
- const authorInfo = {
457
- name: gitAuthorInfo?.name ?? "Scaffolder",
458
- email: gitAuthorInfo?.email ?? "scaffolder@backstage.io"
459
- };
460
- const commitHash = await git.commit({
461
- dir,
462
- message: commitMessage,
463
- author: authorInfo,
464
- committer: authorInfo
465
- });
466
- await git.push({
467
- dir,
468
- remote,
469
- remoteRef: remoteRef ?? `refs/heads/${branch}`
470
- });
471
- return { commitHash };
472
- }
473
-
474
- const getRepoSourceDirectory = (workspacePath, sourcePath) => {
475
- if (sourcePath) {
476
- const safeSuffix = path.normalize(sourcePath).replace(
477
- /^(\.\.(\/|\\|$))+/,
478
- ""
479
- );
480
- const path$1 = path.join(workspacePath, safeSuffix);
481
- if (!backendPluginApi.isChildPath(workspacePath, path$1)) {
482
- throw new Error("Invalid source path");
483
- }
484
- return path$1;
485
- }
486
- return workspacePath;
487
- };
488
- const parseRepoUrl = (repoUrl, integrations) => {
489
- let parsed;
490
- try {
491
- parsed = new URL(`https://${repoUrl}`);
492
- } catch (error) {
493
- throw new errors.InputError(
494
- `Invalid repo URL passed to publisher, got ${repoUrl}, ${error}`
495
- );
496
- }
497
- const host = parsed.host;
498
- const owner = parsed.searchParams.get("owner") ?? void 0;
499
- const organization = parsed.searchParams.get("organization") ?? void 0;
500
- const workspace = parsed.searchParams.get("workspace") ?? void 0;
501
- const project = parsed.searchParams.get("project") ?? void 0;
502
- const type = integrations.byHost(host)?.type;
503
- if (!type) {
504
- throw new errors.InputError(
505
- `No matching integration configuration for host ${host}, please check your integrations config`
506
- );
507
- }
508
- const repo = parsed.searchParams.get("repo");
509
- switch (type) {
510
- case "bitbucket": {
511
- if (host === "www.bitbucket.org") {
512
- checkRequiredParams(parsed, "workspace");
513
- }
514
- checkRequiredParams(parsed, "project", "repo");
515
- break;
516
- }
517
- case "azure": {
518
- checkRequiredParams(parsed, "project", "repo");
519
- break;
520
- }
521
- case "gitlab": {
522
- if (!project) {
523
- checkRequiredParams(parsed, "owner", "repo");
524
- }
525
- break;
526
- }
527
- case "gitea": {
528
- checkRequiredParams(parsed, "repo");
529
- break;
530
- }
531
- case "gerrit": {
532
- checkRequiredParams(parsed, "repo");
533
- break;
534
- }
535
- default: {
536
- checkRequiredParams(parsed, "repo", "owner");
537
- break;
538
- }
539
- }
540
- return { host, owner, repo, organization, workspace, project };
541
- };
542
- function checkRequiredParams(repoUrl, ...params) {
543
- for (let i = 0; i < params.length; i++) {
544
- if (!repoUrl.searchParams.get(params[i])) {
545
- throw new errors.InputError(
546
- `Invalid repo URL passed to publisher: ${repoUrl.toString()}, missing ${params[i]}`
547
- );
548
- }
549
- }
550
- }
551
-
552
- const DEFAULT_GLOB_PATTERNS = ["./**", "!.git"];
553
- const isExecutable = (fileMode) => {
554
- if (!fileMode) {
555
- return false;
556
- }
557
- const executeBitMask = 73;
558
- const res = fileMode & executeBitMask;
559
- return res > 0;
560
- };
561
- async function asyncFilter(array, callback) {
562
- const filterMap = await Promise.all(array.map(callback));
563
- return array.filter((_value, index) => filterMap[index]);
564
- }
565
- async function serializeDirectoryContents(sourcePath, options) {
566
- const paths = await globby__default.default(options?.globPatterns ?? DEFAULT_GLOB_PATTERNS, {
567
- cwd: sourcePath,
568
- dot: true,
569
- gitignore: options?.gitignore,
570
- followSymbolicLinks: false,
571
- // In order to pick up 'broken' symlinks, we oxymoronically request files AND folders yet we filter out folders
572
- // This is because broken symlinks aren't classed as files so we need to glob everything
573
- onlyFiles: false,
574
- objectMode: true,
575
- stats: true
576
- });
577
- const limiter = limiterFactory__default.default(10);
578
- const valid = await asyncFilter(paths, async ({ dirent, path }) => {
579
- if (dirent.isDirectory()) return false;
580
- if (!dirent.isSymbolicLink()) return true;
581
- const safePath = backendPluginApi.resolveSafeChildPath(sourcePath, path);
582
- try {
583
- await fs$1.promises.stat(safePath);
584
- return false;
585
- } catch (e) {
586
- return errors.isError(e) && e.code === "ENOENT";
587
- }
588
- });
589
- return Promise.all(
590
- valid.map(async ({ dirent, path, stats }) => ({
591
- path,
592
- content: await limiter(async () => {
593
- const absFilePath = backendPluginApi.resolveSafeChildPath(sourcePath, path);
594
- if (dirent.isSymbolicLink()) {
595
- return fs$1.promises.readlink(absFilePath, "buffer");
596
- }
597
- return fs$1.promises.readFile(absFilePath);
598
- }),
599
- executable: isExecutable(stats?.mode),
600
- symlink: dirent.isSymbolicLink()
601
- }))
602
- );
603
- }
604
-
605
- async function deserializeDirectoryContents(targetPath, files) {
606
- for (const file of files) {
607
- const filePath = backendPluginApi.resolveSafeChildPath(targetPath, file.path);
608
- await fs__default.default.ensureDir(path.dirname(filePath));
609
- await fs__default.default.writeFile(filePath, file.content);
610
- }
611
- }
612
-
613
- exports.addFiles = addFiles;
614
- exports.cloneRepo = cloneRepo;
615
- exports.commitAndPushBranch = commitAndPushBranch;
616
- exports.commitAndPushRepo = commitAndPushRepo;
617
- exports.createBranch = createBranch;
618
- exports.createTemplateAction = createTemplateAction;
619
- exports.deserializeDirectoryContents = deserializeDirectoryContents;
620
- exports.executeShellCommand = executeShellCommand;
621
- exports.fetchContents = fetchContents;
622
- exports.fetchFile = fetchFile;
623
- exports.getRepoSourceDirectory = getRepoSourceDirectory;
624
- exports.initRepoAndPush = initRepoAndPush;
625
- exports.parseRepoUrl = parseRepoUrl;
626
- exports.serializeDirectoryContents = serializeDirectoryContents;
3
+ var createTemplateAction = require('./actions/createTemplateAction.cjs.js');
4
+ var executeShellCommand = require('./actions/executeShellCommand.cjs.js');
5
+ var fetch = require('./actions/fetch.cjs.js');
6
+ var gitHelpers = require('./actions/gitHelpers.cjs.js');
7
+ var util = require('./actions/util.cjs.js');
8
+ var serializeDirectoryContents = require('./files/serializeDirectoryContents.cjs.js');
9
+ var deserializeDirectoryContents = require('./files/deserializeDirectoryContents.cjs.js');
10
+
11
+
12
+
13
+ exports.createTemplateAction = createTemplateAction.createTemplateAction;
14
+ exports.executeShellCommand = executeShellCommand.executeShellCommand;
15
+ exports.fetchContents = fetch.fetchContents;
16
+ exports.fetchFile = fetch.fetchFile;
17
+ exports.addFiles = gitHelpers.addFiles;
18
+ exports.cloneRepo = gitHelpers.cloneRepo;
19
+ exports.commitAndPushBranch = gitHelpers.commitAndPushBranch;
20
+ exports.commitAndPushRepo = gitHelpers.commitAndPushRepo;
21
+ exports.createBranch = gitHelpers.createBranch;
22
+ exports.initRepoAndPush = gitHelpers.initRepoAndPush;
23
+ exports.getRepoSourceDirectory = util.getRepoSourceDirectory;
24
+ exports.parseRepoUrl = util.parseRepoUrl;
25
+ exports.serializeDirectoryContents = serializeDirectoryContents.serializeDirectoryContents;
26
+ exports.deserializeDirectoryContents = deserializeDirectoryContents.deserializeDirectoryContents;
627
27
  //# sourceMappingURL=index.cjs.js.map