diff --git a/.github/workflows/create-yorkie-app-publish.yml b/.github/workflows/create-yorkie-app-publish.yml new file mode 100644 index 000000000..4c6eaa483 --- /dev/null +++ b/.github/workflows/create-yorkie-app-publish.yml @@ -0,0 +1,31 @@ +name: create-yorkie-app-publish +on: + workflow_dispatch: + push: + branches: + - main + paths: + - tools/create-yorkie-app/** + - examples/** +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout šŸ›Žļø + uses: actions/checkout@v2 + - name: Setup Node šŸ”§ + uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: package-lock.json + registry-url: 'https://registry.npmjs.org' + - run: npm install + - run: cd tools/create-yorkie-app && npm run build + - run: cd tools/create-yorkie-app && npm publish --provenance + continue-on-error: true + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/examples/CONTRIBUTING.md b/examples/CONTRIBUTING.md new file mode 100644 index 000000000..9b53558d1 --- /dev/null +++ b/examples/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for the general guides for contribution. + +## Keeping create-yorkie-app in sync with examples + +When adding a new example, you have to update create-yorkie-app's [frameworks.ts](../tools//create-yorkie-app/frameworks.ts). + +Add FrameworkVariant to the variants array under appropriate category like: + +```js +export const FRAMEWORKS: Array = [ + { + name: 'vanilla', + display: 'Vanilla', + color: yellow, + variants: [ + { + name: 'vanilla-codemirror6', + display: 'codemirror', + }, + { + name: 'vanilla-quill', + display: 'quill', + }, + { + name: 'profile-stack', + display: 'profile-stack', + }, + // Your yorkie example in Vanilla JS + { + name: 'directory-name', + display: 'display-name', + }, + ], + }, + // ... +]; +``` diff --git a/package-lock.json b/package-lock.json index d5cbbdd28..9138f386a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.4.9", "license": "Apache-2.0", "workspaces": [ - "examples/*" + "examples/*", + "tools/*" ], "dependencies": { "google-protobuf": "^3.19.4", @@ -3443,11 +3444,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimist": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", + "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.4.2", "dev": true, "license": "MIT" }, + "node_modules/@types/prompts": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.5.tgz", + "integrity": "sha512-TvrzGMCwARi2qqXcD7VmvMvfMP3F7JRQpeEHECK0oufRNZInoBqzd8v/1zksKFE5XW8OOGto/5FsDT8lnpvGRA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, "node_modules/@types/prop-types": { "version": "15.7.5", "license": "MIT" @@ -5733,6 +5750,10 @@ "dev": true, "license": "MIT" }, + "node_modules/create-yorkie-app": { + "resolved": "tools/create-yorkie-app", + "link": true + }, "node_modules/crelt": { "version": "1.0.5", "license": "MIT" @@ -8658,6 +8679,21 @@ "node": ">=0.10.0" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -8949,9 +8985,13 @@ } }, "node_modules/minimist": { - "version": "1.2.6", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, - "license": "MIT" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/mkdirp": { "version": "0.5.5", @@ -9659,6 +9699,19 @@ "resolved": "examples/profile-stack", "link": true }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", @@ -10660,6 +10713,12 @@ "node": ">= 10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "node_modules/slash": { "version": "3.0.0", "dev": true, @@ -12730,6 +12789,20 @@ "test/vitest/env": { "name": "vitest-environment-custom-jsdom", "dev": true + }, + "tools/create-yorkie-app": { + "version": "0.0.0", + "license": "MIT", + "bin": { + "create-yorkie-app": "dist/create-yorkie-app.mjs" + }, + "devDependencies": { + "@types/minimist": "^1.2.2", + "@types/prompts": "^2.4.4", + "kolorist": "^1.8.0", + "minimist": "^1.2.8", + "prompts": "^2.4.2" + } } } } diff --git a/package.json b/package.json index 707b0d476..175d93c5e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build": "webpack --config ./config/webpack.config.js && npm run api-report && npm run prune", "build:proto": "protoc -I=./src/api --js_out=import_style=commonjs:./src/api --grpc-web_out=import_style=commonjs+dts,mode=grpcwebtext:./src/api ./src/api/yorkie/v1/*.proto", "build:docs": "npm run predoc && api-documenter markdown --input temp --output docs", - "build:examples": "npm run build --workspaces", + "build:examples": "npm run build --workspace examples", + "build:create-yorkie-app": "npm run build --workspace create-yorkie-app", "build:ghpages": "mkdir -p ghpages/examples && cp -r docs ghpages/api-reference && find examples -name 'dist' -type d -exec sh -c 'cp -r {} ghpages/examples/$(basename $(dirname {}))' \\;", "api-report": "api-extractor run --local --verbose --config ./config/api-extractor.json", "prune": "ts-node-script ./scripts/prune-dts.ts --input ./dist/yorkie-js-sdk.d.ts --output ./dist/yorkie-js-sdk.d.ts", @@ -94,6 +95,7 @@ } }, "workspaces": [ - "examples/*" + "examples/*", + "tools/*" ] } diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 000000000..4d52a6dad --- /dev/null +++ b/tools/README.md @@ -0,0 +1,5 @@ +# Tools + +This directory contains tools for yorkie-js-sdk. + +For usage, refer to the README.md of each project. diff --git a/tools/create-yorkie-app/.env b/tools/create-yorkie-app/.env new file mode 100644 index 000000000..f2a7870e4 --- /dev/null +++ b/tools/create-yorkie-app/.env @@ -0,0 +1,2 @@ +VITE_YORKIE_API_ADDR='https://api.yorkie.dev' +VITE_YORKIE_API_KEY= diff --git a/tools/create-yorkie-app/MAINTAINING.md b/tools/create-yorkie-app/MAINTAINING.md new file mode 100644 index 000000000..e5344fc9c --- /dev/null +++ b/tools/create-yorkie-app/MAINTAINING.md @@ -0,0 +1,33 @@ +# Maintaining create-yorkie-app + +This package is an automated tool to scaffold an example project that shows practical usage of yorkie-js-sdk. + +```bash +. +ā”œā”€ā”€ MAINTAINING.md +ā”œā”€ā”€ README.md +ā”œā”€ā”€ frameworks.ts # abstract data object representing examples/ directory +ā”œā”€ā”€ index.ts # main script +ā”œā”€ā”€ package.json +ā”œā”€ā”€ tsconfig.json +ā””ā”€ā”€ webpack.config.js +``` + +## Adding a New Example + +Add information about your new example in [frameworks.ts](https://github.com/yorkie-team/yorkie-js-sdk/blob/main/tools/create-yorkie-app/frameworks.ts). + +Choose or create an appropriate category (e.g. vanilla, react, nextjs, vue, ...) and add an object like below to variants array. + +```ts +{ + name: directory_name, + display: displayed_name_in_prompt +} +``` + +## Publishing a New Version + +Update the version in [package.json](https://github.com/yorkie-team/yorkie-js-sdk/blob/main/tools/create-yorkie-app/package.json#L3). + +Publication will be done via [create-yorkie-app-publish.yml](https://github.com/yorkie-team/yorkie-js-sdk/blob/main/.github/workflows/create-yorkie-app-publish.yml) when changes are pushed into main branch. diff --git a/tools/create-yorkie-app/README.md b/tools/create-yorkie-app/README.md new file mode 100644 index 000000000..80a9a107b --- /dev/null +++ b/tools/create-yorkie-app/README.md @@ -0,0 +1,30 @@ +# create-yorkie-app + +## Usage + +You can scaffold yorkie-js-sdk examples by: + +```bash +$ npx create-yorkie-app +``` + +The examples have own local dependencies. So you should install dependencies before running examples. + +```bash +# In the directory of the example. +$ npm install +``` + +Then you can run the examples. + +```bash +# In the directory of the example. +$ npm run dev +``` + +Open the browser and go to the URL that is printed in the terminal. + +## Note + +Yorkie API key or local server is necessary to run each example. +You can create and manage your projects and API keys at [Yorkie Dashboard](https://yorkie.dev/dashboard). If you want to configure your own server, refer to [this README](../../examples/README.md). diff --git a/tools/create-yorkie-app/frameworks.ts b/tools/create-yorkie-app/frameworks.ts new file mode 100644 index 000000000..02816e3ef --- /dev/null +++ b/tools/create-yorkie-app/frameworks.ts @@ -0,0 +1,87 @@ +import { cyan, lightGreen, reset, yellow } from 'kolorist'; + +/** + * @see https://github.com/marvinhagemeister/kolorist#readme + */ +type ColorFunc = (str: string | number) => string; + +export type Framework = { + name: string; + display: string; + color: ColorFunc; + variants: Array; +}; + +type FrameworkVariant = { + /** + * directory name of the example + */ + name: string; + /** + * display name (in prompt) of the example + */ + display: string; +}; + +export const FRAMEWORKS: Array = [ + { + name: 'vanilla', + display: 'Vanilla', + color: yellow, + variants: [ + { + name: 'vanilla-codemirror6', + display: 'codemirror', + }, + { + name: 'vanilla-quill', + display: 'quill', + }, + { + name: 'profile-stack', + display: 'profile-stack', + }, + ], + }, + { + name: 'react', + display: 'React', + color: cyan, + variants: [ + { + name: 'react-tldraw', + display: 'tldraw', + }, + { + name: 'react-todomvc', + display: 'todomvc', + }, + { + name: 'simultaneous-cursors', + display: 'simultaneous-cursors', + }, + ], + }, + { + name: 'nextjs', + display: 'Next.js', + color: reset, + variants: [ + { + name: 'nextjs-scheduler', + display: 'scheduler', + }, + ], + }, + { + name: 'vue', + display: 'Vue', + color: lightGreen, + variants: [ + { + name: 'vuejs-kanban', + display: 'kanban', + }, + ], + }, +]; diff --git a/tools/create-yorkie-app/index.ts b/tools/create-yorkie-app/index.ts new file mode 100644 index 000000000..0dac069d7 --- /dev/null +++ b/tools/create-yorkie-app/index.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +/* eslint-disable jsdoc/require-jsdoc */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import minimist from 'minimist'; +import prompts from 'prompts'; +import { red, reset } from 'kolorist'; +import { type Framework, FRAMEWORKS } from './frameworks'; + +// Avoids autoconversion to number of the project name by defining that the args +// non associated with an option ( _ ) needs to be parsed as a string. See https://github.com/vitejs/vite/pull/4606 +const argv = minimist<{ + t?: string; + template?: string; +}>(process.argv.slice(2), { string: ['_'] }); +const cwd = process.cwd(); + +const renameFiles: Record = { + _gitignore: '.gitignore', +}; + +const TEMPLATES = FRAMEWORKS.map( + (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name], +).reduce((a, b) => a.concat(b), []); + +const defaultTargetDir = 'yorkie-app'; + +async function init() { + const argTargetDir = formatTargetDir(argv._[0]); + const argTemplate = argv.template || argv.t; + + let targetDir = argTargetDir || defaultTargetDir; + const getProjectName = () => + targetDir === '.' ? path.basename(path.resolve()) : targetDir; + + let result: prompts.Answers< + 'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant' + >; + + try { + result = await prompts( + [ + { + type: argTargetDir ? null : 'text', + name: 'projectName', + message: reset('Project name:'), + initial: defaultTargetDir, + onState: (state) => { + targetDir = formatTargetDir(state.value) || defaultTargetDir; + }, + }, + { + type: () => + !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm', + name: 'overwrite', + message: () => + (targetDir === '.' + ? 'Current directory' + : `Target directory "${targetDir}"`) + + ` is not empty. Remove existing files and continue?`, + }, + { + type: (_, { overwrite }: { overwrite?: boolean }) => { + if (overwrite === false) { + throw new Error(red('āœ–') + ' Operation cancelled'); + } + return null; + }, + name: 'overwriteChecker', + }, + { + type: () => (isValidPackageName(getProjectName()) ? null : 'text'), + name: 'packageName', + message: reset('Package name:'), + initial: () => toValidPackageName(getProjectName()), + validate: (dir) => + isValidPackageName(dir) || 'Invalid package.json name', + }, + { + type: + argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select', + name: 'framework', + message: + typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate) + ? reset( + `"${argTemplate}" isn't a valid template. Please choose from below: `, + ) + : reset('Select a framework:'), + initial: 0, + choices: FRAMEWORKS.map((framework) => { + const frameworkColor = framework.color; + return { + title: frameworkColor(framework.display || framework.name), + value: framework, + }; + }), + }, + { + type: (framework: Framework) => + framework && framework.variants ? 'select' : null, + name: 'variant', + message: reset('Select a variant:'), + choices: ({ variants, color }: Framework) => + variants.map((variant) => { + return { + title: color(variant.display || variant.name), + value: variant.name, + }; + }), + }, + ], + { + onCancel: () => { + throw new Error(red('āœ–') + ' Operation cancelled'); + }, + }, + ); + } catch (cancelled: any) { + console.error(cancelled.message); + return; + } + + // user choice associated with prompts + const { framework, overwrite, packageName, variant } = result; + + const root = path.join(cwd, targetDir); + + if (overwrite) { + emptyDir(root); + } else if (!fs.existsSync(root)) { + fs.mkdirSync(root, { recursive: true }); + } + + // determine template + const template: string = variant || framework?.name || argTemplate; + + const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); + const pkgManager = pkgInfo ? pkgInfo.name : 'npm'; + + console.log(`\nScaffolding project in ${root}...`); + + const templateDir = path.resolve( + fileURLToPath(import.meta.url), + `../examples/${template}`, + ); + + const write = (file: string, content?: string) => { + const targetPath = path.join(root, renameFiles[file] ?? file); + if (content) { + fs.writeFileSync(targetPath, content); + } else { + copy(path.join(templateDir, file), targetPath); + } + }; + + const files = fs.readdirSync(templateDir); + for (const file of files) { + if (file === 'package.json' || file === '.env.production') { + continue; + } + + if (file === '.env') { + const envVariables = fs.readFileSync(file).toString(); + + write( + file, + envVariables.replace('http://localhost:8080', 'https://api.yorkie.dev'), + ); + continue; + } + + write(file); + } + + const pkg = JSON.parse( + fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'), + ); + + pkg.name = packageName || getProjectName(); + + write('package.json', JSON.stringify(pkg, null, 2) + '\n'); + + console.log(`\nDone. Now run:\n`); + + const cdProjectName = path.relative(cwd, root); + if (root !== cwd) { + console.log( + ` cd ${ + cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName + }`, + ); + } + + switch (pkgManager) { + case 'yarn': + console.log(' yarn'); + console.log(' yarn dev'); + break; + default: + console.log(` ${pkgManager} install`); + console.log(` ${pkgManager} run dev`); + break; + } + + console.log( + '\nšŸ”‘ To run these examples, you need Yorkie API key.' + + `\nGet your API key at https://yorkie.dev/dashboard and put it in ${cdProjectName}/.env`, + ); + console.log(); +} + +function formatTargetDir(targetDir: string | undefined) { + return targetDir?.trim().replace(/\/+$/g, ''); +} + +function copy(src: string, dest: string) { + const stat = fs.statSync(src); + if (stat.isDirectory()) { + copyDir(src, dest); + } else { + fs.copyFileSync(src, dest); + } +} + +function isValidPackageName(projectName: string) { + return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( + projectName, + ); +} + +function toValidPackageName(projectName: string) { + return projectName + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z\d\-~]+/g, '-'); +} + +function copyDir(srcDir: string, destDir: string) { + fs.mkdirSync(destDir, { recursive: true }); + for (const file of fs.readdirSync(srcDir)) { + const srcFile = path.resolve(srcDir, file); + const destFile = path.resolve(destDir, file); + copy(srcFile, destFile); + } +} + +function isEmpty(path: string) { + const files = fs.readdirSync(path); + return !files.length || !files.filter((file) => !file.startsWith('.')).length; +} + +function emptyDir(dir: string) { + if (!fs.existsSync(dir)) { + return; + } + + for (const file of fs.readdirSync(dir)) { + if (file.startsWith('.')) { + continue; + } + fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); + } +} + +function pkgFromUserAgent(userAgent: string | undefined) { + if (!userAgent) return undefined; + const pkgSpec = userAgent.split(' ')[0]; + const pkgSpecArr = pkgSpec.split('/'); + return { + name: pkgSpecArr[0], + version: pkgSpecArr[1], + }; +} + +init().catch((e) => { + console.error(e); +}); diff --git a/tools/create-yorkie-app/package.json b/tools/create-yorkie-app/package.json new file mode 100644 index 000000000..b3c365ff4 --- /dev/null +++ b/tools/create-yorkie-app/package.json @@ -0,0 +1,27 @@ +{ + "name": "create-yorkie-app", + "version": "0.4.7", + "bin": { + "create-yorkie-app": "dist/create-yorkie-app.mjs" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "webpack --config ./webpack.config.js && npm run build:copy-assets", + "build:copy-assets": "cp -r ../../examples ./dist && cp ./.env ./dist/.env", + "start": "npm run build && node dist/create-yorkie-app.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/yorkie-team/yorkie-js-sdk.git" + }, + "license": "Apache-2.0", + "devDependencies": { + "@types/minimist": "^1.2.2", + "@types/prompts": "^2.4.4", + "kolorist": "^1.8.0", + "minimist": "^1.2.8", + "prompts": "^2.4.2" + } +} diff --git a/tools/create-yorkie-app/tsconfig.json b/tools/create-yorkie-app/tsconfig.json new file mode 100644 index 000000000..c5943f647 --- /dev/null +++ b/tools/create-yorkie-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "target": "ES6", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false, + "noUnusedLocals": true, + "esModuleInterop": true + }, + "include": ["*"] +} diff --git a/tools/create-yorkie-app/webpack.config.js b/tools/create-yorkie-app/webpack.config.js new file mode 100644 index 000000000..90e097d81 --- /dev/null +++ b/tools/create-yorkie-app/webpack.config.js @@ -0,0 +1,64 @@ +/* + * Copyright 2023 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); +const webpack = require('webpack'); + +module.exports = { + entry: './index', + mode: 'production', + target: 'node', + optimization: { + minimize: false, + }, + module: { + parser: { + javascript: { + importMeta: false, + }, + }, + rules: [ + { + test: /\.ts$/, + use: { + loader: 'ts-loader', + options: { + configFile: path.resolve(__dirname, './tsconfig.json'), + }, + }, + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, + output: { + module: true, + chunkFormat: 'module', + filename: 'create-yorkie-app.mjs', + path: path.resolve(__dirname, './dist'), + clean: true, + }, + plugins: [ + new webpack.BannerPlugin({ + banner: '#!/usr/bin/env node', + raw: true, + }), + ], + experiments: { + outputModule: true, + }, +};