├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app.d.ts ├── bin └── tyler.js ├── biome.json ├── bump.config.ts ├── bun.lockb ├── cases ├── valid-template │ ├── LICENSE │ ├── lib.pdf │ ├── lib.typ │ ├── something_bad.txt │ ├── template │ │ └── main.typ │ ├── thumbnail.png │ └── typst.toml └── valid │ ├── LICENSE │ ├── lib.typ │ ├── something_bad.txt │ └── typst.toml ├── experiments ├── authors.ts ├── categories.ts ├── disciplines.ts ├── keywords.ts └── urls.ts ├── lefthook.yml ├── package.json ├── src ├── build │ ├── index.ts │ ├── package.ts │ └── publish.ts ├── cli │ ├── bump.ts │ ├── commands │ │ ├── build.ts │ │ ├── check.ts │ │ ├── index.ts │ │ └── types.ts │ ├── config.ts │ ├── help.ts │ └── index.ts ├── index.ts └── utils │ ├── file.ts │ ├── format.ts │ ├── git.ts │ ├── index.ts │ ├── manifest.ts │ ├── process.ts │ └── version.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript][typescript][json]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "biome.lspBin": "./node_modules/.bin/biome", 6 | "cSpell.words": ["outdir"] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mkpoli 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tyler 2 | 3 | Tyler is a Typst package compiler for the ease of packaging and publishing Typst libraries and templates. 4 | 5 | https://github.com/user-attachments/assets/49bd7e94-8fd3-4ead-bede-2e58471d1a85 6 | 7 | ## Features 8 | 9 | - 📥 Install package locally to be able to use with `@local/somepkg:0.1.0` 10 | - 📄 Compile relative entrypoint import (e.g. `../lib.typ`) to preview import (e.g. `@preview/somepkg:0.1.0`) 11 | - 🔄 Bump the version of the package interactively or with specified semver as CLI argument 12 | - 🔍 Check if the package manifest (`typst.toml`) is valid before publishing 13 | - 📦 Package the library or package into `typst/packages` ready for publishing 14 | - 🚀 Semi-automatic publishing that creates a PR to the Typst preview package repository 15 | - (TODO) Prompt for PR fulfillment 16 | - (TODO) Automatic publishing 17 | - (TODO) Task runner 18 | - (TODO) Thumbnail compressing 19 | - (TODO) Thumbnail generating 20 | - (TODO) Linting / Type Checking 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install -g @mkpoli/tyler 26 | ``` 27 | 28 | or 29 | 30 | ``` 31 | bun i -g @mkpoli/tyler 32 | ``` 33 | 34 | ## Usage 35 | 36 | It is recommended to put all your source files in a `src` directory and run Tyler from the root of your project, or you can specify custom source directory (even root directory) with `--srcdir` option, however, in that case, you need to add files to `--ignore` option manually (e.g. `--ignore="CONTRIBUTING.md,hello.world,neko/*"`) to remove them from the distributed package. 37 | 38 | ### Basics 39 | 40 | Run the following command in your typst package will check the package and build it, then install the built package to Typst local package group (`-i`) as well as prepare the package for publish and display instructions to create a PR (`-p`): 41 | 42 | ```bash 43 | tyler build -i -p 44 | ``` 45 | 46 | ### Examples 47 | 48 | #### Check 49 | 50 | Check if the package manifest (`typst.toml`) is valid and required properties / files e : 51 | 52 | ``` 53 | tyler check 54 | ``` 55 | 56 | #### Build 57 | 58 | Build the package in current directory and output to `dist` directory: 59 | 60 | ``` 61 | tyler build 62 | ``` 63 | 64 | Build the package then install it to Typst local package group: 65 | 66 | ``` 67 | tyler build -i 68 | ``` 69 | 70 | Build the package in `/home/user/typst/some-package` and output to `/home/user/typst/packages/packages/preview/some-package/0.1.0`: 71 | 72 | ``` 73 | tyler build /home/user/typst/some-package --outdir=/home/user/typst/packages/packages/preview/some-package/0.1.0 74 | ``` 75 | 76 | #### Publish 77 | 78 | You need to have `git` and it is recommended to have `gh` (GitHub CLI) installed to publish the package. 79 | 80 | ``` 81 | tyler build -p 82 | ``` 83 | 84 | If you are experiencing the following error, you can try to downgrade HTTP/2 to HTTP/1.1 by `git config --global http.version HTTP/1.1` (to reverse it, do `git config --global http.version --unset`): 85 | 86 | ``` 87 | error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8) 88 | error: 1143 bytes of body are still expected 89 | fetch-pack: unexpected disconnect while reading sideband packet 90 | fatal: early EOF 91 | fatal: fetch-pack: invalid index-pack output 92 | ``` 93 | 94 | ### Configuration 95 | 96 | You can pass options to `tyler` commands directly or via `[tool.tyler]` section in your `typst.toml` file. The CLI options will override the config options, and the config options are limited to the following (with the default value noted): 97 | 98 | ``` 99 | [tool.tyler] 100 | srcdir = "src" 101 | outdir = "dist" 102 | ignore = [] 103 | ``` 104 | 105 | CLI options can be checked with `tyler --help` and `tyler --help` command. 106 | 107 | ## Development 108 | 109 | ``` 110 | bun install 111 | ``` 112 | 113 | ### Emulating 114 | 115 | ``` 116 | bun tyler [options] 117 | ``` 118 | 119 | ### Publishing 120 | 121 | ``` 122 | bun run bump && bun publish 123 | ``` 124 | 125 | ## Trivia 126 | 127 | Tyler is named after something like **Ty**pst + Compi**ler** or **Ty**pst + But**ler**. 128 | 129 | ## License 130 | 131 | [MIT License](./LICENSE) © 2024 [mkpoli](https://mkpo.li/) 132 | -------------------------------------------------------------------------------- /app.d.ts: -------------------------------------------------------------------------------- 1 | declare module "spdx-expression-validate" { 2 | export default function spdxExpressionValidate(license: string): boolean; 3 | } 4 | -------------------------------------------------------------------------------- /bin/tyler.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { main } from "../dist/cli/index.js"; 3 | 4 | main(); 5 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biome.sh/schema.json", 3 | "files": { 4 | "ignore": ["dist", "node_modules"] 5 | }, 6 | "formatter": { 7 | "enabled": true 8 | }, 9 | "linter": { 10 | "enabled": true 11 | }, 12 | "organizeImports": { 13 | "enabled": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bump.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "bumpp"; 2 | 3 | export default defineConfig({}); 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/bun.lockb -------------------------------------------------------------------------------- /cases/valid-template/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mkpoli 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 | -------------------------------------------------------------------------------- /cases/valid-template/lib.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/cases/valid-template/lib.pdf -------------------------------------------------------------------------------- /cases/valid-template/lib.typ: -------------------------------------------------------------------------------- 1 | #let doc(it) = { 2 | it 3 | } 4 | -------------------------------------------------------------------------------- /cases/valid-template/something_bad.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/cases/valid-template/something_bad.txt -------------------------------------------------------------------------------- /cases/valid-template/template/main.typ: -------------------------------------------------------------------------------- 1 | #import "../lib.typ": doc 2 | 3 | #show: doc.with() 4 | -------------------------------------------------------------------------------- /cases/valid-template/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/cases/valid-template/thumbnail.png -------------------------------------------------------------------------------- /cases/valid-template/typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "my-package" 3 | version = "0.1.0" 4 | entrypoint = "lib.typ" 5 | authors = [ 6 | "Tyler", 7 | "Tyler <@1234>", 8 | "Tyler ", 9 | "Tyler <1234@example.com>", 10 | ] 11 | license = "MIT" 12 | repository = "https://github.com/mkpoli/my-package" 13 | homepage = "https://example.com" 14 | keywords = ["numeral", "stats"] 15 | categories = ["model", "paper"] 16 | disciplines = [ 17 | "architecture", 18 | "drawing", 19 | "fashion", 20 | "film", 21 | "painting", 22 | "photography", 23 | "politics", 24 | "sociology", 25 | "theater", 26 | ] 27 | compiler = "0.12.0" 28 | exclude = ["something_bad.txt"] 29 | 30 | [template] 31 | path = "template" 32 | entrypoint = "main.typ" 33 | thumbnail = "thumbnail.png" 34 | -------------------------------------------------------------------------------- /cases/valid/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mkpoli 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 | -------------------------------------------------------------------------------- /cases/valid/lib.typ: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/cases/valid/lib.typ -------------------------------------------------------------------------------- /cases/valid/something_bad.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/cases/valid/something_bad.txt -------------------------------------------------------------------------------- /cases/valid/typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "my-package" 3 | version = "0.1.0" 4 | entrypoint = "lib.typ" 5 | authors = [ 6 | "Tyler", 7 | "Tyler <@1234>", 8 | "Tyler ", 9 | "Tyler <1234@example.com>", 10 | ] 11 | license = "MIT" 12 | repository = "https://github.com/mkpoli/my-package" 13 | homepage = "https://example.com" 14 | keywords = ["numeral", "stats"] 15 | categories = ["model", "paper"] 16 | disciplines = [ 17 | "architecture", 18 | "drawing", 19 | "fashion", 20 | "film", 21 | "painting", 22 | "photography", 23 | "politics", 24 | "sociology", 25 | "theater", 26 | ] 27 | compiler = "0.12.0" 28 | exclude = ["something_bad.txt"] 29 | -------------------------------------------------------------------------------- /experiments/authors.ts: -------------------------------------------------------------------------------- 1 | import { getTypstIndexPackageMetadata } from "@/build/package"; 2 | 3 | const versionIndex = await getTypstIndexPackageMetadata(); 4 | 5 | for (const pkg of versionIndex) { 6 | for (const author of pkg.authors ?? []) { 7 | if ( 8 | !/^[^<]*(?: <(?:[a-zA-Z0-9_\-\.]*)?@[^<>]+>|]+>)?$/.test( 9 | author, 10 | ) 11 | ) { 12 | console.log({ 13 | name: pkg.name, 14 | version: pkg.version, 15 | authors: pkg.authors, 16 | }); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /experiments/categories.ts: -------------------------------------------------------------------------------- 1 | import { getTypstIndexPackageMetadata } from "@/build/package"; 2 | 3 | const versionIndex = await getTypstIndexPackageMetadata(); 4 | 5 | const categories = new Set(); 6 | for (const pkg of versionIndex) { 7 | if (pkg.categories) { 8 | for (const category of pkg.categories) { 9 | categories.add(category); 10 | } 11 | } 12 | } 13 | 14 | console.log(categories); 15 | -------------------------------------------------------------------------------- /experiments/disciplines.ts: -------------------------------------------------------------------------------- 1 | import { getTypstIndexPackageMetadata } from "@/build/package"; 2 | 3 | const versionIndex = await getTypstIndexPackageMetadata(); 4 | 5 | const disciplines = new Set(); 6 | for (const pkg of versionIndex) { 7 | if (pkg.disciplines) { 8 | for (const discipline of pkg.disciplines) { 9 | disciplines.add(discipline); 10 | } 11 | } 12 | } 13 | 14 | console.log(disciplines); 15 | 16 | const listed = [ 17 | "agriculture", 18 | "anthropology", 19 | "archaeology", 20 | "architecture", 21 | "biology", 22 | "business", 23 | "chemistry", 24 | "communication", 25 | "computer-science", 26 | "design", 27 | "drawing", 28 | "economics", 29 | "education", 30 | "engineering", 31 | "fashion", 32 | "film", 33 | "geography", 34 | "geology", 35 | "history", 36 | "journalism", 37 | "law", 38 | "linguistics", 39 | "literature", 40 | "mathematics", 41 | "medicine", 42 | "music", 43 | "painting", 44 | "philosophy", 45 | "photography", 46 | "physics", 47 | "politics", 48 | "psychology", 49 | "sociology", 50 | "theater", 51 | "theology", 52 | "transportation", 53 | ]; 54 | 55 | console.log(listed.filter((d) => !disciplines.has(d))); 56 | 57 | console.log([...disciplines].filter((d) => !listed.includes(d))); 58 | -------------------------------------------------------------------------------- /experiments/keywords.ts: -------------------------------------------------------------------------------- 1 | import { getTypstIndexPackageMetadata } from "@/build/package"; 2 | 3 | const versionIndex = await getTypstIndexPackageMetadata(); 4 | 5 | const keywords = new Set(); 6 | for (const pkg of versionIndex) { 7 | if (pkg.keywords) { 8 | for (const keyword of pkg.keywords) { 9 | keywords.add(keyword); 10 | } 11 | } 12 | } 13 | 14 | console.log(keywords); 15 | -------------------------------------------------------------------------------- /experiments/urls.ts: -------------------------------------------------------------------------------- 1 | import { getTypstIndexPackageMetadata } from "@/build/package"; 2 | 3 | const versionIndex = await getTypstIndexPackageMetadata(); 4 | 5 | for (const pkg of versionIndex) { 6 | if (pkg.homepage || pkg.repository) { 7 | console.log(pkg.homepage, pkg.repository); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | check: 4 | glob: '*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}' 5 | run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} 6 | stage_fixed: true 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mkpoli/tyler", 3 | "version": "0.5.1", 4 | "author": { 5 | "name": "mkpoli", 6 | "url": "https://mkpo.li/", 7 | "email": "mkpoli@mkpo.li" 8 | }, 9 | "main": "./dist/index.js", 10 | "module": "index.ts", 11 | "devDependencies": { 12 | "@biomejs/biome": "1.9.4", 13 | "@types/bun": "latest", 14 | "@types/command-exists": "^1.2.3", 15 | "@types/command-line-args": "^5.2.3", 16 | "@types/command-line-usage": "^5.0.4", 17 | "@types/semver": "^7.5.8", 18 | "@types/valid-url": "^1.0.7", 19 | "bumpp": "^9.8.1", 20 | "rimraf": "^6.0.1" 21 | }, 22 | "peerDependencies": { 23 | "typescript": "^5.0.0" 24 | }, 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "exports": { 29 | ".": "./dist/index.js" 30 | }, 31 | "bin": { 32 | "tyler": "./bin/tyler.js" 33 | }, 34 | "description": "Typst package compiler for the ease of packaging and publishing Typst templates.", 35 | "files": ["dist", "bin"], 36 | "keywords": ["typst", "package", "compiler"], 37 | "license": "MIT", 38 | "scripts": { 39 | "check": "biome check", 40 | "check:fix": "biome check --write", 41 | "build": "rimraf ./dist && bun build ./src/* --outdir ./dist --target node --minify", 42 | "prebuild": "biome check --write", 43 | "tyler": "bun run build && bun ./bin/tyler.js", 44 | "bump": "bumpp", 45 | "prepare": "bun run build" 46 | }, 47 | "type": "module", 48 | "dependencies": { 49 | "chalk": "^5.3.0", 50 | "command-exists": "^1.2.9", 51 | "command-line-args": "^6.0.1", 52 | "command-line-usage": "^7.0.3", 53 | "image-size": "^1.1.1", 54 | "image-type": "^5.2.0", 55 | "inquirer": "^12.0.1", 56 | "minimatch": "^10.0.1", 57 | "prettier": "^3.3.3", 58 | "prettier-plugin-toml": "^2.0.1", 59 | "semver": "^7.6.3", 60 | "smol-toml": "^1.3.0", 61 | "spdx-expression-validate": "^2.0.0", 62 | "toml": "^3.0.0", 63 | "tree-node-cli": "^1.6.0", 64 | "valid-url": "^1.0.9" 65 | }, 66 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 67 | } 68 | -------------------------------------------------------------------------------- /src/build/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/src/build/index.ts -------------------------------------------------------------------------------- /src/build/package.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import chalk from "chalk"; 3 | import * as toml from "smol-toml"; 4 | 5 | export type TypstToml = { 6 | package: { 7 | name: string; 8 | version: string; 9 | description?: string; 10 | entrypoint?: string; 11 | authors?: string[]; 12 | license?: string; 13 | homepage?: string; 14 | repository?: string; 15 | keywords?: string[]; 16 | categories?: string[]; 17 | disciplines?: string[]; 18 | compiler?: string; 19 | exclude?: string[]; 20 | }; 21 | template?: { 22 | path: string; 23 | entrypoint: string; 24 | thumbnail: string; 25 | }; 26 | tool?: Record; 27 | }; 28 | 29 | export type TypstIndexPackageMetadata = Partial<{ 30 | name: string; 31 | version: string; 32 | entrypoint: string; 33 | authors: string[]; 34 | license: string; 35 | description: string; 36 | homepage: string; 37 | repository: string; 38 | keywords: string[]; 39 | categories: string[]; 40 | disciplines: string[]; 41 | compiler: string; 42 | exclude: string[]; 43 | template: { 44 | path: string; 45 | version: string; 46 | entrypoint: string; 47 | authors: string[]; 48 | license: string; 49 | description: string; 50 | repository: string; 51 | keywords: string[]; 52 | categories: string[]; 53 | }; 54 | }>; 55 | 56 | export async function readTypstToml(path: string): Promise { 57 | try { 58 | const text = await fs.readFile(path, "utf-8"); 59 | return toml.parse(text) as TypstToml; 60 | } catch (error) { 61 | throw new Error("[Tyler] `typst.toml` is invalid"); 62 | } 63 | } 64 | 65 | const VERSION_INDEX_URL = "https://packages.typst.org/preview/index.json"; 66 | export async function getTypstIndexPackageMetadata(): Promise< 67 | TypstIndexPackageMetadata[] 68 | > { 69 | const res = await fetch(VERSION_INDEX_URL); 70 | const versionIndex: TypstIndexPackageMetadata[] = await res.json(); 71 | console.info( 72 | `[Tyler] Found ${chalk.green(versionIndex.length)} packages in the Typst preview package index`, 73 | ); 74 | return versionIndex; 75 | } 76 | -------------------------------------------------------------------------------- /src/build/publish.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | 4 | import chalk from "chalk"; 5 | 6 | import { fileExists } from "@/utils/file"; 7 | import { isValidGitRepository } from "@/utils/git"; 8 | import { exec, execAndRedirect, isCommandInstalled } from "@/utils/process"; 9 | import inquirer from "inquirer"; 10 | 11 | export const TYPST_PACKAGES_REPO_URL = "https://github.com/typst/packages.git"; 12 | export async function cloneOrCleanRepo( 13 | dir: string, 14 | dryRun: boolean, 15 | builtPackageName: string, 16 | builtPackageVersion: string, 17 | ): Promise { 18 | if (!(await isCommandInstalled("git"))) { 19 | throw new Error("[Tyler] Git is not installed, cannot proceed"); 20 | } 21 | 22 | const gitRepoDir = path.join(dir, "packages"); 23 | 24 | if (!(await fileExists(gitRepoDir))) { 25 | const isGitRepo = await isValidGitRepository(gitRepoDir); 26 | if (!isGitRepo) { 27 | console.info( 28 | `[Tyler] ${chalk.gray(gitRepoDir)} is not a valid git repository, can we remove it?`, 29 | ); 30 | 31 | const { remove }: { remove: boolean } = await inquirer.prompt([ 32 | { 33 | type: "confirm", 34 | name: "remove", 35 | message: "Remove the directory?", 36 | }, 37 | ]); 38 | 39 | if (remove) { 40 | // - Remove the directory 41 | if (dryRun) { 42 | console.info( 43 | `[Tyler] ${chalk.gray("(dry-run)")} Would remove ${chalk.gray(gitRepoDir)}`, 44 | ); 45 | } else { 46 | await fs.rm(gitRepoDir, { recursive: true }); 47 | console.info(`[Tyler] Removed ${chalk.gray(gitRepoDir)}`); 48 | } 49 | } else { 50 | throw new Error( 51 | `[Tyler] ${chalk.gray(gitRepoDir)} is not a valid git repository, and we cannot remove it`, 52 | ); 53 | } 54 | } 55 | 56 | const cloneCommand = `git clone ${TYPST_PACKAGES_REPO_URL} ${gitRepoDir} --depth 1`; 57 | 58 | if (dryRun) { 59 | console.info( 60 | `[Tyler] ${chalk.gray("(dry-run)")} Would clone ${chalk.gray(TYPST_PACKAGES_REPO_URL)} into ${chalk.gray(gitRepoDir)}`, 61 | ); 62 | } else { 63 | try { 64 | await execAndRedirect(cloneCommand); 65 | console.info( 66 | `[Tyler] Cloned ${chalk.gray(TYPST_PACKAGES_REPO_URL)} into ${chalk.gray(gitRepoDir)}`, 67 | ); 68 | } catch (error) { 69 | if (error instanceof Error) { 70 | console.info( 71 | `[Tyler] ${chalk.red("Error cloning repository:")} ${error.message}`, 72 | ); 73 | } 74 | } 75 | } 76 | } else { 77 | // #region Remove untracked files 78 | const removeUntrackedCommand = `git -C ${gitRepoDir} clean -fd`; 79 | if (dryRun) { 80 | console.info( 81 | `[Tyler] ${chalk.gray("(dry-run)")} Would remove untracked files in ${chalk.gray(gitRepoDir)} by ${chalk.gray(removeUntrackedCommand)}`, 82 | ); 83 | } else { 84 | await execAndRedirect(removeUntrackedCommand); 85 | console.info( 86 | `[Tyler] Removed untracked files in ${chalk.gray(gitRepoDir)} by ${chalk.gray(removeUntrackedCommand)}`, 87 | ); 88 | } 89 | // #endregion 90 | 91 | // #region Checkout to origin/main 92 | const checkoutCommand = `git -C ${gitRepoDir} checkout origin/main`; 93 | if (dryRun) { 94 | console.info( 95 | `[Tyler] ${chalk.gray("(dry-run)")} Would checkout to origin/main in ${chalk.gray(gitRepoDir)} by ${chalk.gray(checkoutCommand)}`, 96 | ); 97 | } else { 98 | await exec(checkoutCommand); 99 | console.info( 100 | `[Tyler] Checked out to origin/main in ${chalk.gray(gitRepoDir)} by ${chalk.gray(checkoutCommand)}`, 101 | ); 102 | } 103 | // #endregion 104 | 105 | // #region Reset origin url 106 | const originExistsCommand = `git -C ${gitRepoDir} remote get-url origin`; 107 | const setOriginCommand = `git -C ${gitRepoDir} remote set-url origin ${TYPST_PACKAGES_REPO_URL}`; 108 | const addOriginCommand = `git -C ${gitRepoDir} remote add origin ${TYPST_PACKAGES_REPO_URL}`; 109 | if (dryRun) { 110 | console.info( 111 | `[Tyler] ${chalk.gray("(dry-run)")} Would reset origin url in ${chalk.gray(gitRepoDir)}`, 112 | ); 113 | } else { 114 | try { 115 | await execAndRedirect(originExistsCommand); 116 | await execAndRedirect(setOriginCommand); 117 | console.info( 118 | `[Tyler] Reset origin url in ${chalk.gray(gitRepoDir)} by ${chalk.gray(setOriginCommand)}`, 119 | ); 120 | } catch { 121 | await execAndRedirect(addOriginCommand); 122 | console.info( 123 | `[Tyler] Added origin url in ${chalk.gray(gitRepoDir)} by ${chalk.gray(addOriginCommand)}`, 124 | ); 125 | } 126 | } 127 | // #endregion 128 | 129 | // #region Clean up git working tree 130 | const cleanCommand = `git -C ${gitRepoDir} reset --hard origin/main`; 131 | if (dryRun) { 132 | console.info( 133 | `[Tyler] ${chalk.gray("(dry-run)")} Would clean up git working tree in ${chalk.gray(gitRepoDir)} by ${chalk.gray(cleanCommand)}`, 134 | ); 135 | } else { 136 | await execAndRedirect(cleanCommand); 137 | console.info( 138 | `[Tyler] Cleaned up git working tree in ${chalk.gray(gitRepoDir)} by ${chalk.gray(cleanCommand)}`, 139 | ); 140 | } 141 | // #endregion 142 | 143 | // #region Fetch the latest changes from origin 144 | const fetchCommand = `git -C ${gitRepoDir} fetch origin`; 145 | if (dryRun) { 146 | console.info( 147 | `[Tyler] ${chalk.gray("(dry-run)")} Would fetch latest changes from origin in ${chalk.gray(gitRepoDir)} by ${chalk.gray(fetchCommand)}`, 148 | ); 149 | } else { 150 | await execAndRedirect(fetchCommand); 151 | console.info( 152 | `[Tyler] Fetched latest changes from origin in ${chalk.gray(gitRepoDir)} by ${chalk.gray(fetchCommand)}`, 153 | ); 154 | } 155 | // #endregion 156 | 157 | // #region Delete branch if it exists 158 | const targetBranchName = `${builtPackageName}-${builtPackageVersion}`; 159 | const deleteBranchCommand = `git -C ${gitRepoDir} branch -D ${targetBranchName}`; 160 | if (dryRun) { 161 | console.info( 162 | `[Tyler] ${chalk.gray("(dry-run)")} Would delete branch ${chalk.gray(targetBranchName)} in ${chalk.gray(gitRepoDir)} by ${chalk.gray(deleteBranchCommand)}`, 163 | ); 164 | } else { 165 | await exec(deleteBranchCommand); 166 | console.info( 167 | `[Tyler] Deleted branch ${chalk.gray(targetBranchName)} in ${chalk.gray(gitRepoDir)} by ${chalk.gray(deleteBranchCommand)}`, 168 | ); 169 | } 170 | // #endregion 171 | 172 | // #region Create a new branch from origin/main 173 | const createBranchCommand = `git -C ${gitRepoDir} checkout -b ${targetBranchName} HEAD`; 174 | if (dryRun) { 175 | console.info( 176 | `[Tyler] ${chalk.gray("(dry-run)")} Would create a new branch ${chalk.gray(targetBranchName)} in ${chalk.gray(gitRepoDir)} by ${chalk.gray(createBranchCommand)}`, 177 | ); 178 | } else { 179 | await exec(createBranchCommand); 180 | console.info( 181 | `[Tyler] Created a new branch ${chalk.gray(targetBranchName)} in ${chalk.gray(gitRepoDir)} by ${chalk.gray(createBranchCommand)}`, 182 | ); 183 | } 184 | // #endregion 185 | } 186 | 187 | return gitRepoDir; 188 | } 189 | -------------------------------------------------------------------------------- /src/cli/bump.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import inquirer from "inquirer"; 3 | import semver from "semver"; 4 | 5 | export function bumpVersion( 6 | version: string, 7 | bump: "skip" | "patch" | "minor" | "major" | string, 8 | ): string { 9 | if (bump === "skip") { 10 | return version; 11 | } 12 | 13 | if (["patch", "minor", "major"].includes(bump)) { 14 | const bumpedVersion = semver.inc( 15 | version, 16 | bump as "patch" | "minor" | "major", 17 | ); 18 | if (!bumpedVersion) { 19 | throw new Error("[Tyler] Failed to bump the version"); 20 | } 21 | return bumpedVersion; 22 | } 23 | 24 | if (semver.valid(bump)) { 25 | return bump; 26 | } 27 | 28 | throw new Error("[Tyler] Failed to bump the version"); 29 | } 30 | 31 | export async function interactivelyBumpVersion( 32 | version: string, 33 | ): Promise { 34 | const bumpMode: 35 | | { 36 | type: "custom"; 37 | version: string; 38 | } 39 | | { 40 | type: "patch" | "minor" | "major" | "skip" | "cancel"; 41 | version: null; 42 | } = await inquirer.prompt([ 43 | { 44 | type: "list", 45 | name: "type", 46 | message: `current version: ${chalk.bold(chalk.yellow(version))} ->`, 47 | choices: [ 48 | { 49 | name: `${"patch".padStart(16)} ${chalk.bold(semver.inc(version, "patch"))}`, 50 | value: "patch", 51 | }, 52 | { 53 | name: `${"minor".padStart(16)} ${chalk.bold(semver.inc(version, "minor"))}`, 54 | value: "minor", 55 | }, 56 | { 57 | name: `${"major".padStart(16)} ${chalk.bold(semver.inc(version, "major"))}`, 58 | value: "major", 59 | }, 60 | { 61 | name: `${"as-is".padStart(16)} ${chalk.bold(version)}`, 62 | value: "skip", 63 | }, 64 | { 65 | name: `${"custom".padStart(17)}`, 66 | value: "custom", 67 | }, 68 | { 69 | name: `${"cancel".padStart(17)}`, 70 | value: "cancel", 71 | }, 72 | ], 73 | }, 74 | { 75 | type: "input", 76 | name: "version", 77 | message: "Enter the version to bump to", 78 | when: (answers) => answers.type === "custom", 79 | }, 80 | ]); 81 | 82 | if (bumpMode.type === "custom") { 83 | if (!semver.valid(bumpMode.version)) { 84 | console.error("[Tyler] The version is not a valid semver"); 85 | process.exit(1); 86 | } 87 | 88 | console.info(`[Tyler] Bumping version to ${chalk.bold(bumpMode.version)}`); 89 | return bumpVersion(version, bumpMode.version); 90 | } 91 | 92 | if (bumpMode.type === "cancel") { 93 | console.info("[Tyler] Cancelled by user"); 94 | process.exit(0); 95 | } 96 | 97 | const targetVersion = bumpVersion(version, bumpMode.type); 98 | console.info( 99 | `[Tyler] Bumping version by ${chalk.bold(bumpMode.type)} to ${chalk.bold(targetVersion)}...`, 100 | ); 101 | 102 | return targetVersion; 103 | } 104 | -------------------------------------------------------------------------------- /src/cli/commands/build.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import os from "node:os"; 3 | import path from "node:path"; 4 | 5 | import chalk from "chalk"; 6 | import { minimatch } from "minimatch"; 7 | import semver from "semver"; 8 | 9 | import tree from "tree-node-cli"; 10 | 11 | import { 12 | type TypstToml, 13 | getTypstIndexPackageMetadata, 14 | readTypstToml, 15 | } from "@/build/package"; 16 | import { bumpVersion, interactivelyBumpVersion } from "@/cli/bump"; 17 | import type { Command } from "@/cli/commands/types"; 18 | import { type Config, updateOptionFromConfig } from "@/cli/config"; 19 | import { 20 | clearDirectoryWithoutDeletingIt, 21 | fileExists, 22 | getDataDirectory, 23 | getWorkingDirectory, 24 | } from "@/utils/file"; 25 | import { execAndRedirect } from "@/utils/process"; 26 | 27 | import { TYPST_PACKAGES_REPO_URL, cloneOrCleanRepo } from "@/build/publish"; 28 | import { stringifyToml } from "@/utils/manifest"; 29 | import check from "./check"; 30 | 31 | export default { 32 | name: "build", 33 | description: "Bump the version and build a Typst package", 34 | options: [ 35 | { 36 | name: "entrypoint", 37 | description: 38 | "The entrypoint `typst.toml` or the directory with it to build", 39 | type: String, 40 | defaultOption: true, 41 | hide: true, 42 | }, 43 | { 44 | name: "bump", 45 | description: `[${chalk.green("major")}|${chalk.green("minor")}|${chalk.green( 46 | "patch", 47 | )}|${chalk.green("skip")}|${chalk.blue("string")}] The version to bump to: semver bump, skip as-is, or specify a custom version`, 48 | type: String, 49 | alias: "b", 50 | }, 51 | { 52 | name: "dry-run", 53 | description: 54 | "Preview the build process without actually modifying anything", 55 | type: Boolean, 56 | alias: "d", 57 | }, 58 | { 59 | name: "no-bump", 60 | description: "Do not bump the version", 61 | type: Boolean, 62 | alias: "n", 63 | }, 64 | { 65 | name: "srcdir", 66 | description: "The source directory where the source code is located", 67 | type: String, 68 | alias: "s", 69 | }, 70 | { 71 | name: "outdir", 72 | description: 73 | "The output directory where the compiled package will be placed", 74 | type: String, 75 | alias: "o", 76 | }, 77 | { 78 | name: "ignore", 79 | description: "The files to ignore in the output directory", 80 | type: String, 81 | }, 82 | { 83 | name: "install", 84 | description: "Install the built package to Typst local package group", 85 | type: Boolean, 86 | defaultValue: false, 87 | alias: "i", 88 | }, 89 | { 90 | name: "publish", 91 | description: 92 | "Publish the built package to the Typst preview package index", 93 | type: Boolean, 94 | defaultValue: false, 95 | alias: "p", 96 | }, 97 | { 98 | name: "no-check", 99 | description: "Do not check the package before building", 100 | type: Boolean, 101 | }, 102 | ], 103 | usage: " [options]", 104 | async run(options): Promise { 105 | if (!options.noCheck) { 106 | await check.run(options); 107 | } 108 | 109 | // #region Working directory 110 | const workingDirectory = 111 | options.entrypoint === undefined 112 | ? process.cwd() 113 | : await getWorkingDirectory(options.entrypoint as string | undefined); 114 | 115 | console.info( 116 | `[Tyler]${options.dryRun ? chalk.gray(" (dry-run) ") : " "}Building package in ${chalk.gray(workingDirectory)}...`, 117 | ); 118 | // #endregion 119 | 120 | // #region Read typst.toml 121 | const typstTomlPath = path.resolve(workingDirectory, "typst.toml"); 122 | if (!(await fileExists(typstTomlPath))) { 123 | throw new Error(`[Tyler] ${typstTomlPath} not found`); 124 | } 125 | 126 | const typstToml: TypstToml = await readTypstToml(typstTomlPath); 127 | 128 | console.info( 129 | `[Tyler] Loaded ${chalk.green("typst.toml")} for package ${chalk.yellow(typstToml.package.name)}:${chalk.gray( 130 | typstToml.package.version, 131 | )}`, 132 | ); 133 | // #endregion 134 | 135 | // #region Update options 136 | if (typstToml.tool?.tyler) { 137 | console.info( 138 | `[Tyler] Found ${chalk.green("[tool.tyler]")} in package's ${chalk.gray("typst.toml")}`, 139 | ); 140 | } 141 | 142 | const updatedOptions = updateOptionFromConfig( 143 | options, 144 | typstToml.tool?.tyler ?? ({} as Partial), 145 | ); 146 | // #endregion 147 | 148 | // #region Get index package metadata 149 | const versionIndex = await getTypstIndexPackageMetadata(); 150 | const samePackageName = versionIndex.find( 151 | ({ name }) => name === typstToml.package.name, 152 | ); 153 | 154 | if (!samePackageName) { 155 | console.info( 156 | `[Tyler] Building for a unpublished package ${chalk.yellow(typstToml.package.name)}...`, 157 | ); 158 | 159 | if (!semver.valid(typstToml.package.version)) { 160 | console.warn( 161 | `[Tyler] The version of the package is not a valid semver: ${chalk.gray( 162 | typstToml.package.version, 163 | )}`, 164 | ); 165 | } 166 | 167 | if (semver.compare(typstToml.package.version, "0.1.0") > 0) { 168 | console.warn( 169 | `[Tyler] The version of the package (${chalk.gray( 170 | typstToml.package.version, 171 | )}) is more than 0.1.0 before bump while being unpublished`, 172 | ); 173 | } 174 | } 175 | // #endregion 176 | 177 | // #region Get bumped version 178 | const bumpedVersion = options.bump 179 | ? bumpVersion(typstToml.package.version, options.bump) 180 | : await interactivelyBumpVersion(typstToml.package.version); 181 | 182 | if (bumpedVersion === typstToml.package.version) { 183 | console.info( 184 | `[Tyler] The version of the package is not changed: ${chalk.gray(bumpedVersion)}`, 185 | ); 186 | } 187 | 188 | if (samePackageName?.version === bumpedVersion) { 189 | console.warn("[Tyler] The version of the package is already published"); 190 | } 191 | // #endregion 192 | 193 | // #region Bump the version in typst.toml 194 | const typstTomlOutWithoutToolTylerWithBumpedVersion = { 195 | ...typstToml, 196 | package: { ...typstToml.package, version: bumpedVersion }, 197 | tool: typstToml.tool 198 | ? { ...typstToml.tool, tyler: undefined } 199 | : undefined, 200 | }; 201 | 202 | if (options.dryRun) { 203 | console.info( 204 | `[Tyler] ${chalk.gray("(dry-run)")} Would bump version in ${chalk.yellow(path.relative(workingDirectory, typstTomlPath))}`, 205 | ); 206 | } else { 207 | const updatedOriginalTypstTomlData = { 208 | ...typstToml, 209 | package: { ...typstToml.package, version: bumpedVersion }, 210 | }; 211 | await fs.writeFile( 212 | typstTomlPath, 213 | await stringifyToml(updatedOriginalTypstTomlData), 214 | ); 215 | console.info( 216 | `[Tyler] Bumped version in ${chalk.green("typst.toml")} to ${chalk.yellow(path.relative(workingDirectory, typstTomlPath))}`, 217 | ); 218 | } 219 | // #endregion 220 | 221 | // #region Get source directory 222 | const sourceDir = !updatedOptions.srcdir 223 | ? path.resolve(workingDirectory, "src") 224 | : path.isAbsolute(updatedOptions.srcdir) 225 | ? updatedOptions.srcdir 226 | : path.resolve(workingDirectory, updatedOptions.srcdir); 227 | 228 | if (!(await fileExists(sourceDir))) { 229 | throw new Error( 230 | `[Tyler] Source directory not found: ${chalk.gray(sourceDir)}`, 231 | ); 232 | } 233 | console.info(`[Tyler] Source directory found in ${chalk.gray(sourceDir)}`); 234 | // #endregion 235 | 236 | // #region Get output directory 237 | const outputDir = !updatedOptions.outdir 238 | ? path.resolve(workingDirectory, "dist") 239 | : path.isAbsolute(updatedOptions.outdir) 240 | ? updatedOptions.outdir 241 | : path.resolve(workingDirectory, updatedOptions.outdir); 242 | console.info(`[Tyler] Output directory will be ${chalk.gray(outputDir)}`); 243 | // #endregion 244 | 245 | // #region Get entrypoint 246 | const entrypoint = typstToml.package.entrypoint ?? "lib.typ"; 247 | // #endregion 248 | 249 | // #region Create and clear output directory 250 | if (!options.dryRun) { 251 | await fs.mkdir(outputDir, { recursive: true }); 252 | await clearDirectoryWithoutDeletingIt(outputDir); 253 | } 254 | // #endregion 255 | 256 | // #region Remove tool.tyler from typst.toml and copy it to output directory 257 | const distTypstTomlPath = path.resolve(outputDir, "typst.toml"); 258 | if (options.dryRun) { 259 | console.info( 260 | `[Tyler] ${chalk.gray("(dry-run)")} Would copy ${chalk.green("typst.toml")} to ${chalk.yellow(path.relative(workingDirectory, distTypstTomlPath))}${typstToml.tool?.tyler ? " without tool.tyler " : ""}`, 261 | ); 262 | } else { 263 | const outputTypstTomlData = { 264 | ...typstTomlOutWithoutToolTylerWithBumpedVersion, 265 | tool: undefined, 266 | }; 267 | await fs.writeFile( 268 | distTypstTomlPath, 269 | await stringifyToml(outputTypstTomlData), 270 | ); 271 | console.info( 272 | `[Tyler] Copied ${chalk.green("typst.toml")} to ${chalk.yellow(path.relative(workingDirectory, distTypstTomlPath))}`, 273 | ); 274 | } 275 | // #endregion 276 | 277 | // #region Copy meta files (README.md, LICENSE) 278 | async function copyMetaFiles(name: string): Promise { 279 | if (await fileExists(path.resolve(workingDirectory, name))) { 280 | const sourceFilePath = path.resolve(workingDirectory, name); 281 | const relativeSourceFilePath = path.relative( 282 | workingDirectory, 283 | sourceFilePath, 284 | ); 285 | 286 | const outputFilePath = path.resolve(outputDir, name); 287 | const relativeOutputFilePath = path.relative( 288 | workingDirectory, 289 | outputFilePath, 290 | ); 291 | await fs.mkdir(path.dirname(outputFilePath), { recursive: true }); 292 | 293 | if (options.dryRun) { 294 | console.info( 295 | `[Tyler] ${chalk.gray("(dry-run)")} Would copy ${chalk.green(relativeSourceFilePath)} to ${chalk.yellow(relativeOutputFilePath)}`, 296 | ); 297 | } else { 298 | await fs.copyFile(sourceFilePath, outputFilePath); 299 | console.info( 300 | `[Tyler] Copied ${chalk.green(relativeSourceFilePath)} to ${chalk.yellow(relativeOutputFilePath)}`, 301 | ); 302 | } 303 | } else { 304 | console.warn( 305 | `[Tyler] ${chalk.gray(name)} is required but not found in ${chalk.gray(workingDirectory)}`, 306 | ); 307 | } 308 | } 309 | 310 | await copyMetaFiles("README.md"); 311 | await copyMetaFiles("LICENSE"); 312 | 313 | if (typstTomlOutWithoutToolTylerWithBumpedVersion.template?.thumbnail) { 314 | await copyMetaFiles( 315 | typstTomlOutWithoutToolTylerWithBumpedVersion.template.thumbnail, 316 | ); 317 | } 318 | // #endregion 319 | 320 | // #region Replace entrypoint in templates 321 | const modifiedFiles: Record = {}; 322 | 323 | // copy typst.toml 324 | if (typstToml.template) { 325 | const templatePath = path.resolve(sourceDir, typstToml.template.path); 326 | const templateFiles = await fs.readdir(templatePath, { recursive: true }); 327 | 328 | for (const templateFile of templateFiles) { 329 | const libraryEntrypointRelativeToTemplateFile = path.relative( 330 | templateFile, 331 | entrypoint, 332 | ); 333 | const content = await fs.readFile( 334 | path.resolve(templatePath, templateFile), 335 | "utf8", 336 | ); 337 | 338 | const relativeTemplateFilePath = path.relative( 339 | sourceDir, 340 | path.resolve(templatePath, templateFile), 341 | ); 342 | 343 | if ( 344 | content.includes( 345 | `import "${libraryEntrypointRelativeToTemplateFile}"`, 346 | ) 347 | ) { 348 | modifiedFiles[relativeTemplateFilePath] = content.replace( 349 | `import "${libraryEntrypointRelativeToTemplateFile}"`, 350 | `import "@preview/${typstToml.package.name}:${bumpedVersion}"`, 351 | ); 352 | } 353 | } 354 | } 355 | // #endregion 356 | 357 | // #region Copy all files in source directory to output directory while applying modified files 358 | const allFiles = await fs.readdir(sourceDir, { recursive: true }); 359 | 360 | for (const file of allFiles) { 361 | if (updatedOptions.ignore?.some((ignore) => minimatch(file, ignore))) { 362 | console.info( 363 | `[Tyler] Ignoring ${chalk.gray(file)} because it matches ${updatedOptions.ignore?.map((ignore) => chalk.gray(ignore)).join(", ")}`, 364 | ); 365 | continue; 366 | } 367 | 368 | if (modifiedFiles[file]) { 369 | if (options.dryRun) { 370 | console.info( 371 | `[Tyler] ${chalk.gray("(dry-run) ")} Would write ${chalk.green(path.relative(workingDirectory, file))} to ${chalk.yellow(path.relative(workingDirectory, outputDir))}`, 372 | ); 373 | continue; 374 | } 375 | const outputFilePath = path.resolve(outputDir, file); 376 | await fs.writeFile(outputFilePath, modifiedFiles[file]); 377 | console.info( 378 | `[Tyler] Copied modified version of ${chalk.green(file)} to ${chalk.yellow(path.relative(workingDirectory, outputFilePath))}`, 379 | ); 380 | } else { 381 | const sourceFilePath = path.resolve(sourceDir, file); 382 | const outputFilePath = path.resolve(outputDir, file); 383 | 384 | if (options.dryRun) { 385 | console.info( 386 | `[Tyler] ${chalk.gray("(dry-run)")} Would copy ${chalk.green(path.relative(workingDirectory, sourceFilePath))} to ${chalk.yellow(path.relative(workingDirectory, outputFilePath))}`, 387 | ); 388 | } else { 389 | await fs.cp(sourceFilePath, outputFilePath, { recursive: true }); 390 | console.info( 391 | `[Tyler] Copied ${chalk.green(path.relative(workingDirectory, sourceFilePath))} to ${chalk.yellow(path.relative(workingDirectory, outputFilePath))}`, 392 | ); 393 | } 394 | } 395 | } 396 | // #endregion 397 | 398 | // #region Install the built package to Typst local package group 399 | if (options.install) { 400 | // copy all files in to 401 | const localPackagesDirectory = path.resolve( 402 | await getDataDirectory(), 403 | "typst", 404 | "packages", 405 | "local", 406 | ); 407 | const currentPackageDirectory = path.resolve( 408 | localPackagesDirectory, 409 | typstTomlOutWithoutToolTylerWithBumpedVersion.package.name, 410 | typstTomlOutWithoutToolTylerWithBumpedVersion.package.version, 411 | ); 412 | 413 | if (options.dryRun) { 414 | console.info( 415 | `[Tyler] ${chalk.gray("(dry-run)")} Would install to ${chalk.yellow(path.relative(workingDirectory, currentPackageDirectory))}`, 416 | ); 417 | } else { 418 | await fs.mkdir(currentPackageDirectory, { recursive: true }); 419 | await fs.cp(outputDir, currentPackageDirectory, { recursive: true }); 420 | console.info( 421 | `[Tyler] Installed to ${chalk.yellow(currentPackageDirectory)}`, 422 | ); 423 | } 424 | } 425 | // #endregion 426 | 427 | // #region Publish the built package to the Typst preview package index 428 | if (options.publish) { 429 | const builtPackageName = 430 | typstTomlOutWithoutToolTylerWithBumpedVersion.package.name; 431 | const builtPackageVersion = 432 | typstTomlOutWithoutToolTylerWithBumpedVersion.package.version; 433 | 434 | // - Make a temporary directory 435 | const tempDirPath = path.join(os.tmpdir(), "tyler-publish"); 436 | if (options.dryRun) { 437 | console.info( 438 | `[Tyler] ${chalk.gray("(dry-run)")} Would make a temporary directory in ${chalk.gray(tempDirPath)}`, 439 | ); 440 | } else { 441 | await fs.mkdir(tempDirPath, { recursive: true }); 442 | console.info( 443 | `[Tyler] Made a temporary directory in ${chalk.gray(tempDirPath)}`, 444 | ); 445 | } 446 | 447 | // - Clone github:typst/packages or clean the repository 448 | const gitRepoDir = await cloneOrCleanRepo( 449 | tempDirPath, 450 | options.dryRun ?? false, 451 | builtPackageName, 452 | builtPackageVersion, 453 | ); 454 | 455 | // - Copy the built package to the cloned repository 456 | const packageDir = path.join( 457 | gitRepoDir, 458 | "packages", 459 | "preview", 460 | builtPackageName, 461 | builtPackageVersion, 462 | ); 463 | if (options.dryRun) { 464 | console.info( 465 | `[Tyler] ${chalk.gray("(dry-run)")} Would copy files from ${chalk.gray(outputDir)} to ${chalk.gray(packageDir)}`, 466 | ); 467 | } else { 468 | await fs.mkdir(packageDir, { recursive: true }); 469 | await fs.cp(outputDir, packageDir, { recursive: true }); 470 | console.info( 471 | `[Tyler] Copied files from ${chalk.gray(outputDir)} to ${chalk.gray(packageDir)}`, 472 | ); 473 | } 474 | 475 | // - Tree the copied files 476 | const treeResult = tree(packageDir, { 477 | allFiles: true, 478 | sizes: true, 479 | }); 480 | console.info( 481 | `[Tyler] Tree of the copied files: \n${chalk.gray(treeResult)}`, 482 | ); 483 | 484 | // - Stage, commit and push the built package to the cloned repository 485 | const stageCommand = `git -C ${gitRepoDir} add packages/preview/${builtPackageName}/${builtPackageVersion}`; 486 | const commitCommand = `git -C ${gitRepoDir} commit -m ${builtPackageName}@${builtPackageVersion}`; 487 | 488 | if (options.dryRun) { 489 | console.info( 490 | `[Tyler] ${chalk.gray("(dry-run)")} Would run ${chalk.gray(stageCommand)}`, 491 | ); 492 | } else { 493 | await execAndRedirect(stageCommand); 494 | console.info(`[Tyler] Ran ${chalk.gray(stageCommand)}`); 495 | } 496 | 497 | if (options.dryRun) { 498 | console.info( 499 | `[Tyler] ${chalk.gray("(dry-run)")} Would run ${chalk.gray(commitCommand)}`, 500 | ); 501 | } else { 502 | await execAndRedirect(commitCommand); 503 | console.info(`[Tyler] Ran ${chalk.gray(commitCommand)}`); 504 | } 505 | 506 | // - Show instructions on how to publish the package 507 | if (options.publish) { 508 | // -- Check if gh is installed 509 | let ghInstalled = false; 510 | try { 511 | await execAndRedirect("gh --version"); 512 | ghInstalled = true; 513 | } catch (error) { 514 | ghInstalled = false; 515 | } 516 | 517 | // -- If gh is not installed, show instructions on how to install it 518 | if (!ghInstalled) { 519 | console.info( 520 | "[Tyler] To publish the package from command line, you can install GitHub CLI: https://cli.github.com/manual/installation", 521 | ); 522 | } 523 | 524 | // -- Show the command to create a pull request 525 | console.info( 526 | `[Tyler] To publish the package, run the following commands (if you are not already logged in via GitHub CLI, run \`${chalk.bold("gh")} ${chalk.green("auth login")}\` first):`, 527 | ); 528 | console.info( 529 | ` ${chalk.cyan("$")} ${chalk.bold("cd")} ${chalk.gray(gitRepoDir)}`, 530 | ); 531 | console.info( 532 | ` ${chalk.cyan("$")} ${chalk.bold("gh")} ${chalk.green("repo set-default")} ${chalk.gray(TYPST_PACKAGES_REPO_URL)}`, 533 | ); 534 | console.info( 535 | ` ${chalk.cyan("$")} ${chalk.bold("gh")} ${chalk.green("pr create")} --title ${chalk.gray(`"${builtPackageName}:${builtPackageVersion}"`)} --body-file ${chalk.gray('".github/pull_request_template.md"')} --draft`, 536 | ); 537 | console.info( 538 | ` ${chalk.cyan("$")} ${chalk.bold("cd")} ${chalk.gray("-")}`, 539 | ); 540 | console.info( 541 | `Then go to your draft pull request on GitHub (following the link similar to ${chalk.gray( 542 | "https://github.com/typst/packages/pull/", 543 | )} from the output of the command above) and fill in the details to wait for the package to be approved`, 544 | ); 545 | } 546 | } 547 | }, 548 | } satisfies Command<{ 549 | entrypoint: string | undefined; 550 | bump: string | undefined; 551 | dryRun: boolean | undefined; 552 | noBump: boolean | undefined; 553 | srcdir: string | undefined; 554 | outdir: string | undefined; 555 | ignore: string | undefined; 556 | install: boolean | undefined; 557 | publish: boolean | undefined; 558 | noCheck: boolean | undefined; 559 | }>; 560 | -------------------------------------------------------------------------------- /src/cli/commands/check.ts: -------------------------------------------------------------------------------- 1 | // Check if thumbnail.png exists 2 | 3 | import fs from "node:fs/promises"; 4 | import os from "node:os"; 5 | import path from "node:path"; 6 | 7 | import chalk from "chalk"; 8 | import imageSize from "image-size"; 9 | import imageType from "image-type"; 10 | import { minimatch } from "minimatch"; 11 | import semver from "semver"; 12 | import * as toml from "smol-toml"; 13 | import spdxExpressionValidate from "spdx-expression-validate"; 14 | import validUrl from "valid-url"; 15 | 16 | import { 17 | type TypstToml, 18 | getTypstIndexPackageMetadata, 19 | readTypstToml, 20 | } from "@/build/package"; 21 | import { bumpVersion, interactivelyBumpVersion } from "@/cli/bump"; 22 | import type { Command } from "@/cli/commands/types"; 23 | import { type Config, updateOptionFromConfig } from "@/cli/config"; 24 | import { 25 | clearDirectoryWithoutDeletingIt, 26 | fileExists, 27 | getDataDirectory, 28 | getWorkingDirectory, 29 | } from "@/utils/file"; 30 | import { execAndRedirect } from "@/utils/process"; 31 | 32 | export default { 33 | name: "check", 34 | description: "Check the current package for errors", 35 | options: [ 36 | { 37 | name: "entrypoint", 38 | description: 39 | "The entrypoint `typst.toml` or the directory with it to build", 40 | type: String, 41 | defaultOption: true, 42 | hide: true, 43 | }, 44 | { 45 | name: "bump", 46 | description: `[${chalk.green("major")}|${chalk.green("minor")}|${chalk.green( 47 | "patch", 48 | )}|${chalk.green("skip")}|${chalk.blue("string")}] The version to bump to: semver bump, skip as-is, or specify a custom version`, 49 | type: String, 50 | alias: "b", 51 | }, 52 | { 53 | name: "dry-run", 54 | description: 55 | "Preview the build process without actually modifying anything", 56 | type: Boolean, 57 | alias: "d", 58 | }, 59 | { 60 | name: "no-bump", 61 | description: "Do not bump the version", 62 | type: Boolean, 63 | alias: "n", 64 | }, 65 | { 66 | name: "srcdir", 67 | description: "The source directory where the source code is located", 68 | type: String, 69 | alias: "s", 70 | }, 71 | { 72 | name: "outdir", 73 | description: 74 | "The output directory where the compiled package will be placed", 75 | type: String, 76 | alias: "o", 77 | }, 78 | { 79 | name: "ignore", 80 | description: "The files to ignore in the output directory", 81 | type: String, 82 | }, 83 | { 84 | name: "install", 85 | description: "Install the built package to Typst local package group", 86 | type: Boolean, 87 | defaultValue: false, 88 | alias: "i", 89 | }, 90 | { 91 | name: "publish", 92 | description: 93 | "Publish the built package to the Typst preview package index", 94 | type: Boolean, 95 | defaultValue: false, 96 | alias: "p", 97 | }, 98 | { 99 | name: "write", 100 | description: "Write the result to a file", 101 | type: String, 102 | }, 103 | ], 104 | usage: " [options]", 105 | async run(options): Promise { 106 | // #region Get working directory 107 | const workingDirectory = 108 | options.entrypoint === undefined 109 | ? process.cwd() 110 | : await getWorkingDirectory(options.entrypoint as string | undefined); 111 | 112 | console.info( 113 | `[Tyler] Checking package in ${chalk.gray(workingDirectory)}...`, 114 | ); 115 | // #endregion 116 | 117 | // #region Read typst.toml 118 | const typstTomlPath = path.resolve(workingDirectory, "typst.toml"); 119 | if (!(await fileExists(typstTomlPath))) { 120 | console.info( 121 | `${chalk.red("[Tyler]")} ${chalk.red(typstTomlPath)} not found`, 122 | ); 123 | return; 124 | } 125 | 126 | const typstToml: TypstToml = await readTypstToml(typstTomlPath); 127 | 128 | if (!typstToml.package) { 129 | console.info( 130 | `${chalk.red("[Tyler]")} ${chalk.red("typst.toml")} is missing required ${chalk.red("package")} section`, 131 | ); 132 | return; 133 | } 134 | 135 | console.info( 136 | `[Tyler] Loaded ${chalk.green("typst.toml")} for package ${chalk.yellow(typstToml.package.name)}:${chalk.gray( 137 | typstToml.package.version, 138 | )}`, 139 | ); 140 | // #endregion 141 | 142 | // #region Check package.name 143 | if (!typstToml.package.name) { 144 | console.info( 145 | `${chalk.red("[Tyler]")} ${chalk.red("typst.toml")} is missing required ${chalk.red("package.name")}`, 146 | ); 147 | return; 148 | } 149 | 150 | if (!/^[a-z0-9\-]+$/.test(typstToml.package.name)) { 151 | console.info( 152 | `${chalk.red("[Tyler]")} The package name ${chalk.red( 153 | typstToml.package.name, 154 | )} is invalid, it must only contain lowercase letters, numbers, and hyphens, however it contains: ${typstToml.package.name 155 | .split("") 156 | .filter((char) => !/^[a-z0-9\-]+$/.test(char)) 157 | .map((char) => chalk.red(char)) 158 | .join(", ")}`, 159 | ); 160 | return; 161 | } 162 | 163 | console.info( 164 | `${chalk.greenBright("[Tyler]")} Package name is valid: ${chalk.yellow( 165 | typstToml.package.name, 166 | )}`, 167 | ); 168 | // #endregion 169 | 170 | // #region Update options 171 | if (typstToml.tool?.tyler) { 172 | console.info( 173 | `[Tyler] Found ${chalk.green("[tool.tyler]")} in package's ${chalk.gray("typst.toml")}`, 174 | ); 175 | } 176 | 177 | const updatedOptions = updateOptionFromConfig( 178 | options, 179 | typstToml.tool?.tyler ?? ({} as Partial), 180 | ); 181 | // #endregion 182 | 183 | // #region Get index package metadata 184 | const versionIndex = await getTypstIndexPackageMetadata(); 185 | 186 | const samePackageName = versionIndex.find( 187 | ({ name }) => name === typstToml.package.name, 188 | ); 189 | // #endregion 190 | 191 | // #region Check package.version 192 | if (!typstToml.package.version) { 193 | console.info( 194 | `${chalk.red("[Tyler]")} ${chalk.green("typst.toml")} is missing required ${chalk.red("package.version")}`, 195 | ); 196 | return; 197 | } 198 | 199 | if (!semver.valid(typstToml.package.version)) { 200 | console.info( 201 | `${chalk.red("[Tyler]")} The version of the package is not a valid semver: ${chalk.red(typstToml.package.version)}`, 202 | ); 203 | return; 204 | } 205 | 206 | const currentPackageVersion = typstToml.package.version; 207 | const indexPackageVersion = samePackageName?.version; 208 | 209 | if ( 210 | indexPackageVersion && 211 | semver.compare(indexPackageVersion, currentPackageVersion) > 0 212 | ) { 213 | console.warn( 214 | `${chalk.red("[Tyler]")} The version of the package ${chalk.green( 215 | typstToml.package.name, 216 | )}:${chalk.blue(indexPackageVersion)} on the index is greater than current package ${chalk.green( 217 | typstToml.package.name, 218 | )}:${chalk.red(currentPackageVersion)}.`, 219 | ); 220 | } else if (!indexPackageVersion && currentPackageVersion !== "0.1.0") { 221 | console.warn( 222 | `${chalk.red("[Tyler]")} The version of the package ${chalk.green( 223 | typstToml.package.name, 224 | )}:${chalk.red(currentPackageVersion)} is not published on the index, but it is not ${chalk.gray("0.1.0")}`, 225 | ); 226 | } else { 227 | console.info( 228 | `${chalk.greenBright("[Tyler]")} Package version is valid: ${chalk.yellow( 229 | typstToml.package.version, 230 | )}`, 231 | ); 232 | } 233 | // #endregion 234 | 235 | // #region Get source directory 236 | const sourceDir = !updatedOptions.srcdir 237 | ? path.resolve(workingDirectory, "src") 238 | : path.isAbsolute(updatedOptions.srcdir) 239 | ? updatedOptions.srcdir 240 | : path.resolve(workingDirectory, updatedOptions.srcdir); 241 | 242 | // if (!(await fileExists(sourceDir))) { 243 | // throw new Error( 244 | // `[Tyler] Source directory not found: ${chalk.gray(sourceDir)}`, 245 | // ); 246 | // } 247 | console.info(`[Tyler] Source directory found in ${chalk.gray(sourceDir)}`); 248 | // #endregion 249 | 250 | // #region Check package.entrypoint 251 | if (!typstToml.package.entrypoint) { 252 | console.info( 253 | `${chalk.red("[Tyler]")} ${chalk.green("typst.toml")} is missing required ${chalk.red("package.entrypoint")}`, 254 | ); 255 | return; 256 | } 257 | 258 | const entrypoint = path.resolve( 259 | workingDirectory, 260 | typstToml.package.entrypoint, 261 | ); 262 | 263 | const entrypointInSourceDirectory = path.resolve( 264 | workingDirectory, 265 | "src", 266 | typstToml.package.entrypoint, 267 | ); 268 | 269 | if (await fileExists(entrypointInSourceDirectory)) { 270 | console.info( 271 | `${chalk.greenBright("[Tyler]")} Entrypoint ${chalk.yellow( 272 | typstToml.package.entrypoint, 273 | )} found in ${chalk.gray(entrypointInSourceDirectory)}`, 274 | ); 275 | } else if (await fileExists(entrypoint)) { 276 | console.info( 277 | `${chalk.greenBright("[Tyler]")} Entrypoint ${chalk.yellow( 278 | typstToml.package.entrypoint, 279 | )} found in ${chalk.gray(entrypoint)}`, 280 | ); 281 | } else { 282 | console.info( 283 | `${chalk.red("[Tyler]")} The entrypoint file ${chalk.red(entrypoint)} does not exist`, 284 | ); 285 | return; 286 | } 287 | // #endregion 288 | 289 | // #region Check authors 290 | if (!typstToml.package.authors) { 291 | console.info( 292 | `${chalk.red("[Tyler]")} ${chalk.green("typst.toml")} is missing required ${chalk.red("package.authors")}`, 293 | ); 294 | return; 295 | } 296 | 297 | if (!Array.isArray(typstToml.package.authors)) { 298 | console.info( 299 | `${chalk.red("[Tyler]")} ${chalk.red("package.authors")} must be an array`, 300 | ); 301 | return; 302 | } 303 | 304 | for (const author of typstToml.package.authors) { 305 | if (typeof author !== "string") { 306 | console.info( 307 | `${chalk.red("[Tyler]")} ${chalk.red("package.authors")} must be an array of strings`, 308 | ); 309 | return; 310 | } 311 | 312 | if ( 313 | !/^[^<]*(?: <(?:[a-zA-Z0-9_\-\.]*)?@[^<>]+>|]+>)?$/.test( 314 | author, 315 | ) 316 | ) { 317 | console.info( 318 | `${chalk.red("[Tyler]")} ${chalk.green("package.authors")} has ${chalk.red(author)} that is invalid, it must be in the format of either "${chalk.gray("Name")}", "${chalk.gray("Name ")}", "${chalk.gray("Name ")}" or "${chalk.gray("Name <@github_handle>")}"`, 319 | ); 320 | return; 321 | } 322 | } 323 | 324 | console.info( 325 | `${chalk.greenBright("[Tyler]")} Package authors are valid: ${chalk.yellow( 326 | typstToml.package.authors.join(", "), 327 | )}`, 328 | ); 329 | // #endregion 330 | 331 | // #region Check license 332 | if (!typstToml.package.license) { 333 | console.info( 334 | `${chalk.red("[Tyler]")} ${chalk.green("typst.toml")} is missing required ${chalk.red("package.license")}`, 335 | ); 336 | return; 337 | } 338 | 339 | // "Must contain a valid SPDX-2 expression describing one or multiple OSI-approved licenses." 340 | let isValidLicense = false; 341 | 342 | try { 343 | isValidLicense = spdxExpressionValidate(typstToml.package.license); 344 | } catch (error) { 345 | isValidLicense = false; 346 | } 347 | 348 | if (!isValidLicense) { 349 | console.info( 350 | `${chalk.red("[Tyler]")} The license ${chalk.red( 351 | typstToml.package.license, 352 | )} is not a valid SPDX-2 expression or OSI approved license`, 353 | ); 354 | return; 355 | } 356 | 357 | console.info( 358 | `${chalk.greenBright("[Tyler]")} Package license is valid: ${chalk.yellow( 359 | typstToml.package.license, 360 | )}`, 361 | ); 362 | 363 | const LICENSE_PATH = path.resolve(workingDirectory, "LICENSE"); 364 | if (!(await fileExists(LICENSE_PATH))) { 365 | console.info( 366 | `${chalk.red("[Tyler]")} The license file ${chalk.red(LICENSE_PATH)} does not exist`, 367 | ); 368 | } else { 369 | console.info( 370 | `${chalk.greenBright("[Tyler]")} License file found in ${chalk.gray( 371 | LICENSE_PATH, 372 | )}`, 373 | ); 374 | } 375 | // #endregion 376 | 377 | // #region Check description 378 | if (typstToml.package.description) { 379 | console.info( 380 | `${chalk.greenBright("[Tyler]")} Package description is valid: ${chalk.yellow( 381 | typstToml.package.description, 382 | )}`, 383 | ); 384 | } 385 | // #endregion 386 | 387 | // #region Check optionals 388 | if ( 389 | typstToml.package.homepage && 390 | validUrl.isUri(typstToml.package.homepage) 391 | ) { 392 | console.info( 393 | `${chalk.greenBright("[Tyler]")} Package homepage is valid: ${chalk.yellow( 394 | typstToml.package.homepage, 395 | )}`, 396 | ); 397 | } 398 | 399 | if ( 400 | typstToml.package.repository && 401 | validUrl.isUri(typstToml.package.repository) 402 | ) { 403 | console.info( 404 | `${chalk.greenBright("[Tyler]")} Package repository is valid: ${chalk.yellow( 405 | typstToml.package.repository, 406 | )}`, 407 | ); 408 | } 409 | 410 | if (typstToml.package.keywords) { 411 | if (!Array.isArray(typstToml.package.keywords)) { 412 | console.info( 413 | `${chalk.red("[Tyler]")} ${chalk.red("package.keywords")} must be an array`, 414 | ); 415 | return; 416 | } 417 | 418 | const allExistingKeywords = new Set( 419 | versionIndex.flatMap((pkg) => pkg.keywords ?? []), 420 | ); 421 | 422 | for (const keyword of typstToml.package.keywords) { 423 | if (!allExistingKeywords.has(keyword)) { 424 | console.info( 425 | `${chalk.red("[Tyler]")} The keyword ${chalk.red(keyword)} is not in the index already, are you sure you want to add it?`, 426 | ); 427 | } 428 | } 429 | 430 | console.info( 431 | `${chalk.greenBright("[Tyler]")} Package keywords are valid: ${chalk.yellow( 432 | typstToml.package.keywords.join(", "), 433 | )}`, 434 | ); 435 | } 436 | 437 | if (typstToml.package.categories) { 438 | if (!Array.isArray(typstToml.package.categories)) { 439 | console.info( 440 | `${chalk.red("[Tyler]")} ${chalk.red("package.categories")} must be an array`, 441 | ); 442 | return; 443 | } 444 | 445 | const VALID_CATEGORIES = [ 446 | "model", 447 | "paper", 448 | "presentation", 449 | "utility", 450 | "thesis", 451 | "visualization", 452 | "components", 453 | "office", 454 | "text", 455 | "languages", 456 | "fun", 457 | "report", 458 | "scripting", 459 | "cv", 460 | "book", 461 | "layout", 462 | "flyer", 463 | "integration", 464 | "poster", 465 | ]; 466 | 467 | for (const category of typstToml.package.categories) { 468 | if (!VALID_CATEGORIES.includes(category)) { 469 | console.info( 470 | `${chalk.red("[Tyler]")} The category ${chalk.red(category)} is not valid`, 471 | ); 472 | } 473 | } 474 | 475 | console.info( 476 | `${chalk.greenBright("[Tyler]")} Package categories are valid: ${chalk.yellow( 477 | typstToml.package.categories.join(", "), 478 | )}`, 479 | ); 480 | } 481 | 482 | if (typstToml.package.disciplines) { 483 | if (!Array.isArray(typstToml.package.disciplines)) { 484 | console.info( 485 | `${chalk.red("[Tyler]")} ${chalk.red("package.disciplines")} must be an array`, 486 | ); 487 | } 488 | 489 | const VALID_DISCIPLINES = [ 490 | "computer-science", 491 | "engineering", 492 | "physics", 493 | "chemistry", 494 | "biology", 495 | "mathematics", 496 | "linguistics", 497 | "business", 498 | "communication", 499 | "transportation", 500 | "education", 501 | "theology", 502 | "design", 503 | "law", 504 | "philosophy", 505 | "agriculture", 506 | "economics", 507 | "anthropology", 508 | "history", 509 | "medicine", 510 | "geology", 511 | "literature", 512 | "journalism", 513 | "geography", 514 | "music", 515 | "archaeology", 516 | "psychology", 517 | "architecture", 518 | "drawing", 519 | "fashion", 520 | "film", 521 | "painting", 522 | "photography", 523 | "politics", 524 | "sociology", 525 | "theater", 526 | ]; 527 | 528 | for (const discipline of typstToml.package.disciplines) { 529 | if (!VALID_DISCIPLINES.includes(discipline)) { 530 | console.info( 531 | `${chalk.red("[Tyler]")} The discipline ${chalk.red(discipline)} is not valid`, 532 | ); 533 | } 534 | } 535 | 536 | console.info( 537 | `${chalk.greenBright("[Tyler]")} Package disciplines are valid: ${chalk.yellow( 538 | typstToml.package.disciplines.join(", "), 539 | )}`, 540 | ); 541 | } 542 | 543 | if (typstToml.package.compiler) { 544 | if (!semver.valid(typstToml.package.compiler)) { 545 | console.info( 546 | `${chalk.red("[Tyler]")} The compiler version ${chalk.red( 547 | typstToml.package.compiler, 548 | )} is not a valid semver`, 549 | ); 550 | } 551 | 552 | console.info( 553 | `${chalk.greenBright("[Tyler]")} Package compiler is valid: ${chalk.yellow( 554 | typstToml.package.compiler, 555 | )}`, 556 | ); 557 | } 558 | 559 | if (typstToml.package.exclude) { 560 | if (!Array.isArray(typstToml.package.exclude)) { 561 | console.info( 562 | `${chalk.red("[Tyler]")} ${chalk.red("package.exclude")} must be an array`, 563 | ); 564 | } 565 | 566 | let found = false; 567 | for (const exclude of typstToml.package.exclude) { 568 | if (!(await fileExists(path.resolve(workingDirectory, exclude)))) { 569 | console.info( 570 | `${chalk.red("[Tyler]")} The file ${chalk.red(exclude)} does not exist`, 571 | ); 572 | found = true; 573 | } 574 | } 575 | 576 | if (!found) { 577 | console.info( 578 | `${chalk.greenBright("[Tyler]")} Package exclude is valid: ${chalk.yellow( 579 | typstToml.package.exclude.join(", "), 580 | )}`, 581 | ); 582 | } 583 | } 584 | // #endregion 585 | 586 | // #region Check template 587 | if (typstToml.template) { 588 | console.info("[Tyler] Package is template"); 589 | 590 | if (!typstToml.template.path) { 591 | console.info( 592 | `${chalk.red("[Tyler]")} ${chalk.red("template.path")} is required`, 593 | ); 594 | return; 595 | } 596 | 597 | if ( 598 | !(await fileExists( 599 | path.resolve(workingDirectory, typstToml.template.path), 600 | )) && 601 | !(await fileExists(path.resolve(sourceDir, typstToml.template.path))) 602 | ) { 603 | console.info( 604 | `${chalk.red("[Tyler]")} The template path ${chalk.red(typstToml.template.path)} does not exist`, 605 | ); 606 | return; 607 | } 608 | 609 | console.info( 610 | `${chalk.greenBright("[Tyler]")} Template path is valid: ${chalk.yellow( 611 | typstToml.template.path, 612 | )}`, 613 | ); 614 | 615 | if (!typstToml.template.entrypoint) { 616 | console.info( 617 | `${chalk.red("[Tyler]")} ${chalk.red("template.entrypoint")} is required`, 618 | ); 619 | return; 620 | } 621 | 622 | if ( 623 | !(await fileExists( 624 | path.resolve( 625 | workingDirectory, 626 | typstToml.template.path, 627 | typstToml.template.entrypoint, 628 | ), 629 | )) && 630 | !(await fileExists( 631 | path.resolve( 632 | sourceDir, 633 | typstToml.template.path, 634 | typstToml.template.entrypoint, 635 | ), 636 | )) 637 | ) { 638 | console.info( 639 | `${chalk.red("[Tyler]")} The entrypoint ${chalk.red( 640 | typstToml.template.entrypoint, 641 | )} does not exist`, 642 | ); 643 | return; 644 | } 645 | 646 | console.info( 647 | `${chalk.greenBright("[Tyler]")} Template entrypoint is valid: ${chalk.yellow( 648 | typstToml.template.entrypoint, 649 | )}`, 650 | ); 651 | 652 | if (!typstToml.template.thumbnail) { 653 | console.info( 654 | `${chalk.red("[Tyler]")} ${chalk.red("template.thumbnail")} is required`, 655 | ); 656 | return; 657 | } 658 | 659 | if ( 660 | !(await fileExists( 661 | path.resolve(workingDirectory, typstToml.template.thumbnail), 662 | )) 663 | ) { 664 | console.info( 665 | `${chalk.red("[Tyler]")} The thumbnail file ${chalk.red( 666 | typstToml.template.thumbnail, 667 | )} does not exist`, 668 | ); 669 | return; 670 | } 671 | 672 | if ( 673 | !["png", "webp"].includes( 674 | path.extname(typstToml.template.thumbnail).slice(1), 675 | ) 676 | ) { 677 | console.info( 678 | `${chalk.red("[Tyler]")} The thumbnail ${chalk.red( 679 | typstToml.template.thumbnail, 680 | )} is not a valid image`, 681 | ); 682 | return; 683 | } 684 | 685 | const thumbnailType = await imageType( 686 | await fs.readFile( 687 | path.resolve(workingDirectory, typstToml.template.thumbnail), 688 | ), 689 | ); 690 | 691 | if ( 692 | !thumbnailType || 693 | !["image/png", "image/webp"].includes(thumbnailType.mime) 694 | ) { 695 | console.info( 696 | `${chalk.red("[Tyler]")} The thumbnail ${chalk.red( 697 | typstToml.template.thumbnail, 698 | )} is not a valid image`, 699 | ); 700 | return; 701 | } 702 | 703 | const thumbnailDimensions = imageSize( 704 | path.resolve(workingDirectory, typstToml.template.thumbnail), 705 | ); 706 | const MIN_THUMBNAIL_SIZE = 1080; 707 | if (!thumbnailDimensions.width || !thumbnailDimensions.height) { 708 | console.info( 709 | `${chalk.red("[Tyler]")} The thumbnail ${chalk.red( 710 | typstToml.template.thumbnail, 711 | )} does not have valid dimensions`, 712 | ); 713 | return; 714 | } 715 | 716 | if ( 717 | Math.max(thumbnailDimensions.width, thumbnailDimensions.height) < 718 | MIN_THUMBNAIL_SIZE 719 | ) { 720 | console.info( 721 | `${chalk.red("[Tyler]")} The thumbnail ${chalk.red( 722 | typstToml.template.thumbnail, 723 | )} is too small, it must be at least ${MIN_THUMBNAIL_SIZE}px on the longest side`, 724 | ); 725 | } 726 | 727 | const thumbnailFilesize = ( 728 | await fs.stat( 729 | path.resolve(workingDirectory, typstToml.template.thumbnail), 730 | ) 731 | ).size; 732 | 733 | // "Its file size must not exceed 3MB." 734 | const MAX_THUMBNAIL_SIZE = 1024 * 1024 * 3; 735 | 736 | if (thumbnailFilesize > MAX_THUMBNAIL_SIZE) { 737 | console.info( 738 | `${chalk.red("[Tyler]")} The thumbnail ${chalk.red( 739 | typstToml.template.thumbnail, 740 | )} is too large, it must be less than ${MAX_THUMBNAIL_SIZE} bytes`, 741 | ); 742 | } 743 | 744 | console.info( 745 | `${chalk.greenBright("[Tyler]")} Template thumbnail is valid: ${chalk.yellow( 746 | typstToml.template.thumbnail, 747 | )} (${thumbnailFilesize} bytes < ${MAX_THUMBNAIL_SIZE} bytes, ${thumbnailDimensions.width}x${thumbnailDimensions.height}) >= ${MIN_THUMBNAIL_SIZE}px`, 748 | ); 749 | } 750 | // #endregion 751 | 752 | // #region Get entrypoint 753 | // const entrypoint = typstToml.package.entrypoint ?? "lib.typ"; 754 | // // TODO: Check if the entrypoint is valid 755 | // #endregion 756 | }, 757 | } satisfies Command<{ 758 | entrypoint: string | undefined; 759 | bump: string | undefined; 760 | dryRun: boolean | undefined; 761 | noBump: boolean | undefined; 762 | srcdir: string | undefined; 763 | outdir: string | undefined; 764 | ignore: string | undefined; 765 | install: boolean | undefined; 766 | publish: boolean | undefined; 767 | }>; 768 | -------------------------------------------------------------------------------- /src/cli/commands/index.ts: -------------------------------------------------------------------------------- 1 | import build from "./build"; 2 | import check from "./check"; 3 | import type { Command } from "./types"; 4 | 5 | const help = { 6 | name: "help", 7 | type: Boolean, 8 | alias: "h", 9 | description: "Display help (this message)", 10 | }; 11 | 12 | const version = { 13 | name: "version", 14 | type: Boolean, 15 | alias: "v", 16 | description: "Display version", 17 | }; 18 | 19 | const root: Command = { 20 | name: undefined, 21 | description: "Root command", 22 | options: [ 23 | { 24 | name: "command", 25 | type: String, 26 | defaultOption: true, 27 | description: "The command to run", 28 | hide: true, 29 | }, 30 | help, 31 | version, 32 | ], 33 | run: async () => {}, 34 | usage: " [options]", 35 | } satisfies Command; 36 | 37 | const commands = { 38 | build, 39 | check, 40 | }; 41 | 42 | const injected = Object.fromEntries( 43 | Object.entries(commands).map(([key, command]) => [ 44 | key, 45 | { 46 | ...command, 47 | options: [...command.options], 48 | }, 49 | ]), 50 | ); 51 | 52 | export default { root, ...injected } satisfies Record; 53 | -------------------------------------------------------------------------------- /src/cli/commands/types.ts: -------------------------------------------------------------------------------- 1 | import type { OptionDefinition } from "command-line-args"; 2 | 3 | // Typst Script magic to convert OptionDefinition to Result 4 | 5 | export type Command> = { 6 | name: string | undefined; 7 | description: string; 8 | options: Option[]; 9 | alias?: string; 10 | run: (options: T) => Promise; 11 | usage: string; 12 | }; 13 | 14 | export type Option = OptionDefinition & { 15 | description: string; 16 | hide?: boolean; 17 | }; 18 | -------------------------------------------------------------------------------- /src/cli/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | srcdir: string; 3 | outdir: string; 4 | ignore: string[]; 5 | } 6 | 7 | function parseList(value: string): string[] { 8 | return value.split(",").map((s) => s.trim()); 9 | } 10 | 11 | export function updateOptionFromConfig>( 12 | options: T, 13 | config: Partial, 14 | ) { 15 | // CLI options override config options 16 | return { 17 | ...options, 18 | srcdir: options.srcdir ?? config.srcdir, 19 | outdir: options.outdir ?? config.outdir, 20 | ignore: 21 | typeof options.ignore === "string" 22 | ? parseList(options.ignore) 23 | : config.ignore, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/help.ts: -------------------------------------------------------------------------------- 1 | import commands from "@/cli/commands"; 2 | import type { Command } from "@/cli/commands/types"; 3 | import { version } from "@/utils/version"; 4 | import chalk from "chalk"; 5 | import clu from "command-line-usage"; 6 | 7 | const LOGO = ` 8 | . oooo 9 | .o8 \`888 10 | .o888oo oooo ooo 888 .ooooo.v oooo d8b 11 | 888 \`88. .8' 888 d88' \`88b \`888\"\"8P 12 | 888 \`88..8' 888 888ooo888 888 13 | 888 . \`888' 888 888 .o 888 14 | "888" .8' o888o \`Y8bod8P' d888b 15 | .o..P' 16 | \`Y8P' 17 | `; 18 | 19 | export function help(command: Command): void { 20 | const sections: clu.Section[] = []; 21 | 22 | if (command.name === undefined) { 23 | sections.push({ 24 | header: `Tyler v${version}`, 25 | content: 26 | "Typst package compiler for the ease of packaging and publishing Typst packages and templates.", 27 | }); 28 | sections.push({ 29 | content: chalk.blueBright(LOGO), 30 | raw: true, 31 | }); 32 | } else { 33 | sections.push({ 34 | header: `Tyler v${version}`, 35 | }); 36 | 37 | sections.push({ 38 | header: "Description", 39 | content: `${chalk.green.bold(command.name)} ${command.description}`, 40 | }); 41 | } 42 | 43 | sections.push({ 44 | header: "Usage", 45 | content: `${chalk.cyan("$")} ${chalk.bold("tyler")} ${command.name ? `${chalk.green(command.name)} ` : ""}${chalk.green( 46 | command.usage, 47 | )}`, 48 | }); 49 | 50 | const parameters = command.options.find((p) => p.defaultOption); 51 | if (parameters) { 52 | sections.push({ 53 | header: "Parameters", 54 | content: generateParametersSection(command), 55 | }); 56 | } 57 | 58 | if (command.name === undefined) { 59 | sections.push({ 60 | header: "Commands", 61 | content: generateCommandsSection(commands), 62 | }); 63 | } 64 | 65 | sections.push({ 66 | header: "Options", 67 | optionList: command.options.filter((p) => !p.hide), 68 | }); 69 | 70 | const usage = clu(sections); 71 | console.log(usage); 72 | } 73 | 74 | function generateCommandsSection(commands: Record): string { 75 | const commandList = Object.keys(commands) 76 | .filter((command) => command !== "root") 77 | .map((command) => { 78 | return ` ${chalk.green(command)} ${commands[command].description}`; 79 | }); 80 | 81 | const commandUsage = commandList.join("\n"); 82 | const commandHelp = `To see more about a command, run: ${chalk.bold("tyler")} ${chalk.green("")} ${chalk.cyan("--help")}, for example:\n\n\t${chalk.cyan("$")} ${chalk.bold("tyler")} ${chalk.green("build")} ${chalk.cyan("--help")}`; 83 | 84 | return `${commandUsage}\n\n${commandHelp}`; 85 | } 86 | 87 | function generateParametersSection(command: Command): string { 88 | const parameters = command.options.find((p) => p.defaultOption); 89 | if (parameters) { 90 | return `\t${chalk.cyan("<")}${parameters.name}${chalk.cyan(">")} ${parameters.description}`; 91 | } 92 | return ""; 93 | } 94 | 95 | export function commandsOnly(): void { 96 | console.log( 97 | clu([ 98 | { 99 | header: "Commands", 100 | content: generateCommandsSection(commands), 101 | }, 102 | ]), 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import commands from "@/cli/commands"; 2 | import type { Command } from "@/cli/commands/types"; 3 | import { commandsOnly, help } from "@/cli/help"; 4 | import { version } from "@/utils/version"; 5 | import chalk from "chalk"; 6 | import cla from "command-line-args"; 7 | 8 | export async function main(): Promise { 9 | const options = cla(commands.root.options, { 10 | stopAtFirstUnknown: true, 11 | }) as { 12 | command: string | undefined; 13 | help: boolean; 14 | version: boolean; 15 | _unknown: string[]; 16 | }; 17 | const argv = options._unknown || []; 18 | 19 | if (options.version) { 20 | console.log(version); 21 | return; 22 | } 23 | 24 | if (options.command === undefined) { 25 | help(commands.root); 26 | return; 27 | } 28 | 29 | if (!(options.command in commands)) { 30 | console.error(`Command ${chalk.bold(options.command)} not found`); 31 | commandsOnly(); 32 | return; 33 | } 34 | 35 | const command: Command = commands[options.command as keyof typeof commands]; 36 | if (options.help) { 37 | help(command); 38 | } else { 39 | const options = cla(command.options, { 40 | argv, 41 | camelCase: true, 42 | }); 43 | try { 44 | await command.run(options); 45 | } catch (error) { 46 | console.error(error); 47 | process.exit(1); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/src/index.ts -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import os from "node:os"; 3 | import path from "node:path"; 4 | 5 | export async function fileExists(filePath: string): Promise { 6 | try { 7 | await fs.access(filePath); 8 | return true; 9 | } catch { 10 | return false; 11 | } 12 | } 13 | 14 | export async function getWorkingDirectory( 15 | entrypoint: string | undefined, 16 | ): Promise { 17 | if (entrypoint === undefined) return process.cwd(); 18 | 19 | const resolved = path.resolve(entrypoint); 20 | 21 | if (!(await fileExists(resolved))) { 22 | throw new Error(`Entrypoint ${entrypoint} does not exist`); 23 | } 24 | 25 | if (!(await fs.lstat(resolved)).isDirectory()) { 26 | return path.dirname(resolved); 27 | } 28 | 29 | return resolved; 30 | } 31 | 32 | export async function clearDirectoryWithoutDeletingIt( 33 | dir: string, 34 | ): Promise { 35 | for (const file of await fs.readdir(dir)) { 36 | await fs.rm(path.resolve(dir, file), { recursive: true, force: true }); 37 | } 38 | } 39 | 40 | export async function getDataDirectory(): Promise { 41 | const system = os.platform(); 42 | switch (system) { 43 | case "linux": 44 | return path.join(os.homedir(), ".local", "share"); 45 | case "darwin": 46 | return path.join(os.homedir(), "Library", "Application Support"); 47 | case "win32": 48 | return path.join(os.homedir(), "%APPDATA%"); 49 | default: 50 | throw new Error(`Unsupported platform: ${system}`); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from "prettier"; 2 | import prettierPluginToml from "prettier-plugin-toml"; 3 | 4 | export async function formatToml(toml: string): Promise { 5 | return prettier.format(toml, { 6 | parser: "toml", 7 | plugins: [prettierPluginToml], 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/git.ts: -------------------------------------------------------------------------------- 1 | import { execAndRedirect } from "@/utils/process"; 2 | 3 | export async function isValidGitRepository( 4 | gitRepoDir: string, 5 | ): Promise { 6 | // git rev-parse --is-inside-work-tree 7 | try { 8 | await execAndRedirect( 9 | `git -C ${gitRepoDir} rev-parse --is-inside-work-tree`, 10 | ); 11 | return true; 12 | } catch { 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkpoli/tyler/8ceb973886c2e1dd1afe9a31a007d7a3db43edf5/src/utils/index.ts -------------------------------------------------------------------------------- /src/utils/manifest.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from "prettier"; 2 | import prettierPluginToml from "prettier-plugin-toml"; 3 | import * as toml from "smol-toml"; 4 | 5 | export async function stringifyToml(something: unknown): Promise { 6 | return await prettier.format(toml.stringify(something), { 7 | parser: "toml", 8 | plugins: [prettierPluginToml], 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/process.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import commandExists from "command-exists"; 3 | 4 | export async function execAndRedirect(command: string): Promise { 5 | return new Promise((resolve, reject) => { 6 | const [cmd, ...args] = command.split(" "); 7 | const child = spawn(cmd, args, { 8 | stdio: "inherit", 9 | }); 10 | // child.stdout?.pipe(process.stdout); 11 | child.stdout?.on("data", (data) => { 12 | console.log(data.toString()); 13 | }); 14 | child.on("error", (error) => { 15 | reject(error); 16 | }); 17 | child.on("exit", () => { 18 | resolve(); 19 | }); 20 | }); 21 | } 22 | 23 | export async function exec(command: string): Promise { 24 | return new Promise((resolve, reject) => { 25 | const [cmd, ...args] = command.split(" "); 26 | const child = spawn(cmd, args, { 27 | stdio: "ignore", 28 | }); 29 | child.on("error", (error) => { 30 | reject(error); 31 | }); 32 | child.on("exit", () => { 33 | resolve(); 34 | }); 35 | }); 36 | } 37 | 38 | export async function isCommandInstalled(command: string): Promise { 39 | try { 40 | await commandExists(command); 41 | return true; 42 | } catch { 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/version.ts: -------------------------------------------------------------------------------- 1 | import { version as packageVersion } from "../../package.json"; 2 | 3 | export const version = packageVersion; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false, 26 | 27 | // Path aliases 28 | "baseUrl": ".", 29 | "paths": { 30 | "@/*": ["src/*"] 31 | } 32 | } 33 | } 34 | --------------------------------------------------------------------------------