@dittowords/cli 1.2.7-beta → 2.1.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.
package/README.md CHANGED
@@ -16,9 +16,39 @@ npm install --global @dittowords/cli
16
16
 
17
17
  The installed binary is named `ditto-cli`. You can execute it directly in `node_modules/.bin/ditto-cli` or using [npx](https://www.npmjs.com/package/npx) (with or without installation) like `npx @dittowords/cli`.
18
18
 
19
- The first time you run the CLI, you'll be asked to provide an API key (found at [https://beta.dittowords.com/account/user](https://beta.dittowords.com/account/user) under **API Keys**).
19
+ The first time you run the CLI, you'll be asked to provide an API key (found at [https://beta.dittowords.com/account/user](https://beta.dittowords.com/account/user) under **API Keys**):
20
20
 
21
- Once you've successfully authenticated, you’re ready to start fetching copy! You can set up the CLI in multiple directories by running `ditto-cli` and choosing an initial project to sync from.
21
+ ```
22
+ $ npx @dittowords/cli
23
+
24
+ ┌──────────────────────────────────┐
25
+ │ │
26
+ │ Welcome to the Ditto CLI. │
27
+ │ │
28
+ │ We're glad to have you here. │
29
+ │ │
30
+ └──────────────────────────────────┘
31
+
32
+ What is your API key? > xxx-xxx-xxx
33
+
34
+ Thanks for authenticating.
35
+ We'll save the key to: /Users/{username}/.config/ditto
36
+ ```
37
+
38
+ Once you've successfully authenticated, you'll be asked to configure the CLI with an initial project from your workspace:
39
+
40
+ ```
41
+ Looks like there are no Ditto projects selected for your current directory.
42
+
43
+ ? Choose the project you'd like to sync text from:
44
+ - Ditto Component Library https://beta.dittowords.com/components/all
45
+ - NUX Onboarding Flow https://beta.dittowords.com/doc/609e9981c313f8018d0c346a
46
+ ...
47
+ ```
48
+
49
+ After selecting a project, a configuration file will automatically be created at the path `./ditto/config.yml`, relative to your current working directory. The CLI will attempt to read from this file every time a command is executed. See the [documentation on config.yml](#files) further down in this README for a full reference of how the CLI can be configured.
50
+
51
+ Once you've successfully authenticated and a config file has been created, you’re ready to start fetching copy! You can set up the CLI in multiple directories by running `ditto-cli` and choosing an initial project to sync from.
22
52
 
23
53
  ## Commands
24
54
 
@@ -52,7 +82,7 @@ This folder houses the configuration file (`ditto/config.yml`) used by the CLI a
52
82
 
53
83
  If you run the CLI in a directory that does not contain a `ditto/` folder, the folder and a `config.yml` file will be automatically created.
54
84
 
55
- - #### `config.yml`
85
+ - #### config.yml
56
86
 
57
87
  This is the source of truth for a given directory about how the CLI should fetch and store data from Ditto. It includes information about which Ditto projects the CLI should pull text from and in what format the text should be stored.
58
88
 
@@ -60,16 +90,62 @@ If you run the CLI in a directory that does not contain a `ditto/` folder, the f
60
90
 
61
91
  **Supported properties**
62
92
 
63
- - `projects` (required) - a list of project names and ids to pull text from (see example)
64
- - `variants` (optional) - a `true` or `false` value indicating whether or not variant data should be pulled for the specified projects. Defaults to `false` if not specified (will likely default to `true` in future major releases).
65
- - `format` (optional) - the format the specified projects should be stored in. Acceptable values are `structured` or `flat`. If not specified, the default format containing block and frame data will be used.
93
+ ##### `projects`
94
+
95
+ A list of project names and ids to pull text from. R
96
+
97
+ equired if `components: true` is not specified.
66
98
 
67
- **Example**:
99
+ **Note**: the `name` property is used for display purposes when referencing a project in the CLI, but does not have to be an
100
+ exact match with the project name in Ditto.
68
101
 
102
+ ```yml
103
+ projects:
104
+ - name: Landing Page Copy
105
+ id: 61b8d26105f8f400e97fdd14
106
+ - name: User Settings
107
+ id: 606cb89ac55041013d552f8b
69
108
  ```
109
+
110
+ ##### `components`
111
+
112
+ If included with a value of `true`, data from the component library will be fetched and included in the CLI's output.
113
+
114
+ Required if `projects` is not specified with a valid list of projects.
115
+
116
+ ```yml
117
+ components: true
118
+ ```
119
+
120
+ ##### `variants`
121
+
122
+ If included with a value of `true`, variant data will be pulled for the specified projects and/or the component library.
123
+
124
+ Defaults to `false`.
125
+
126
+ ```yml
127
+ variants: true
128
+ ```
129
+
130
+ ##### `format`
131
+
132
+ The format the specified projects should be stored in. Acceptable values are `structured` or `flat`.
133
+
134
+ If not specified, the default format containing block and frame data will be used.
135
+
136
+ ```yml
137
+ format: flat
138
+ ```
139
+
140
+ **Full Example**
141
+
142
+ ```yml
70
143
  projects:
71
- - name: Ditto Component Library
72
- id: ditto_component_library
144
+ - name: Landing Page Copy
145
+ id: 61b8d26105f8f400e97fdd14
146
+ - name: User Settings
147
+ id: 606cb89ac55041013d552f8b
148
+ components: true
73
149
  variants: true
74
150
  format: flat
75
151
  ```
@@ -89,6 +165,8 @@ If you run the CLI in a directory that does not contain a `ditto/` folder, the f
89
165
 
90
166
  An automatically generated driver file that simplifies the process of passing text data to Ditto JavaScript SDKs. This file has a standardized format that is always the same independent of the CLI configuration used to generate it.
91
167
 
168
+ **Since this file is designed to be consumed by other internal Ditto libraries, it is not recommended that you depend on it - its format may change between major releases.**
169
+
92
170
  ```ts
93
171
  interface DriverFile {
94
172
  [projectId: string]: {
package/lib/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const url = require("url");
4
-
5
4
  const yaml = require("js-yaml");
6
5
 
6
+ const consts = require("./consts");
7
+
7
8
  function createFileIfMissing(filename) {
8
9
  const dir = path.dirname(filename);
9
10
 
@@ -14,7 +15,13 @@ function createFileIfMissing(filename) {
14
15
  }
15
16
  }
16
17
 
17
- function readData(file, defaultData = {}) {
18
+ /**
19
+ * Read data from a file
20
+ * @param {string} file defaults to `PROJECT_CONFIG_FILE` defined in `constants.js`
21
+ * @param {*} defaultData defaults to `{}`
22
+ * @returns
23
+ */
24
+ function readData(file = consts.PROJECT_CONFIG_FILE, defaultData = {}) {
18
25
  createFileIfMissing(file);
19
26
  const fileContents = fs.readFileSync(file, "utf8");
20
27
  return yaml.safeLoad(fileContents) || defaultData;
@@ -72,6 +79,73 @@ function save(file, key, value) {
72
79
  writeData(file, data);
73
80
  }
74
81
 
82
+ const IS_DUPLICATE = /-(\d+$)/;
83
+ function dedupeProjectName(projectNames, projectName) {
84
+ let dedupedName = projectName;
85
+
86
+ if (projectNames.has(dedupedName)) {
87
+ while (projectNames.has(dedupedName)) {
88
+ const [_, numberStr] = dedupedName.match(IS_DUPLICATE) || [];
89
+ if (numberStr && !isNaN(parseInt(numberStr))) {
90
+ dedupedName = `${dedupedName.replace(IS_DUPLICATE, "")}-${
91
+ parseInt(numberStr) + 1
92
+ }`;
93
+ } else {
94
+ dedupedName = `${dedupedName}-1`;
95
+ }
96
+ }
97
+ }
98
+
99
+ return dedupedName;
100
+ }
101
+
102
+ /**
103
+ * Reads from the config file, filters out
104
+ * invalid projects, dedupes those remaining, and returns:
105
+ * - whether or not the data required to `pull` is present
106
+ * - whether or not the component library should be fetched
107
+ * - an array of valid, deduped projects
108
+ * - the `variants` and `format` config options
109
+ */
110
+ function parseSourceInformation() {
111
+ const { projects, components, variants, format } = readData();
112
+
113
+ const projectNames = {};
114
+ const validProjects = [];
115
+
116
+ let componentLibraryInProjects = false;
117
+
118
+ (projects || []).forEach((project) => {
119
+ const isValid = project.id && project.name;
120
+ if (!isValid) {
121
+ return;
122
+ }
123
+
124
+ if (project.id === "ditto_component_library") {
125
+ componentLibraryInProjects = true;
126
+ return;
127
+ }
128
+
129
+ project.fileName = dedupeProjectName(projectNames, project.name);
130
+ projectNames.add(project.fileName);
131
+
132
+ validProjects.push(project);
133
+ });
134
+
135
+ const shouldFetchComponentLibrary =
136
+ !!components || componentLibraryInProjects;
137
+
138
+ const hasSourceData = validProjects.length || shouldFetchComponentLibrary;
139
+
140
+ return {
141
+ hasSourceData,
142
+ validProjects,
143
+ shouldFetchComponentLibrary,
144
+ variants,
145
+ format,
146
+ };
147
+ }
148
+
75
149
  module.exports = {
76
150
  createFileIfMissing,
77
151
  readData,
@@ -81,4 +155,5 @@ module.exports = {
81
155
  deleteToken,
82
156
  getToken,
83
157
  save,
158
+ parseSourceInformation,
84
159
  };
package/lib/init/init.js CHANGED
@@ -2,13 +2,16 @@
2
2
  // expected to be run once per project.
3
3
  const boxen = require("boxen");
4
4
  const chalk = require("chalk");
5
- const getSelectedProjects = require("../utils/getSelectedProjects");
6
5
  const projectsToText = require("../utils/projectsToText");
7
6
 
8
- const { needsProjects, collectAndSaveProject } = require("./project");
7
+ const { needsSource, collectAndSaveProject } = require("./project");
9
8
  const { needsToken, collectAndSaveToken } = require("./token");
10
9
 
11
- const needsInit = () => needsToken() || needsProjects();
10
+ const config = require("../config");
11
+ const output = require("../output");
12
+ const sourcesToText = require("../utils/sourcesToText");
13
+
14
+ const needsInit = () => needsToken() || needsSource();
12
15
 
13
16
  function welcome() {
14
17
  const msg = chalk.white(`${chalk.bold(
@@ -22,19 +25,24 @@ We're glad to have you here.`);
22
25
 
23
26
  async function init() {
24
27
  welcome();
28
+
25
29
  if (needsToken()) {
26
30
  await collectAndSaveToken();
27
31
  }
28
- const selectedProjects = getSelectedProjects();
29
- if (selectedProjects.length) {
30
- console.log(
31
- `You're currently set up to sync text from the following projects: ${projectsToText(
32
- selectedProjects
33
- )}\n`
34
- );
35
- } else {
32
+
33
+ const { hasSourceData, validProjects, shouldFetchComponentLibrary } =
34
+ config.parseSourceInformation();
35
+
36
+ if (!hasSourceData) {
36
37
  await collectAndSaveProject(true);
38
+ return;
37
39
  }
40
+
41
+ const message =
42
+ "You're currently set up to sync text from " +
43
+ sourcesToText(validProjects, shouldFetchComponentLibrary);
44
+
45
+ console.log(message);
38
46
  }
39
47
 
40
48
  module.exports = { needsInit, init };
@@ -15,14 +15,21 @@ function quit(exitCode = 2) {
15
15
  }
16
16
 
17
17
  function saveProject(file, name, id) {
18
+ // old functionality included "ditto_component_library" in the `projects`
19
+ // array, but we want to always treat the component library as a separate
20
+ // entity and use the new notation of a top-level `components` key
21
+ if (id === "ditto_component_library") {
22
+ config.writeData(file, { components: true });
23
+ return;
24
+ }
25
+
18
26
  const projects = [...getSelectedProjects(), { name, id }];
19
27
 
20
28
  config.writeData(file, { projects });
21
29
  }
22
30
 
23
- function needsProjects(configFile) {
24
- const projects = getSelectedProjects(configFile);
25
- return !(projects && projects.length);
31
+ function needsSource() {
32
+ return !config.parseSourceInformation().hasSourceData;
26
33
  }
27
34
 
28
35
  async function askForAnotherToken() {
@@ -60,7 +67,7 @@ async function collectProject(token, initialize) {
60
67
  const path = process.cwd();
61
68
  if (initialize) {
62
69
  console.log(
63
- `Looks like are no Ditto projects selected for your current directory: ${output.info(
70
+ `Looks like there are no Ditto projects selected for your current directory: ${output.info(
64
71
  path
65
72
  )}.`
66
73
  );
@@ -113,4 +120,4 @@ async function collectAndSaveProject(initialize = false) {
113
120
  }
114
121
  }
115
122
 
116
- module.exports = { needsProjects, collectAndSaveProject };
123
+ module.exports = { needsSource, collectAndSaveProject };
package/lib/pull.js CHANGED
@@ -9,6 +9,7 @@ const consts = require("./consts");
9
9
  const output = require("./output");
10
10
  const { collectAndSaveToken } = require("./init/token");
11
11
  const projectsToText = require("./utils/projectsToText");
12
+ const sourcesToText = require("./utils/sourcesToText");
12
13
 
13
14
  const NON_DEFAULT_FORMATS = ["flat", "structured"];
14
15
 
@@ -30,54 +31,6 @@ async function askForAnotherToken() {
30
31
  await collectAndSaveToken(message);
31
32
  }
32
33
 
33
- function cleanProjectName(projectName) {
34
- return (
35
- projectName
36
- .replace(/\s/g, "-")
37
- // replace double underscore since this is what we use
38
- // to separate project names and API IDs
39
- .replace(/__/g, "_")
40
- .toLowerCase()
41
- .trim()
42
- );
43
- }
44
-
45
- /**
46
- * Return the passed array of projects with `project.name` modified
47
- * to be `${project.name}-${duplicate_number}` for each project
48
- * that has the same name as another project in the original array.
49
- */
50
- const IS_DUPLICATE = /-(\d+$)/;
51
- function getProjectsWithDedupedNames(projects) {
52
- const projectsWithDedupedNames = [];
53
- const projectNames = {};
54
-
55
- projects.forEach(({ id, name }) => {
56
- let dedupedName = name;
57
-
58
- if (projectNames[dedupedName]) {
59
- while (projectNames[dedupedName]) {
60
- const [_, numberStr] = dedupedName.match(IS_DUPLICATE) || [];
61
- if (numberStr && !isNaN(parseInt(numberStr))) {
62
- dedupedName = `${dedupedName.replace(IS_DUPLICATE, "")}-${
63
- parseInt(numberStr) + 1
64
- }`;
65
- } else {
66
- dedupedName = `${dedupedName}-1`;
67
- }
68
- }
69
- }
70
-
71
- projectNames[dedupedName] = true;
72
- projectsWithDedupedNames.push({
73
- id,
74
- name: cleanProjectName(dedupedName),
75
- });
76
- });
77
-
78
- return projectsWithDedupedNames;
79
- }
80
-
81
34
  /**
82
35
  * For a given variant:
83
36
  * - if format is unspecified, fetch data for all projects from `/projects` and
@@ -93,7 +46,7 @@ async function downloadAndSaveVariant(variantApiId, projects, format, token) {
93
46
 
94
47
  if (NON_DEFAULT_FORMATS.includes(format)) {
95
48
  const savedMessages = await Promise.all(
96
- projects.map(async ({ id, name }) => {
49
+ projects.map(async ({ id, fileName }) => {
97
50
  const { data } = await api.get(`/projects/${id}`, {
98
51
  params,
99
52
  headers: { Authorization: `token ${token}` },
@@ -103,7 +56,7 @@ async function downloadAndSaveVariant(variantApiId, projects, format, token) {
103
56
  return "";
104
57
  }
105
58
 
106
- const filename = name + ("__" + (variantApiId || "base")) + ".json";
59
+ const filename = fileName + ("__" + (variantApiId || "base")) + ".json";
107
60
  const filepath = path.join(consts.TEXT_DIR, filename);
108
61
 
109
62
  const dataString = JSON.stringify(data, null, 2);
@@ -162,13 +115,13 @@ async function downloadAndSaveBase(projects, format, token) {
162
115
 
163
116
  if (NON_DEFAULT_FORMATS.includes(format)) {
164
117
  const savedMessages = await Promise.all(
165
- projects.map(async ({ id, name }) => {
118
+ projects.map(async ({ id, fileName }) => {
166
119
  const { data } = await api.get(`/projects/${id}`, {
167
120
  params,
168
121
  headers: { Authorization: `token ${token}` },
169
122
  });
170
123
 
171
- const filename = `${name}.json`;
124
+ const filename = `${fileName}.json`;
172
125
  const filepath = path.join(consts.TEXT_DIR, filename);
173
126
 
174
127
  const dataString = JSON.stringify(data, null, 2);
@@ -195,36 +148,20 @@ async function downloadAndSaveBase(projects, format, token) {
195
148
  }
196
149
 
197
150
  function getSavedMessage(file) {
198
- return `Successfully saved to ${output.info(file)}.\n`;
151
+ return `Successfully saved to ${output.info(file)}\n`;
199
152
  }
200
153
 
201
- async function cleanOutputFiles() {
202
- const exists = await new Promise((resolve) => {
203
- fs.exists(consts.TEXT_DIR, (exists) => resolve(exists));
204
- });
205
-
206
- if (!exists) {
207
- await new Promise((resolve) => fs.mkdir(consts.TEXT_DIR, () => resolve()));
154
+ function cleanOutputFiles() {
155
+ if (!fs.existsSync(consts.TEXT_DIR)) {
156
+ fs.mkdirSync(consts.TEXT_DIR);
208
157
  }
209
158
 
210
- const fileNames = await new Promise((resolve) =>
211
- fs.readdir(consts.TEXT_DIR, (err, fileNames) => {
212
- if (err) throw err;
213
- resolve(fileNames);
214
- })
215
- );
216
-
217
- await Promise.all(
218
- fileNames.map((fileName) => {
219
- if (/\.js(on)?$/.test(fileName)) {
220
- return new Promise((resolve) => {
221
- fs.unlink(path.resolve(consts.TEXT_DIR, fileName), () => resolve());
222
- });
223
- }
224
-
225
- return Promise.resolve();
226
- })
227
- );
159
+ const fileNames = fs.readdirSync(consts.TEXT_DIR);
160
+ fileNames.forEach((fileName) => {
161
+ if (/\.js(on)?$/.test(fileName)) {
162
+ fs.unlinkSync(path.resolve(consts.TEXT_DIR, fileName));
163
+ }
164
+ });
228
165
 
229
166
  return "Cleaning old output files..\n";
230
167
  }
@@ -251,7 +188,7 @@ function generateJsDriver(projects, variants, format) {
251
188
  .filter((fileName) => /\.json$/.test(fileName));
252
189
 
253
190
  const projectIdsByName = projects.reduce(
254
- (agg, project) => ({ ...agg, [project.name]: project.id }),
191
+ (agg, project) => ({ ...agg, [project.fileName]: project.id }),
255
192
  {}
256
193
  );
257
194
 
@@ -321,32 +258,40 @@ function generateJsDriver(projects, variants, format) {
321
258
  const filePath = path.resolve(consts.TEXT_DIR, "index.js");
322
259
  fs.writeFileSync(filePath, dataString, { encoding: "utf8" });
323
260
 
324
- return `Generated .js SDK driver at ${output.info(filePath)}..`;
261
+ return `Generated .js SDK driver at ${output.info(filePath)}`;
325
262
  }
326
263
 
327
- async function downloadAndSave(projectConfig, token) {
328
- const { projects, variants, format } = projectConfig;
329
- const projectsDeduped = getProjectsWithDedupedNames(projects);
264
+ async function downloadAndSave(sourceInformation, token) {
265
+ const { validProjects, variants, format, shouldFetchComponentLibrary } =
266
+ sourceInformation;
330
267
 
331
- let msg = `\nFetching the latest text from your selected projects: ${projectsToText(
332
- projects
268
+ let msg = `\nFetching the latest text from ${sourcesToText(
269
+ validProjects,
270
+ shouldFetchComponentLibrary
333
271
  )}\n`;
334
272
 
335
273
  const spinner = ora(msg);
336
274
  spinner.start();
337
275
 
338
- try {
339
- msg += await cleanOutputFiles();
276
+ // We'll need to move away from this solution if at some
277
+ // point down the road we stop allowing the component
278
+ // library to be returned from the /projects endpoint
279
+ if (shouldFetchComponentLibrary) {
280
+ validProjects.push({
281
+ id: "ditto_component_library",
282
+ name: "Ditto Component Library",
283
+ fileName: "ditto-component-library",
284
+ });
285
+ }
340
286
 
341
- // terrible stopgap solution to avoid some sort of unseen
342
- // race condition
343
- await new Promise((resolve) => setTimeout(resolve, 3000));
287
+ try {
288
+ msg += cleanOutputFiles();
344
289
 
345
290
  msg += variants
346
- ? await downloadAndSaveVariants(projectsDeduped, format, token)
347
- : await downloadAndSaveBase(projectsDeduped, format, token);
291
+ ? await downloadAndSaveVariants(validProjects, format, token)
292
+ : await downloadAndSaveBase(validProjects, format, token);
348
293
 
349
- msg += generateJsDriver(projectsDeduped, variants, format);
294
+ msg += generateJsDriver(validProjects, variants, format);
350
295
 
351
296
  msg += `\n${output.success("Done")}!`;
352
297
 
@@ -391,15 +336,15 @@ async function downloadAndSave(projectConfig, token) {
391
336
 
392
337
  function pull() {
393
338
  const token = config.getToken(consts.CONFIG_FILE, consts.API_HOST);
394
- const pConfig = config.readData(consts.PROJECT_CONFIG_FILE);
395
- return downloadAndSave(pConfig, token);
339
+ const sourceInformation = config.parseSourceInformation();
340
+
341
+ return downloadAndSave(sourceInformation, token);
396
342
  }
397
343
 
398
344
  module.exports = {
399
345
  pull,
400
346
  _testing: {
401
347
  cleanOutputFiles,
402
- getProjectsWithDedupedNames,
403
348
  downloadAndSaveVariant,
404
349
  downloadAndSaveVariants,
405
350
  downloadAndSaveBase,
@@ -29,7 +29,7 @@ function getSelectedProjects(configFile = PROJECT_CONFIG_FILE) {
29
29
  return [];
30
30
  }
31
31
 
32
- return contentJson.projects.filter(({ name, id }) => name && id);
32
+ return contentjson.projects.filter(({ name, id }) => name && id);
33
33
  }
34
34
 
35
35
  module.exports = getSelectedProjects;
@@ -2,7 +2,7 @@ const output = require("../output");
2
2
 
3
3
  function projectsToText(projects) {
4
4
  return (
5
- projects.reduce(
5
+ (projects || []).reduce(
6
6
  (outputString, { name, id }) =>
7
7
  outputString +
8
8
  ("\n" +
@@ -0,0 +1,24 @@
1
+ const output = require("../output");
2
+ const projectsToText = require("./projectsToText");
3
+
4
+ function sourcesToText(projects, componentLibrary) {
5
+ let message = "";
6
+
7
+ if (componentLibrary) {
8
+ message += `the ${output.info("Ditto Component Library")}`;
9
+
10
+ if ((projects || []).length) {
11
+ message += " and ";
12
+ } else {
13
+ message += "..";
14
+ }
15
+ }
16
+
17
+ if ((projects || []).length) {
18
+ message += `the following projects: ${projectsToText(projects)}\n`;
19
+ }
20
+
21
+ return message;
22
+ }
23
+
24
+ module.exports = sourcesToText;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dittowords/cli",
3
- "version": "1.2.7-beta",
3
+ "version": "2.1.1",
4
4
  "description": "Command Line Interface for Ditto (dittowords.com).",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {