├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .eslintignore ├── .gitignore ├── packages ├── cli │ ├── src │ │ ├── hmr │ │ │ ├── index.ts │ │ │ └── hmr.ts │ │ ├── plugins │ │ │ ├── index.ts │ │ │ ├── esbuild-plugin-vue-jsx.ts │ │ │ └── esbuild-plugin-vue.ts │ │ ├── index.ts │ │ ├── cli.ts │ │ ├── build.ts │ │ └── dev.ts │ ├── temir-cli.mjs │ ├── build.config.ts │ ├── README.md │ └── package.json ├── temir-tab │ ├── media │ │ └── temir-tab.gif │ ├── tsup.config.ts │ ├── README.md │ ├── package.json │ └── src │ │ └── index.ts ├── .test │ ├── index.ts │ ├── setup.ts │ ├── render-to-string.ts │ ├── component.ts │ └── create-stdout.ts ├── temir-link │ ├── media │ │ └── temir-link.png │ ├── tsup.config.ts │ ├── src │ │ └── index.ts │ ├── package.json │ └── README.md ├── temir │ ├── src │ │ ├── composables │ │ │ ├── index.ts │ │ │ ├── useRender.ts │ │ │ ├── useStdin.ts │ │ │ └── useInput.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── Spacer.ts │ │ │ ├── Newline.ts │ │ │ ├── Box.ts │ │ │ ├── Text.ts │ │ │ └── App.ts │ │ ├── instances.ts │ │ ├── index.ts │ │ ├── dom │ │ │ ├── get-max-width.ts │ │ │ ├── measure-text.ts │ │ │ ├── wrap-text.ts │ │ │ ├── render-border.ts │ │ │ ├── squash-text-nodes.ts │ │ │ ├── colorize.ts │ │ │ ├── index.ts │ │ │ └── styles.ts │ │ ├── log-update.ts │ │ ├── createRenderer.ts │ │ ├── renderer.ts │ │ ├── output.ts │ │ ├── slice-ansi │ │ │ └── index.ts │ │ ├── render-node-to-output.ts │ │ ├── render.ts │ │ └── temir.ts │ ├── tsup.config.ts │ ├── __test__ │ │ ├── display.test.ts │ │ ├── flex-direction.test.ts │ │ ├── flex-align-items.test.ts │ │ ├── flex-align-self.test.ts │ │ ├── flex-justify-content.test.ts │ │ ├── width-height.test.ts │ │ ├── margin.test.ts │ │ ├── padding.test.ts │ │ ├── flex.test.ts │ │ ├── components.test.ts │ │ └── borders.test.ts │ └── package.json ├── temir-spinner │ ├── media │ │ └── temir-spinner.gif │ ├── tsup.config.ts │ ├── README.md │ ├── src │ │ └── index.ts │ └── package.json └── temir-select-input │ ├── media │ └── temir-select-input.gif │ ├── src │ ├── index.ts │ ├── Item.ts │ ├── Indicator.ts │ └── SelectInput.ts │ ├── tsup.config.ts │ ├── package.json │ └── README.md ├── media ├── logo.png ├── hi-temir.png ├── temir-demo.gif ├── temir-hmr.gif ├── temir-borders.png ├── temir-table.png ├── temir-vitest.gif ├── text-inverse.png ├── temir-text-props.png ├── temir-text-bg-color.png ├── temir-text-props-color.png ├── temir-text-props-dimmed-color.png └── logo.svg ├── pnpm-workspace.yaml ├── .npmrc ├── examples ├── borders │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json ├── hi-temir │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json ├── table │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json ├── vitest │ ├── src │ │ ├── main.ts │ │ ├── Test.vue │ │ ├── Summary.vue │ │ └── App.vue │ └── package.json ├── temir-link │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json ├── temir-spinner │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json ├── temir-tab │ ├── src │ │ ├── main.ts │ │ └── App.vue │ └── package.json └── temir-select-input │ ├── src │ ├── main.ts │ └── App.vue │ └── package.json ├── .eslintrc ├── playground ├── main.ts ├── App.tsx ├── App.vue ├── App.ts └── package.json ├── tsconfig.json ├── vitest.config.ts ├── LICENSE ├── package.json └── README.zh-CN.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [webfansplz] 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .output 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /packages/cli/src/hmr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hmr' 2 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/logo.png -------------------------------------------------------------------------------- /packages/cli/temir-cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('./dist/cli.mjs') 3 | -------------------------------------------------------------------------------- /media/hi-temir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/hi-temir.png -------------------------------------------------------------------------------- /media/temir-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-demo.gif -------------------------------------------------------------------------------- /media/temir-hmr.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-hmr.gif -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - examples/* 4 | - playground 5 | -------------------------------------------------------------------------------- /media/temir-borders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-borders.png -------------------------------------------------------------------------------- /media/temir-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-table.png -------------------------------------------------------------------------------- /media/temir-vitest.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-vitest.gif -------------------------------------------------------------------------------- /media/text-inverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/text-inverse.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shell-emulator=true 2 | strict-peer-dependencies=false 3 | ignore-workspace-root-check=true 4 | -------------------------------------------------------------------------------- /media/temir-text-props.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-text-props.png -------------------------------------------------------------------------------- /media/temir-text-bg-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-text-bg-color.png -------------------------------------------------------------------------------- /media/temir-text-props-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-text-props-color.png -------------------------------------------------------------------------------- /packages/cli/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './esbuild-plugin-vue' 2 | export * from './esbuild-plugin-vue-jsx' 3 | -------------------------------------------------------------------------------- /examples/borders/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /examples/hi-temir/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /examples/table/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /examples/vitest/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /packages/temir-tab/media/temir-tab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/packages/temir-tab/media/temir-tab.gif -------------------------------------------------------------------------------- /examples/temir-link/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /examples/temir-spinner/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /examples/temir-tab/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /media/temir-text-props-dimmed-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/media/temir-text-props-dimmed-color.png -------------------------------------------------------------------------------- /packages/.test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './render-to-string' 2 | export * from './create-stdout' 3 | export * from './component' 4 | -------------------------------------------------------------------------------- /packages/temir-link/media/temir-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/packages/temir-link/media/temir-link.png -------------------------------------------------------------------------------- /packages/temir/src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useStdin' 2 | export * from './useInput' 3 | export * from './useRender' 4 | -------------------------------------------------------------------------------- /examples/temir-select-input/src/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | 5 | render(App) 6 | -------------------------------------------------------------------------------- /packages/temir-spinner/media/temir-spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/packages/temir-spinner/media/temir-spinner.gif -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import { runDevServer } from './dev' 2 | import { buildBundle } from './build' 3 | export { runDevServer, buildBundle as build } 4 | -------------------------------------------------------------------------------- /packages/temir-select-input/media/temir-select-input.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webfansplz/temir/HEAD/packages/temir-select-input/media/temir-select-input.gif -------------------------------------------------------------------------------- /packages/temir/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './App' 2 | export * from './Box' 3 | export * from './Text' 4 | export * from './Newline' 5 | export * from './Spacer' 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "rules":{ 4 | "no-console":0, 5 | "@typescript-eslint/ban-ts-comment":0, 6 | "vue/one-component-per-file":0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /playground/main.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@temir/core' 2 | 3 | import App from './App.vue' 4 | // import App from './App.tsx' 5 | // import App from './App' 6 | 7 | render(App) 8 | -------------------------------------------------------------------------------- /packages/temir/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs'], 6 | clean: false, 7 | dts: true, 8 | }) 9 | -------------------------------------------------------------------------------- /packages/.test/setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach } from 'vitest' 2 | import { getActiveStdout } from './create-stdout' 3 | 4 | beforeEach(() => { 5 | const stdout = getActiveStdout() 6 | stdout && delete global[stdout as any] 7 | }) 8 | -------------------------------------------------------------------------------- /packages/temir-select-input/src/index.ts: -------------------------------------------------------------------------------- 1 | import SelectInput from './SelectInput' 2 | export { default as Indicator, IndicatorProps } from './Indicator' 3 | export { default as Item, ItemProps } from './Item' 4 | 5 | export default SelectInput 6 | -------------------------------------------------------------------------------- /packages/cli/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: ['src/index', 'src/cli'], 5 | clean: false, 6 | declaration: true, 7 | rollup: { 8 | emitCJS: true, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "allowJs": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/temir/src/instances.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | has(key) { 3 | return !!global[key] 4 | }, 5 | get(key) { 6 | return global[key] 7 | }, 8 | set(key, value) { 9 | global[key] = value 10 | }, 11 | delete(key) { 12 | delete global[key] 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/temir/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as render } from './render' 2 | export type { RenderOptions } from './render' 3 | export { TBox, TText, TNewline, TSpacer } from './components' 4 | export type { AppProps, TBoxProps, TTextProps, TNewlineProps } from './components' 5 | export * from './composables' 6 | -------------------------------------------------------------------------------- /examples/borders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "borders", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20" 11 | }, 12 | "devDependencies": { 13 | "@temir/cli": "0.0.20" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/hi-temir/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hi-temir", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20" 11 | }, 12 | "devDependencies": { 13 | "@temir/cli": "0.0.20" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /playground/App.tsx: -------------------------------------------------------------------------------- 1 | import { TBox, TText } from '@temir/core' 2 | import { defineComponent, ref } from 'vue' 3 | 4 | export default defineComponent({ 5 | setup() { 6 | const count = ref(1) 7 | setInterval(() => { 8 | count.value++ 9 | }, 1000) 10 | return () => ( 11 | 12 | {count.value} 13 | 14 | ) 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /packages/temir/src/composables/useRender.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from '@vue/runtime-core' 2 | import { inject } from '@vue/runtime-core' 3 | import type Temir from '../temir' 4 | 5 | export const useRender = () => { 6 | const instance = inject>('instance') 7 | function render(node: Component) { 8 | instance.render(node) 9 | } 10 | return render 11 | } 12 | 13 | -------------------------------------------------------------------------------- /packages/temir/src/dom/get-max-width.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | 3 | export default (yogaNode: Yoga.YogaNode) => { 4 | return ( 5 | yogaNode.getComputedWidth() 6 | - yogaNode.getComputedPadding(Yoga.EDGE_LEFT) 7 | - yogaNode.getComputedPadding(Yoga.EDGE_RIGHT) 8 | - yogaNode.getComputedBorder(Yoga.EDGE_LEFT) 9 | - yogaNode.getComputedBorder(Yoga.EDGE_RIGHT) 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /examples/table/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "table", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@faker-js/faker": "^7.3.0", 11 | "@temir/core": "0.0.20" 12 | }, 13 | "devDependencies": { 14 | "@temir/cli": "0.0.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/temir-tab/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temir-tab", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20", 11 | "@temir/tab": "0.0.20" 12 | }, 13 | "devDependencies": { 14 | "@temir/cli": "0.0.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/temir-link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temir-link", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20", 11 | "@temir/link": "0.0.20" 12 | }, 13 | "devDependencies": { 14 | "@temir/cli": "0.0.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/temir-spinner/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temir-spinner", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20", 11 | "@temir/spinner": "0.0.20" 12 | }, 13 | "devDependencies": { 14 | "@temir/cli": "0.0.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/temir-link/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | clean: false, 7 | dts: true, 8 | esbuildOptions(options) { 9 | if (options.format === 'esm') 10 | options.outExtension = { '.js': '.mjs' } 11 | if (options.format === 'cjs') 12 | options.outExtension = { '.js': '.cjs' } 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /packages/temir-tab/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | clean: false, 7 | dts: true, 8 | esbuildOptions(options) { 9 | if (options.format === 'esm') 10 | options.outExtension = { '.js': '.mjs' } 11 | if (options.format === 'cjs') 12 | options.outExtension = { '.js': '.cjs' } 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /packages/temir-spinner/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | clean: false, 7 | dts: true, 8 | esbuildOptions(options) { 9 | if (options.format === 'esm') 10 | options.outExtension = { '.js': '.mjs' } 11 | if (options.format === 'cjs') 12 | options.outExtension = { '.js': '.cjs' } 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /packages/temir-select-input/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | clean: false, 7 | dts: true, 8 | esbuildOptions(options) { 9 | if (options.format === 'esm') 10 | options.outExtension = { '.js': '.mjs' } 11 | if (options.format === 'cjs') 12 | options.outExtension = { '.js': '.cjs' } 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /examples/temir-select-input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temir-select-input", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@temir/core": "0.0.20", 11 | "@temir/select-input": "0.0.20" 12 | }, 13 | "devDependencies": { 14 | "@temir/cli": "0.0.20" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/vitest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vitest", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "temir src/main.ts", 6 | "build": "temir build src/main.ts", 7 | "start": "pnpm run build && node dist/main.js" 8 | }, 9 | "dependencies": { 10 | "@antfu/p": "^0.1.0", 11 | "@temir/core": "0.0.20", 12 | "delay": "^5.0.0", 13 | "ms": "^2.1.3" 14 | }, 15 | "devDependencies": { 16 | "@temir/cli": "0.0.20" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/temir-spinner/src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vitest/config' 3 | import Vue from '@vitejs/plugin-vue' 4 | 5 | export default defineConfig({ 6 | test: { 7 | // include: ['**/__tests__/**'], 8 | globals: true, 9 | environment: 'node', 10 | transformMode: { 11 | web: [/\.[jt]sx$/], 12 | }, 13 | setupFiles: [ 14 | resolve(__dirname, 'packages/.test/setup.ts'), 15 | ], 16 | }, 17 | plugins: [ 18 | Vue(), 19 | ], 20 | }) 21 | -------------------------------------------------------------------------------- /examples/temir-link/src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 23 | -------------------------------------------------------------------------------- /examples/temir-select-input/src/App.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /packages/.test/render-to-string.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { render } from '../temir/src'; 4 | import { createStdout } from './create-stdout'; 5 | import type { Component } from '@vue/runtime-core' 6 | 7 | export const renderToString: ( 8 | node: Component, 9 | options?: { columns: number } 10 | ) => string = (node, options = { columns: 100 }) => { 11 | const stdout = createStdout(options.columns); 12 | 13 | render(node, { 14 | // @ts-ignore 15 | stdout, 16 | debug: true 17 | }); 18 | 19 | 20 | return stdout.get(); 21 | }; 22 | -------------------------------------------------------------------------------- /playground/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /packages/temir/src/components/Spacer.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from '@vue/runtime-core' 2 | import { TBox } from './' 3 | 4 | /** 5 | * A flexible space that expands along the major axis of its containing layout. 6 | * It's useful as a shortcut for filling all the available spaces between elements. 7 | */ 8 | export const TSpacer = defineComponent({ 9 | name: 'TSpacer', 10 | inheritAttrs: false, 11 | setup() { 12 | return () => { 13 | return h(TBox, { 14 | flexGrow: 1, 15 | }) 16 | } 17 | }, 18 | }) 19 | 20 | -------------------------------------------------------------------------------- /examples/hi-temir/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /packages/.test/component.ts: -------------------------------------------------------------------------------- 1 | import type { Component, VNode } from '@vue/runtime-core' 2 | import { h } from '@vue/runtime-core' 3 | import { TBox, TText } from '../temir/src/components' 4 | import type { TBoxProps, TTextProps } from '../temir/src/components' 5 | 6 | 7 | export function createTextComponent(text: string | Component, options: TTextProps = {}) { 8 | return h(TText, options, text as string) 9 | } 10 | 11 | export function createBoxComponent(content: VNode | VNode[], options: TBoxProps = {}) { 12 | return h(TBox, options, content) 13 | } 14 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import cac from 'cac' 2 | 3 | import { version } from '../package.json' 4 | import { runDevServer } from './dev' 5 | import { buildBundle } from './build' 6 | const cli = cac('temir') 7 | 8 | cli 9 | .command('[file]') 10 | .action(runDevServer) 11 | 12 | cli 13 | .command('build [file]') 14 | .option('-od, --outDir', 'Output Dir') 15 | .option('-m, --minify', 'Minify the output') 16 | .option('-a, --all', 'Build all the deps into bundles') 17 | .action(buildBundle) 18 | 19 | cli 20 | .version(version) 21 | .help() 22 | 23 | cli.parse() 24 | 25 | -------------------------------------------------------------------------------- /packages/temir/src/dom/measure-text.ts: -------------------------------------------------------------------------------- 1 | import widestLine from 'widest-line' 2 | 3 | const cache: Record = {} 4 | 5 | interface Output { 6 | width: number 7 | height: number 8 | } 9 | 10 | export default (text: string): Output => { 11 | if (text.length === 0) { 12 | return { 13 | width: 0, 14 | height: 0, 15 | } 16 | } 17 | 18 | if (cache[text]) 19 | return cache[text] 20 | 21 | const width = widestLine(text) 22 | const height = text.split('\n').length 23 | cache[text] = { width, height } 24 | 25 | return { width, height } 26 | } 27 | -------------------------------------------------------------------------------- /packages/temir-select-input/src/Item.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue' 2 | import { TText } from '@temir/core' 3 | export interface ItemProps { 4 | isSelected?: boolean 5 | label: string 6 | } 7 | 8 | /** 9 | * SelectInputItem. 10 | */ 11 | const SelectInputItem = defineComponent({ 12 | name: 'TWrap', 13 | props: ([ 14 | 'isSelected', 15 | 'label', 16 | ] as undefined), 17 | setup(props) { 18 | return () => { 19 | return h(TText, { 20 | color: props.isSelected ? 'blue' : undefined, 21 | }, props.label) 22 | } 23 | }, 24 | }) 25 | 26 | export default SelectInputItem 27 | -------------------------------------------------------------------------------- /packages/temir/__test__/display.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Display', () => { 5 | it('display flex', () => { 6 | const output = renderToString(createBoxComponent(createTextComponent('X'), { display: 'flex' })) 7 | expect(output).toBe('X') 8 | }) 9 | 10 | it('display none', () => { 11 | const output = renderToString(createBoxComponent([createBoxComponent(createTextComponent('Kitty!'), { display: 'none' }), createTextComponent('Doggo')], { flexDirection: 'column' })) 12 | expect(output).toBe('Doggo') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /packages/temir-select-input/src/Indicator.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue' 2 | import { TBox, TText } from '@temir/core' 3 | import figures from 'figures' 4 | export interface IndicatorProps { 5 | isSelected?: boolean 6 | } 7 | 8 | /** 9 | * Indicator. 10 | */ 11 | const Indicator = defineComponent({ 12 | name: 'TWrap', 13 | props: ([ 14 | 'isSelected', 15 | ] as undefined), 16 | setup(props) { 17 | return () => { 18 | const children = props.isSelected ? h(TText, { color: 'blue' }, figures.pointer) : h(TText, {}, ' ') 19 | return h(TBox, { 20 | marginRight: 1, 21 | }, children) 22 | } 23 | }, 24 | }) 25 | 26 | export default Indicator 27 | -------------------------------------------------------------------------------- /playground/App.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h, ref } from '@vue/runtime-core' 2 | import { TBox, TText } from '@temir/core' 3 | 4 | export default defineComponent({ 5 | setup() { 6 | const count = ref(0) 7 | const j = computed(() => count.value === 0 ? 'center' : 'flex-start') 8 | const c = computed(() => count.value === 0 ? 'red' : 'yellow') 9 | 10 | setInterval(() => { 11 | // count.value++ 12 | count.value = count.value === 0 ? 1 : 0 13 | }, 1000) 14 | 15 | return () => h(TBox, { 16 | width: 80, 17 | justifyContent: j.value, 18 | borderStyle: 'round', 19 | marginRight: 2, 20 | }, h(TText, { 21 | color: c.value, 22 | }, 'Hello World!!')) 23 | }, 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /examples/temir-tab/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /packages/temir/src/composables/useStdin.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@vue/runtime-core' 2 | 3 | /** 4 | * `useStdin` which exposes stdin stream. 5 | */ 6 | 7 | export interface StdinProps { 8 | stdin: NodeJS.ReadStream 9 | setRawMode: (isEnabled: boolean) => void 10 | isRawModeSupported: boolean 11 | internal_exitOnCtrlC: boolean 12 | } 13 | 14 | export function useStdin() { 15 | const stdin = inject('stdin') 16 | const setRawMode = inject<(isEnabled: boolean) => void>('setRawMode') 17 | const isRawModeSupported = inject('isRawModeSupported') 18 | const internal_exitOnCtrlC = inject('internal_exitOnCtrlC') 19 | 20 | return { 21 | stdin, 22 | setRawMode, 23 | isRawModeSupported, 24 | internal_exitOnCtrlC, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/playground", 3 | "private": true, 4 | "description": "Playground for temir", 5 | "author": "webfansplz", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "scripts": { 9 | "dev": "temir main.ts", 10 | "build": "temir build main.ts", 11 | "start": "pnpm run build && node dist/main.js" 12 | 13 | }, 14 | "devDependencies": { 15 | "@temir/cli": "workspace:*", 16 | "@temir/core": "workspace:*", 17 | "@temir/link": "workspace:*", 18 | "@temir/select-input": "workspace:*", 19 | "@temir/spinner": "workspace:*", 20 | "@temir/tab": "workspace:*", 21 | "@vitejs/plugin-vue": "^3.0.1", 22 | "@vue/runtime-core": "^3.2.37", 23 | "vite": "^2.9.14", 24 | "vite-node": "^0.19.1", 25 | "vue": "^3.2.37" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/.test/create-stdout.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import { spy } from 'sinon'; 3 | 4 | // Fake process.stdout 5 | interface Stream extends EventEmitter { 6 | output: string; 7 | columns: number; 8 | write(str: string): void; 9 | get(): string; 10 | } 11 | 12 | interface StdoutInstance { 13 | columns: number 14 | write: typeof spy 15 | get: () => string 16 | } 17 | 18 | let activeStdout: StdoutInstance 19 | 20 | export function getActiveStdout() { 21 | return activeStdout 22 | } 23 | 24 | export const createStdout = (columns?: number): Stream => { 25 | const stdout = new EventEmitter() as StdoutInstance; 26 | stdout.columns = columns ?? 100; 27 | stdout.write = spy(); 28 | stdout.get = () => (stdout.write.lastCall?.args?.[0]) 29 | 30 | activeStdout = stdout 31 | return stdout; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/cli/src/build.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { build } from 'tsup' 3 | import { VueJsxPlugin, vue } from './plugins' 4 | 5 | export interface BuildOptions { 6 | // Minify the output 7 | minify?: boolean 8 | // Build all the deps into bundles 9 | all?: boolean 10 | // Output dir 11 | outDir?: string 12 | } 13 | 14 | function normalizePath(filePath: string): string { 15 | return filePath.split(path.sep).join(path.posix.sep) 16 | } 17 | 18 | export function buildBundle(file = 'src/main.ts', options: BuildOptions = {}) { 19 | const { 20 | minify = false, all = false, outDir = 'dist', 21 | } = options 22 | 23 | build({ 24 | entry: [normalizePath(path.resolve('./', file))], 25 | esbuildPlugins: [vue(), VueJsxPlugin()], 26 | minify, 27 | outDir, 28 | external: all ? [] : [/@temir/, '@vue/runtime-core'], 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # temir-cli 2 | 3 | > CLI for Temir. 4 | 5 | ## Install 6 | 7 | ``` 8 | $ npm install @temir/cli 9 | ``` 10 | 11 | ## usage 12 | 13 | 14 | ### With Command 15 | 16 | ```sh 17 | # Dev 18 | 19 | temir [file] 20 | 21 | # Build 22 | 23 | ## Option for build 24 | 25 | # '-od, --outDir', 'Output Dir' 26 | # '-m, --minify', 'Minify the output' 27 | # '-a, --all', 'Build all the deps into bundles' 28 | 29 | temir build [file] 30 | 31 | 32 | ``` 33 | 34 | ### With API 35 | 36 | ```ts 37 | import { build, runDevServer } from '@temir/cli' 38 | 39 | // Options for build 40 | export interface BuildOptions { 41 | // Minify the output 42 | minify?: boolean 43 | // Build all the deps into bundles 44 | all?: boolean 45 | // Output dir 46 | outDir?: string 47 | } 48 | 49 | // Dev 50 | runDevServer('file') 51 | 52 | // Build 53 | build('file', options) 54 | 55 | ``` 56 | -------------------------------------------------------------------------------- /examples/vitest/src/Test.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 39 | -------------------------------------------------------------------------------- /packages/temir-link/src/index.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, getCurrentInstance, h } from 'vue' 2 | import terminalLink from 'terminal-link' 3 | 4 | export interface TLinkProps { 5 | url: string 6 | fallback?: boolean 7 | } 8 | 9 | /** 10 | * Link. 11 | */ 12 | const TLink = defineComponent({ 13 | name: 'TLink', 14 | props: ([ 15 | 'url', 16 | 'fallback', 17 | ] as undefined), 18 | setup(props, { slots }) { 19 | const instance = getCurrentInstance() 20 | return () => { 21 | const children = slots.default() 22 | return h('temir-text', { 23 | _temir_text: children, 24 | isInsideText: !['TBox', 'TApp', 'TWrap'].includes(instance.parent.type.name), 25 | internal_transform: (text: string) => { 26 | return terminalLink(text, props.url, { fallback: props.fallback ?? true }) 27 | }, 28 | }, children) 29 | } 30 | }, 31 | }) 32 | 33 | export default TLink 34 | -------------------------------------------------------------------------------- /packages/temir/src/components/Newline.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, getCurrentInstance, h } from '@vue/runtime-core' 2 | 3 | export interface TNewlineProps { 4 | /** 5 | * Number of newlines to insert. 6 | * 7 | * @default 1 8 | */ 9 | readonly count?: number 10 | } 11 | 12 | /** 13 | * Adds one or more newline (\n) characters. Must be used within components. 14 | */ 15 | export const TNewline = defineComponent({ 16 | name: 'TNewline', 17 | props: ([ 18 | 'count', 19 | ] as undefined), 20 | setup(props, { slots }) { 21 | const instance = getCurrentInstance() 22 | return () => { 23 | const children = slots.default?.() 24 | const count = props.count ?? 1 25 | return h('temir-text', { 26 | _temir_text: children, 27 | isInsideText: !['TBox', 'TApp', 'TWrap'].includes(instance.parent.type.name), 28 | }, '\n'.repeat(count)) 29 | } 30 | }, 31 | }) 32 | 33 | -------------------------------------------------------------------------------- /packages/temir-tab/README.md: -------------------------------------------------------------------------------- 1 | # temir-tab 2 | 3 | > Tab component for Temir. 4 | 5 | ## Install 6 | 7 | ``` 8 | $ npm install @temir/tab 9 | ``` 10 | 11 | ## usage 12 | 13 | ![temir-tab](./media/temir-tab.gif) 14 | 15 | ```vue 16 | 25 | 26 | 44 | 45 | ``` 46 | 47 | -------------------------------------------------------------------------------- /packages/temir/src/dom/wrap-text.ts: -------------------------------------------------------------------------------- 1 | import wrapAnsi from 'wrap-ansi' 2 | import cliTruncate from 'cli-truncate' 3 | import type { Styles } from './styles' 4 | 5 | const cache: Record = {} 6 | 7 | export default ( 8 | text: string, 9 | maxWidth: number, 10 | wrapType: Styles['textWrap'], 11 | ): string => { 12 | const cacheKey = text + String(maxWidth) + String(wrapType) 13 | 14 | if (cache[cacheKey]) 15 | return cache[cacheKey] 16 | 17 | let wrappedText = text 18 | 19 | if (wrapType === 'wrap') { 20 | wrappedText = wrapAnsi(text, maxWidth, { 21 | trim: false, 22 | hard: true, 23 | }) 24 | } 25 | 26 | if (wrapType!.startsWith('truncate')) { 27 | let position: 'end' | 'middle' | 'start' = 'end' 28 | 29 | if (wrapType === 'truncate-middle') 30 | position = 'middle' 31 | 32 | if (wrapType === 'truncate-start') 33 | position = 'start' 34 | 35 | wrappedText = cliTruncate(text, maxWidth, { position }) 36 | } 37 | 38 | cache[cacheKey] = wrappedText 39 | 40 | return wrappedText 41 | } 42 | -------------------------------------------------------------------------------- /examples/borders/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 40 | -------------------------------------------------------------------------------- /examples/table/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 webfansplz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/temir-spinner/README.md: -------------------------------------------------------------------------------- 1 | # temir-spinner 2 | 3 | > Spinner component for Temir. Uses [cli-spinners](https://github.com/sindresorhus/cli-spinners) for the collection of spinners. 4 | 5 | ## Install 6 | 7 | ``` 8 | $ npm install @temir/spinner 9 | ``` 10 | 11 | ## usage 12 | 13 | ![temir-spinner](./media/temir-spinner.gif) 14 | 15 | ```vue 16 | 20 | 21 | 36 | 37 | ``` 38 | 39 | ## Props 40 | 41 | ### type 42 | 43 | Type: `string`
44 | Defaults: `dots` 45 | 46 | Type of a spinner. See [cli-spinners](https://github.com/sindresorhus/cli-spinners) for available spinners. 47 | 48 | 49 | ## Related 50 | 51 | - [cli-spinners](https://github.com/sindresorhus/cli-spinners) 52 | 53 | - [ink-spinner](https://github.com/vadimdemedes/ink-spinner) 54 | -------------------------------------------------------------------------------- /packages/temir-spinner/src/index.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue' 2 | import spinners from 'cli-spinners' 3 | import type { SpinnerName } from 'cli-spinners' 4 | import { TText } from '@temir/core' 5 | export type { SpinnerName } from 'cli-spinners' 6 | 7 | export interface TSpinnerProps { 8 | /** 9 | * Type of a spinner. 10 | * See [cli-spinners](https://github.com/sindresorhus/cli-spinners) for available spinners. 11 | * 12 | * @default dots 13 | */ 14 | type?: SpinnerName 15 | } 16 | 17 | /** 18 | * Spinner. 19 | */ 20 | const TSpinner = defineComponent({ 21 | name: 'TSpinner', 22 | props: ([ 23 | 'type', 24 | ] as undefined), 25 | setup(props) { 26 | const spinner = computed(() => spinners[props.type ?? 'dots']) 27 | const frame = ref(0) 28 | let timer = null 29 | onMounted(() => { 30 | timer = setInterval(() => { 31 | frame.value = (frame.value + 1) % spinner.value?.frames?.length 32 | }, spinner.value.interval) 33 | }) 34 | onUnmounted(() => { 35 | clearInterval(timer) 36 | }) 37 | return () => { 38 | return h(TText, {}, spinner.value?.frames[frame.value]) 39 | } 40 | }, 41 | }) 42 | 43 | export default TSpinner 44 | -------------------------------------------------------------------------------- /packages/temir/src/dom/render-border.ts: -------------------------------------------------------------------------------- 1 | import cliBoxes from 'cli-boxes' 2 | import type Output from '../output' 3 | import colorize from './colorize' 4 | import type { DOMNode } from '.' 5 | 6 | export default (x: number, y: number, node: DOMNode, output: Output): void => { 7 | if (typeof node.style.borderStyle === 'string') { 8 | const width = node.yogaNode!.getComputedWidth() 9 | const height = node.yogaNode!.getComputedHeight() 10 | const color = node.style.borderColor 11 | const box = cliBoxes[node.style.borderStyle] 12 | 13 | const topBorder = colorize( 14 | box.topLeft + box.horizontal.repeat(width - 2) + box.topRight, 15 | color, 16 | 'foreground', 17 | ) 18 | 19 | const verticalBorder = ( 20 | `${colorize(box.vertical, color, 'foreground')}\n` 21 | ).repeat(height - 2) 22 | 23 | const bottomBorder = colorize( 24 | box.bottomLeft + box.horizontal.repeat(width - 2) + box.bottomRight, 25 | color, 26 | 'foreground', 27 | ) 28 | 29 | output.write(x, y, topBorder, { transformers: [] }) 30 | output.write(x, y + 1, verticalBorder, { transformers: [] }) 31 | output.write(x + width - 1, y + 1, verticalBorder, { transformers: [] }) 32 | output.write(x, y + height - 1, bottomBorder, { transformers: [] }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/temir-tab/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/tab", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "description": "Tab component for Temir", 6 | "author": "webfansplz", 7 | "license": "MIT", 8 | "homepage": "https://github.com/webfansplz/temir#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/webfansplz/temir.git" 12 | }, 13 | "keywords": [ 14 | "vue", 15 | "cli", 16 | "stdout", 17 | "components", 18 | "command-line", 19 | "print", 20 | "render", 21 | "colors", 22 | "text", 23 | "tab" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "require": "./dist/index.cjs", 29 | "import": "./dist/index.mjs" 30 | }, 31 | "./*": "./*" 32 | }, 33 | "main": "dist/index.cjs", 34 | "module": "dist/index.mjs", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist/**/*.cjs", 38 | "dist/**/*.mjs", 39 | "dist/**/*.d.ts" 40 | ], 41 | "engines": { 42 | "node": ">=14" 43 | }, 44 | "scripts": { 45 | "build": "tsup", 46 | "dev": "tsup" 47 | }, 48 | "dependencies": { 49 | "@temir/core": "workspace:*", 50 | "@vue/runtime-core": "^3.2.37", 51 | "readline": "^1.3.0" 52 | }, 53 | "devDependencies": { 54 | "tsup": "^6.2.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/temir/src/log-update.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import type { Writable } from 'stream' 4 | import ansiEscapes from 'ansi-escapes' 5 | import cliCursor from 'cli-cursor' 6 | 7 | export interface LogUpdate { 8 | clear: () => void 9 | done: () => void 10 | (str: string): void 11 | } 12 | 13 | const create = (stream: Writable, { showCursor = false } = {}): LogUpdate => { 14 | let previousLineCount = 0 15 | let previousOutput = '' 16 | let hasHiddenCursor = false 17 | 18 | const render = (str: string) => { 19 | if (!showCursor && !hasHiddenCursor) { 20 | cliCursor.hide() 21 | hasHiddenCursor = true 22 | } 23 | 24 | const output = `${str}\n` 25 | if (output === previousOutput) 26 | return 27 | 28 | previousOutput = output 29 | stream.write(ansiEscapes.eraseLines(previousLineCount) + output) 30 | previousLineCount = output.split('\n').length 31 | } 32 | 33 | render.clear = () => { 34 | stream.write(ansiEscapes.eraseLines(previousLineCount)) 35 | previousOutput = '' 36 | previousLineCount = 0 37 | } 38 | 39 | render.done = () => { 40 | previousOutput = '' 41 | previousLineCount = 0 42 | if (!showCursor) { 43 | cliCursor.show() 44 | hasHiddenCursor = false 45 | } 46 | } 47 | 48 | return render 49 | } 50 | 51 | export default { create } 52 | -------------------------------------------------------------------------------- /packages/cli/src/plugins/esbuild-plugin-vue-jsx.ts: -------------------------------------------------------------------------------- 1 | // Fork from https://github.com/chenjiahan/esbuild-plugin-vue-jsx 2 | 3 | import fs from 'fs' 4 | import type { Plugin } from 'esbuild' 5 | import type { VueJSXPluginOptions } from '@vue/babel-plugin-jsx' 6 | 7 | const name = 'vue-jsx' 8 | 9 | const isTsxPath = (path: string) => /\.tsx$/.test(path) 10 | 11 | export const VueJsxPlugin = (options: VueJSXPluginOptions = {}): Plugin => ({ 12 | name, 13 | 14 | setup(build) { 15 | build.onResolve({ filter: /\.(j|t)sx$/ }, args => ({ 16 | path: args.path, 17 | namespace: name, 18 | pluginData: { 19 | resolveDir: args.resolveDir, 20 | }, 21 | })) 22 | 23 | build.onLoad({ filter: /.*/, namespace: name }, async (args) => { 24 | const { path } = args 25 | 26 | const code = await fs.promises.readFile(path, 'utf8') 27 | 28 | const babel = await import('@babel/core') 29 | const babelResult = await babel.transformAsync(code, { 30 | filename: path, 31 | babelrc: false, 32 | presets: isTsxPath(path) ? ['@babel/preset-typescript'] : [], 33 | plugins: [['@vue/babel-plugin-jsx', options]], 34 | }) 35 | 36 | return { 37 | contents: babelResult?.code || '', 38 | resolveDir: args.pluginData.resolveDir, 39 | } 40 | }) 41 | }, 42 | }) 43 | 44 | -------------------------------------------------------------------------------- /packages/temir-spinner/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/spinner", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "description": "Spinner component for Temir", 6 | "author": "webfansplz", 7 | "license": "MIT", 8 | "homepage": "https://github.com/webfansplz/temir#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/webfansplz/temir.git" 12 | }, 13 | "keywords": [ 14 | "vue", 15 | "cli", 16 | "stdout", 17 | "components", 18 | "command-line", 19 | "print", 20 | "render", 21 | "colors", 22 | "text", 23 | "spinner" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "require": "./dist/index.cjs", 29 | "import": "./dist/index.mjs" 30 | }, 31 | "./*": "./*" 32 | }, 33 | "main": "dist/index.cjs", 34 | "module": "dist/index.mjs", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist/**/*.cjs", 38 | "dist/**/*.mjs", 39 | "dist/**/*.d.ts" 40 | ], 41 | "engines": { 42 | "node": ">=14" 43 | }, 44 | "scripts": { 45 | "build": "tsup", 46 | "dev": "tsup" 47 | }, 48 | "dependencies": { 49 | "@temir/core": "workspace:*", 50 | "@vue/runtime-core": "^3.2.37", 51 | "cli-spinners": "^2.7.0" 52 | }, 53 | "devDependencies": { 54 | "tsup": "^6.2.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/temir-link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/link", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "description": "Link component for Temir", 6 | "author": "webfansplz", 7 | "license": "MIT", 8 | "homepage": "https://github.com/webfansplz/temir#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/webfansplz/temir.git" 12 | }, 13 | "keywords": [ 14 | "vue", 15 | "cli", 16 | "stdout", 17 | "components", 18 | "command-line", 19 | "print", 20 | "render", 21 | "colors", 22 | "text", 23 | "link" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "require": "./dist/index.cjs", 29 | "import": "./dist/index.mjs" 30 | }, 31 | "./*": "./*" 32 | }, 33 | "main": "dist/index.cjs", 34 | "module": "dist/index.mjs", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist/**/*.cjs", 38 | "dist/**/*.mjs", 39 | "dist/**/*.d.ts" 40 | ], 41 | "engines": { 42 | "node": ">=14" 43 | }, 44 | "scripts": { 45 | "build": "tsup", 46 | "dev": "tsup" 47 | }, 48 | "dependencies": { 49 | "@temir/core": "workspace:*", 50 | "@vue/runtime-core": "^3.2.37", 51 | "terminal-link": "^2.1.1" 52 | }, 53 | "devDependencies": { 54 | "@types/terminal-link": "^1.2.0", 55 | "tsup": "^6.2.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/temir-select-input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/select-input", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "description": "Select Input component for Temir", 6 | "author": "webfansplz", 7 | "license": "MIT", 8 | "homepage": "https://github.com/webfansplz/temir#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/webfansplz/temir.git" 12 | }, 13 | "keywords": [ 14 | "vue", 15 | "cli", 16 | "stdout", 17 | "components", 18 | "command-line", 19 | "print", 20 | "render", 21 | "colors", 22 | "text", 23 | "select-input" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "require": "./dist/index.cjs", 29 | "import": "./dist/index.mjs" 30 | }, 31 | "./*": "./*" 32 | }, 33 | "main": "dist/index.cjs", 34 | "module": "dist/index.mjs", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist/**/*.cjs", 38 | "dist/**/*.mjs", 39 | "dist/**/*.d.ts" 40 | ], 41 | "engines": { 42 | "node": ">=14" 43 | }, 44 | "scripts": { 45 | "build": "tsup", 46 | "dev": "tsup" 47 | }, 48 | "dependencies": { 49 | "@temir/core": "workspace:*", 50 | "@vue/runtime-core": "^3.2.37", 51 | "arr-rotate": "^1.0.0", 52 | "figures": "^3.2.0" 53 | }, 54 | "devDependencies": { 55 | "tsup": "^6.2.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/temir/__test__/flex-direction.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Flex Direction', () => { 5 | it('direction row', () => { 6 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'row' })) 7 | expect(output).toBe('AB') 8 | }) 9 | 10 | it('direction row reverse', () => { 11 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'row-reverse', width: 4 })) 12 | expect(output).toBe(' BA') 13 | }) 14 | 15 | it('direction column', () => { 16 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'column' })) 17 | expect(output).toBe('A\nB') 18 | }) 19 | 20 | it('direction column reverse', () => { 21 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'column-reverse', height: 4 })) 22 | expect(output).toBe('\n\nB\nA') 23 | }) 24 | 25 | it('don’t squash text nodes when column direction is applied', () => { 26 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'column' })) 27 | expect(output).toBe('A\nB') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /packages/temir/src/createRenderer.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { createRenderer } from '@vue/runtime-core' 4 | import { diff } from 'object-diff-patch' 5 | import type { DOMElement, DOMNode } from './dom' 6 | import { appendChildNode, cleanupYogaNode, createElement, createTextNode, removeChildNode, setTextNodeValue, updateProps } from './dom' 7 | 8 | global.__VUE_OPTIONS_API__ = true 9 | global.__VUE_PROD_DEVTOOLS__ = true 10 | 11 | const renderder = createRenderer({ 12 | createElement, 13 | createText(text) { 14 | return createTextNode(text) 15 | }, 16 | insert: appendChildNode, 17 | patchProp(el, key, oldProps, newProps) { 18 | if (!diff(oldProps, newProps)) 19 | return 20 | if (!key.startsWith('_temir_')) 21 | el[key] = newProps 22 | updateProps(el, key, newProps) 23 | }, 24 | setText: setTextNodeValue, 25 | setElementText: setTextNodeValue, 26 | createComment() { 27 | return null 28 | }, 29 | remove(el) { 30 | if (!el) 31 | return 32 | el.parentNode && removeChildNode(el.parentNode, el) 33 | el.yogaNode && cleanupYogaNode(el.yogaNode) 34 | }, 35 | parentNode(node) { 36 | return node.parentNode 37 | }, 38 | nextSibling(node) { 39 | if (!node.parentNode) return null 40 | const index = node.parentNode.childNodes.indexOf(node) 41 | return node.parentNode.childNodes[index + 1] || null 42 | }, 43 | }) 44 | 45 | export default renderder 46 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/cli", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "license": "MIT", 6 | "keywords": [ 7 | "vue", 8 | "cli", 9 | "stdout", 10 | "components", 11 | "command-line", 12 | "print", 13 | "render", 14 | "colors", 15 | "text" 16 | ], 17 | "exports": { 18 | ".": { 19 | "types": "./dist/index.d.ts", 20 | "require": "./dist/index.cjs", 21 | "import": "./dist/index.mjs" 22 | }, 23 | "./*": "./*" 24 | }, 25 | "main": "./dist/index.cjs", 26 | "module": "./dist/index.mjs", 27 | "types": "./dist/index.d.ts", 28 | "bin": { 29 | "temir": "./temir-cli.mjs" 30 | }, 31 | "files": [ 32 | "dist/**/*.cjs", 33 | "dist/**/*.mjs", 34 | "dist/**/*.d.ts", 35 | "temir-cli.mjs" 36 | ], 37 | "engines": { 38 | "node": ">=14.0.0" 39 | }, 40 | "scripts": { 41 | "build": "unbuild", 42 | "dev": "unbuild --stub" 43 | }, 44 | "dependencies": { 45 | "@babel/core": "^7.18.13", 46 | "@vitejs/plugin-vue": "3.0.1", 47 | "@vitejs/plugin-vue-jsx": "2.0.0", 48 | "@vue/babel-plugin-jsx": "^1.1.1", 49 | "@vue/compiler-sfc": "^3.2.37", 50 | "cac": "^6.7.12", 51 | "hash-sum": "^2.0.0", 52 | "resolve-from": "^5.0.0", 53 | "tsup": "^6.2.1", 54 | "vite": "3.0.5", 55 | "vite-node": "0.21.1" 56 | }, 57 | "devDependencies": { 58 | "esbuild": "^0.15.1", 59 | "unbuild": "^0.7.6" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/temir-link/README.md: -------------------------------------------------------------------------------- 1 | # temir-link 2 | 3 | > Link component for Temir. 4 | 5 | ## Install 6 | 7 | ``` 8 | $ npm install @temir/link 9 | ``` 10 | 11 | ## usage 12 | 13 | ![temir-link](https://raw.githubusercontent.com/webfansplz/temir/main/packages/temir-link/media/temir-link.png?token=GHSAT0AAAAAABUKZAFQJL3UPJ6XVGYB5YA6YXQ3Q2A) 14 | 15 | ```vue 16 | 20 | 21 | 38 | 39 | ``` 40 | 41 | ## API 42 | 43 | ### `` 44 | 45 | [Supported terminals.](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) 46 | 47 | For unsupported terminals, the link will be printed in parens after the text: `Link`. 48 | 49 | #### url 50 | 51 | Type: `string` 52 | 53 | The URL to link to. 54 | 55 | #### fallback 56 | 57 | Type: `boolean`\ 58 | Default: `true` 59 | 60 | Determines whether the URL should be printed in parens after the text for unsupported terminals: `Link`. 61 | 62 | ## Related 63 | 64 | - [terminal-link](https://github.com/sindresorhus/terminal-link) - Create clickable links in the terminal 65 | 66 | - [ink-link](https://github.com/sindresorhus/ink-link) - Link component for Ink 67 | -------------------------------------------------------------------------------- /packages/temir/src/dom/squash-text-nodes.ts: -------------------------------------------------------------------------------- 1 | import type { DOMElement } from '.' 2 | 3 | // Squashing text nodes allows to combine multiple text nodes into one and write 4 | // to `Output` instance only once. For example, hello{' '}world 5 | // is actually 3 text nodes, which would result 3 writes to `Output`. 6 | // 7 | // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), 8 | // which need to wrap all children at once, instead of wrapping 3 text nodes separately. 9 | const squashTextNodes = (node: DOMElement): string => { 10 | let text = '' 11 | 12 | if (node.childNodes.length > 0) { 13 | for (const childNode of node.childNodes) { 14 | let nodeText = '' 15 | 16 | if (childNode.nodeName === '#text') { 17 | nodeText = (childNode as any).nodeValue 18 | } 19 | else { 20 | if ( 21 | childNode.nodeName === 'temir-text' 22 | || childNode.nodeName === 'temir-virtual-text' 23 | ) 24 | nodeText = squashTextNodes(childNode) 25 | 26 | // Since these text nodes are being concatenated, `Output` instance won't be able to 27 | // apply children transform, so we have to do it manually here for each text node 28 | if ( 29 | nodeText.length > 0 30 | && typeof (childNode as any).internal_transform === 'function' 31 | ) 32 | nodeText = (childNode as any).internal_transform(nodeText) 33 | } 34 | text += nodeText 35 | } 36 | } 37 | 38 | return text 39 | } 40 | 41 | export default squashTextNodes 42 | -------------------------------------------------------------------------------- /examples/vitest/src/Summary.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temir", 3 | "version": "0.0.20", 4 | "private": true, 5 | "packageManager": "pnpm@7.9.0", 6 | "description": "Vue for CLI", 7 | "author": "webfansplz", 8 | "license": "MIT", 9 | "homepage": "https://github.com/webfansplz/temir#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/webfansplz/temir.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/webfansplz/temir/issues" 16 | }, 17 | "keywords": [ 18 | "vue", 19 | "cli", 20 | "stdout", 21 | "components", 22 | "command-line", 23 | "print", 24 | "render", 25 | "colors", 26 | "text" 27 | ], 28 | "main": "dist", 29 | "module": "dist/index.mjs", 30 | "engines": { 31 | "node": ">=14" 32 | }, 33 | "scripts": { 34 | "test": "vitest", 35 | "play:dev": "pnpm --filter './playground' run dev", 36 | "play:start": "pnpm --filter './playground' run start", 37 | "dev": "pnpm --filter './packages/**' run dev", 38 | "build": "pnpm --filter './packages/**' run build", 39 | "lint": "eslint --fix --ext .js,.ts,.vue .", 40 | "release": "pnpm run build && pnpm publish -r --filter='@temir/**' --no-git-checks --access public" 41 | }, 42 | "dependencies": { 43 | "object-diff-patch": "^0.1.2" 44 | }, 45 | "devDependencies": { 46 | "@antfu/eslint-config": "^0.25.2", 47 | "@types/node": "*", 48 | "@vitejs/plugin-vue": "^3.0.1", 49 | "@vue/runtime-core": "^3.2.37", 50 | "eslint": "^8.20.0", 51 | "sinon": "^14.0.0", 52 | "tsup": "^6.2.1", 53 | "typescript": "^4.7.4", 54 | "unbuild": "^0.7.6", 55 | "vitest": "^0.20.3", 56 | "vue": "^3.2.37" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/temir/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | import renderNodeToOutput from './render-node-to-output' 3 | import Output from './output' 4 | import type { DOMElement } from './dom' 5 | 6 | interface Result { 7 | output: string 8 | outputHeight: number 9 | staticOutput: string 10 | } 11 | 12 | export default (node: DOMElement, terminalWidth: number): Result => { 13 | node.yogaNode!.setWidth(terminalWidth) 14 | 15 | if (node.yogaNode) { 16 | node.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR) 17 | 18 | const output = new Output({ 19 | width: node.yogaNode.getComputedWidth(), 20 | height: node.yogaNode.getComputedHeight(), 21 | }) 22 | 23 | renderNodeToOutput(node, output, { skipStaticElements: true }) 24 | 25 | let staticOutput 26 | 27 | if (node.staticNode?.yogaNode) { 28 | staticOutput = new Output({ 29 | width: node.staticNode.yogaNode.getComputedWidth(), 30 | height: node.staticNode.yogaNode.getComputedHeight(), 31 | }) 32 | 33 | renderNodeToOutput(node.staticNode, staticOutput, { 34 | skipStaticElements: false, 35 | }) 36 | } 37 | 38 | const { output: generatedOutput, height: outputHeight } = output.get() 39 | 40 | return { 41 | output: generatedOutput, 42 | outputHeight, 43 | // Newline at the end is needed, because static output doesn't have one, so 44 | // interactive output will override last line of static output 45 | staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '', 46 | } 47 | } 48 | 49 | return { 50 | output: '', 51 | outputHeight: 0, 52 | staticOutput: '', 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/temir/__test__/flex-align-items.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Flex Align Items', () => { 5 | it('row - align text to center', () => { 6 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { alignItems: 'center', height: 3 })) 7 | expect(output).toBe('\nTest\n') 8 | }) 9 | 10 | it('row - align multiple text nodes to center', () => { 11 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { alignItems: 'center', height: 3 })) 12 | expect(output).toBe('\nAB\n') 13 | }) 14 | 15 | it('row - align text to bottom', () => { 16 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { alignItems: 'flex-end', height: 3 })) 17 | expect(output).toBe('\n\nTest') 18 | }) 19 | 20 | it('row - align multiple text nodes to bottom', () => { 21 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { alignItems: 'flex-end', height: 3 })) 22 | expect(output).toBe('\n\nAB') 23 | }) 24 | 25 | it('column - align text to center', () => { 26 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { flexDirection: 'column', alignItems: 'center', width: 10 })) 27 | expect(output).toBe(' Test') 28 | }) 29 | 30 | it('column - align text to right', () => { 31 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { flexDirection: 'column', alignItems: 'flex-end', width: 10 })) 32 | expect(output).toBe(' Test') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /packages/temir/src/dom/colorize.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | type ColorType = 'foreground' | 'background' 4 | 5 | const RGB_LIKE_REGEX = /^(rgb|hsl|hsv|hwb)\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ 6 | const ANSI_REGEX = /^(ansi|ansi256)\(\s?(\d+)\s?\)$/ 7 | 8 | const getMethod = (name: string, type: ColorType): string => { 9 | if (type === 'foreground') 10 | return name 11 | 12 | return `bg${name[0].toUpperCase()}${name.slice(1)}` 13 | } 14 | 15 | export default ( 16 | str: string, 17 | color: string | undefined, 18 | type: ColorType, 19 | ): string => { 20 | if (!color) 21 | return str 22 | 23 | if (color in chalk) { 24 | const method = getMethod(color, type) 25 | return (chalk as any)[method](str) 26 | } 27 | 28 | if (color.startsWith('#')) { 29 | const method = getMethod('hex', type) 30 | return (chalk as any)[method](color)(str) 31 | } 32 | 33 | if (color.startsWith('ansi')) { 34 | const matches = ANSI_REGEX.exec(color) 35 | 36 | if (!matches) 37 | return str 38 | 39 | const method = getMethod(matches[1], type) 40 | const value = Number(matches[2]) 41 | 42 | return (chalk as any)[method](value)(str) 43 | } 44 | 45 | const isRgbLike 46 | = color.startsWith('rgb') 47 | || color.startsWith('hsl') 48 | || color.startsWith('hsv') 49 | || color.startsWith('hwb') 50 | 51 | if (isRgbLike) { 52 | const matches = RGB_LIKE_REGEX.exec(color) 53 | 54 | if (!matches) 55 | return str 56 | 57 | const method = getMethod(matches[1], type) 58 | const firstValue = Number(matches[2]) 59 | const secondValue = Number(matches[3]) 60 | const thirdValue = Number(matches[4]) 61 | 62 | return (chalk as any)[method](firstValue, secondValue, thirdValue)(str) 63 | } 64 | 65 | return str 66 | } 67 | -------------------------------------------------------------------------------- /packages/temir/__test__/flex-align-self.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Flex Align Self', () => { 5 | it('row - align text to center', () => { 6 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('Test'), { alignSelf: 'center' }), { height: 3 })) 7 | expect(output).toBe('\nTest\n') 8 | }) 9 | 10 | it('row - align multiple text nodes to center', () => { 11 | const output = renderToString(createBoxComponent(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { alignSelf: 'center' }), { height: 3 })) 12 | expect(output).toBe('\nAB\n') 13 | }) 14 | 15 | it('row - align text to bottom', () => { 16 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('Test'), { alignSelf: 'flex-end' }), { height: 3 })) 17 | expect(output).toBe('\n\nTest') 18 | }) 19 | 20 | it('row - align multiple text nodes to bottom', () => { 21 | const output = renderToString(createBoxComponent(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { alignSelf: 'flex-end' }), { height: 3 })) 22 | expect(output).toBe('\n\nAB') 23 | }) 24 | 25 | it('column - align text to center', () => { 26 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('Test'), { alignSelf: 'center' }), { width: 10, flexDirection: 'column' })) 27 | expect(output).toBe(' Test') 28 | }) 29 | 30 | it('column - align text to right', () => { 31 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('Test'), { alignSelf: 'flex-end' }), { width: 10, flexDirection: 'column' })) 32 | expect(output).toBe(' Test') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'playground/**' 9 | - 'media/**' 10 | - 'examples/**' 11 | 12 | pull_request: 13 | branches: 14 | - main 15 | paths-ignore: 16 | - 'playground/**' 17 | - 'media/**' 18 | - 'examples/**' 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v2.2.1 28 | 29 | - name: Set node 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 16.x 33 | cache: pnpm 34 | 35 | - name: Install 36 | run: pnpm i 37 | 38 | - name: Lint 39 | run: pnpm run lint 40 | 41 | build: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | 46 | - name: Install pnpm 47 | uses: pnpm/action-setup@v2.2.1 48 | 49 | - name: Set node 50 | uses: actions/setup-node@v3 51 | with: 52 | node-version: 16.x 53 | cache: pnpm 54 | 55 | - name: Install 56 | run: pnpm i 57 | 58 | - name: Build 59 | run: pnpm run build 60 | 61 | test: 62 | runs-on: ${{ matrix.os }} 63 | 64 | timeout-minutes: 10 65 | 66 | strategy: 67 | matrix: 68 | node_version: [14.x, 16.x] 69 | os: [ubuntu-latest] # , macos-latest] 70 | fail-fast: false 71 | 72 | steps: 73 | - uses: actions/checkout@v3 74 | 75 | - name: Install pnpm 76 | uses: pnpm/action-setup@v2.2.1 77 | 78 | - name: Set node version to ${{ matrix.node_version }} 79 | uses: actions/setup-node@v3 80 | with: 81 | node-version: ${{ matrix.node_version }} 82 | cache: pnpm 83 | 84 | - name: Install 85 | run: pnpm i 86 | 87 | - name: Test 88 | run: pnpm run test 89 | -------------------------------------------------------------------------------- /packages/temir/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@temir/core", 3 | "type": "module", 4 | "version": "0.0.20", 5 | "description": "Vue for CLI", 6 | "author": "webfansplz", 7 | "license": "MIT", 8 | "homepage": "https://github.com/webfansplz/temir#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/webfansplz/temir.git" 12 | }, 13 | "keywords": [ 14 | "vue", 15 | "cli", 16 | "stdout", 17 | "components", 18 | "command-line", 19 | "print", 20 | "render", 21 | "colors", 22 | "text" 23 | ], 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "require": "./dist/index.cjs", 28 | "import": "./dist/index.cjs" 29 | }, 30 | "./*": "./*" 31 | }, 32 | "main": "dist/index.cjs", 33 | "module": "dist/index.cjs", 34 | "types": "./dist/index.d.ts", 35 | "files": [ 36 | "dist/**/*.cjs", 37 | "dist/**/*.mjs", 38 | "dist/**/*.d.ts" 39 | ], 40 | "engines": { 41 | "node": ">=14" 42 | }, 43 | "scripts": { 44 | "build": "tsup", 45 | "dev": "tsup" 46 | }, 47 | "dependencies": { 48 | "@vue/runtime-core": "^3.2.37", 49 | "ansi-escapes": "^4.3.2", 50 | "ansi-styles": "^5.2.0", 51 | "auto-bind": "4.0.0", 52 | "chalk": "^4.1.2", 53 | "cli-boxes": "^2.2.1", 54 | "cli-cursor": "^3.1.0", 55 | "cli-truncate": "^2.1.0", 56 | "indent-string": "^4.0.0", 57 | "is-ci": "^3.0.1", 58 | "is-fullwidth-code-point": "^3.0.0", 59 | "lodash": "^4.17.21", 60 | "lodash-es": "^4.17.21", 61 | "patch-console": "^1.0.0", 62 | "signal-exit": "^3.0.7", 63 | "slice-ansi": "^4.0.0", 64 | "string-width": "4.2.3", 65 | "vue": "^3.2.37", 66 | "widest-line": "3.1.0", 67 | "wrap-ansi": "^7.0.0", 68 | "yoga-layout-prebuilt": "^1.10.0" 69 | }, 70 | "devDependencies": { 71 | "@types/is-ci": "^3.0.0", 72 | "@types/lodash": "^4.14.182", 73 | "@types/signal-exit": "^3.0.1", 74 | "boxen": "7.0.0", 75 | "tsup": "^6.2.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/cli/src/dev.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vite' 2 | import vuePlugin from '@vitejs/plugin-vue' 3 | import viteJsxPlugin from '@vitejs/plugin-vue-jsx' 4 | import { ViteNodeServer } from 'vite-node/server' 5 | import { ViteNodeRunner } from 'vite-node/client' 6 | 7 | function reload(runner: ViteNodeRunner, files: string[]) { 8 | // invalidate module cache but not node_modules 9 | Array.from(runner.moduleCache.keys()) 10 | .forEach((fsPath) => { 11 | if (!fsPath.includes('node_modules')) 12 | runner.moduleCache.delete(fsPath) 13 | }) 14 | 15 | return Promise.all(files.map(file => runner.executeId(file))) 16 | } 17 | 18 | export async function runDevServer(file = 'src/main.ts') { 19 | const server = await createServer({ 20 | clearScreen: false, 21 | logLevel: 'error', 22 | resolve: { 23 | mainFields: ['main'], 24 | }, 25 | plugins: [ 26 | vuePlugin(), 27 | viteJsxPlugin(), 28 | ], 29 | }) 30 | await server.pluginContainer.buildStart({}) 31 | const node = new ViteNodeServer(server, { 32 | deps: { 33 | fallbackCJS: true, 34 | }, 35 | }) 36 | const runner = new ViteNodeRunner({ 37 | root: server.config.root, 38 | base: server.config.base, 39 | fetchModule(id) { 40 | return node.fetchModule(id) 41 | }, 42 | resolveId(id, importer) { 43 | return node.resolveId(id, importer) 44 | }, 45 | requestStubs: { 46 | '/@vite/client': { 47 | injectQuery: (id: string) => id, 48 | createHotContext(runner, url) { 49 | if (!url) { 50 | return { 51 | accept: () => { }, 52 | prune: () => { }, 53 | } 54 | } 55 | }, 56 | updateStyle() { }, 57 | }, 58 | }, 59 | }) 60 | 61 | // provide the vite define variable in this context 62 | await runner.executeId('/@vite/env') 63 | 64 | await runner.executeId(`/${file}`) 65 | 66 | server.watcher.on('change', async () => { 67 | reload(runner, [`/${file}`]) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /examples/vitest/src/App.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 69 | -------------------------------------------------------------------------------- /packages/temir/__test__/flex-justify-content.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Flex Justify Content', () => { 5 | it('row - align text to center', () => { 6 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { justifyContent: 'center', width: 10 })) 7 | expect(output).toBe(' Test') 8 | }) 9 | 10 | it('row - align multiple text nodes to center', () => { 11 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { justifyContent: 'center', width: 10 })) 12 | expect(output).toBe(' AB') 13 | }) 14 | 15 | it('row - align text to right', () => { 16 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { justifyContent: 'flex-end', width: 10 })) 17 | expect(output).toBe(' Test') 18 | }) 19 | 20 | it('row - align multiple text nodes to right', () => { 21 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { justifyContent: 'flex-end', width: 10 })) 22 | expect(output).toBe(' AB') 23 | }) 24 | 25 | it('row - align two text nodes on the edges', () => { 26 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { justifyContent: 'space-between', width: 4 })) 27 | expect(output).toBe('A B') 28 | }) 29 | 30 | it('column - align text to center', () => { 31 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { flexDirection: 'column', justifyContent: 'center', height: 3 })) 32 | expect(output).toBe('\nTest\n') 33 | }) 34 | 35 | it('column - align text to bottom', () => { 36 | const output = renderToString(createBoxComponent(createTextComponent('Test'), { flexDirection: 'column', justifyContent: 'flex-end', height: 3 })) 37 | expect(output).toBe('\n\nTest') 38 | }) 39 | 40 | it('column - align two text nodes on the edges', () => { 41 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { flexDirection: 'column', justifyContent: 'space-between', height: 4 })) 42 | expect(output).toBe('A\n\n\nB') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /packages/temir-select-input/README.md: -------------------------------------------------------------------------------- 1 | # temir-select-input 2 | 3 | > Select Input component for Temir. 4 | 5 | ## Install 6 | 7 | ``` 8 | $ npm install @temir/select-input 9 | ``` 10 | 11 | ## usage 12 | 13 | ![temir-select-input](./media/temir-select-input.gif) 14 | 15 | 16 | ```vue 17 | 38 | 39 | 42 | 43 | ``` 44 | 45 | ## Props 46 | 47 | ### items 48 | 49 | Type: `array`
50 | Default: `[]` 51 | 52 | Items to display in a list. Each item must be an object and have `label` and `value` props, it may also optionally have a `key` prop. 53 | If no `key` prop is provided, `value` will be used as the item key. 54 | 55 | ### isFocused 56 | 57 | Type: `boolean`
58 | Default: `true` 59 | 60 | Listen to user's input. Useful in case there are multiple input components at the same time and input must be "routed" to a specific component. 61 | 62 | ### initialIndex 63 | 64 | Type: `number` 65 | Default: `0` 66 | 67 | Index of initially-selected item in `items` array. 68 | 69 | ### limit 70 | 71 | Type: `number` 72 | 73 | Number of items to display. 74 | 75 | ### indicatorComponent 76 | 77 | Type: `Component` 78 | 79 | Custom component to override the default indicator component. 80 | 81 | ### itemComponent 82 | 83 | Type: `Component` 84 | 85 | Custom component to override the default item component. 86 | 87 | ### onSelect 88 | 89 | Type: `function` 90 | 91 | Function to call when user selects an item. Item object is passed to that function as an argument. 92 | 93 | ### onHighlight 94 | 95 | Type: `function` 96 | 97 | Function to call when user highlights an item. Item object is passed to that function as an argument. 98 | 99 | 100 | ## Related 101 | 102 | 103 | - [ink-select-input](https://github.com/vadimdemedes/ink-select-input) 104 | -------------------------------------------------------------------------------- /packages/temir/__test__/width-height.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Width-Height', () => { 5 | it('set width', () => { 6 | const A = createBoxComponent(createTextComponent('A'), { width: 5 }) 7 | const output = renderToString(createBoxComponent([A, createTextComponent('B')])) 8 | expect(output).toBe('A B') 9 | }) 10 | 11 | it('set width in percent', () => { 12 | const A = createBoxComponent(createTextComponent('A'), { width: '50%' }) 13 | const output = renderToString(createBoxComponent([A, createTextComponent('B')], { width: 10 })) 14 | expect(output).toBe('A B') 15 | }) 16 | 17 | it('set min width', () => { 18 | const A = createBoxComponent(createTextComponent('A'), { minWidth: 5 }) 19 | const output = renderToString(createBoxComponent([A, createTextComponent('B')])) 20 | expect(output).toBe('A B') 21 | }) 22 | 23 | it('set min width', () => { 24 | const C = createBoxComponent(createTextComponent('AAAAA'), { minWidth: 2 }) 25 | const largerOutput = renderToString(createBoxComponent([C, createTextComponent('B')])) 26 | expect(largerOutput).toBe('AAAAAB') 27 | }) 28 | 29 | it('set height', () => { 30 | const output = renderToString(createBoxComponent([createTextComponent('A'), createTextComponent('B')], { height: 4 })) 31 | expect(output).toBe('AB\n\n\n') 32 | }) 33 | 34 | it('set height in percent', () => { 35 | const A = createBoxComponent(createTextComponent('A'), { height: '50%' }) 36 | const output = renderToString(createBoxComponent([A, createTextComponent('B')], { height: 6, flexDirection: 'column' })) 37 | expect(output).toBe('A\n\n\nB\n\n') 38 | }) 39 | 40 | it('cut text over the set height', () => { 41 | const output = renderToString(createBoxComponent(createTextComponent('AAAABBBBCCCC'), { height: 2, textWrap: 'wrap' }), { columns: 4 }) 42 | expect(output).toBe('AAAA\nBBBB') 43 | }) 44 | 45 | it('set min width', () => { 46 | const output = renderToString(createBoxComponent(createTextComponent('A'), { minHeight: 4 })) 47 | expect(output).toBe('A\n\n\n') 48 | }) 49 | 50 | it('set min width', () => { 51 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('A'), { height: 4 }), { minHeight: 2 })) 52 | expect(output).toBe('A\n\n\n') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /packages/temir/__test__/margin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Margin', () => { 5 | it('margin', () => { 6 | const output = renderToString(createBoxComponent(createTextComponent('X'), { margin: 2 })) 7 | expect(output).toBe('\n\n X\n\n') 8 | }) 9 | 10 | it('margin X', () => { 11 | const output = renderToString(createBoxComponent([createBoxComponent(createTextComponent('X'), { marginX: 2 }), createTextComponent('Y')])) 12 | expect(output).toBe(' X Y') 13 | }) 14 | 15 | it('margin Y', () => { 16 | const output = renderToString(createBoxComponent(createTextComponent('X'), { marginY: 2 })) 17 | expect(output).toBe('\n\nX\n\n') 18 | }) 19 | 20 | it('margin top', () => { 21 | const output = renderToString(createBoxComponent(createTextComponent('X'), { marginTop: 2 })) 22 | expect(output).toBe('\n\nX') 23 | }) 24 | 25 | it('margin bottom', () => { 26 | const output = renderToString(createBoxComponent(createTextComponent('X'), { marginBottom: 2 })) 27 | expect(output).toBe('X\n\n') 28 | }) 29 | 30 | it('margin left', () => { 31 | const output = renderToString(createBoxComponent(createTextComponent('X'), { marginLeft: 2 })) 32 | expect(output).toBe(' X') 33 | }) 34 | 35 | it('margin right', () => { 36 | const output = renderToString(createBoxComponent([createBoxComponent(createTextComponent('X'), { marginRight: 2 }), createTextComponent('Y')])) 37 | 38 | expect(output).toBe('X Y') 39 | }) 40 | 41 | it('nested margin', () => { 42 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('X'), { margin: 2 }), { margin: 2 })) 43 | 44 | expect(output).toBe('\n\n\n\n X\n\n\n\n') 45 | }) 46 | 47 | it('margin with multiline string', () => { 48 | const output = renderToString(createBoxComponent(createTextComponent('A\nB'), { margin: 2 })) 49 | 50 | expect(output).toBe('\n\n A\n B\n\n') 51 | }) 52 | 53 | it('apply margin to text with newlines', () => { 54 | const output = renderToString(createBoxComponent(createTextComponent('Hello\nWorld'), { margin: 1 })) 55 | 56 | expect(output).toBe('\n Hello\n World\n') 57 | }) 58 | 59 | it('apply margin to wrapped text', () => { 60 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { margin: 1, width: 6 })) 61 | 62 | expect(output).toBe('\n Hello\n World\n') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /packages/temir/__test__/padding.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Padding', () => { 5 | it('padding', () => { 6 | const output = renderToString(createBoxComponent(createTextComponent('X'), { padding: 2 })) 7 | expect(output).toBe('\n\n X\n\n') 8 | }) 9 | 10 | it('padding X', () => { 11 | const output = renderToString(createBoxComponent([createBoxComponent(createTextComponent('X'), { paddingX: 2 }), createTextComponent('Y')])) 12 | expect(output).toBe(' X Y') 13 | }) 14 | 15 | it('padding Y', () => { 16 | const output = renderToString(createBoxComponent(createTextComponent('X'), { paddingY: 2 })) 17 | expect(output).toBe('\n\nX\n\n') 18 | }) 19 | 20 | it('padding top', () => { 21 | const output = renderToString(createBoxComponent(createTextComponent('X'), { paddingTop: 2 })) 22 | expect(output).toBe('\n\nX') 23 | }) 24 | 25 | it('padding bottom', () => { 26 | const output = renderToString(createBoxComponent(createTextComponent('X'), { paddingBottom: 2 })) 27 | expect(output).toBe('X\n\n') 28 | }) 29 | 30 | it('padding left', () => { 31 | const output = renderToString(createBoxComponent(createTextComponent('X'), { paddingLeft: 2 })) 32 | expect(output).toBe(' X') 33 | }) 34 | 35 | it('padding right', () => { 36 | const output = renderToString(createBoxComponent([createBoxComponent(createTextComponent('X'), { paddingRight: 2 }), createTextComponent('Y')])) 37 | 38 | expect(output).toBe('X Y') 39 | }) 40 | 41 | it('nested padding', () => { 42 | const output = renderToString(createBoxComponent(createBoxComponent(createTextComponent('X'), { padding: 2 }), { padding: 2 })) 43 | 44 | expect(output).toBe('\n\n\n\n X\n\n\n\n') 45 | }) 46 | 47 | it('padding with multiline string', () => { 48 | const output = renderToString(createBoxComponent(createTextComponent('A\nB'), { padding: 2 })) 49 | 50 | expect(output).toBe('\n\n A\n B\n\n') 51 | }) 52 | 53 | it('apply padding to text with newlines', () => { 54 | const output = renderToString(createBoxComponent(createTextComponent('Hello\nWorld'), { padding: 1 })) 55 | 56 | expect(output).toBe('\n Hello\n World\n') 57 | }) 58 | 59 | it('apply padding to wrapped text', () => { 60 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { padding: 1, width: 5 })) 61 | 62 | expect(output).toBe('\n Hel\n lo\n Wor\n ld\n') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /packages/temir/src/output.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import stringWidth from 'string-width' 4 | import sliceAnsi from './slice-ansi' 5 | import type { OutputTransformer } from './render-node-to-output' 6 | /** 7 | * "Virtual" output class 8 | * 9 | * Handles the positioning and saving of the output of each node in the tree. 10 | * Also responsible for applying transformations to each character of the output. 11 | * 12 | * Used to generate the final output of all nodes before writing it to actual output stream (e.g. stdout) 13 | */ 14 | 15 | interface Options { 16 | width: number 17 | height: number 18 | } 19 | 20 | interface Writes { 21 | x: number 22 | y: number 23 | text: string 24 | transformers: OutputTransformer[] 25 | } 26 | 27 | export default class Output { 28 | width: number 29 | height: number 30 | 31 | // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved 32 | private readonly writes: Writes[] = [] 33 | 34 | constructor(options: Options) { 35 | const { width, height } = options 36 | 37 | this.width = width 38 | this.height = height 39 | } 40 | 41 | write( 42 | x: number, 43 | y: number, 44 | text: string, 45 | options: { transformers: OutputTransformer[] }, 46 | ): void { 47 | const { transformers } = options 48 | 49 | if (!text) 50 | return 51 | 52 | this.writes.push({ x, y, text, transformers }) 53 | } 54 | 55 | get(): { output: string; height: number } { 56 | const output: string[] = [] 57 | for (let y = 0; y < this.height; y++) 58 | output.push(' '.repeat(this.width)) 59 | 60 | for (const write of this.writes) { 61 | const { x, y, text, transformers } = write 62 | const lines = text.split('\n') 63 | let offsetY = 0 64 | for (let line of lines) { 65 | const currentLine = output[y + offsetY] 66 | 67 | // Line can be missing if `text` is taller than height of pre-initialized `this.output` 68 | if (!currentLine) 69 | continue 70 | 71 | const width = stringWidth(line) 72 | 73 | for (const transformer of transformers) 74 | line = transformer(line) 75 | 76 | output[y + offsetY] 77 | = sliceAnsi(currentLine, 0, x) 78 | + line 79 | + sliceAnsi(currentLine, x + width) 80 | 81 | offsetY++ 82 | } 83 | } 84 | 85 | const generatedOutput = output.map(line => line.trimRight()).join('\n') 86 | 87 | return { 88 | output: generatedOutput, 89 | height: output.length, 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/cli/src/hmr/hmr.ts: -------------------------------------------------------------------------------- 1 | import type { ViteNodeRunner } from 'vite-node/client' 2 | import type { ViteHotContext } from 'vite/types/hot' 3 | 4 | export type HotContext = Omit 5 | 6 | export interface HotCallback { 7 | // the dependencies must be fetchable paths 8 | deps: string[] 9 | fn: (modules: object[]) => void 10 | } 11 | 12 | export interface HotModule { 13 | id: string 14 | callbacks: HotCallback[] 15 | } 16 | 17 | interface CacheData { 18 | hotModulesMap: Map 19 | dataMap: Map 20 | pruneMap: Map void | Promise> 21 | } 22 | 23 | const cache: WeakMap = new WeakMap() 24 | 25 | export function getCache(runner: ViteNodeRunner): CacheData { 26 | if (!cache.has(runner)) { 27 | cache.set(runner, { 28 | hotModulesMap: new Map(), 29 | dataMap: new Map(), 30 | pruneMap: new Map(), 31 | }) 32 | } 33 | return cache.get(runner) as CacheData 34 | } 35 | 36 | export function createHotContext( 37 | runner: ViteNodeRunner, 38 | file: string, 39 | ownerPath: string) { 40 | const maps = getCache(runner) 41 | if (!maps.dataMap.has(ownerPath)) 42 | maps.dataMap.set(ownerPath, {}) 43 | 44 | // when a file is hot updated, a new context is created 45 | // clear its stale callbacks 46 | const mod = maps.hotModulesMap.get(ownerPath) 47 | if (mod) 48 | mod.callbacks = [] 49 | 50 | function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => { }) { 51 | const mod: HotModule = maps.hotModulesMap.get(ownerPath) || { 52 | id: ownerPath, 53 | callbacks: [], 54 | } 55 | mod.callbacks.push({ 56 | deps, 57 | fn: callback, 58 | }) 59 | maps.hotModulesMap.set(ownerPath, mod) 60 | } 61 | 62 | const hot: HotContext = { 63 | get data() { 64 | return maps.dataMap.get(ownerPath) 65 | }, 66 | accept(deps?: any, callback?: any) { 67 | if (typeof deps === 'function' || !deps) { 68 | // self-accept: hot.accept(() => {}) 69 | acceptDeps([ownerPath], ([mod]) => deps && deps(mod)) 70 | } 71 | else if (typeof deps === 'string') { 72 | // explicit deps 73 | acceptDeps([deps], ([mod]) => callback && callback(mod)) 74 | } 75 | else if (Array.isArray(deps)) { 76 | acceptDeps(deps, callback) 77 | } 78 | else { 79 | throw new TypeError('invalid hot.accept() usage.') 80 | } 81 | }, 82 | 83 | // @ts-expect-error untyped 84 | prune(cb: (data: any) => void) { 85 | maps.pruneMap.set(ownerPath, cb) 86 | }, 87 | } 88 | return hot 89 | } 90 | -------------------------------------------------------------------------------- /packages/temir/__test__/flex.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 3 | 4 | describe('Flex', () => { 5 | it('grow equally', () => { 6 | const A = createBoxComponent(createTextComponent('A'), { flexGrow: 1 }) 7 | const B = createBoxComponent(createTextComponent('B'), { flexGrow: 1 }) 8 | const output = renderToString(createBoxComponent([A, B], { width: 6 })) 9 | expect(output).toBe('A B') 10 | }) 11 | 12 | it('grow one element', () => { 13 | const A = createBoxComponent(createTextComponent('A'), { flexGrow: 1 }) 14 | const B = createTextComponent('B') 15 | const output = renderToString(createBoxComponent([A, B], { width: 6 })) 16 | expect(output).toBe('A B') 17 | }) 18 | 19 | it('dont shrink', () => { 20 | const A = createBoxComponent(createTextComponent('A'), { flexShrink: 0, width: 6 }) 21 | const B = createBoxComponent(createTextComponent('B'), { flexShrink: 0, width: 6 }) 22 | const C = createBoxComponent(createTextComponent('C'), { width: 6 }) 23 | const output = renderToString(createBoxComponent([A, B, C], { width: 16 })) 24 | expect(output).toBe('A B C') 25 | }) 26 | 27 | it('shrink equally', () => { 28 | const A = createBoxComponent(createTextComponent('A'), { flexShrink: 0, width: 6 }) 29 | const B = createBoxComponent(createTextComponent('B'), { flexShrink: 0, width: 6 }) 30 | const C = createTextComponent('C') 31 | const output = renderToString(createBoxComponent([A, B, C], { width: 10 })) 32 | expect(output).toBe('A B C') 33 | }) 34 | 35 | it('set flex basis with flexDirection="row" container', () => { 36 | const A = createBoxComponent(createTextComponent('A'), { flexBasis: '50%' }) 37 | const B = createTextComponent('B') 38 | const output = renderToString(createBoxComponent([A, B], { width: 6 })) 39 | expect(output).toBe('A B') 40 | }) 41 | 42 | it('set flex basis with flexDirection="column" container', () => { 43 | const A = createBoxComponent(createTextComponent('A'), { flexBasis: 3 }) 44 | const B = createTextComponent('B') 45 | const output = renderToString(createBoxComponent([A, B], { height: 6, flexDirection: 'column' })) 46 | expect(output).toBe('A\n\n\nB\n\n') 47 | }) 48 | 49 | it('set flex basis in percent with flexDirection="column" container', () => { 50 | const A = createBoxComponent(createTextComponent('A'), { flexBasis: '50%' }) 51 | const B = createTextComponent('B') 52 | const output = renderToString(createBoxComponent([A, B], { height: 6, flexDirection: 'column' })) 53 | expect(output).toBe('A\n\n\nB\n\n') 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /packages/temir/__test__/components.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { ref } from '@vue/runtime-core' 3 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 4 | 5 | describe('Text Components', () => { 6 | it('text', () => { 7 | const output = renderToString(createTextComponent('Hello World')) 8 | expect(output).toBe('Hello World') 9 | }) 10 | 11 | it('text w/ variable', () => { 12 | const value = 'World' 13 | const output = renderToString(createTextComponent(`Hello ${value}`)) 14 | expect(output).toBe('Hello World') 15 | }) 16 | 17 | it('multiple text nodes', () => { 18 | const output = renderToString(createTextComponent(['Hello ', 'World'])) 19 | expect(output).toBe('Hello World') 20 | }) 21 | 22 | it('text with component', () => { 23 | const World = createTextComponent('World') 24 | const output = renderToString(createTextComponent(['Hello ', World])) 25 | expect(output).toBe('Hello World') 26 | }) 27 | 28 | it('wrap text', () => { 29 | const text = createTextComponent('Hello World', { wrap: 'wrap' }) 30 | const output = renderToString(createBoxComponent(text, { width: 7 })) 31 | expect(output).toBe('Hello\nWorld') 32 | }) 33 | 34 | it('don’t wrap text if there is enough space', () => { 35 | const text = createTextComponent('Hello World', { wrap: 'wrap' }) 36 | const output = renderToString(createBoxComponent(text, { width: 20 })) 37 | expect(output).toBe('Hello World') 38 | }) 39 | 40 | it('truncate text in the end', () => { 41 | const text = createTextComponent('Hello World', { wrap: 'truncate' }) 42 | const output = renderToString(createBoxComponent(text, { width: 7 })) 43 | expect(output).toBe('Hello …') 44 | }) 45 | 46 | it('truncate text in the middle', () => { 47 | const text = createTextComponent('Hello World', { wrap: 'truncate-middle' }) 48 | const output = renderToString(createBoxComponent(text, { width: 7 })) 49 | expect(output).toBe('Hel…rld') 50 | }) 51 | 52 | it('truncate text in the beginning', () => { 53 | const text = createTextComponent('Hello World', { wrap: 'truncate-start' }) 54 | const output = renderToString(createBoxComponent(text, { width: 7 })) 55 | expect(output).toBe('… World') 56 | }) 57 | 58 | it('ignore empty text node', () => { 59 | const text = createTextComponent('Hello World') 60 | const textWrap = createBoxComponent(text) 61 | const emptyText = createTextComponent('') 62 | 63 | const output = renderToString(createBoxComponent([textWrap, emptyText], { flexDirection: 'column' })) 64 | expect(output).toBe('Hello World') 65 | }) 66 | 67 | it('render a single empty text node', () => { 68 | const output = renderToString(createTextComponent('')) 69 | expect(output).toBe('') 70 | }) 71 | 72 | it('number', () => { 73 | const output = renderToString(createTextComponent('1')) 74 | expect(output).toBe('1') 75 | }) 76 | 77 | it('with ref', () => { 78 | const functional = () => { 79 | const count = ref('1') 80 | return createTextComponent(count.value) 81 | } 82 | const output = renderToString(functional()) 83 | expect(output).toBe('1') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /packages/temir/src/components/Box.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from '@vue/runtime-core' 2 | import { identity, pickBy } from 'lodash' 3 | import type { Styles } from '../dom/styles' 4 | 5 | export interface TBoxProps extends Styles { 6 | /** 7 | * Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`. 8 | * 9 | * @default 0 10 | */ 11 | readonly margin?: number 12 | 13 | /** 14 | * Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. 15 | * 16 | * @default 0 17 | */ 18 | readonly marginX?: number 19 | 20 | /** 21 | * Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. 22 | * 23 | * @default 0 24 | */ 25 | readonly marginY?: number 26 | 27 | /** 28 | * Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft` and `paddingRight`. 29 | * 30 | * @default 0 31 | */ 32 | readonly padding?: number 33 | 34 | /** 35 | * Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`. 36 | * 37 | * @default 0 38 | */ 39 | readonly paddingX?: number 40 | 41 | /** 42 | * Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`. 43 | * 44 | * @default 0 45 | */ 46 | readonly paddingY?: number 47 | } 48 | 49 | /** 50 | * `` is an essential Temir component to build your layout. It's like `
` in the browser. 51 | */ 52 | 53 | export const TBox = defineComponent({ 54 | name: 'TBox', 55 | inheritAttrs: false, 56 | props: ([ 57 | 'textWrap', 58 | 'position', 59 | 'margin', 60 | 'marginX', 61 | 'marginY', 62 | 'marginTop', 63 | 'marginBottom', 64 | 'marginLeft', 65 | 'marginRight', 66 | 'padding', 67 | 'paddingX', 68 | 'paddingY', 69 | 'paddingTop', 70 | 'paddingBottom', 71 | 'paddingLeft', 72 | 'paddingRight', 73 | 'flexGrow', 74 | 'flexShrink', 75 | 'flexDirection', 76 | 'flexBasis', 77 | 'alignItems', 78 | 'alignSelf', 79 | 'justifyContent', 80 | 'width', 81 | 'height', 82 | 'minWidth', 83 | 'minHeight', 84 | 'display', 85 | 'borderStyle', 86 | 'borderColor', 87 | ] as undefined), 88 | setup(style, { slots }) { 89 | const transformedStyle = () => ({ 90 | flexDirection: 'row', 91 | flexGrow: 0, 92 | flexShrink: 1, 93 | ...pickBy(style, identity), 94 | marginLeft: style.marginLeft || style.marginX || style.margin || 0, 95 | marginRight: style.marginRight || style.marginX || style.margin || 0, 96 | marginTop: style.marginTop || style.marginY || style.margin || 0, 97 | marginBottom: style.marginBottom || style.marginY || style.margin || 0, 98 | paddingLeft: style.paddingLeft || style.paddingX || style.padding || 0, 99 | paddingRight: style.paddingRight || style.paddingX || style.padding || 0, 100 | paddingTop: style.paddingTop || style.paddingY || style.padding || 0, 101 | paddingBottom: style.paddingBottom || style.paddingY || style.padding || 0, 102 | }) 103 | 104 | return () => { 105 | const children = slots.default?.() 106 | return h('temir-box', { 107 | style: transformedStyle(), 108 | }, children) 109 | } 110 | }, 111 | }) 112 | 113 | -------------------------------------------------------------------------------- /packages/temir/src/slice-ansi/index.ts: -------------------------------------------------------------------------------- 1 | import isFullwidthCodePoint from 'is-fullwidth-code-point' 2 | import ansiStyles from 'ansi-styles' 3 | 4 | const astralRegex = /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/ 5 | const regex = /\d[^m]*/ 6 | 7 | const ESCAPES = [ 8 | '\u001B', 9 | '\u009B', 10 | ] 11 | 12 | const wrapAnsi = code => `${ESCAPES[0]}[${code}m` 13 | 14 | const checkAnsi = (ansiCodes: string[], isEscapes?: boolean, endAnsiCode?: string) => { 15 | let output = [] 16 | ansiCodes = [...ansiCodes] 17 | 18 | for (let i = 0; i < ansiCodes.length; i++) { 19 | let ansiCode = ansiCodes[i] 20 | const ansiCodeOrigin = ansiCode 21 | if (ansiCode.includes(';')) 22 | ansiCode = `${ansiCode.split(';')[0][0]}0` 23 | 24 | const item = ansiStyles.codes.get(Number.parseInt(ansiCode, 10)); 25 | 26 | if (item) { 27 | const indexEscape = ansiCodes.indexOf(item.toString()) 28 | if (indexEscape === -1) 29 | output.push(wrapAnsi(isEscapes ? item : ansiCodeOrigin)) 30 | 31 | else 32 | ansiCodes.splice(indexEscape, 1) 33 | } 34 | else if (isEscapes) { 35 | output.push(wrapAnsi(0)) 36 | break 37 | } 38 | else { 39 | output.push(wrapAnsi(ansiCodeOrigin)) 40 | } 41 | } 42 | 43 | if (isEscapes) { 44 | output = output.filter((element, index) => output.indexOf(element) === index) 45 | 46 | if (endAnsiCode !== undefined) { 47 | const fistEscapeCode = wrapAnsi(ansiStyles.codes.get(Number.parseInt(endAnsiCode, 10))); 48 | // TODO: Remove the use of `.reduce` here. 49 | 50 | output = output.reduce((current, next) => next === fistEscapeCode ? [next, ...current] : [...current, next], []) 51 | } 52 | } 53 | 54 | return output.join('') 55 | } 56 | 57 | export default function sliceAnsi(string, begin, end) { 58 | const characters = [...string] 59 | const ansiCodes = [] 60 | 61 | let stringEnd = typeof end === 'number' ? end : characters.length 62 | let isInsideEscape = false 63 | let ansiCode 64 | let visible = 0 65 | let output = '' 66 | 67 | for (let index = 0; index < characters.length; index++) { 68 | const character = characters[index] 69 | let leftEscape = false 70 | 71 | if (ESCAPES.includes(character)) { 72 | const code = regex.exec(string.slice(index, index + 18)) 73 | ansiCode = code && code.length > 0 ? code[0] : undefined 74 | 75 | if (visible < stringEnd) { 76 | isInsideEscape = true 77 | 78 | if (ansiCode !== undefined) 79 | ansiCodes.push(ansiCode) 80 | } 81 | } 82 | else if (isInsideEscape && character === 'm') { 83 | isInsideEscape = false 84 | leftEscape = true 85 | } 86 | 87 | if (!isInsideEscape && !leftEscape) 88 | visible++ 89 | 90 | if (!astralRegex.test(character) && isFullwidthCodePoint(character.codePointAt())) { 91 | visible++ 92 | 93 | if (typeof end !== 'number') 94 | stringEnd++ 95 | } 96 | 97 | if (visible > begin && visible <= stringEnd) { 98 | output += character 99 | } 100 | else if (visible === begin && !isInsideEscape && ansiCode !== undefined) { 101 | output = checkAnsi(ansiCodes) 102 | } 103 | else if (visible >= stringEnd) { 104 | output += checkAnsi(ansiCodes, true, ansiCode) 105 | break 106 | } 107 | } 108 | 109 | return output 110 | } 111 | -------------------------------------------------------------------------------- /packages/temir/__test__/borders.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import type { Options } from 'boxen' 3 | import boxen from 'boxen' 4 | import { createBoxComponent, createTextComponent, renderToString } from '../../.test' 5 | 6 | const box = (text: string, options?: Options): string => { 7 | return boxen(text, { 8 | ...options, 9 | borderStyle: 'round', 10 | }) 11 | } 12 | 13 | describe('Borders', () => { 14 | it('single node - full width box', () => { 15 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 50, borderStyle: 'round' })) 16 | expect(output).toBe(box('Hello World'.padEnd(48, ' '))) 17 | }) 18 | 19 | it('single node - full width box with colorful border', () => { 20 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 50, borderStyle: 'round', borderColor: 'green' })) 21 | expect(output).toBe(box('Hello World'.padEnd(48, ' '), { borderColor: 'green' })) 22 | }) 23 | 24 | it('single node - fit-content box', () => { 25 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 50, borderStyle: 'round', alignSelf: 'flex-start' })) 26 | expect(output).toBe(box('Hello World'.padEnd(48, ' '))) 27 | }) 28 | 29 | it('single node - fit-content box with wide characters', () => { 30 | const output = renderToString(createBoxComponent(createTextComponent('こんにちは'), { borderStyle: 'round', alignSelf: 'flex-start' })) 31 | expect(output).toBe(box('こんにちは')) 32 | }) 33 | 34 | it('single node - fit-content box with emojis', () => { 35 | const output = renderToString(createBoxComponent(createTextComponent('🌊🌊'), { borderStyle: 'round', alignSelf: 'flex-start' })) 36 | expect(output).toBe(box('🌊🌊')) 37 | }) 38 | 39 | it('single node - fixed width box', () => { 40 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 20, borderStyle: 'round' })) 41 | expect(output).toBe(box('Hello World'.padEnd(18, ' '))) 42 | }) 43 | 44 | it('single node - fixed width and height box', () => { 45 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 20, height: 20, borderStyle: 'round' })) 46 | expect(output).toBe(box('Hello World'.padEnd(18, ' ') + '\n'.repeat(17))) 47 | }) 48 | 49 | it('single node - box with padding', () => { 50 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { alignSelf: 'flex-start', padding: 1, borderStyle: 'round' })) 51 | expect(output).toBe(box('\n Hello World \n')) 52 | }) 53 | 54 | it('single node - box with horizontal alignment', () => { 55 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 20, justifyContent: 'center', borderStyle: 'round' })) 56 | expect(output).toBe(box(' Hello World ')) 57 | }) 58 | 59 | it('single node - box with vertical alignment', () => { 60 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { height: 20, alignItems: 'center', alignSelf: 'flex-start', borderStyle: 'round' })) 61 | expect(output).toBe(box(`${'\n'.repeat(8)}Hello World${'\n'.repeat(9)}`)) 62 | }) 63 | 64 | it('single node - box with wrapping', () => { 65 | const output = renderToString(createBoxComponent(createTextComponent('Hello World'), { width: 10, borderStyle: 'round' })) 66 | expect(output).toBe(box('Hello \nWorld')) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /packages/temir/src/render-node-to-output.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | import indentString from 'indent-string' 3 | import widestLine from 'widest-line' 4 | import wrapText from './dom/wrap-text' 5 | import getMaxWidth from './dom/get-max-width' 6 | import squashTextNodes from './dom/squash-text-nodes' 7 | import renderBorder from './dom/render-border' 8 | import type { DOMElement } from './dom' 9 | import type Output from './output' 10 | 11 | // If parent container is ``, text nodes will be treated as separate nodes in 12 | // the tree and will have their own coordinates in the layout. 13 | // To ensure text nodes are aligned correctly, take X and Y of the first text node 14 | // and use it as offset for the rest of the nodes 15 | // Only first node is taken into account, because other text nodes can't have margin or padding, 16 | // so their coordinates will be relative to the first node anyway 17 | const applyPaddingToText = (node: DOMElement, text: string): string => { 18 | const yogaNode = node.childNodes[0]?.yogaNode 19 | 20 | if (yogaNode) { 21 | const offsetX = yogaNode.getComputedLeft() 22 | const offsetY = yogaNode.getComputedTop() 23 | text = '\n'.repeat(offsetY) + indentString(text, offsetX) 24 | } 25 | 26 | return text 27 | } 28 | 29 | export type OutputTransformer = (s: string) => string 30 | 31 | // After nodes are laid out, render each to output object, which later gets rendered to terminal 32 | const renderNodeToOutput = ( 33 | node: DOMElement, 34 | output: Output, 35 | options: { 36 | offsetX?: number 37 | offsetY?: number 38 | transformers?: OutputTransformer[] 39 | skipStaticElements: boolean 40 | }, 41 | ) => { 42 | const { 43 | offsetX = 0, 44 | offsetY = 0, 45 | transformers = [], 46 | skipStaticElements, 47 | } = options 48 | 49 | if (skipStaticElements && node.internal_static) 50 | return 51 | 52 | const { yogaNode } = node 53 | if (yogaNode) { 54 | if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) 55 | return 56 | 57 | // Left and top positions in Yoga are relative to their parent node 58 | const x = offsetX + yogaNode.getComputedLeft() 59 | const y = offsetY + yogaNode.getComputedTop() 60 | 61 | // Transformers are functions that transform final text output of each component 62 | // See Output class for logic that applies transformers 63 | let newTransformers = transformers 64 | 65 | if (typeof node.internal_transform === 'function') 66 | newTransformers = [node.internal_transform, ...transformers] 67 | 68 | if (node.nodeName === 'temir-text') { 69 | let text = squashTextNodes(node) 70 | 71 | if (text.length > 0) { 72 | const currentWidth = widestLine(text) 73 | const maxWidth = getMaxWidth(yogaNode) 74 | 75 | if (currentWidth > maxWidth) { 76 | const textWrap = node.style.textWrap ?? 'wrap' 77 | text = wrapText(text, maxWidth, textWrap) 78 | } 79 | 80 | text = applyPaddingToText(node, text) 81 | output.write(x, y, text, { transformers: newTransformers }) 82 | } 83 | 84 | return 85 | } 86 | 87 | if (node.nodeName === 'temir-box') 88 | renderBorder(x, y, node, output) 89 | 90 | if (node.nodeName === 'temir-root' || node.nodeName === 'temir-box') { 91 | for (const childNode of node.childNodes) { 92 | renderNodeToOutput(childNode as DOMElement, output, { 93 | offsetX: x, 94 | offsetY: y, 95 | transformers: newTransformers, 96 | skipStaticElements, 97 | }) 98 | } 99 | } 100 | } 101 | } 102 | 103 | export default renderNodeToOutput 104 | -------------------------------------------------------------------------------- /packages/temir/src/components/Text.ts: -------------------------------------------------------------------------------- 1 | import type { ForegroundColor } from 'chalk' 2 | import chalk from 'chalk' 3 | import { defineComponent, getCurrentInstance, h } from '@vue/runtime-core' 4 | import colorize from '../dom/colorize' 5 | import type { Styles } from '../dom/styles' 6 | 7 | export interface TTextProps { 8 | /** 9 | * Change text color. Temir uses chalk under the hood, so all its functionality is supported. 10 | */ 11 | readonly color?: typeof ForegroundColor | string 12 | 13 | /** 14 | * Same as `color`, but for background. 15 | */ 16 | readonly backgroundColor?: typeof ForegroundColor | string 17 | 18 | /** 19 | * Dim the color (emit a small amount of light). 20 | */ 21 | readonly dimColor?: boolean 22 | 23 | /** 24 | * Make the text bold. 25 | */ 26 | readonly bold?: boolean 27 | 28 | /** 29 | * Make the text italic. 30 | */ 31 | readonly italic?: boolean 32 | 33 | /** 34 | * Make the text underlined. 35 | */ 36 | readonly underline?: boolean 37 | 38 | /** 39 | * Make the text crossed with a line. 40 | */ 41 | readonly strikethrough?: boolean 42 | 43 | /** 44 | * Inverse background and foreground colors. 45 | */ 46 | readonly inverse?: boolean 47 | 48 | /** 49 | * This property tells Temir to wrap or truncate text if its width is larger than container. 50 | * If `wrap` is passed (by default), Temir will wrap text and split it into multiple lines. 51 | * If `truncate-*` is passed, Temir will truncate text instead, which will result in one line of text with the rest cut off. 52 | */ 53 | readonly wrap?: Styles['textWrap'] 54 | readonly children?: any 55 | } 56 | 57 | /** 58 | * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. 59 | */ 60 | export const TText = defineComponent({ 61 | 62 | name: 'TText', 63 | inheritAttrs: false, 64 | props: ([ 65 | 'color', 66 | 'backgroundColor', 67 | 'dimColor', 68 | 'bold', 69 | 'italic', 70 | 'underline', 71 | 'strikethrough', 72 | 'inverse', 73 | 'wrap', 74 | 'children', 75 | ] as undefined), 76 | setup(props, { slots }) { 77 | const instance = getCurrentInstance() 78 | const children = slots.default?.() 79 | if (children === undefined || children === null) 80 | return null 81 | const transform = (children: string): string => { 82 | const { dimColor, color, backgroundColor, bold, italic, underline, strikethrough, inverse } = props 83 | if (dimColor) 84 | children = chalk.dim(children) 85 | 86 | if (color) 87 | children = colorize(children, color, 'foreground') 88 | 89 | if (backgroundColor) 90 | children = colorize(children, backgroundColor, 'background') 91 | 92 | if (bold !== undefined) 93 | children = chalk.bold(children) 94 | 95 | if (italic !== undefined) 96 | children = chalk.italic(children) 97 | 98 | if (underline !== undefined) 99 | children = chalk.underline(children) 100 | 101 | if (strikethrough !== undefined) 102 | children = chalk.strikethrough(children) 103 | 104 | if (inverse !== undefined) 105 | children = chalk.inverse(children) 106 | 107 | return children 108 | } 109 | 110 | return () => { 111 | const children = slots.default?.() 112 | return h('temir-text', { 113 | style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: props.wrap ?? 'wrap', ...props }, 114 | _temir_text: children, 115 | isInsideText: !['TBox', 'TApp', 'TWrap'].includes(instance.parent.type.name), 116 | internal_transform: transform, 117 | }, children) 118 | } 119 | }, 120 | }) 121 | 122 | -------------------------------------------------------------------------------- /packages/temir/src/render.ts: -------------------------------------------------------------------------------- 1 | import stream from 'stream' 2 | import type { Component } from '@vue/runtime-core' 3 | import instances from './instances' 4 | import Temir from './temir' 5 | import type { TemirOptions } from './temir' 6 | 7 | export interface RenderOptions { 8 | /** 9 | * Output stream where app will be rendered. 10 | * 11 | * @default process.stdout 12 | */ 13 | stdout?: NodeJS.WriteStream 14 | /** 15 | * Input stream where app will listen for input. 16 | * 17 | * @default process.stdin 18 | */ 19 | stdin?: NodeJS.ReadStream 20 | /** 21 | * Error stream. 22 | * @default process.stderr 23 | */ 24 | stderr?: NodeJS.WriteStream 25 | /** 26 | * If true, each update will be rendered as a separate output, without replacing the previous one. 27 | * 28 | * @default false 29 | */ 30 | debug?: boolean 31 | /** 32 | * Configure whether Temir should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually. 33 | * 34 | * @default true 35 | */ 36 | exitOnCtrlC?: boolean 37 | 38 | /** 39 | * Patch console methods to ensure console output doesn't mix with Temir output. 40 | * 41 | * @default true 42 | */ 43 | patchConsole?: boolean 44 | } 45 | 46 | export interface Instance { 47 | /** 48 | * Replace previous root node with a new one or update props of the current root node. 49 | */ 50 | rerender: unknown 51 | /** 52 | * Manually unmount the whole Temir app. 53 | */ 54 | unmount: unknown 55 | /** 56 | * Returns a promise, which resolves when app is unmounted. 57 | */ 58 | waitUntilExit: unknown 59 | cleanup: () => void 60 | 61 | /** 62 | * Clear output. 63 | */ 64 | clear: () => void 65 | } 66 | 67 | type RenderFunction = ( 68 | tree: Component, 69 | options?: K 70 | ) => Instance 71 | 72 | const getOptions = ( 73 | stdout: NodeJS.WriteStream | RenderOptions | undefined = {}, 74 | ): RenderOptions => { 75 | // await new Promise((resolve) => { 76 | // import('stream').then(({ Stream }) => { 77 | // console.log(Stream) 78 | // }) 79 | // }) 80 | 81 | if (stdout instanceof stream.Stream) { 82 | return { 83 | stdout, 84 | stdin: process.stdin, 85 | } 86 | } 87 | 88 | return stdout 89 | } 90 | 91 | const getInstance = ( 92 | stdout: NodeJS.WriteStream, 93 | createInstance: () => Temir, 94 | node, 95 | ): Temir => { 96 | let instance: Temir 97 | 98 | if (instances.has(stdout)) { 99 | instance = instances.get(stdout) 100 | instance.render(node) 101 | } 102 | else { 103 | instance = createInstance() 104 | instances.set(stdout, instance) 105 | } 106 | 107 | return instance 108 | } 109 | 110 | /** 111 | * Mount a component and render the output. 112 | */ 113 | const render: RenderFunction = (node, options): Instance => { 114 | const temirOptions: TemirOptions = { 115 | stdout: process.stdout, 116 | stdin: process.stdin, 117 | stderr: process.stderr, 118 | debug: false, 119 | exitOnCtrlC: true, 120 | patchConsole: true, 121 | ...getOptions(options), 122 | } 123 | 124 | const instance: Temir = getInstance( 125 | temirOptions.stdout, 126 | () => { 127 | const temir = new Temir(temirOptions) 128 | temir.createVueApp(node) 129 | temir.onRender() 130 | return temir 131 | }, 132 | node, 133 | ) 134 | 135 | return { 136 | rerender: instance.render, 137 | unmount: () => instance.unmount(), 138 | waitUntilExit: instance.waitUntilExit, 139 | cleanup: () => instances.delete(temirOptions.stdout), 140 | clear: instance.clear, 141 | } 142 | } 143 | 144 | export default render 145 | 146 | -------------------------------------------------------------------------------- /packages/temir/src/composables/useInput.ts: -------------------------------------------------------------------------------- 1 | import { watchEffect } from '@vue/runtime-core' 2 | import { useStdin } from './useStdin' 3 | 4 | /** 5 | * Handy information about a key that was pressed. 6 | */ 7 | export interface Key { 8 | /** 9 | * Up arrow key was pressed. 10 | */ 11 | upArrow: boolean 12 | 13 | /** 14 | * Down arrow key was pressed. 15 | */ 16 | downArrow: boolean 17 | 18 | /** 19 | * Left arrow key was pressed. 20 | */ 21 | leftArrow: boolean 22 | 23 | /** 24 | * Right arrow key was pressed. 25 | */ 26 | rightArrow: boolean 27 | 28 | /** 29 | * Page Down key was pressed. 30 | */ 31 | pageDown: boolean 32 | 33 | /** 34 | * Page Up key was pressed. 35 | */ 36 | pageUp: boolean 37 | 38 | /** 39 | * Return (Enter) key was pressed. 40 | */ 41 | return: boolean 42 | 43 | /** 44 | * Escape key was pressed. 45 | */ 46 | escape: boolean 47 | 48 | /** 49 | * Ctrl key was pressed. 50 | */ 51 | ctrl: boolean 52 | 53 | /** 54 | * Shift key was pressed. 55 | */ 56 | shift: boolean 57 | 58 | /** 59 | * Tab key was pressed. 60 | */ 61 | tab: boolean 62 | 63 | /** 64 | * Backspace key was pressed. 65 | */ 66 | backspace: boolean 67 | 68 | /** 69 | * Delete key was pressed. 70 | */ 71 | delete: boolean 72 | 73 | /** 74 | * [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. 75 | */ 76 | meta: boolean 77 | } 78 | 79 | type Handler = (input: string, key: Key) => void 80 | 81 | interface Options { 82 | /** 83 | * Enable or disable capturing of user input. 84 | * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times. 85 | * 86 | * @default true 87 | */ 88 | isActive?: boolean 89 | } 90 | 91 | /** 92 | * This hook is used for handling user input. 93 | * It's a more convenient alternative to using `StdinContext` and listening to `data` events. 94 | * The callback you pass to `useInput` is called for each character when user enters any input. 95 | * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`. 96 | * 97 | * ``` 98 | * import {useInput} from 'temir'; 99 | * 100 | * const UserInput = () => { 101 | * useInput((input, key) => { 102 | * if (input === 'q') { 103 | * // Exit program 104 | * } 105 | * 106 | * if (key.leftArrow) { 107 | * // Left arrow key pressed 108 | * } 109 | * }); 110 | * 111 | * return … 112 | * }; 113 | * ``` 114 | */ 115 | export const useInput = (inputHandler: Handler, options: Options = { isActive: true }) => { 116 | const { stdin, setRawMode, internal_exitOnCtrlC } = useStdin() 117 | 118 | watchEffect(() => { 119 | setRawMode(options.isActive) 120 | }) 121 | 122 | const handleData = (data: Buffer) => { 123 | let input = String(data) 124 | 125 | const key = { 126 | upArrow: input === '\u001B[A', 127 | downArrow: input === '\u001B[B', 128 | leftArrow: input === '\u001B[D', 129 | rightArrow: input === '\u001B[C', 130 | pageDown: input === '\u001B[6~', 131 | pageUp: input === '\u001B[5~', 132 | return: input === '\r', 133 | escape: input === '\u001B', 134 | ctrl: false, 135 | shift: false, 136 | tab: input === '\t' || input === '\u001B[Z', 137 | backspace: input === '\u0008', 138 | delete: input === '\u007F' || input === '\u001B[3~', 139 | meta: false, 140 | } 141 | 142 | // Copied from `keypress` module 143 | if (input <= '\u001A' && !key.return) { 144 | input = String.fromCharCode( 145 | input.charCodeAt(0) + 'a'.charCodeAt(0) - 1, 146 | ) 147 | key.ctrl = true 148 | } 149 | 150 | if (input.startsWith('\u001B')) { 151 | input = input.slice(1) 152 | key.meta = true 153 | } 154 | 155 | const isLatinUppercase = input >= 'A' && input <= 'Z' 156 | const isCyrillicUppercase = input >= 'А' && input <= 'Я' 157 | if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) 158 | key.shift = true 159 | 160 | // Shift+Tab 161 | if (key.tab && input === '[Z') 162 | key.shift = true 163 | 164 | if (key.tab || key.backspace || key.delete) 165 | input = '' 166 | 167 | // If app is not supposed to exit on Ctrl+C, then let input listener handle it 168 | if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) 169 | inputHandler(input, key) 170 | } 171 | 172 | watchEffect(() => { 173 | stdin?.off('data', handleData) 174 | stdin?.on('data', handleData) 175 | }) 176 | } 177 | 178 | -------------------------------------------------------------------------------- /packages/temir-select-input/src/SelectInput.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentOptions } from 'vue' 2 | import { computed, defineComponent, h, ref, watch } from 'vue' 3 | import { TBox, useInput } from '@temir/core' 4 | import arrayRotate from 'arr-rotate' 5 | import type { ItemProps } from './Item' 6 | import SelectInputItem from './Item' 7 | import type { IndicatorProps } from './Indicator' 8 | import Indicator from './Indicator' 9 | 10 | export interface TSelectInputProps { 11 | /** 12 | * Items to display in a list. Each item must be an object and have `label` and `value` props, it may also optionally have a `key` prop. 13 | * If no `key` prop is provided, `value` will be used as the item key. 14 | */ 15 | items?: Array 16 | 17 | /** 18 | * Listen to user's input. Useful in case there are multiple input components at the same time and input must be "routed" to a specific component. 19 | * 20 | * @default true 21 | */ 22 | isFocused?: boolean 23 | 24 | /** 25 | * Index of initially-selected item in `items` array. 26 | * 27 | * @default 0 28 | */ 29 | initialIndex?: number 30 | 31 | /** 32 | * Number of items to display. 33 | */ 34 | limit?: number 35 | 36 | /** 37 | * Custom component to override the default indicator component. 38 | */ 39 | indicatorComponent?: ComponentOptions 40 | 41 | /** 42 | * Custom component to override the default item component. 43 | */ 44 | itemComponent?: ComponentOptions 45 | 46 | /** 47 | * Function to call when user selects an item. Item object is passed to that function as an argument. 48 | */ 49 | onSelect?: (item: any) => void 50 | 51 | /** 52 | * Function to call when user highlights an item. Item object is passed to that function as an argument. 53 | */ 54 | onHighlight?: (item: any) => void 55 | } 56 | 57 | /** 58 | * TSelectInput. 59 | */ 60 | const TSelectInput = defineComponent({ 61 | name: 'TSelectInput', 62 | props: ([ 63 | 'items', 64 | 'isFocused', 65 | 'initialIndex', 66 | 'limit', 67 | 'indicatorComponent', 68 | 'itemComponent', 69 | 'onSelect', 70 | 'onHighlight', 71 | ] as undefined), 72 | setup(props) { 73 | const { 74 | items = [], 75 | isFocused = true, 76 | initialIndex = 0, 77 | limit: customLimit, 78 | indicatorComponent = Indicator, 79 | itemComponent = SelectInputItem, 80 | onSelect, 81 | onHighlight, 82 | } = props 83 | const rotateIndex = ref(0) 84 | function setRotateIndex(value) { 85 | rotateIndex.value = value 86 | } 87 | 88 | const selectedIndex = ref(initialIndex) 89 | function setSelectedIndex(value) { 90 | selectedIndex.value = value 91 | } 92 | 93 | const slicedItems = ref(null) 94 | 95 | 96 | function onHandle(input, key) { 97 | const items = props.items 98 | const hasLimit 99 | = typeof customLimit === 'number' && items.length > customLimit 100 | const limit = hasLimit ? Math.min(customLimit!, items.length) : items.length 101 | 102 | if (input === 'k' || key.upArrow) { 103 | const lastIndex = (hasLimit ? limit : items.length) - 1 104 | const atFirstIndex = selectedIndex.value === 0 105 | const nextIndex = hasLimit ? selectedIndex.value : lastIndex 106 | const nextRotateIndex = atFirstIndex ? rotateIndex.value + 1 : rotateIndex 107 | const nextSelectedIndex = atFirstIndex 108 | ? nextIndex 109 | : selectedIndex.value - 1 110 | 111 | setRotateIndex(nextRotateIndex) 112 | setSelectedIndex(nextSelectedIndex) 113 | 114 | slicedItems.value = hasLimit 115 | ? arrayRotate(items, nextRotateIndex).slice(0, limit) 116 | : items 117 | 118 | if (typeof onHighlight === 'function') 119 | onHighlight(slicedItems.value[nextSelectedIndex]) 120 | } 121 | 122 | if (input === 'j' || key.downArrow) { 123 | const atLastIndex 124 | = selectedIndex.value === (hasLimit ? limit : items.length) - 1 125 | const nextIndex = hasLimit ? selectedIndex.value : 0 126 | const nextRotateIndex = atLastIndex ? rotateIndex.value - 1 : rotateIndex.value 127 | const nextSelectedIndex = atLastIndex ? nextIndex : selectedIndex.value + 1 128 | 129 | setRotateIndex(nextRotateIndex) 130 | setSelectedIndex(nextSelectedIndex) 131 | 132 | slicedItems.value = hasLimit 133 | ? arrayRotate(items, nextRotateIndex).slice(0, limit) 134 | : items 135 | 136 | if (typeof onHighlight === 'function') 137 | onHighlight(slicedItems.value[nextSelectedIndex]) 138 | } 139 | 140 | if (key.return) { 141 | slicedItems.value = hasLimit 142 | ? arrayRotate(items, rotateIndex).slice(0, limit) 143 | : items 144 | 145 | if (typeof onSelect === 'function') 146 | onSelect(slicedItems.value[selectedIndex.value]) 147 | } 148 | } 149 | 150 | useInput(onHandle, { isActive: isFocused }) 151 | 152 | watch(() => props.items, (value) => { 153 | const hasLimit 154 | = typeof customLimit === 'number' && value.length > customLimit 155 | const limit = hasLimit ? Math.min(customLimit!, value.length) : value.length 156 | 157 | slicedItems.value = hasLimit 158 | ? arrayRotate(items, rotateIndex).slice(0, limit) 159 | : value 160 | }, { 161 | immediate: true 162 | }) 163 | 164 | const children = computed(() => { 165 | return slicedItems.value.map((item, index) => { 166 | const isSelected = index === selectedIndex.value 167 | return h(TBox, {}, [ 168 | h(indicatorComponent, { 169 | isSelected, 170 | }), 171 | h(itemComponent, { 172 | ...item, 173 | isSelected, 174 | }), 175 | ], 176 | ) 177 | }) 178 | }) 179 | return () => { 180 | return h(TBox, { 181 | flexDirection: 'column', 182 | }, children.value) 183 | } 184 | }, 185 | }) 186 | 187 | export default TSelectInput 188 | -------------------------------------------------------------------------------- /packages/temir/src/dom/index.ts: -------------------------------------------------------------------------------- 1 | import Yoga from 'yoga-layout-prebuilt' 2 | import type { YogaNode } from 'yoga-layout-prebuilt' 3 | import type { OutputTransformer } from '../render-node-to-output' 4 | import type { Styles } from './styles' 5 | import applyStyles from './styles' 6 | import measureText from './measure-text' 7 | import squashTextNodes from './squash-text-nodes' 8 | import wrapText from './wrap-text' 9 | 10 | interface TemirNode { 11 | parentNode: DOMElement | null 12 | yogaNode?: YogaNode 13 | internal_static?: boolean 14 | style: Styles 15 | } 16 | 17 | export const TEXT_NAME = '#text' 18 | export type TextName = '#text' 19 | export type ElementNames = 20 | | 'temir-root' 21 | | 'temir-box' 22 | | 'temir-text' 23 | | 'temir-virtual-text' 24 | export type DOMNodeAttribute = boolean | string | number 25 | export type NodeNames = ElementNames | TextName 26 | 27 | export type TextNode = { 28 | nodeName: TextName 29 | nodeValue: string 30 | } & TemirNode 31 | 32 | export type DOMNode = T extends { 33 | nodeName: infer U 34 | } 35 | ? U extends '#text' ? TextNode : DOMElement 36 | : never 37 | 38 | export type DOMElement = { 39 | nodeName: string 40 | attributes: { 41 | [key: string]: DOMNodeAttribute 42 | } 43 | childNodes: DOMNode[] 44 | internal_transform?: OutputTransformer 45 | 46 | // Internal properties 47 | isStaticDirty?: boolean 48 | staticNode?: any 49 | onRender?: () => void 50 | onImmediateRender?: () => void 51 | } & TemirNode 52 | 53 | const measureTextNode = function ( 54 | node: DOMElement, 55 | width: number, 56 | ): { width: number; height: number } { 57 | const text 58 | = node.nodeName === '#text' ? (node as unknown as TextNode).nodeValue : squashTextNodes(node) 59 | 60 | const dimensions = measureText(text) 61 | 62 | // Text fits into container, no need to wrap 63 | if (dimensions.width <= width) 64 | return dimensions 65 | 66 | // This is happening when is shrinking child nodes and Yoga asks 67 | // if we can fit this text node in a <1px space, so we just tell Yoga "no" 68 | if (dimensions.width >= 1 && width > 0 && width < 1) 69 | return dimensions 70 | 71 | const textWrap = node.style?.textWrap ?? 'wrap' 72 | const wrappedText = wrapText(text, width, textWrap) 73 | 74 | return measureText(wrappedText) 75 | } 76 | 77 | export const createNode = (nodeName: string): DOMElement => { 78 | const node: DOMElement = { 79 | nodeName, 80 | style: {}, 81 | attributes: {}, 82 | childNodes: [], 83 | parentNode: null, 84 | yogaNode: nodeName === 'temir-virtual-text' ? undefined : Yoga.Node.create(), 85 | } 86 | 87 | if (nodeName === 'temir-text') 88 | node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) 89 | 90 | return node 91 | } 92 | 93 | const findClosestYogaNode = (node?: DOMNode): YogaNode | undefined => { 94 | if (!node || !node.parentNode) 95 | return undefined 96 | 97 | return node.yogaNode ?? findClosestYogaNode(node.parentNode) 98 | } 99 | 100 | export const findRootNode = (node?: DOMElement | TextNode): DOMElement | undefined => { 101 | if (node.nodeName === 'temir-root') 102 | return node 103 | 104 | if (!node.parentNode) 105 | return null 106 | return findRootNode(node.parentNode) 107 | } 108 | 109 | const markNodeAsDirty = (node?: DOMNode): void => { 110 | // Mark closest Yoga node as dirty to measure text dimensions again 111 | const yogaNode = findClosestYogaNode(node) 112 | yogaNode?.markDirty() 113 | } 114 | 115 | export const cleanupYogaNode = (node?: Yoga.YogaNode): void => { 116 | node?.unsetMeasureFunc() 117 | node?.freeRecursive() 118 | } 119 | 120 | export const removeChildNode = ( 121 | node: DOMElement, 122 | removeNode: DOMNode, 123 | ): void => { 124 | 125 | if (removeNode.yogaNode) 126 | removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) 127 | 128 | removeNode.parentNode = null 129 | 130 | const index = node.childNodes.indexOf(removeNode) 131 | if (index >= 0) 132 | node.childNodes.splice(index, 1) 133 | 134 | if (node.nodeName === 'temir-text' || node.nodeName === 'temir-virtual-text') 135 | markNodeAsDirty(node) 136 | } 137 | 138 | export const appendChildNode = ( 139 | childNode: DOMElement, 140 | node: DOMElement, 141 | ): void => { 142 | if ( 143 | !childNode 144 | || (!(childNode as unknown as TextNode).nodeValue && !childNode.yogaNode && childNode.nodeName !== 'temir-virtual-text')) 145 | return null 146 | if (childNode.parentNode) 147 | removeChildNode(childNode.parentNode, childNode) 148 | 149 | childNode.parentNode = node 150 | node.childNodes.push(childNode) 151 | if (childNode.yogaNode) { 152 | node.yogaNode?.insertChild( 153 | childNode.yogaNode, 154 | node.yogaNode.getChildCount(), 155 | ) 156 | } 157 | if (node.nodeName === 'temir-text' || node.nodeName === 'temir-virtual-text') 158 | markNodeAsDirty(node) 159 | 160 | } 161 | 162 | export const setTextNodeValue = (node: TextNode, text: string): void => { 163 | if (typeof text !== 'string') 164 | text = String(text) 165 | 166 | if ((node as unknown as DOMElement).nodeName === 'temir-virtual-text') { 167 | (node as unknown as DOMElement).childNodes = [] 168 | const textNode: TextNode = { 169 | nodeName: '#text', 170 | nodeValue: text, 171 | yogaNode: undefined, 172 | parentNode: null, 173 | style: {}, 174 | } 175 | appendChildNode(textNode as unknown as DOMElement, (node as unknown as DOMElement)) 176 | } 177 | else { 178 | node.nodeValue = text 179 | } 180 | 181 | markNodeAsDirty(node) 182 | } 183 | 184 | export const createTextNode = (text: string): TextNode => { 185 | const node: TextNode = { 186 | nodeName: '#text', 187 | nodeValue: text, 188 | yogaNode: undefined, 189 | parentNode: null, 190 | style: {}, 191 | } 192 | 193 | setTextNodeValue(node, text) 194 | return node 195 | } 196 | 197 | export const setAttribute = ( 198 | node: DOMElement, 199 | key: string, 200 | value: DOMNodeAttribute, 201 | ): void => { 202 | node.attributes[key] = value 203 | } 204 | 205 | export const setStyle = (node: DOMNode, style: Styles): void => { 206 | node.style = style 207 | 208 | if (node.yogaNode) 209 | applyStyles(node.yogaNode, style) 210 | } 211 | 212 | export const updateProps = (node, key, value) => { 213 | // update Text Component text 214 | if (key === '_temir_text') 215 | return 216 | 217 | if (key === 'style') 218 | setStyle(node, value as Styles) 219 | 220 | else if (key === 'internal_transform') 221 | node.internal_transform = value as OutputTransformer 222 | 223 | else if (key === 'internal_static') 224 | node.internal_static = true 225 | 226 | else setAttribute(node, key, value as DOMNodeAttribute) 227 | } 228 | export const createElement = (nodeName: string, _, __, props): DOMElement => { 229 | const type = nodeName === 'temir-text' && props.isInsideText ? 'temir-virtual-text' : nodeName 230 | const node = createNode(type) 231 | for (const key in props) 232 | updateProps(node, key, props[key]) 233 | 234 | return node 235 | } 236 | -------------------------------------------------------------------------------- /packages/temir-tab/src/index.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline' 2 | import type { Component, VNode } from 'vue' 3 | import { Fragment, computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue' 4 | import type { StdinProps, TBoxProps } from '@temir/core' 5 | import { TBox, TText, useStdin } from '@temir/core' 6 | 7 | export interface TTabProps { 8 | name?: string 9 | } 10 | 11 | /** 12 | * Declare how does the keyboard interacts with ink-tab here 13 | */ 14 | interface KeyMapProps { 15 | useNumbers?: boolean 16 | useTab?: boolean 17 | previous?: string[] 18 | next?: string[] 19 | } 20 | 21 | /** 22 | * A component 23 | */ 24 | const TTab = defineComponent({ 25 | props: (['name'] as undefined), 26 | setup(props, { slots }) { 27 | const text = slots?.default?.() 28 | return () => (text ? h(Fragment, text) : props.name ?? '') 29 | }, 30 | }) 31 | 32 | /** 33 | * Props for the component 34 | */ 35 | export interface TTabsProps { 36 | /** 37 | * A function called whenever a tab is changing. 38 | * @param {string} name the name of the tab passed in the `name` prop 39 | * @param {React.Component} activeTab the current active tab component 40 | */ 41 | onChange?: (name: string, activeTab: Component) => void 42 | children?: Component[] 43 | flexDirection?: TBoxProps['flexDirection'] 44 | width?: TBoxProps['width'] 45 | keyMap?: KeyMapProps 46 | isFocused?: boolean 47 | defaultValue?: string 48 | showIndex?: boolean 49 | separator?: string 50 | } 51 | 52 | interface RequiredKeyMapProps { 53 | useNumbers: boolean 54 | useTab: boolean 55 | previous: string[] 56 | next: string[] 57 | } 58 | 59 | interface TabsWithStdinProps extends TTabsProps { 60 | isRawModeSupported: boolean 61 | setRawMode: StdinProps['setRawMode'] 62 | stdin: StdinProps['stdin'] 63 | } 64 | 65 | const TabsWithStdin = defineComponent({ 66 | name: 'TabsWithStdin', 67 | props: ([ 68 | 'stdin', 69 | 'isRawModeSupported', 70 | 'setRawMode', 71 | 'onChange', 72 | 'children', 73 | 'width', 74 | 'flexDirection', 75 | 'keyMap', 76 | 'isFocused', 77 | 'defaultValue', 78 | 'showIndex', 79 | ] as undefined), 80 | setup(props, { slots }) { 81 | const activeTab = ref(0) 82 | const children = slots.default() 83 | 84 | const isColumn = computed(() => (props.flexDirection === 'column' || props.flexDirection === 'column-reverse')) 85 | const normalizedSeparator = computed(() => { 86 | const separatorWidth = props.width || 6 87 | return props.separator ?? (isColumn.value 88 | ? new Array(separatorWidth).fill('─').join('') 89 | : ' | ') 90 | }) 91 | 92 | const defaultKeyMap: RequiredKeyMapProps = { 93 | useNumbers: true, 94 | useTab: true, 95 | previous: [isColumn.value ? 'up' : 'left'], 96 | next: [isColumn.value ? 'down' : 'right'], 97 | } 98 | 99 | function onTabChange(id: number) { 100 | const { onChange } = props 101 | 102 | activeTab.value = id 103 | onChange(id) 104 | } 105 | 106 | function moveToNextTab() { 107 | const nextTabid = activeTab.value + 1 108 | onTabChange(nextTabid > children.length - 1 ? 0 : nextTabid) 109 | } 110 | 111 | function moveToPreviousTab() { 112 | const prevTabid = activeTab.value - 1 113 | onTabChange(prevTabid < 0 ? children.length - 1 : prevTabid) 114 | } 115 | 116 | function onKeyPress(ch: string, 117 | key: null | { name: string; shift: boolean; meta: boolean }) { 118 | const { keyMap, isFocused } = props 119 | 120 | if (!key || isFocused === false) 121 | return 122 | 123 | const currentKeyMap = { ...defaultKeyMap, ...keyMap } 124 | const { useNumbers, useTab, previous, next } = currentKeyMap 125 | 126 | if (previous.includes(key.name)) 127 | moveToPreviousTab() 128 | 129 | if (next.includes(key.name)) 130 | moveToNextTab() 131 | 132 | switch (key.name) { 133 | case 'tab': { 134 | if (!useTab || isFocused !== null) { 135 | // if isFocused != null, then the focus is managed by ink and thus we can not use this key 136 | return 137 | } 138 | 139 | if (key.shift === true) 140 | moveToPreviousTab() 141 | 142 | else 143 | moveToNextTab() 144 | 145 | break 146 | } 147 | 148 | case '0': 149 | case '1': 150 | case '2': 151 | case '3': 152 | case '4': 153 | case '5': 154 | case '6': 155 | case '7': 156 | case '8': 157 | case '9': { 158 | if (!useNumbers) 159 | return 160 | 161 | if (key.meta === true) { 162 | const tabId = key.name === '0' ? 9 : parseInt(key.name, 10) - 1 163 | onTabChange(tabId) 164 | } 165 | 166 | break 167 | } 168 | 169 | default: 170 | break 171 | } 172 | } 173 | 174 | onMounted(() => { 175 | const { 176 | stdin, 177 | setRawMode, 178 | isRawModeSupported, 179 | defaultValue, 180 | } = props 181 | 182 | if (isRawModeSupported && stdin) { 183 | // use temir / node `setRawMode` to read key-by-key 184 | if (setRawMode) 185 | setRawMode(true) 186 | 187 | readline.emitKeypressEvents(stdin) 188 | stdin.on('keypress', onKeyPress.bind(this)) 189 | } 190 | 191 | // select defaultValue if it's valid otherwise select the first tab on component mount 192 | const initialTabIndex = defaultValue 193 | ? children?.findIndex( 194 | child => child.props.name === defaultValue, 195 | ) 196 | : 0 197 | 198 | onTabChange(initialTabIndex) 199 | }) 200 | 201 | onUnmounted(() => { 202 | const { stdin, setRawMode, isRawModeSupported } = props 203 | 204 | if (isRawModeSupported && stdin) { 205 | if (setRawMode) 206 | setRawMode(false) // remove set raw mode, as it might interfere with CTRL-C 207 | 208 | stdin.removeListener('keypress', onKeyPress.bind(this)) 209 | } 210 | }) 211 | 212 | function normalizeChild(children: VNode[]) { 213 | return children.map((item, key) => { 214 | const colors = { 215 | backgroundColor: activeTab.value === key ? (props.isFocused !== false ? 'green' : 'gray') : undefined, 216 | color: activeTab.value === key ? 'black' : undefined, 217 | } as const 218 | 219 | const content = [ 220 | key > 0 221 | && h(TText, { 222 | color: 'gray', 223 | }, normalizedSeparator.value), 224 | h(TBox, { 225 | flexDirection: props.flexDirection, 226 | }, [ 227 | props.showIndex && h(TText, { 228 | color: 'grey', 229 | }, key + 1), 230 | h(TText, colors, item), 231 | ].filter(Boolean)), 232 | ].filter(Boolean) 233 | return content 234 | }) 235 | } 236 | 237 | return () => { 238 | const children = slots.default() 239 | 240 | return h(TBox, { 241 | flexDirection: props.flexDirection, 242 | width: props.width, 243 | }, normalizeChild(children)) 244 | } 245 | }, 246 | }) 247 | 248 | /** 249 | * The component 250 | */ 251 | const TTabs = defineComponent({ 252 | name: 'TTabs', 253 | inheritAttrs: false, 254 | props: ([ 255 | 'onChange', 256 | 'flexDirection', 257 | 'width', 258 | 'keyMap', 259 | 'isFocused', 260 | 'defaultValue', 261 | 'showIndex', 262 | ] as undefined), 263 | setup(props, { slots }) { 264 | const { isRawModeSupported, stdin, setRawMode } = useStdin() 265 | return () => { 266 | const child = slots.default() 267 | const children = child?.[0]?.shapeFlag === 16 ? child?.[0]?.children : child 268 | return h(TabsWithStdin, { 269 | isRawModeSupported, 270 | stdin, 271 | setRawMode, 272 | ...props, 273 | }, children) 274 | } 275 | }, 276 | }) 277 | 278 | export { TTabs, TTab } 279 | -------------------------------------------------------------------------------- /packages/temir/src/components/App.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from '@vue/runtime-core' 2 | import { defineComponent, h, onMounted, onUnmounted, provide, ref } from '@vue/runtime-core' 3 | import cliCursor from 'cli-cursor' 4 | import type Temir from '../temir' 5 | 6 | const TAB = '\t' 7 | const SHIFT_TAB = '\u001B[Z' 8 | const ESC = '\u001B' 9 | 10 | export interface AppProps { 11 | children: Component 12 | instance: InstanceType 13 | stdin: NodeJS.ReadStream 14 | stdout: NodeJS.WriteStream 15 | stderr: NodeJS.WriteStream 16 | writeToStdout: (data: string) => void 17 | writeToStderr: (data: string) => void 18 | exitOnCtrlC: boolean 19 | onExit: (error?: Error) => void 20 | } 21 | 22 | interface Focusable { 23 | readonly id: string 24 | readonly isActive: boolean 25 | } 26 | 27 | export const App = defineComponent({ 28 | name: 'TApp', 29 | props: (['instance', 'children', 'stdin', 'stdout', 'stderr', 'writeToStdout', 'writeToStderr', 'exitOnCtrlC', 'onExit'] as undefined), 30 | setup(props) { 31 | // Count how many components enabled raw mode to avoid disabling 32 | // raw mode until all components don't need it anymore 33 | let rawModeEnabledCount = 0 34 | 35 | const activeFocusId = ref() 36 | const focusables = ref() 37 | const isFocusEnabled = ref() 38 | 39 | provide('instance', props.instance) 40 | provide('exit', handleExit) 41 | provide('stdin', props.stdin) 42 | provide('setRawMode', handleSetRawMode) 43 | provide('isRawModeSupported', isRawModeSupported()) 44 | provide('internal_exitOnCtrlC', props.exitOnCtrlC) 45 | 46 | provide('stdout', props.stdout) 47 | provide('stdout-write', props.writeToStdout) 48 | 49 | provide('stderr', props.stderr) 50 | provide('stderr-write', props.writeToStderr) 51 | 52 | provide('activeFocusId', activeFocusId) 53 | provide('addFocusable', addFocusable) 54 | provide('removeFocusable', removeFocusable) 55 | provide('activateFocusable', activateFocusable) 56 | provide('deactivateFocusable', deactivateFocusable) 57 | provide('enableFocus', enableFocus) 58 | provide('disableFocus', disableFocus) 59 | provide('focusNext', focusNext) 60 | provide('focusPrevious', focusPrevious) 61 | provide('focus', focus) 62 | 63 | onMounted(() => { 64 | (cliCursor as any).hide(props.stdout) 65 | }) 66 | onUnmounted(() => { 67 | (cliCursor as any).show(props.stdout) 68 | }) 69 | 70 | // Determines if TTY is supported on the provided stdin 71 | function isRawModeSupported(): boolean { 72 | return props?.stdin?.isTTY 73 | } 74 | 75 | function handleSetRawMode(isEnabled: boolean): void { 76 | const { stdin } = props 77 | 78 | if (!isRawModeSupported()) { 79 | if (stdin === process.stdin) { 80 | throw new Error( 81 | 'Raw mode is not supported on the current process.stdin, which Temir uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', 82 | ) 83 | } 84 | else { 85 | throw new Error( 86 | 'Raw mode is not supported on the stdin provided to Temir.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', 87 | ) 88 | } 89 | } 90 | 91 | stdin.setEncoding('utf8') 92 | 93 | if (isEnabled) { 94 | // Ensure raw mode is enabled only once 95 | if (rawModeEnabledCount === 0) { 96 | stdin.addListener('data', handleInput) 97 | stdin.resume() 98 | stdin.setRawMode(true) 99 | } 100 | 101 | rawModeEnabledCount++ 102 | return 103 | } 104 | 105 | // Disable raw mode only when no components left that are using it 106 | if (--rawModeEnabledCount === 0) { 107 | stdin.setRawMode(false) 108 | stdin.removeListener('data', handleInput) 109 | stdin.pause() 110 | } 111 | } 112 | 113 | function enableFocus(): void { 114 | isFocusEnabled.value = true 115 | } 116 | 117 | function disableFocus(): void { 118 | isFocusEnabled.value = false 119 | } 120 | 121 | function focus(id: string): void { 122 | const hasFocusableId = focusables.value.some( 123 | focusable => focusable?.id === id, 124 | ) 125 | if (hasFocusableId) 126 | activeFocusId.value = id 127 | } 128 | 129 | function handleInput(input: string): void { 130 | // Exit on Ctrl+C 131 | 132 | if (input === '\x03' && props.exitOnCtrlC) 133 | handleExit() 134 | 135 | // Reset focus when there's an active focused component on Esc 136 | if (input === ESC && activeFocusId.value) 137 | activeFocusId.value = undefined 138 | 139 | if (isFocusEnabled.value && focusables.value.length > 0) { 140 | if (input === TAB) 141 | focusNext() 142 | 143 | if (input === SHIFT_TAB) 144 | focusPrevious() 145 | } 146 | } 147 | 148 | function handleExit(error?: Error): void { 149 | if (isRawModeSupported()) 150 | handleSetRawMode(false) 151 | 152 | props.onExit(error) 153 | } 154 | 155 | function addFocusable(id: string, { autoFocus }: { autoFocus: boolean }): void { 156 | if (!activeFocusId.value && autoFocus) 157 | activeFocusId.value = id 158 | focusables.value = [ 159 | ...focusables.value, 160 | { 161 | id, 162 | isActive: true, 163 | }, 164 | ] 165 | } 166 | 167 | function removeFocusable(id: string): void { 168 | activeFocusId.value = activeFocusId.value === id ? undefined : activeFocusId.value 169 | focusables.value = focusables.value.filter(({ id: focusableId }) => focusableId !== id) 170 | } 171 | function activateFocusable(id: string): void { 172 | focusables.value = focusables.value.map((focusable) => { 173 | if (focusable.id !== id) 174 | return focusable 175 | 176 | return { 177 | id, 178 | isActive: true, 179 | } 180 | }) 181 | } 182 | 183 | function deactivateFocusable(id: string): void { 184 | activeFocusId.value = activeFocusId.value === id ? undefined : activeFocusId.value 185 | focusables.value = focusables.value.map((focusable) => { 186 | if (focusable.id !== id) 187 | return focusable 188 | 189 | return { 190 | id, 191 | isActive: false, 192 | } 193 | }) 194 | } 195 | 196 | function findNextFocusable(): string | undefined { 197 | const activeIndex = focusables.value.findIndex((focusable) => { 198 | return focusable.id === activeFocusId.value 199 | }) 200 | 201 | for ( 202 | let index = activeIndex + 1; 203 | index < focusables.value.length; 204 | index++ 205 | ) { 206 | if (focusables.value[index]?.isActive) 207 | return focusables.value[index].id 208 | } 209 | 210 | return undefined 211 | } 212 | 213 | function findPreviousFocusable(): string | undefined { 214 | const activeIndex = focusables.value.findIndex((focusable) => { 215 | return focusable.id === activeFocusId.value 216 | }) 217 | 218 | for (let index = activeIndex - 1; index >= 0; index--) { 219 | if (focusables.value[index]?.isActive) 220 | return focusables.value[index].id 221 | } 222 | 223 | return undefined 224 | } 225 | 226 | function focusNext(): void { 227 | const firstFocusableId = focusables.value[0]?.id 228 | const nextFocusableId = findNextFocusable() 229 | activeFocusId.value = nextFocusableId || firstFocusableId 230 | } 231 | 232 | function focusPrevious(): void { 233 | const lastFocusableId 234 | = focusables.value[focusables.value.length - 1]?.id 235 | const previousFocusableId = findPreviousFocusable() 236 | activeFocusId.value = previousFocusableId || lastFocusableId 237 | } 238 | 239 | return () => { 240 | // Override component name 241 | (props.children as { name: string }).name = 'TApp' 242 | return h(props.children) 243 | } 244 | }, 245 | }) 246 | 247 | -------------------------------------------------------------------------------- /packages/temir/src/temir.ts: -------------------------------------------------------------------------------- 1 | import originalIsCI from 'is-ci' 2 | import type { DebouncedFunc } from 'lodash' 3 | import { throttle } from 'lodash' 4 | import type { Component, App as VueAppInstance } from '@vue/runtime-core' 5 | import { defineComponent, h, ssrContextKey } from '@vue/runtime-core' 6 | import signalExit from 'signal-exit' 7 | import patchConsole from 'patch-console' 8 | import ansiEscapes from 'ansi-escapes' 9 | import autoBind from 'auto-bind' 10 | import * as dom from './dom' 11 | import renderer from './createRenderer' 12 | import type { LogUpdate } from './log-update' 13 | import logUpdate from './log-update' 14 | import render from './renderer' 15 | import instances from './instances' 16 | import { App } from './components/App' 17 | 18 | const isCI = process.env.CI === 'false' ? false : originalIsCI 19 | 20 | export interface TemirOptions { 21 | stdout: NodeJS.WriteStream 22 | stdin: NodeJS.ReadStream 23 | stderr: NodeJS.WriteStream 24 | debug: boolean 25 | exitOnCtrlC: boolean 26 | patchConsole: boolean 27 | waitUntilExit?: () => Promise 28 | } 29 | 30 | export default class Temir { 31 | private readonly options: TemirOptions 32 | private readonly log: LogUpdate 33 | private readonly throttledLog: LogUpdate | DebouncedFunc 34 | // Ignore last render after unmounting a tree to prevent empty output before exit 35 | private isUnmounted: boolean 36 | private rootNode: dom.DOMElement 37 | private fullStaticOutput: string 38 | private lastOutput: string 39 | private exitPromise?: Promise 40 | private restoreConsole?: () => void 41 | private readonly unsubscribeResize?: () => void 42 | private vueApp: VueAppInstance 43 | private logTimer: NodeJS.Timer 44 | 45 | constructor(options: TemirOptions) { 46 | autoBind(this) 47 | 48 | this.options = options 49 | this.rootNode = dom.createNode('temir-root') 50 | this.log = logUpdate.create(options.stdout) 51 | this.rootNode.onRender = options.debug 52 | ? this.onRender : throttle(this.onRender, 32, { 53 | leading: true, 54 | trailing: true, 55 | }) 56 | 57 | this.throttledLog = options.debug 58 | ? this.log 59 | : throttle(this.log, undefined, { 60 | leading: true, 61 | trailing: true, 62 | }) 63 | 64 | 65 | this.logTimer = null 66 | 67 | // Ignore last render after unmounting a tree to prevent empty output before exit 68 | this.isUnmounted = false 69 | 70 | // Store last output to only rerender when needed 71 | this.lastOutput = '' 72 | 73 | // This variable is used only in debug mode to store full static output 74 | // so that it's rerendered every time, not just new static parts, like in non-debug mode 75 | this.fullStaticOutput = '' 76 | 77 | // Unmount when process exits 78 | this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false }) 79 | 80 | if (options.patchConsole) 81 | this.patchConsole() 82 | 83 | if (!isCI) { 84 | options.stdout.on('resize', this.onRender) 85 | 86 | this.unsubscribeResize = () => { 87 | options.stdout.off('resize', this.onRender) 88 | } 89 | } 90 | } 91 | 92 | resolveExitPromise: () => void = () => { } 93 | rejectExitPromise: (reason?: Error) => void = () => { } 94 | unsubscribeExit: () => void = () => { } 95 | 96 | onRender: () => void = () => { 97 | const { output, outputHeight, staticOutput } = render( 98 | this.rootNode, 99 | // The 'columns' property can be undefined or 0 when not using a TTY. 100 | // In that case we fall back to 80. 101 | this.options.stdout.columns || 80, 102 | ) 103 | 104 | // If output isn't empty, it means new children have been added to it 105 | const hasStaticOutput = staticOutput && staticOutput !== '\n' 106 | 107 | if (this.options.debug) { 108 | if (hasStaticOutput) 109 | this.fullStaticOutput += staticOutput 110 | 111 | this.options.stdout.write(`${this.fullStaticOutput}${output}`) 112 | return 113 | } 114 | 115 | if (isCI) { 116 | if (hasStaticOutput) 117 | this.options.stdout.write(staticOutput) 118 | 119 | this.lastOutput = output 120 | return 121 | } 122 | 123 | if (hasStaticOutput) 124 | this.fullStaticOutput += staticOutput 125 | 126 | if (outputHeight >= this.options.stdout.rows) { 127 | this.options.stdout.write( 128 | (ansiEscapes as any).clearTerminal + this.fullStaticOutput + output, 129 | ) 130 | this.lastOutput = output 131 | return 132 | } 133 | 134 | // To ensure static output is cleanly rendered before main output, clear main output first 135 | if (hasStaticOutput) { 136 | this.log.clear() 137 | this.options.stdout.write(staticOutput) 138 | this.log(output) 139 | } 140 | 141 | if (!hasStaticOutput && output !== this.lastOutput) { 142 | this.throttledLog(output) 143 | } 144 | 145 | this.lastOutput = output 146 | } 147 | 148 | createVueApp(node: Component) { 149 | const options = this.options 150 | /* eslint-disable @typescript-eslint/no-this-alias */ 151 | const context = this 152 | 153 | const Root = defineComponent({ 154 | setup() { 155 | return () => h(App, { 156 | stdin: options.stdin, 157 | stdout: options.stdout, 158 | stderr: options.stderr, 159 | writeToStdout: context.writeToStdout, 160 | writeToStderr: context.writeToStderr, 161 | exitOnCtrlC: options.exitOnCtrlC, 162 | instance: context, 163 | onExit: context.unmount, 164 | children: node, 165 | }) 166 | }, 167 | }) 168 | 169 | this.vueApp = renderer.createApp(Root) 170 | this.vueApp.provide(ssrContextKey, {}) 171 | this.vueApp.config.warnHandler = () => null 172 | this.vueApp.mount(this.rootNode) 173 | clearInterval(this.logTimer) 174 | this.logTimer = setInterval(() => { 175 | this.rootNode?.onRender() 176 | }, 32) 177 | } 178 | 179 | render(node: Component) { 180 | this.rootNode = dom.createNode('temir-root') 181 | this.rootNode.onRender = this.options.debug 182 | ? this.onRender : throttle(this.onRender, 32, { 183 | leading: true, 184 | trailing: true, 185 | }) 186 | // this.vueApp?.unmount() 187 | this.createVueApp(node) 188 | // this.onRender() 189 | } 190 | 191 | unmount(error?: Error | number | null): void { 192 | clearInterval(this.logTimer) 193 | if (this.isUnmounted) 194 | return 195 | 196 | this.onRender() 197 | this.unsubscribeExit() 198 | 199 | if (typeof this.restoreConsole === 'function') 200 | this.restoreConsole() 201 | 202 | if (typeof this.unsubscribeResize === 'function') 203 | this.unsubscribeResize() 204 | 205 | // CIs don't handle erasing ansi escapes well, so it's better to 206 | // only render last frame of non-static output 207 | if (isCI) 208 | this.options.stdout.write(`${this.lastOutput}\n`) 209 | 210 | else if (!this.options.debug) 211 | this.log.done() 212 | 213 | this.isUnmounted = true 214 | 215 | this.vueApp?.unmount() 216 | 217 | instances.delete(this.options.stdout) 218 | 219 | if (error instanceof Error) 220 | this.rejectExitPromise(error) 221 | 222 | else 223 | this.resolveExitPromise() 224 | } 225 | 226 | waitUntilExit(): Promise { 227 | if (!this.exitPromise) { 228 | this.exitPromise = new Promise((resolve, reject) => { 229 | this.resolveExitPromise = resolve 230 | this.rejectExitPromise = reject 231 | }) 232 | } 233 | 234 | return this.exitPromise 235 | } 236 | 237 | clear(): void { 238 | if (!isCI && !this.options.debug) 239 | this.log.clear() 240 | } 241 | 242 | writeToStdout(data: string): void { 243 | if (this.isUnmounted) 244 | return 245 | 246 | if (this.options.debug) { 247 | this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput) 248 | return 249 | } 250 | 251 | if (isCI) { 252 | this.options.stdout.write(data) 253 | return 254 | } 255 | 256 | this.log.clear() 257 | this.options.stdout.write(data) 258 | this.log(this.lastOutput) 259 | } 260 | 261 | writeToStderr(data: string): void { 262 | if (this.isUnmounted) 263 | return 264 | 265 | if (this.options.debug) { 266 | this.options.stderr.write(data) 267 | this.options.stdout.write(this.fullStaticOutput + this.lastOutput) 268 | return 269 | } 270 | 271 | if (isCI) { 272 | this.options.stderr.write(data) 273 | return 274 | } 275 | 276 | this.log.clear() 277 | this.options.stderr.write(data) 278 | this.log(this.lastOutput) 279 | } 280 | 281 | patchConsole(): void { 282 | if (this.options.debug) 283 | return 284 | 285 | this.restoreConsole = patchConsole((stream, data) => { 286 | if (stream === 'stdout') 287 | this.writeToStdout(data) 288 | 289 | this.writeToStderr(data) 290 | }) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /packages/cli/src/plugins/esbuild-plugin-vue.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { pathToFileURL } from 'url' 4 | import type { Plugin } from 'esbuild' 5 | import hash from 'hash-sum' 6 | import resolveFrom from 'resolve-from' 7 | import type { CompilerOptions } from '@vue/compiler-sfc' 8 | 9 | const isWindows = process.platform === 'win32' 10 | 11 | async function importAbs(targetPath) { 12 | const fileUrl = pathToFileURL(targetPath).href 13 | return await import(fileUrl) 14 | } 15 | 16 | const removeQuery = (p: string) => p.replace(/\?.+$/, '') 17 | 18 | const genId = (filepath: string) => hash(path.relative(process.cwd(), filepath)) 19 | 20 | let compiler: typeof import('@vue/compiler-sfc') | undefined 21 | 22 | const getCompiler = async ( 23 | cwd: string, 24 | ): Promise> => { 25 | if (compiler) 26 | return compiler 27 | const id = resolveFrom(cwd, '@vue/compiler-sfc') 28 | compiler = isWindows ? await importAbs(id) : await import(id) 29 | return compiler! 30 | } 31 | 32 | export const vue = (): Plugin => { 33 | return { 34 | name: 'vue', 35 | 36 | setup(build) { 37 | const absPath = path.resolve( 38 | process.cwd(), 39 | build.initialOptions.absWorkingDir || '', 40 | ) 41 | const useSourceMap = !!build.initialOptions.sourcemap 42 | 43 | build.initialOptions.define = build.initialOptions.define || {} 44 | Object.assign(build.initialOptions.define, { 45 | __VUE_OPTIONS_API__: 46 | build.initialOptions.define?.__VUE_OPTIONS_API__ ?? true, 47 | __VUE_PROD_DEVTOOLS__: 48 | build.initialOptions.define?.__VUE_PROD_DEVTOOLS__ ?? false, 49 | }) 50 | 51 | const formatPath = (p: string, resolveDir: string) => { 52 | if (p.startsWith('.')) 53 | return path.resolve(resolveDir, p) 54 | 55 | if (p.startsWith(`${absPath}/`)) 56 | return p 57 | 58 | return path.join(absPath, p) 59 | } 60 | 61 | build.onResolve({ filter: /\.vue$/ }, (args) => { 62 | return { 63 | path: args.path, 64 | namespace: 'vue', 65 | pluginData: { resolveDir: args.resolveDir }, 66 | } 67 | }) 68 | 69 | build.onResolve({ filter: /\?vue&type=template/ }, (args) => { 70 | return { 71 | path: args.path, 72 | namespace: 'vue', 73 | pluginData: { resolveDir: args.resolveDir }, 74 | } 75 | }) 76 | 77 | build.onResolve({ filter: /\?vue&type=script/ }, (args) => { 78 | return { 79 | path: args.path, 80 | namespace: 'vue', 81 | pluginData: { resolveDir: args.resolveDir }, 82 | } 83 | }) 84 | 85 | build.onResolve({ filter: /\?vue&type=style/ }, (args) => { 86 | return { 87 | path: args.path, 88 | namespace: 'vue', 89 | pluginData: { resolveDir: args.resolveDir }, 90 | } 91 | }) 92 | 93 | build.onLoad({ filter: /\.vue$/, namespace: 'vue' }, async (args) => { 94 | const compiler = await getCompiler(absPath) 95 | const { resolveDir } = args.pluginData 96 | const filepath = formatPath(args.path, resolveDir) 97 | const parsedResolveDir = path.dirname(path.resolve(resolveDir, args.path)) 98 | 99 | const content = await fs.promises.readFile(filepath, 'utf8') 100 | const sfc = compiler.parse(content) 101 | 102 | let contents = '' 103 | 104 | const inlineTemplate 105 | = !!sfc.descriptor.scriptSetup && !sfc.descriptor.template?.src 106 | const isTS 107 | = sfc.descriptor.scriptSetup?.lang === 'ts' 108 | || sfc.descriptor.script?.lang === 'ts' 109 | const hasScoped = sfc.descriptor.styles.some(s => s.scoped) 110 | 111 | if (sfc.descriptor.script || sfc.descriptor.scriptSetup) { 112 | const scriptResult = compiler.compileScript(sfc.descriptor, { 113 | id: genId(args.path), 114 | inlineTemplate, 115 | sourceMap: useSourceMap, 116 | }) 117 | contents += compiler.rewriteDefault( 118 | scriptResult.content, 119 | '__sfc_main', 120 | ) 121 | } 122 | else { 123 | contents += 'let __sfc_main = {}' 124 | } 125 | 126 | if (sfc.descriptor.styles.length > 0) { 127 | contents += ` 128 | import "${args.path}?vue&type=style" 129 | ` 130 | } 131 | 132 | if (sfc.descriptor.template && !inlineTemplate) { 133 | contents += ` 134 | import { render } from "${args.path}?vue&type=template" 135 | 136 | __sfc_main.render = render 137 | ` 138 | } 139 | 140 | if (hasScoped) 141 | contents += `__sfc_main.__scopeId = "data-v-${genId(args.path)}"\n` 142 | 143 | contents += '\nexport default __sfc_main' 144 | 145 | return { 146 | contents, 147 | resolveDir: parsedResolveDir, 148 | loader: isTS ? 'ts' : 'js', 149 | watchFiles: [filepath], 150 | } 151 | }) 152 | 153 | build.onLoad( 154 | { filter: /\?vue&type=template/, namespace: 'vue' }, 155 | async (args) => { 156 | const compiler = await getCompiler(absPath) 157 | const { resolveDir } = args.pluginData 158 | const relativePath = removeQuery(args.path) 159 | const filepath = formatPath(relativePath, resolveDir) 160 | const source = await fs.promises.readFile(filepath, 'utf8') 161 | const { descriptor } = compiler.parse(source) 162 | if (descriptor.template) { 163 | const hasScoped = descriptor.styles.some(s => s.scoped) 164 | const id = genId(relativePath) 165 | // if using TS, support TS syntax in template expressions 166 | const expressionPlugins: CompilerOptions['expressionPlugins'] = [] 167 | const lang = descriptor.scriptSetup?.lang || descriptor.script?.lang 168 | if ( 169 | lang 170 | && /tsx?$/.test(lang) 171 | && !expressionPlugins.includes('typescript') 172 | ) 173 | expressionPlugins.push('typescript') 174 | 175 | const compiled = compiler.compileTemplate({ 176 | source: descriptor.template.content, 177 | filename: filepath, 178 | id, 179 | scoped: hasScoped, 180 | isProd: process.env.NODE_ENV === 'production', 181 | slotted: descriptor.slotted, 182 | preprocessLang: descriptor.template.lang, 183 | compilerOptions: { 184 | scopeId: hasScoped ? `data-v-${id}` : undefined, 185 | sourceMap: useSourceMap, 186 | expressionPlugins, 187 | }, 188 | }) 189 | return { 190 | resolveDir, 191 | contents: compiled.code, 192 | } 193 | } 194 | }, 195 | ) 196 | 197 | build.onLoad( 198 | { filter: /\?vue&type=script/, namespace: 'vue' }, 199 | async (args) => { 200 | const compiler = await getCompiler(absPath) 201 | const { resolveDir } = args.pluginData 202 | const relativePath = removeQuery(args.path) 203 | const filepath = formatPath(relativePath, resolveDir) 204 | const source = await fs.promises.readFile(filepath, 'utf8') 205 | 206 | const { descriptor } = compiler.parse(source, { filename: filepath }) 207 | if (descriptor.script) { 208 | const compiled = compiler.compileScript(descriptor, { 209 | id: genId(relativePath), 210 | }) 211 | return { 212 | resolveDir, 213 | contents: compiled.content, 214 | loader: compiled.lang === 'ts' ? 'ts' : 'js', 215 | } 216 | } 217 | }, 218 | ) 219 | 220 | build.onLoad( 221 | { filter: /\?vue&type=style/, namespace: 'vue' }, 222 | async (args) => { 223 | const compiler = await getCompiler(absPath) 224 | const { resolveDir } = args.pluginData 225 | const relativePath = removeQuery(args.path) 226 | const filepath = formatPath(relativePath, resolveDir) 227 | const source = await fs.promises.readFile(filepath, 'utf8') 228 | const { descriptor } = compiler.parse(source) 229 | if (descriptor.styles.length > 0) { 230 | const id = genId(relativePath) 231 | let content = '' 232 | for (const style of descriptor.styles) { 233 | const compiled = await compiler.compileStyleAsync({ 234 | source: style.content, 235 | filename: filepath, 236 | id, 237 | scoped: style.scoped, 238 | preprocessLang: style.lang as any, 239 | modules: !!style.module, 240 | }) 241 | 242 | if (compiled.errors.length > 0) 243 | throw compiled.errors[0] 244 | 245 | content += compiled.code 246 | } 247 | return { 248 | resolveDir, 249 | contents: content, 250 | loader: 'css', 251 | } 252 | } 253 | }, 254 | ) 255 | 256 | build.onEnd((result) => { 257 | const collectCssFile: (file: string) => void = build.collectCssFile 258 | if (result.metafile && collectCssFile) { 259 | for (const filename in result.metafile.outputs) { 260 | if (!filename.endsWith('.css')) 261 | continue 262 | const inputs = Object.keys(result.metafile.outputs[filename].inputs) 263 | if (inputs.some(name => name.includes('?vue&type=style'))) { 264 | collectCssFile( 265 | path.join( 266 | build.initialOptions.absWorkingDir || process.cwd(), 267 | filename, 268 | ), 269 | ) 270 | } 271 | } 272 | } 273 | }) 274 | }, 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /packages/temir/src/dom/styles.ts: -------------------------------------------------------------------------------- 1 | import type { YogaNode } from 'yoga-layout-prebuilt' 2 | import Yoga from 'yoga-layout-prebuilt' 3 | import type { Boxes } from 'cli-boxes' 4 | import type { ForegroundColor } from 'chalk' 5 | 6 | export interface Styles { 7 | readonly textWrap?: 8 | | 'wrap' 9 | | 'end' 10 | | 'middle' 11 | | 'truncate-end' 12 | | 'truncate' 13 | | 'truncate-middle' 14 | | 'truncate-start' 15 | 16 | readonly position?: 'absolute' | 'relative' 17 | 18 | /** 19 | * Top margin. 20 | */ 21 | readonly marginTop?: number 22 | 23 | /** 24 | * Bottom margin. 25 | */ 26 | readonly marginBottom?: number 27 | 28 | /** 29 | * Left margin. 30 | */ 31 | readonly marginLeft?: number 32 | 33 | /** 34 | * Right margin. 35 | */ 36 | readonly marginRight?: number 37 | 38 | /** 39 | * Top padding. 40 | */ 41 | readonly paddingTop?: number 42 | 43 | /** 44 | * Bottom padding. 45 | */ 46 | readonly paddingBottom?: number 47 | 48 | /** 49 | * Left padding. 50 | */ 51 | readonly paddingLeft?: number 52 | 53 | /** 54 | * Right padding. 55 | */ 56 | readonly paddingRight?: number 57 | 58 | /** 59 | * This property defines the ability for a flex item to grow if necessary. 60 | * See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/). 61 | */ 62 | readonly flexGrow?: number 63 | 64 | /** 65 | * It specifies the “flex shrink factor”, which determines how much the flex item will shrink relative to the rest of the flex items in the flex container when there isn’t enough space on the row. 66 | * See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/). 67 | */ 68 | readonly flexShrink?: number 69 | 70 | /** 71 | * It establishes the main-axis, thus defining the direction flex items are placed in the flex container. 72 | * See [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/). 73 | */ 74 | readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse' 75 | 76 | /** 77 | * It specifies the initial size of the flex item, before any available space is distributed according to the flex factors. 78 | * See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/). 79 | */ 80 | readonly flexBasis?: number | string 81 | 82 | /** 83 | * The align-items property defines the default behavior for how items are laid out along the cross axis (perpendicular to the main axis). 84 | * See [align-items](https://css-tricks.com/almanac/properties/a/align-items/). 85 | */ 86 | readonly alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch' 87 | 88 | /** 89 | * It makes possible to override the align-items value for specific flex items. 90 | * See [align-self](https://css-tricks.com/almanac/properties/a/align-self/). 91 | */ 92 | readonly alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto' 93 | 94 | /** 95 | * It defines the alignment along the main axis. 96 | * See [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/). 97 | */ 98 | readonly justifyContent?: 99 | | 'flex-start' 100 | | 'flex-end' 101 | | 'space-between' 102 | | 'space-around' 103 | | 'center' 104 | 105 | /** 106 | * Width of the element in spaces. 107 | * You can also set it in percent, which will calculate the width based on the width of parent element. 108 | */ 109 | readonly width?: number | string 110 | 111 | /** 112 | * Height of the element in lines (rows). 113 | * You can also set it in percent, which will calculate the height based on the height of parent element. 114 | */ 115 | readonly height?: number | string 116 | 117 | /** 118 | * Sets a minimum width of the element. 119 | */ 120 | readonly minWidth?: number | string 121 | 122 | /** 123 | * Sets a minimum height of the element. 124 | */ 125 | readonly minHeight?: number | string 126 | 127 | /** 128 | * Set this property to `none` to hide the element. 129 | */ 130 | readonly display?: 'flex' | 'none' 131 | 132 | /** 133 | * Add a border with a specified style. 134 | * If `borderStyle` is `undefined` (which it is by default), no border will be added. 135 | */ 136 | readonly borderStyle?: keyof Boxes 137 | 138 | /** 139 | * Change border color. 140 | * Accepts the same values as `color` in component. 141 | */ 142 | readonly borderColor?: typeof ForegroundColor 143 | } 144 | 145 | const applyPositionStyles = (node: Yoga.YogaNode, style: Styles): void => { 146 | if ('position' in style) { 147 | node.setPositionType( 148 | style.position === 'absolute' 149 | ? Yoga.POSITION_TYPE_ABSOLUTE 150 | : Yoga.POSITION_TYPE_RELATIVE, 151 | ) 152 | } 153 | } 154 | 155 | const applyMarginStyles = (node: Yoga.YogaNode, style: Styles): void => { 156 | if ('marginLeft' in style) 157 | node.setMargin(Yoga.EDGE_START, style.marginLeft || 0) 158 | 159 | if ('marginRight' in style) 160 | node.setMargin(Yoga.EDGE_END, style.marginRight || 0) 161 | 162 | if ('marginTop' in style) 163 | node.setMargin(Yoga.EDGE_TOP, style.marginTop || 0) 164 | 165 | if ('marginBottom' in style) 166 | node.setMargin(Yoga.EDGE_BOTTOM, style.marginBottom || 0) 167 | } 168 | 169 | const applyPaddingStyles = (node: Yoga.YogaNode, style: Styles): void => { 170 | if ('paddingLeft' in style) 171 | node.setPadding(Yoga.EDGE_LEFT, style.paddingLeft || 0) 172 | 173 | if ('paddingRight' in style) 174 | node.setPadding(Yoga.EDGE_RIGHT, style.paddingRight || 0) 175 | 176 | if ('paddingTop' in style) 177 | node.setPadding(Yoga.EDGE_TOP, style.paddingTop || 0) 178 | 179 | if ('paddingBottom' in style) 180 | node.setPadding(Yoga.EDGE_BOTTOM, style.paddingBottom || 0) 181 | } 182 | 183 | const applyFlexStyles = (node: YogaNode, style: Styles): void => { 184 | if ('flexGrow' in style) 185 | node.setFlexGrow(style.flexGrow ?? 0) 186 | 187 | if ('flexShrink' in style) { 188 | node.setFlexShrink( 189 | typeof style.flexShrink === 'number' ? style.flexShrink : 1, 190 | ) 191 | } 192 | 193 | if ('flexDirection' in style) { 194 | if (style.flexDirection === 'row') 195 | node.setFlexDirection(Yoga.FLEX_DIRECTION_ROW) 196 | 197 | if (style.flexDirection === 'row-reverse') 198 | node.setFlexDirection(Yoga.FLEX_DIRECTION_ROW_REVERSE) 199 | 200 | if (style.flexDirection === 'column') 201 | node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN) 202 | 203 | if (style.flexDirection === 'column-reverse') 204 | node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN_REVERSE) 205 | } 206 | 207 | if ('flexBasis' in style) { 208 | if (typeof style.flexBasis === 'number') { 209 | node.setFlexBasis(style.flexBasis) 210 | } 211 | else if (typeof style.flexBasis === 'string') { 212 | node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10)) 213 | } 214 | else { 215 | // This should be replaced with node.setFlexBasisAuto() when new Yoga release is out 216 | node.setFlexBasis(NaN) 217 | } 218 | } 219 | 220 | if ('alignItems' in style) { 221 | if (style.alignItems === 'stretch' || !style.alignItems) 222 | node.setAlignItems(Yoga.ALIGN_STRETCH) 223 | 224 | if (style.alignItems === 'flex-start') 225 | node.setAlignItems(Yoga.ALIGN_FLEX_START) 226 | 227 | if (style.alignItems === 'center') 228 | node.setAlignItems(Yoga.ALIGN_CENTER) 229 | 230 | if (style.alignItems === 'flex-end') 231 | node.setAlignItems(Yoga.ALIGN_FLEX_END) 232 | } 233 | 234 | if ('alignSelf' in style) { 235 | if (style.alignSelf === 'auto' || !style.alignSelf) 236 | node.setAlignSelf(Yoga.ALIGN_AUTO) 237 | 238 | if (style.alignSelf === 'flex-start') 239 | node.setAlignSelf(Yoga.ALIGN_FLEX_START) 240 | 241 | if (style.alignSelf === 'center') 242 | node.setAlignSelf(Yoga.ALIGN_CENTER) 243 | 244 | if (style.alignSelf === 'flex-end') 245 | node.setAlignSelf(Yoga.ALIGN_FLEX_END) 246 | } 247 | 248 | if ('justifyContent' in style) { 249 | if (style.justifyContent === 'flex-start' || !style.justifyContent) 250 | node.setJustifyContent(Yoga.JUSTIFY_FLEX_START) 251 | 252 | if (style.justifyContent === 'center') 253 | node.setJustifyContent(Yoga.JUSTIFY_CENTER) 254 | 255 | if (style.justifyContent === 'flex-end') 256 | node.setJustifyContent(Yoga.JUSTIFY_FLEX_END) 257 | 258 | if (style.justifyContent === 'space-between') 259 | node.setJustifyContent(Yoga.JUSTIFY_SPACE_BETWEEN) 260 | 261 | if (style.justifyContent === 'space-around') 262 | node.setJustifyContent(Yoga.JUSTIFY_SPACE_AROUND) 263 | } 264 | } 265 | 266 | const applyDimensionStyles = (node: YogaNode, style: Styles): void => { 267 | if ('width' in style) { 268 | if (typeof style.width === 'number') 269 | node.setWidth(style.width) 270 | 271 | else if (typeof style.width === 'string') 272 | node.setWidthPercent(Number.parseInt(style.width, 10)) 273 | 274 | else 275 | node.setWidthAuto() 276 | } 277 | 278 | if ('height' in style) { 279 | if (typeof style.height === 'number') 280 | node.setHeight(style.height) 281 | 282 | else if (typeof style.height === 'string') 283 | node.setHeightPercent(Number.parseInt(style.height, 10)) 284 | 285 | else 286 | node.setHeightAuto() 287 | } 288 | 289 | if ('minWidth' in style) { 290 | if (typeof style.minWidth === 'string') 291 | node.setMinWidthPercent(Number.parseInt(style.minWidth, 10)) 292 | 293 | else 294 | node.setMinWidth(style.minWidth ?? 0) 295 | } 296 | 297 | if ('minHeight' in style) { 298 | if (typeof style.minHeight === 'string') 299 | node.setMinHeightPercent(Number.parseInt(style.minHeight, 10)) 300 | 301 | else 302 | node.setMinHeight(style.minHeight ?? 0) 303 | } 304 | } 305 | 306 | const applyDisplayStyles = (node: YogaNode, style: Styles): void => { 307 | if ('display' in style) { 308 | node.setDisplay( 309 | style.display === 'flex' ? Yoga.DISPLAY_FLEX : Yoga.DISPLAY_NONE, 310 | ) 311 | } 312 | } 313 | 314 | const applyBorderStyles = (node: YogaNode, style: Styles): void => { 315 | if ('borderStyle' in style) { 316 | const borderWidth = typeof style.borderStyle === 'string' ? 1 : 0 317 | 318 | node.setBorder(Yoga.EDGE_TOP, borderWidth) 319 | node.setBorder(Yoga.EDGE_BOTTOM, borderWidth) 320 | node.setBorder(Yoga.EDGE_LEFT, borderWidth) 321 | node.setBorder(Yoga.EDGE_RIGHT, borderWidth) 322 | } 323 | } 324 | 325 | export default (node: YogaNode, style: Styles = {}): void => { 326 | applyPositionStyles(node, style) 327 | applyMarginStyles(node, style) 328 | applyPaddingStyles(node, style) 329 | applyFlexStyles(node, style) 330 | applyDimensionStyles(node, style) 331 | applyDisplayStyles(node, style) 332 | applyBorderStyles(node, style) 333 | } 334 | -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | 2 | # 该项目已停止维护,请使用 [Vue TermUI](https://github.com/vue-terminal/vue-termui) 替代。 3 | 4 | 5 |
6 |
7 |
8 | Temir 9 |
10 |
11 |
12 |
13 | 14 | 15 |

16 | 简体中文 | English 17 |

18 | 19 | > 使用Vue组件构建你的命令行界面应用 20 | 21 | Temir提供了与Vue在浏览器基于组件的UI构建体验相同,但是它面向的是命令行应用。 22 | 23 | 它使用 [Yoga](https://github.com/facebook/yoga) 在终端构建Flexbox布局,所以许多和CSS一样的属性在Temir中一样可用。 24 | 如果你已经熟悉Vue.js,那么其实你已经熟悉Temir了。 25 | 26 | 由于 Temir 是一个Vue渲染器,这意味着Vue的大多数特性都得到了支持。 27 | 28 | 本文档只介绍关于Temir的使用方法,关于Vue可查看[官方文档](https://vuejs.org/)。 29 | 30 | --- 31 | 32 | ## 安装 33 | 34 | ```sh 35 | npm install @temir/core 36 | ``` 37 | 38 | ## 使用 39 | 40 | ```vue 41 | 49 | 50 | 51 | 58 | 59 | ``` 60 | 61 | 62 | 63 | 64 | ## HMR支持 65 | 66 | 67 | 68 | 69 | ## 内容 70 | 71 | - [快速开始](#快速开始) 72 | - [组件](#组件) 73 | - [``](#text) 74 | - [``](#box) 75 | - [``](#newline) 76 | - [``](#spacer) 77 | - [``](https://github.com/webfansplz/temir/tree/main/packages/temir-link) 78 | - [``](https://github.com/webfansplz/temir/tree/main/packages/temir-tab) 79 | - [``](https://github.com/webfansplz/temir/tree/main/packages/temir-spinner) 80 | - [``](https://github.com/webfansplz/temir/tree/main/packages/temir-select-input) 81 | - 💻 持续补充中,欢迎贡献。 82 | - [例子](https://github.com/webfansplz/temir/tree/main/examples) 83 | - [hi-temir](https://github.com/webfansplz/temir/tree/main/examples/hi-temir) 84 | ![](./media/hi-temir.png) 85 | - [borders](https://github.com/webfansplz/temir/tree/main/examples/borders) 86 | ![](./media/temir-borders.png) 87 | - [table](https://github.com/webfansplz/temir/tree/main/examples/table) 88 | ![](./media/temir-table.png) 89 | - [temir-link](https://github.com/webfansplz/temir/tree/main/examples/temir-link) 90 | ![](./packages/temir-link/media/temir-link.png) 91 | - [temir-spinner](https://github.com/webfansplz/temir/tree/main/examples/temir-spinner) 92 | ![](./packages/temir-spinner/media/temir-spinner.gif) 93 | - [temir-tab](https://github.com/webfansplz/temir/tree/main/examples/temir-tab) 94 | ![](./packages/temir-tab/media/temir-tab.gif) 95 | - [temir-select-input](https://github.com/webfansplz/temir/tree/main/examples/temir-select-input) 96 | ![](./packages/temir-select-input/media/temir-select-input.gif) 97 | - [Vitest](https://github.com/webfansplz/temir/tree/main/examples/vitest) 98 | ![](./media/temir-vitest.gif) 99 | 100 | ## 快速开始 101 | 102 | 使用 `@temir/cli` 快速搭建一个基于Temir的CLI。 103 | 104 | ```sh 105 | 106 | mkdir my-temir-cli 107 | 108 | cd my-temir-cli 109 | 110 | touch main.ts 111 | 112 | npm install @temir/cli 113 | 114 | # Dev (开发) 115 | 116 | temir main.ts 117 | 118 | # Build (打包) 119 | 120 | temir build main.ts 121 | ``` 122 | 123 | 你也可以通过下载这个 [例子](https://github.com/webfansplz/temir/tree/main/examples/hi-temir) 来快速开始,你也可以打开 [repl.it sandbox](https://replit.com/@webfansplz/hi-temir?v=1)来在线体验和尝试它。 124 | 125 | Temir 使用 Yoga - 一款Flexbox布局引擎使用你在构建浏览器应用时使用过的类似CSS的属性,为你的CLI构建出色的用户界面。 重要的是要记住,每个元素都是一个Flexbox容器。可以认为浏览器中的每个
都有display: flex。有关如何在Temir中使用Flexbox布局的文档,请参阅下面的内置组件。注意,所有文本都必须包装在组件中。 126 | 127 | ## 组件 128 | 129 | ### `` 130 | 131 | 这个组件可以显示文本,并将其样式更改为粗体、下划线、斜体或删除线。 132 | 133 | ![temir-text-props](./media/temir-text-props.png) 134 | 135 | ```vue 136 | 137 | I am green 138 | 139 | 140 | 141 | I am black on white 142 | 143 | 144 | 145 | I am white 146 | 147 | 148 | 149 | I am bold 150 | 151 | 152 | 153 | I am italic 154 | 155 | 156 | 157 | I am underline 158 | 159 | 160 | 161 | I am strikethrough 162 | 163 | 164 | 165 | I am inversed 166 | 167 | 168 | ``` 169 | 170 | **注意:** `` 只允许文本节点和嵌套的 `` 组件作为他的子元素。例如, `` 组件不能在 `` 组件中使用。 171 | 172 | #### color 173 | 174 | Type: `string` 175 | 176 | 改变文本颜色。 177 | 178 | Temir在内部使用[chalk](https://github.com/chalk/chalk),所以它的所有功能都是支持的。 179 | 180 | ```vue 181 | 182 | 183 | Green 184 | 185 | 186 | Blue 187 | 188 | 189 | Red 190 | 191 | 192 | ``` 193 | 194 | 195 | 196 | #### backgroundColor 197 | 198 | Type: `string` 199 | 200 | 与上面的“颜色”相同,但用于背景。 201 | 202 | ```vue 203 | 204 | 205 | Green 206 | 207 | 208 | Blue 209 | 210 | 211 | Red 212 | 213 | 214 | 215 | ``` 216 | 217 | 218 | 219 | #### dimColor 220 | 221 | Type: `boolean`\ 222 | Default: `false` 223 | 224 | 调暗颜色(减少亮度)。 225 | 226 | ```vue 227 | 228 | Dimmed Red 229 | 230 | ``` 231 | 232 | 233 | 234 | #### bold 235 | 236 | Type: `boolean`\ 237 | Default: `false` 238 | 239 | 将文本加粗。 240 | 241 | #### italic 242 | 243 | Type: `boolean`\ 244 | Default: `false` 245 | 246 | 使文本斜体。 247 | 248 | #### underline 249 | 250 | Type: `boolean`\ 251 | Default: `false` 252 | 253 | 给文字添加下划线。 254 | 255 | #### strikethrough 256 | 257 | Type: `boolean`\ 258 | Default: `false` 259 | 260 | 给文字添加删除线。 261 | 262 | #### inverse 263 | 264 | Type: `boolean`\ 265 | Default: `false` 266 | 267 | 调换字体和背景的颜色。 268 | 269 | ```vue 270 | 271 | Inversed Yellow 272 | 273 | ``` 274 | 275 | 276 | 277 | #### wrap 278 | 279 | Type: `string`\ 280 | Allowed values: `wrap` `truncate` `truncate-start` `truncate-middle` `truncate-end`\ 281 | Default: `wrap` 282 | 283 | 284 | 此属性告诉Temir,如果文本宽度大于容器,则对其进行换行或截断。 285 | 默认情况下,Temir将会对文本进行换行并将其分成多行。 286 | 如果传入`truncate-*`, Temir将替换截断文本,这将导致只输出一行文本,其余部分被截断。 287 | 288 | ```vue 289 | 317 | ``` 318 | 319 | ### `` 320 | 321 | ``是构建布局必不可少的Temir组件。 322 | 这就像在浏览器中`
`。 323 | 324 | ```vue 325 | 328 | 329 | 334 | ``` 335 | 336 | #### 尺寸 337 | 338 | ##### width 339 | 340 | Type: `number` `string` 341 | 342 | 元素在空间中的宽度。 343 | 你还可以将其设置为百分比,它将根据父元素的宽度计算宽度。 344 | 345 | ```vue 346 | 353 | ``` 354 | 355 | ```vue 356 | 369 | ``` 370 | 371 | ##### height 372 | 373 | Type: `number` `string` 374 | 375 | 元素的行高。 376 | 你还可以将其设置为百分比,它将根据父元素的高度计算高度。 377 | 378 | ```vue 379 | 385 | ``` 386 | 387 | ```vue 388 | 400 | ``` 401 | 402 | ##### minWidth 403 | 404 | Type: `number` 405 | 406 | 设置元素的最小宽度。 407 | 目前还不支持百分比,请参阅 https://github.com/facebook/yoga/issues/872。 408 | 409 | ##### minHeight 410 | 411 | Type: `number` 412 | 413 | 设置元素的最小高度。 414 | 目前还不支持百分比,请参阅 https://github.com/facebook/yoga/issues/872. 415 | 416 | #### Padding 417 | 418 | ##### paddingTop 419 | 420 | Type: `number`\ 421 | Default: `0` 422 | 423 | 顶部内边距 424 | 425 | ##### paddingBottom 426 | 427 | Type: `number`\ 428 | Default: `0` 429 | 430 | 底部内边距 431 | 432 | ##### paddingLeft 433 | 434 | Type: `number`\ 435 | Default: `0` 436 | 437 | 左侧内边距 438 | 439 | ##### paddingRight 440 | 441 | Type: `number`\ 442 | Default: `0` 443 | 444 | 右侧内边距 445 | 446 | ##### paddingX 447 | 448 | Type: `number`\ 449 | Default: `0` 450 | 451 | 水平内边距。相当于设置`paddingLeft`和`paddingRight`。 452 | 453 | ##### paddingY 454 | 455 | Type: `number`\ 456 | Default: `0` 457 | 458 | 垂直内边距。相当于设置`paddingTop` and `paddingBottom`。 459 | 460 | ##### padding 461 | 462 | Type: `number`\ 463 | Default: `0` 464 | 465 | 所有的内边距。相当于设置 `paddingTop`,`paddingBottom`,`paddingLeft` and `paddingRight`。 466 | 467 | ```vue 468 | 497 | 498 | ``` 499 | 500 | #### Margin 501 | 502 | ##### marginTop 503 | 504 | Type: `number`\ 505 | Default: `0` 506 | 507 | 顶部外边距 508 | 509 | ##### marginBottom 510 | 511 | Type: `number`\ 512 | Default: `0` 513 | 514 | 底部外边距 515 | 516 | ##### marginLeft 517 | 518 | Type: `number`\ 519 | Default: `0` 520 | 521 | 左侧外边距 522 | 523 | ##### marginRight 524 | 525 | Type: `number`\ 526 | Default: `0` 527 | 528 | 右侧外边距 529 | 530 | ##### marginX 531 | 532 | Type: `number`\ 533 | Default: `0` 534 | 535 | 水平外边距。相当于设置 `marginLeft` and `marginRight`。 536 | 537 | ##### marginY 538 | 539 | Type: `number`\ 540 | Default: `0` 541 | 542 | 垂直外边距。相当于设置 `marginTop` and `marginBottom`。 543 | 544 | ##### margin 545 | 546 | Type: `number`\ 547 | Default: `0` 548 | 549 | 所有的外边距。相当于设置 `marginTop`, `marginBottom`, `marginLeft` and `marginRight`。 550 | 551 | ```vue 552 | 581 | ``` 582 | 583 | #### Flex 584 | 585 | ##### flexGrow 586 | 587 | Type: `number`\ 588 | Default: `0` 589 | 590 | 请查阅 [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/). 591 | 592 | ```vue 593 | 601 | ``` 602 | 603 | ##### flexShrink 604 | 605 | Type: `number`\ 606 | Default: `1` 607 | 608 | 请查阅 [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/). 609 | 610 | ```vue 611 | 622 | ``` 623 | 624 | ##### flexBasis 625 | 626 | Type: `number` `string` 627 | 628 | 请查阅 [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/). 629 | 630 | ```vue 631 | 643 | ``` 644 | 645 | ```vue 646 | 658 | ``` 659 | 660 | ##### flexDirection 661 | 662 | Type: `string`\ 663 | Allowed values: `row` `row-reverse` `column` `column-reverse` 664 | 665 | 请查阅 [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/). 666 | 667 | ```vue 668 | 702 | ``` 703 | 704 | ##### alignItems 705 | 706 | Type: `string`\ 707 | Allowed values: `flex-start` `center` `flex-end` 708 | 709 | 请查阅 [align-items](https://css-tricks.com/almanac/properties/a/align-items/). 710 | 711 | ```vue 712 | 764 | ``` 765 | 766 | ##### alignSelf 767 | 768 | Type: `string`\ 769 | Default: `auto`\ 770 | Allowed values: `auto` `flex-start` `center` `flex-end` 771 | 772 | 请查阅 [align-self](https://css-tricks.com/almanac/properties/a/align-self/). 773 | 774 | ```vue 775 | 803 | ``` 804 | 805 | ##### justifyContent 806 | 807 | Type: `string`\ 808 | Allowed values: `flex-start` `center` `flex-end` `space-between` `space-around` 809 | 810 | 请查阅 [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/). 811 | 812 | ```vue 813 | 841 | ``` 842 | 843 | #### Visibility 844 | 845 | ##### display 846 | 847 | Type: `string`\ 848 | Allowed values: `flex` `none`\ 849 | Default: `flex` 850 | 851 | 设置此属性为`none`以隐藏该元素。 852 | 853 | #### Borders 854 | 855 | ##### borderStyle 856 | 857 | Type: `string`\ 858 | Allowed values: `single` `double` `round` `bold` `singleDouble` `doubleSingle` `classic` 859 | 860 | 添加具有指定样式的边框,默认情况下不添加。 861 | Temir从[`cli-boxes`](https://github.com/sindresorhus/cli-boxes)模块中使用边框样式。 862 | 863 | ```vue 864 | 899 | ``` 900 | 901 | 902 | ##### borderColor 903 | 904 | Type: `string` 905 | 906 | 改变边框颜色。 907 | 908 | 接受与``组件中的[`color`](#color)相同的值。 909 | 910 | ```vue 911 | ```vue 912 | 917 | ``` 918 | 919 | ### `` 920 | 921 | 添加一个或多个换行符(`\n`)。 922 | 必须在``组件中使用。 923 | 924 | #### count 925 | 926 | Type: `number`\ 927 | Default: `1` 928 | 929 | 要插入的换行数。 930 | 931 | ```vue 932 | 935 | 936 | 949 | 950 | ``` 951 | 952 | Output: 953 | 954 | ``` 955 | Hello 956 | World 957 | ``` 958 | 959 | ### `` 960 | 961 | 沿其包含布局的主轴展开的灵活空间。 962 | 作为填充元素之间所有可用空间的快捷方式,它非常有用。 963 | 964 | 例如,在具有默认伸缩方向(`row`)的``中使用``将把"Left"定位到左边,并将"Right"推到右边。 965 | 966 | ```vue 967 | 970 | 971 | 978 | 979 | ``` 980 | 981 | 在垂直伸缩方向(`column`),它会将"Top"定位到容器的顶部,并将"Bottom"推到它的底部。 982 | 注意,容器需要足够高才能看到效果。 983 | 984 | ```vue 985 | 988 | 989 | 996 | 997 | ``` 998 | 999 | ## 致谢 1000 | 1001 | 这个项目的灵感来源于[ink](https://github.com/vadimdemedes/ink) 1002 | 1003 | [vite-node](https://github.com/antfu/vite-node)为实现HMR提供了强力的支持 1004 | --------------------------------------------------------------------------------