├── .changeset ├── README.md ├── brave-vans-cough.md └── config.json ├── .github ├── FUNDING.yml └── workflows │ └── check.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── gro.config.ts ├── package-lock.json ├── package.json ├── src ├── app.html ├── docs │ ├── README.gen.md.ts │ ├── README.md │ ├── build.md │ ├── config.md │ ├── deploy.md │ ├── dev.md │ ├── gen.md │ ├── gro_plugin_sveltekit_app.md │ ├── package_json.md │ ├── plugin.md │ ├── publish.md │ ├── task.md │ ├── tasks.gen.md.ts │ ├── tasks.md │ └── test.md ├── fixtures │ ├── bar1 │ │ └── test1.bar.ts │ ├── bar2 │ │ └── test2.bar.ts │ ├── baz1 │ │ └── test1.baz.ts │ ├── baz2 │ │ └── test2.baz.ts │ ├── changelog_cache.json │ ├── changelog_example.md │ ├── modules │ │ ├── Some_Test_Svelte.svelte │ │ ├── some_test_css.css │ │ ├── some_test_js.js │ │ ├── some_test_json.json │ │ ├── some_test_json_without_extension │ │ ├── some_test_script.ts │ │ ├── some_test_server.ts │ │ ├── some_test_svelte_js.svelte.js │ │ ├── some_test_svelte_ts.svelte.ts │ │ ├── some_test_ts.ts │ │ └── src_json_sample_exports.ts │ ├── some_test_exports.ts │ ├── some_test_exports2.ts │ ├── some_test_exports3.ts │ ├── some_test_side_effect.ts │ ├── test1.foo.ts │ ├── test2.foo.ts │ ├── test_failing_task_module.ts │ ├── test_file.other.ext │ ├── test_invalid_task_module.ts │ ├── test_js.js │ ├── test_sveltekit_env.ts │ ├── test_task_module.task_fixture.ts │ └── test_ts.ts ├── lib │ ├── args.test.ts │ ├── args.ts │ ├── build.task.ts │ ├── changelog.test.ts │ ├── changelog.ts │ ├── changeset.task.ts │ ├── changeset_helpers.ts │ ├── check.task.ts │ ├── child_process_logging.ts │ ├── clean.task.ts │ ├── clean_fs.ts │ ├── cli.ts │ ├── commit.task.ts │ ├── constants.ts │ ├── deploy.task.ts │ ├── dev.task.ts │ ├── env.ts │ ├── esbuild_helpers.ts │ ├── esbuild_plugin_external_worker.ts │ ├── esbuild_plugin_svelte.test.ts │ ├── esbuild_plugin_svelte.ts │ ├── esbuild_plugin_sveltekit_local_imports.ts │ ├── esbuild_plugin_sveltekit_shim_alias.ts │ ├── esbuild_plugin_sveltekit_shim_app.ts │ ├── esbuild_plugin_sveltekit_shim_env.ts │ ├── filer.ts │ ├── format.task.ts │ ├── format_directory.ts │ ├── format_file.test.ts │ ├── format_file.ts │ ├── fs.ts │ ├── gen.task.ts │ ├── gen.test.ts │ ├── gen.ts │ ├── git.test.ts │ ├── git.ts │ ├── github.ts │ ├── gro.config.default.ts │ ├── gro.ts │ ├── gro_config.test.ts │ ├── gro_config.ts │ ├── gro_helpers.ts │ ├── gro_plugin_gen.ts │ ├── gro_plugin_server.ts │ ├── gro_plugin_sveltekit_app.ts │ ├── gro_plugin_sveltekit_library.ts │ ├── hash.test.ts │ ├── hash.ts │ ├── index.ts │ ├── input_path.test.ts │ ├── input_path.ts │ ├── invoke.ts │ ├── invoke_task.ts │ ├── lint.task.ts │ ├── loader.test.ts │ ├── loader.ts │ ├── module.test.ts │ ├── module.ts │ ├── modules.test.ts │ ├── modules.ts │ ├── package.gen.ts │ ├── package.ts │ ├── package_json.test.ts │ ├── package_json.ts │ ├── package_meta.ts │ ├── parse_exports.test.ts │ ├── parse_exports.ts │ ├── parse_exports_context.ts │ ├── parse_imports.test.ts │ ├── parse_imports.ts │ ├── path.ts │ ├── paths.test.ts │ ├── paths.ts │ ├── plugin.test.ts │ ├── plugin.ts │ ├── publish.task.ts │ ├── register.ts │ ├── reinstall.task.ts │ ├── release.task.ts │ ├── resolve.task.ts │ ├── resolve_specifier.test.ts │ ├── resolve_specifier.ts │ ├── run.task.ts │ ├── run_gen.test.ts │ ├── run_gen.ts │ ├── run_task.test.ts │ ├── run_task.ts │ ├── search_fs.test.ts │ ├── search_fs.ts │ ├── src_json.test.ts │ ├── src_json.ts │ ├── svelte_config.ts │ ├── sveltekit_helpers.ts │ ├── sveltekit_shim_app.ts │ ├── sveltekit_shim_app_environment.ts │ ├── sveltekit_shim_app_forms.ts │ ├── sveltekit_shim_app_navigation.ts │ ├── sveltekit_shim_app_paths.ts │ ├── sveltekit_shim_app_state.ts │ ├── sveltekit_shim_env.test.ts │ ├── sveltekit_shim_env.ts │ ├── sync.task.ts │ ├── task.test.ts │ ├── task.ts │ ├── task_logging.ts │ ├── test.task.ts │ ├── test_helpers.ts │ ├── typecheck.task.ts │ ├── upgrade.task.ts │ └── watch_dir.ts └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── about │ └── +page.svelte │ ├── history │ └── +page.svelte │ └── moss.css ├── static ├── CNAME ├── favicon.png ├── logo.svg └── robots.txt ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/brave-vans-cough.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@ryanatkn/gro": patch 3 | --- 4 | 5 | improve gen logging 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/changelog-git", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ryanatkn 2 | patreon: ryanatkn 3 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # Checks and builds the project. For more info: 2 | # https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: check 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: ['**'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: ['22.15'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm ci 27 | - run: npm run bootstrap 28 | - run: npx @ryanatkn/gro check --workspace 29 | - run: npx @ryanatkn/gro build 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deps 2 | node_modules 3 | 4 | # Output 5 | /.svelte-kit 6 | /build 7 | /dist 8 | /dist_* 9 | /.gro 10 | /.zzz 11 | 12 | # Env 13 | .env 14 | .env.* 15 | !.env.example 16 | !.env.*.example 17 | !.env.test 18 | !.env.*.test 19 | 20 | # Secrets 21 | *.pem 22 | id_rsa 23 | 24 | # Ignore 25 | *.ignore 26 | *.ignore.* 27 | ignore 28 | 29 | # Workflow 30 | /worktree 31 | 32 | # Os 33 | .DS_Store 34 | Thumbs.db 35 | 36 | # GitHub 37 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "gro", 8 | "program": "${workspaceFolder}/dist/gro.js", 9 | "args": ["foo"], 10 | "runtimeArgs": [], 11 | // TODO need to add support sourcemaps to fix this, need to add back since we removed the build system 12 | // enables breakpoints in TS source 13 | "outFiles": ["${workspaceRoot}/dist/**/*.js"], 14 | "env": { 15 | "NODE_ENV": "development" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Ryan Atkinson 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 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import {configs, ts_config} from '@ryanatkn/eslint-config'; 2 | 3 | ts_config.rules['no-console'] = 1; 4 | 5 | export default configs; 6 | -------------------------------------------------------------------------------- /gro.config.ts: -------------------------------------------------------------------------------- 1 | import {gro_plugin_moss} from '@ryanatkn/moss/gro_plugin_moss.js'; 2 | 3 | import {create_empty_gro_config} from './src/lib/gro_config.ts'; 4 | import {gro_plugin_sveltekit_library} from './src/lib/gro_plugin_sveltekit_library.ts'; 5 | import {gro_plugin_sveltekit_app} from './src/lib/gro_plugin_sveltekit_app.ts'; 6 | import {gro_plugin_gen} from './src/lib/gro_plugin_gen.ts'; 7 | 8 | /** 9 | * This is the config for the Gro project itself. 10 | * The default config for dependent projects is located at `./lib/gro.config.default.ts`. 11 | * The default should be referenced as an example implementation, not this one. 12 | * We use different patterns here for demonstration purposes. 13 | */ 14 | const config = create_empty_gro_config(); 15 | 16 | config.plugins = () => [ 17 | gro_plugin_moss(), 18 | gro_plugin_sveltekit_library(), 19 | gro_plugin_sveltekit_app(), 20 | gro_plugin_gen(), 21 | ]; 22 | 23 | export default config; 24 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | 18 | %sveltekit.head% 19 | 20 | 21 |
%sveltekit.body%
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/docs/README.gen.md.ts: -------------------------------------------------------------------------------- 1 | import {dirname, relative, basename} from 'node:path'; 2 | import {parse_path_parts, parse_path_segments} from '@ryanatkn/belt/path.js'; 3 | import {strip_start} from '@ryanatkn/belt/string.js'; 4 | 5 | import {type Gen, to_output_file_name} from '../lib/gen.ts'; 6 | import {paths, base_path_to_path_id} from '../lib/paths.ts'; 7 | import {search_fs} from '../lib/search_fs.ts'; 8 | 9 | // TODO look at `tasks.gen.md.ts` to refactor and generalize 10 | // TODO show nested structure, not a flat list 11 | // TODO work with file types beyond markdown 12 | 13 | /** 14 | * Renders a simple index of a possibly nested directory of files. 15 | */ 16 | export const gen: Gen = ({origin_id}) => { 17 | // TODO need to get this from project config or something 18 | const root_path = parse_path_segments(paths.root).at(-1); 19 | 20 | const origin_dir = dirname(origin_id); 21 | const origin_base = basename(origin_id); 22 | 23 | const base_dir = paths.source; 24 | const relative_path = strip_start(origin_id, base_dir); 25 | const relative_dir = dirname(relative_path); 26 | 27 | // TODO should this be passed in the context, like `defaultOutputFileName`? 28 | const output_file_name = to_output_file_name(origin_base); 29 | 30 | // TODO this is GitHub-specific 31 | const root_link = `[${root_path}](/..)`; 32 | const doc_files = search_fs(origin_dir); 33 | const doc_paths: Array = []; 34 | for (const {path} of doc_files) { 35 | if (path === output_file_name || !path.endsWith('.md')) { 36 | continue; 37 | } 38 | doc_paths.push(path); 39 | } 40 | 41 | // TODO do we want to use absolute paths instead of relative paths, 42 | // because GitHub works with them and it simplifies the code? 43 | const is_index_file = output_file_name === 'README.md'; 44 | const path_parts = parse_path_parts(relative_dir).map((relative_path_part) => { 45 | const segment = parse_path_segments(relative_path_part).at(-1); 46 | return is_index_file && relative_path_part === relative_dir 47 | ? segment 48 | : `[${segment}](${relative(origin_dir, base_path_to_path_id(relative_path_part)) || './'})`; 49 | }); 50 | const breadcrumbs = 51 | '> ' + [root_link, ...path_parts, output_file_name].join(' / ') + ''; 52 | 53 | // TODO render the footer with the origin_id 54 | return `# docs 55 | 56 | ${breadcrumbs} 57 | 58 | ${doc_paths.reduce((docList, doc) => docList + `- [${basename(doc, '.md')}](${doc})\n`, '')} 59 | ${breadcrumbs} 60 | 61 | > generated by [${origin_base}](${origin_base}) 62 | `; 63 | }; 64 | -------------------------------------------------------------------------------- /src/docs/README.md: -------------------------------------------------------------------------------- 1 | # docs 2 | 3 | > [gro](/..) / docs / README.md 4 | 5 | - [build](build.md) 6 | - [config](config.md) 7 | - [deploy](deploy.md) 8 | - [dev](dev.md) 9 | - [gen](gen.md) 10 | - [gro_plugin_sveltekit_app](gro_plugin_sveltekit_app.md) 11 | - [package_json](package_json.md) 12 | - [plugin](plugin.md) 13 | - [publish](publish.md) 14 | - [task](task.md) 15 | - [tasks](tasks.md) 16 | - [test](test.md) 17 | 18 | > [gro](/..) / docs / README.md 19 | 20 | > generated by [README.gen.md.ts](README.gen.md.ts) 21 | -------------------------------------------------------------------------------- /src/docs/build.md: -------------------------------------------------------------------------------- 1 | # build 2 | 3 | > these docs are for production builds, for development see [dev.md](dev.md) 4 | 5 | ## usage 6 | 7 | The `gro build` task produces outputs for production: 8 | 9 | ```bash 10 | gro build 11 | ``` 12 | 13 | This runs the configured Gro plugins, `setup -> adapt -> teardown`, in production mode. 14 | 15 | If your project has a SvelteKit frontend, 16 | [the default plugin](../lib/gro_plugin_sveltekit_app.ts) calls `vite build`, 17 | forwarding any [`-- vite [...]` args](https://vitejs.dev/config/): 18 | 19 | ```bash 20 | gro build -- vite --config my-config.js 21 | ``` 22 | 23 | ## plugins 24 | 25 | `Plugin`s are objects that customize the behavior of `gro build` and `gro dev`. 26 | They try to defer to underlying tools as much as possible, and exist to glue everything together. 27 | For example, the library plugin internally uses 28 | [`svelte-package`](https://kit.svelte.dev/docs/packaging). 29 | See [plugin.md](plugin.md) to learn more. 30 | 31 | ## deploying and publishing 32 | 33 | Now that we can produce builds, how do we share them with the world? 34 | 35 | The [`gro deploy`](deploy.md) task outputs builds to a branch, 36 | like for static publishing to GitHub pages. 37 | 38 | The [`gro publish`](publish.md) task publishes packages to npm. 39 | 40 | Both of these tasks call `gro build` internally, 41 | and you can always run it manually if you're curious. 42 | -------------------------------------------------------------------------------- /src/docs/deploy.md: -------------------------------------------------------------------------------- 1 | # deploy 2 | 3 | The [`gro deploy`](/src/lib/deploy.task.ts) 4 | task was originally designed to support static deployments to 5 | [GitHub pages](https://pages.github.com/), 6 | but what it actually does is just [build](./build.md) and push to a branch. 7 | 8 | Importantly, Gro **destructively force pushes** to the `--target` branch, `deploy` by default. 9 | This is because Gro treats your deployment 10 | branch as disposable, able to be deleted or squashed or whatever whenever. 11 | Internally, `gro deploy` uses [git worktree](https://git-scm.com/docs/git-worktree) 12 | for tidiness. 13 | 14 | ```bash 15 | gro deploy # prepare build/ and commit it to the `deploy` branch, then push to go live 16 | gro deploy --source my-branch # deploy from `my-branch` instead of the default `main` 17 | 18 | # deploy to `custom-deploy-branch` instead of the default `deploy` 19 | # WARNING! this force pushes to the target branch! 20 | gro deploy --target custom-deploy-branch 21 | # the above actually fails because force pushing is destructive, so add `--force` to be extra clear: 22 | gro deploy --target custom-deploy-branch --force 23 | # TODO maybe it should be `--dangerous-target-branch` instead of `--target` and `--force`? 24 | 25 | gro deploy --dry # prepare build/ but don't commit or push 26 | gro deploy --clean # if something goes wrong, use this to reset git and gro state 27 | ``` 28 | 29 | Run `gro deploy --help` or see [`src/lib/deploy.task.ts`](/src/lib/deploy.task.ts) for the details. 30 | 31 | For needs more advanced than pushing to a remote branch, 32 | projects can implement a custom `src/lib/deploy.task.ts`. 33 | -------------------------------------------------------------------------------- /src/docs/dev.md: -------------------------------------------------------------------------------- 1 | # dev 2 | 3 | Gro is designed to extend [SvelteKit](https://github.com/sveltejs/kit) 4 | with helpful tools. It supports: 5 | 6 | - frontends with SvelteKit and [Vite](https://github.com/vitejs/vite) 7 | - Node libraries 8 | - Node servers 9 | 10 | ## usage 11 | 12 | ```bash 13 | gro dev 14 | gro dev --no-watch # outputs dev artifacts and exits without watch mode 15 | ``` 16 | 17 | To configure a project, see [the config docs](config.md). 18 | 19 | ## plugin 20 | 21 | `Plugin`s are objects that customize the behavior of `gro build` and `gro dev`. 22 | See [plugin.md](plugin.md) to learn more. 23 | 24 | ## todo 25 | 26 | - [x] basics 27 | - [ ] add API using esbuild to optionally bundle specific pieces to speed up development 28 | - [ ] livereload CSS (and fix pop-in during dev) 29 | - [ ] HMR 30 | - [ ] probably support Rollup plugins in development, but how? 31 | - [ ] improve loading speed with `cache-control: immutable` and 32 | [import maps](https://github.com/WICG/import-maps/) 33 | (on my machine Firefox is much slower than Chrome 34 | handling a module import waterfall, locally, and http2 didn't help) 35 | 36 |

37 | 38 | a pixelated green oak acorn with a glint of sun 39 | 40 |

41 | -------------------------------------------------------------------------------- /src/docs/package_json.md: -------------------------------------------------------------------------------- 1 | # `package.json` 2 | 3 | Gro extends [`package.json`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json) 4 | with additional functionality. 5 | 6 | ## `public` packages 7 | 8 | Setting `"public": true` in `package.json` opts into 9 | behavior designed for public open source projects: 10 | 11 | - [`gro_plugin_sveltekit_app`](./gro_plugin_sveltekit_app.md) 12 | copies `package.json` from your project root to your 13 | SvelteKit static directory at `.well-known/package.json` during `vite build`, 14 | mapping it with the optional 15 | [`well_known_package_json` option](./gro_plugin_sveltekit_app.md#well_known_package_json). 16 | - `gro_plugin_sveltekit_app` outputs `.well-known/src.json` 17 | using the `exports` property of `package.json` during `vite build`, 18 | containing additional information about the source modules, 19 | mapping it with the optional 20 | [`well_known_src_json` option](./gro_plugin_sveltekit_app.md#well_known_src_json). 21 | - If you define a truthy value for the 22 | [`well_known_src_files` option](./gro_plugin_sveltekit_app.md#well_known_src_files), 23 | `gro_plugin_sveltekit_app` outputs `.well-known/src/` by 24 | copying over `src/` during `vite build`, filtered by `well_known_src_files` if it's a function. 25 | This is costly (usually more than doubling the final output size 26 | of the code files in bytes, not counting images and such), 27 | it slows the build because it copies your entire source tree (sorry to hard drives), 28 | and it exposes your source code the same as the built files. 29 | 30 | > ⚠️ Setting `"public": true` in `package.json` exposes your `package.json` 31 | > and `src.json` metadata with your other built files by default! 32 | > Further opting in with `well_known_src_files` exposes your actual source files. 33 | > If your built files are public, that means these additional files are also public. 34 | -------------------------------------------------------------------------------- /src/docs/plugin.md: -------------------------------------------------------------------------------- 1 | # plugin 2 | 3 | During the [`gro dev`](dev.md) and [`gro build`](build.md) tasks, 4 | Gro uses `Plugin`s to support custom usecases outside of the normal build pipeline. 5 | 6 | In this early implementation of plugins in Gro, 7 | plugins run serially, in the order they are returned from `plugins` in the `gro.config.ts`. 8 | Each step of Gro's build processes - `gro dev` for development and `gro build` for production - 9 | runs a method of each plugin, batched together as `setup -> adapt -> teardown`, 10 | with some behavioral inconsistencies: 11 | 12 | - `adapt` only runs during production aka `gro build` 13 | - `teardown` does not run for `gro dev` in the default `watch` mode, 14 | but it does run with `gro dev --no-watch` 15 | - there should probably be a finalization step that runs `teardown` on uncaught exceptions 16 | 17 | The API needs to be improved for more advanced usecases, 18 | currently it offers little flexibility - 19 | we'll follow the Vite/SvelteKit APIs probably. (`pre` etc) 20 | Maybe let you map the array of each method batch. (is that possible with those?) 21 | 22 | Gro's builtin plugins: 23 | 24 | - [`@ryanatkn/gro/gro_plugin_server.js`](../lib/gro_plugin_server.ts) - Node server support 25 | - [`@ryanatkn/gro/gro_plugin_sveltekit_library.js`](../lib/gro_plugin_sveltekit_library.ts) - 26 | for publishing from `$lib/` with [`svelte-package`](https://svelte.dev/docs/kit/packaging) 27 | - [`@ryanatkn/gro/gro_plugin_sveltekit_app.js`](../lib/gro_plugin_sveltekit_app.ts) - 28 | see [the docs](./gro_plugin_sveltekit_app.md) 29 | - [`@ryanatkn/gro/gro_plugin_gen.js`](../lib/gro_plugin_gen.ts) - watch `src/` 30 | and efficiently run `gen` when genfiles or their deps change 31 | - [`@ryanatkn/moss/gro_plugin_moss.js`](https://github.com/ryanatkn/moss/tree/main/src/lib/gro_plugin_moss.ts) - generate the optimized 32 | [Moss](https://moss.ryanatkn.com/) classes file `$routes/moss.css`, 33 | efficiently watching `src/` and the deps 34 | 35 | > TODO add docs for the above 36 | 37 | Also see [`config.plugin` in the config docs](config.md#plugin) 38 | and usage in [the default config](../lib/gro.config.default.ts). 39 | The default config detects which plugins are included by inspecting the current project. 40 | 41 | The implementation is at [`src/lib/plugin.ts`](../lib/plugin.ts) with more details. 42 | 43 | ```ts 44 | export interface Plugin { 45 | name: string; 46 | setup?: (ctx: T_Plugin_Context) => void | Promise; 47 | adapt?: (ctx: T_Plugin_Context) => void | Promise; 48 | teardown?: (ctx: T_Plugin_Context) => void | Promise; 49 | } 50 | 51 | export interface Plugin_Context extends Task_Context { 52 | dev: boolean; 53 | watch: boolean; 54 | } 55 | ``` 56 | 57 | The `adapt` step only runs for production during `gro build`, taking after SvelteKit adapters. 58 | -------------------------------------------------------------------------------- /src/docs/tasks.gen.md.ts: -------------------------------------------------------------------------------- 1 | import {dirname, relative, basename} from 'node:path'; 2 | import {parse_path_parts, parse_path_segments} from '@ryanatkn/belt/path.js'; 3 | import {strip_start} from '@ryanatkn/belt/string.js'; 4 | 5 | import {type Gen, to_output_file_name} from '../lib/gen.ts'; 6 | import {paths, base_path_to_path_id} from '../lib/paths.ts'; 7 | import {log_error_reasons} from '../lib/task_logging.ts'; 8 | import {find_tasks, load_tasks, Task_Error} from '../lib/task.ts'; 9 | 10 | // This is the first simple implementation of Gro's automated docs. 11 | // It combines Gro's gen and task systems 12 | // to generate a markdown file with a summary of all of Gro's tasks. 13 | // Other projects that use Gro should be able to import this module 14 | // or other otherwise get frictionless access to this specific use case, 15 | // and they should be able to extend or customize it to any degree. 16 | 17 | // TODO display more info about each task, including a summary and params 18 | // TODO needs some cleanup and better APIs - paths are confusing and verbose! 19 | // TODO add backlinks to every document that links to this one 20 | 21 | export const gen: Gen = async ({origin_id, log, config}) => { 22 | const found = find_tasks(['.'], [paths.lib], config); 23 | if (!found.ok) { 24 | log_error_reasons(log, found.reasons); 25 | throw new Task_Error(`Failed to generate task docs: ${found.type}`); 26 | } 27 | const found_tasks = found.value; 28 | 29 | const loaded = await load_tasks(found_tasks); 30 | if (!loaded.ok) { 31 | log_error_reasons(log, loaded.reasons); 32 | throw new Task_Error(`Failed to generate task docs: ${loaded.type}`); 33 | } 34 | const loaded_tasks = loaded.value; 35 | const tasks = loaded_tasks.modules; 36 | 37 | const root_path = parse_path_segments(paths.root).at(-1); 38 | 39 | const origin_dir = dirname(origin_id); 40 | const origin_base = basename(origin_id); 41 | 42 | const base_dir = paths.source; 43 | const relative_path = strip_start(origin_id, base_dir); 44 | const relative_dir = dirname(relative_path); 45 | 46 | // TODO should this be passed in the context, like `defaultOutputFileName`? 47 | const output_file_name = to_output_file_name(origin_base); 48 | 49 | // TODO this is GitHub-specific 50 | const root_link = `[${root_path}](/..)`; 51 | 52 | // TODO do we want to use absolute paths instead of relative paths, 53 | // because GitHub works with them and it simplifies the code? 54 | const path_parts = parse_path_parts(relative_dir).map( 55 | (relative_path_part) => 56 | `[${parse_path_segments(relative_path_part).at(-1)}](${ 57 | relative(origin_dir, base_path_to_path_id(relative_path_part)) || './' 58 | })`, 59 | ); 60 | const breadcrumbs = 61 | '> ' + [root_link, ...path_parts, output_file_name].join(' / ') + ''; 62 | 63 | // TODO render the footer with the origin_id 64 | return `# tasks 65 | 66 | ${breadcrumbs} 67 | 68 | What is a \`Task\`? See [\`task.md\`](./task.md). 69 | 70 | ## all tasks 71 | 72 | ${tasks.reduce( 73 | (taskList, task) => 74 | taskList + 75 | `- [${task.name}](${relative(origin_dir, task.id)})${ 76 | task.mod.task.summary ? ` - ${task.mod.task.summary}` : '' 77 | }\n`, 78 | '', 79 | )} 80 | ## usage 81 | 82 | \`\`\`bash 83 | $ gro some/name 84 | \`\`\` 85 | 86 | ${breadcrumbs} 87 | 88 | > generated by [${origin_base}](${origin_base}) 89 | `; 90 | }; 91 | -------------------------------------------------------------------------------- /src/docs/tasks.md: -------------------------------------------------------------------------------- 1 | # tasks 2 | 3 | > [gro](/..) / [docs](./) / tasks.md 4 | 5 | What is a `Task`? See [`task.md`](./task.md). 6 | 7 | ## all tasks 8 | 9 | - [build](../lib/build.task.ts) - build the project 10 | - [changeset](../lib/changeset.task.ts) - call changeset with gro patterns 11 | - [check](../lib/check.task.ts) - check that everything is ready to commit 12 | - [clean](../lib/clean.task.ts) - remove temporary dev and build files, and optionally prune git branches 13 | - [commit](../lib/commit.task.ts) - commit and push to a new branch 14 | - [deploy](../lib/deploy.task.ts) - deploy to a branch 15 | - [dev](../lib/dev.task.ts) - start SvelteKit and other dev plugins 16 | - [format](../lib/format.task.ts) - format source files 17 | - [gen](../lib/gen.task.ts) - run code generation scripts 18 | - [lint](../lib/lint.task.ts) - run eslint 19 | - [publish](../lib/publish.task.ts) - bump version, publish to the configured registry, and git push 20 | - [reinstall](../lib/reinstall.task.ts) - refreshes package-lock.json with the latest and cleanest deps 21 | - [release](../lib/release.task.ts) - publish and deploy 22 | - [resolve](../lib/resolve.task.ts) - diagnostic that logs resolved filesystem info for the given input paths 23 | - [run](../lib/run.task.ts) - execute a file with the loader, like `node` but works for TypeScript 24 | - [sync](../lib/sync.task.ts) - run `gro gen`, update `package.json`, and optionally install packages to sync up 25 | - [test](../lib/test.task.ts) - run tests with uvu 26 | - [typecheck](../lib/typecheck.task.ts) - run tsc on the project without emitting any files 27 | - [upgrade](../lib/upgrade.task.ts) - upgrade deps 28 | 29 | ## usage 30 | 31 | ```bash 32 | $ gro some/name 33 | ``` 34 | 35 | > [gro](/..) / [docs](./) / tasks.md 36 | 37 | > generated by [tasks.gen.md.ts](tasks.gen.md.ts) 38 | -------------------------------------------------------------------------------- /src/docs/test.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | Gro integrates [`uvu`](https://github.com/lukeed/uvu) for tests: 4 | 5 | ```bash 6 | gro test # run all tests with Gro's default `*.test.ts` pattern 7 | gro test thing.test somedir test/a.+b # run tests matching regexp patterns 8 | ``` 9 | 10 | > Running `gro test [...args]` calls `uvu`'s `parse` and `run` helpers 11 | > inside Gro's normal [task context](/src/docs/task.md) instead of using the `uvu` CLI. 12 | > Gro typically defers to a tool's CLI, so it can transparently forward args without wrapping, 13 | > but in this case `uvu` doesn't support [loaders](https://nodejs.org/api/esm.html#loaders) 14 | > for running TypeScript files directly. 15 | > `uvu` does support require hooks, but Gro prefers the loader API. 16 | 17 | Like other tasks, use `--help` to see the args info: 18 | 19 | ```bash 20 | gro test --help 21 | ``` 22 | 23 | outputs: 24 | 25 | ``` 26 | gro test: run tests 27 | [...args] Array ["\\.test\\.ts$"] file patterns to test 28 | bail boolean false the bail option to uvu run, exit immediately on failure 29 | cwd string undefined the cwd option to uvu parse 30 | ignore string | Array undefined the ignore option to uvu parse 31 | ``` 32 | 33 | [`gro test`](/src/lib/test.task.ts) runs all `*.test.ts` 34 | files in your project by default using the regexp `"\\.test\\.ts$"`. 35 | So to add a new test, create a new file: 36 | 37 | ```ts 38 | // by convention, create `src/lib/thing.ts` 39 | // to test `src/lib/thing.test.ts` 40 | import {test} from 'uvu'; 41 | import * as assert from 'uvu/assert'; 42 | 43 | import {thing} from './thing.ts'; 44 | 45 | test('the thing', async () => { 46 | assert.equal(thing, {expected: true}); 47 | }); 48 | 49 | test.run(); 50 | ``` 51 | 52 | See [the `uvu` docs](https://github.com/lukeed/uvu) for more. 53 | -------------------------------------------------------------------------------- /src/fixtures/bar1/test1.bar.ts: -------------------------------------------------------------------------------- 1 | export const bar = 1; 2 | -------------------------------------------------------------------------------- /src/fixtures/bar2/test2.bar.ts: -------------------------------------------------------------------------------- 1 | export const bar = 2; 2 | -------------------------------------------------------------------------------- /src/fixtures/baz1/test1.baz.ts: -------------------------------------------------------------------------------- 1 | export const baz = 1; 2 | -------------------------------------------------------------------------------- /src/fixtures/baz2/test2.baz.ts: -------------------------------------------------------------------------------- 1 | export const baz = 2; 2 | -------------------------------------------------------------------------------- /src/fixtures/changelog_example.md: -------------------------------------------------------------------------------- 1 | # @ryanatkn/gro 2 | 3 | ## 0.6.0 4 | 5 | ### Minor Changes 6 | 7 | - 03da698: duplicate 1 8 | - 03da698: duplicate 2 9 | - 88f4b00: abc 10 | 11 | - 123 12 | - 123 13 | - 123 14 | 15 | ### Patch Changes 16 | 17 | - 5e94cd4: abc 18 | 19 | ## 0.5.2 20 | 21 | ### Patch Changes 22 | 23 | - e345eaa: abc 24 | 25 | ## 0.5.1 26 | 27 | ### Patch Changes 28 | 29 | - 094279d: abc 30 | 31 | ## 0.5.0 32 | 33 | ### Minor Changes 34 | 35 | - f6133f7: abc 36 | 37 | ## 0.4.3 38 | 39 | ### Patch Changes 40 | 41 | - 54b65ec: abc 42 | 43 | ## 0.4.2 44 | 45 | ### Patch Changes 46 | 47 | - 80365d0: abc 48 | 49 | ## 0.4.1 50 | 51 | ### Patch Changes 52 | 53 | - 3d84dfd: abc 54 | - fc64b77: abc 55 | 56 | ## 0.4.0 57 | 58 | ### Minor Changes 59 | 60 | - 3620932: abc 61 | - 123 62 | - 123 63 | 64 | ### Patch Changes 65 | 66 | - 3620932: abc 67 | 68 | ## 0.3.1 69 | 70 | - e 71 | 72 | ## 0.3.0 73 | 74 | - b2 75 | - c2 76 | - d2 77 | 78 | ## 0.2.0 79 | 80 | - a2 81 | 82 | ## 0.1.2 83 | 84 | - e 85 | 86 | ## 0.1.1 87 | 88 | - b 89 | - c 90 | - d 91 | 92 | ## 0.1.0 93 | 94 | - a 95 | -------------------------------------------------------------------------------- /src/fixtures/modules/Some_Test_Svelte.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 25 | 26 | Some_Test_Svelte.svelte contents 27 | 28 | {a} 29 | 30 | {b} 31 | 32 | ` `` ``` 33 | 34 | 35 | 36 | {`backticks`} 37 | {'`'} 38 | {'``'} 39 | {'```'} 40 | {`\``} 41 | {`\`\``} 42 | {`\`\`\``} 43 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_css.css: -------------------------------------------------------------------------------- 1 | /* some_test_css */ 2 | .some_test_css { 3 | --a: ok; 4 | --some_test_property: red; 5 | } 6 | 7 | /* 8 | 9 | // ` 10 | // `` 11 | // ``` 12 | `backticks`; 13 | ('`'); 14 | ('``'); 15 | ('```'); 16 | `\``; 17 | `\`\``; 18 | `\`\`\``; 19 | 20 | */ 21 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_js.js: -------------------------------------------------------------------------------- 1 | export const a = 'ok'; 2 | 3 | export const some_test_js = '.js'; 4 | 5 | export const some_test_fn = () => true; // eslint-disable-line @typescript-eslint/explicit-module-boundary-types 6 | 7 | export class Some_Test_Class { 8 | a = 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "ok", 3 | "backtick": "`", 4 | "backticks": "`back`ticks`", 5 | "some_test_json": ".json" 6 | } 7 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_json_without_extension: -------------------------------------------------------------------------------- 1 | { 2 | "some_test_json_without_extension": "no `.json` or extension needed" 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_script.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-console 2 | console.log('just a script without any exports'); 3 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_server.ts: -------------------------------------------------------------------------------- 1 | export {Some_Test_Svelte_Ts} from './some_test_svelte_ts.svelte.ts'; 2 | export {Some_Test_Svelte_Js} from './some_test_svelte_js.svelte.js'; 3 | export {some_test_ts} from './some_test_ts.ts'; 4 | export {some_test_js} from './some_test_js.js'; 5 | 6 | export const some_test_server = 'some_test_server'; 7 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_svelte_js.svelte.js: -------------------------------------------------------------------------------- 1 | export class Some_Test_Svelte_Js { 2 | a = $state('ok'); 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_svelte_ts.svelte.ts: -------------------------------------------------------------------------------- 1 | export class Some_Test_Svelte_Ts { 2 | a: string = $state('ok'); 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/modules/some_test_ts.ts: -------------------------------------------------------------------------------- 1 | export const a = 'ok'; 2 | 3 | export const some_test_ts = '.ts'; 4 | 5 | export const some_test_fn = (): boolean => true; 6 | 7 | export type Some_Test_Type = 'some_test_type_value'; 8 | 9 | export interface Some_Test_Interface { 10 | a: 1; 11 | } 12 | 13 | export class Some_Test_Class { 14 | prop: string; 15 | 16 | constructor(prop: string) { 17 | this.prop = prop; 18 | } 19 | } 20 | 21 | // ` 22 | // `` 23 | // ``` 24 | // ```` 25 | `backticks`; 26 | ('`'); 27 | ('``'); 28 | ('```'); 29 | ('````'); 30 | `\``; 31 | `\`\``; 32 | `\`\`\``; 33 | `\`\`\`\``; 34 | -------------------------------------------------------------------------------- /src/fixtures/modules/src_json_sample_exports.ts: -------------------------------------------------------------------------------- 1 | // This file contains systematic examples of all different kinds of exports in the `src_json` 2 | 3 | // Type declarations 4 | type Simple_Type = string; 5 | interface Simple_Interface { 6 | prop: string; 7 | } 8 | 9 | // Variable declarations with different kinds of values 10 | const simple_variable = 'test string'; 11 | const extra_variable = 'extra value'; // Added for mixed exports to avoid duplication 12 | const arrow_function = (): string => 'arrow function result'; 13 | const multi_line_arrow = (): string => { 14 | return 'multi-line arrow result'; 15 | }; 16 | const object_value = {key: 'value'}; 17 | const numeric_value = 123; 18 | function declared_function(): string { 19 | return 'declared function result'; 20 | } 21 | class Simple_Class { 22 | property: string; 23 | constructor(property: string) { 24 | this.property = property; 25 | } 26 | } 27 | const class_expression = class Named_Class { 28 | property: string; 29 | constructor(property: string) { 30 | this.property = property; 31 | } 32 | }; 33 | 34 | // Direct exports 35 | export const direct_variable = 'direct variable'; 36 | export const direct_arrow_function = (): string => 'direct arrow function'; 37 | export function direct_function(): string { 38 | return 'direct function'; 39 | } 40 | export type Direct_Type = boolean; 41 | export interface Direct_Interface { 42 | value: boolean; 43 | } 44 | export class Direct_Class { 45 | property: string; 46 | constructor(property: string) { 47 | this.property = property; 48 | } 49 | } 50 | 51 | // Named exports 52 | export {simple_variable}; 53 | export {arrow_function, multi_line_arrow}; 54 | export {declared_function}; 55 | export {Simple_Class}; 56 | export {class_expression}; 57 | export {object_value, numeric_value}; 58 | 59 | // Renamed exports 60 | export {simple_variable as renamed_variable}; 61 | export {arrow_function as renamed_function}; 62 | export {Simple_Class as Renamed_Class}; 63 | export type {Simple_Type as Renamed_Type}; 64 | 65 | // Type exports 66 | export type {Simple_Type}; 67 | export type {Simple_Interface}; 68 | export type {simple_variable as Variable_Type}; 69 | 70 | // Mixed exports with type specifier - using extra_variable to avoid duplicate 71 | export {extra_variable, type Simple_Type as Explicit_Type}; 72 | 73 | // Default export 74 | export default arrow_function; 75 | 76 | // Dual exports (as both type and value) 77 | const dual_purpose = 'I am both value and type'; 78 | type dual_purpose = string; 79 | export {dual_purpose}; 80 | export type {dual_purpose as dual_purpose_type}; 81 | -------------------------------------------------------------------------------- /src/fixtures/some_test_exports.ts: -------------------------------------------------------------------------------- 1 | export interface A { 2 | t: T; 3 | } 4 | export interface B { 5 | t: T; 6 | } 7 | export interface C { 8 | t: T; 9 | } 10 | export interface D { 11 | t1: T1; 12 | t2: T2; 13 | t3: T3; 14 | t4: T4; 15 | t5: T5; 16 | t6: T6; 17 | t7: T7; 18 | t8: T8; 19 | } 20 | export const E = {}; 21 | export default E; 22 | export const F = {}; 23 | -------------------------------------------------------------------------------- /src/fixtures/some_test_exports2.ts: -------------------------------------------------------------------------------- 1 | export const E3a = {}; 2 | export const E3b = {}; 3 | -------------------------------------------------------------------------------- /src/fixtures/some_test_exports3.ts: -------------------------------------------------------------------------------- 1 | export const E = {}; 2 | export default E; 3 | -------------------------------------------------------------------------------- /src/fixtures/some_test_side_effect.ts: -------------------------------------------------------------------------------- 1 | import type A from './some_test_exports.ts'; 2 | 3 | export const a: typeof A = {}; 4 | -------------------------------------------------------------------------------- /src/fixtures/test1.foo.ts: -------------------------------------------------------------------------------- 1 | export const foo = 1; 2 | -------------------------------------------------------------------------------- /src/fixtures/test2.foo.ts: -------------------------------------------------------------------------------- 1 | export const foo = 2; 2 | -------------------------------------------------------------------------------- /src/fixtures/test_failing_task_module.ts: -------------------------------------------------------------------------------- 1 | throw Error('for testing purposes'); 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/fixtures/test_file.other.ext: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /src/fixtures/test_invalid_task_module.ts: -------------------------------------------------------------------------------- 1 | // Tasks must conform to the `Task` interface, and this one does not. 2 | // Mabybe we want to support a shorthand task notation using just a function? 3 | // If so, we'll update this test fixture. 4 | export const task = (): Promise => { 5 | throw Error('This invalid task should never run!'); 6 | }; 7 | -------------------------------------------------------------------------------- /src/fixtures/test_js.js: -------------------------------------------------------------------------------- 1 | export const test1 = 'test_js'; 2 | -------------------------------------------------------------------------------- /src/fixtures/test_sveltekit_env.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import {PUBLIC_SOME_PUBLIC_ENV_VAR} from '$env/static/public'; 3 | 4 | export const exported_env_static_public = PUBLIC_SOME_PUBLIC_ENV_VAR; 5 | -------------------------------------------------------------------------------- /src/fixtures/test_task_module.task_fixture.ts: -------------------------------------------------------------------------------- 1 | import type {Task} from '../lib/task.ts'; 2 | 3 | export const task: Task = { 4 | summary: 'a test task for basic task behavior', 5 | run: ({log, args}) => { 6 | log.info('test task 1!', args); 7 | return args; 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/fixtures/test_ts.ts: -------------------------------------------------------------------------------- 1 | export const test1 = 'test_ts'; 2 | -------------------------------------------------------------------------------- /src/lib/args.test.ts: -------------------------------------------------------------------------------- 1 | import mri from 'mri'; 2 | import {suite} from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | 5 | import { 6 | serialize_args, 7 | to_forwarded_args, 8 | to_forwarded_args_by_command, 9 | to_raw_rest_args, 10 | } from './args.ts'; 11 | 12 | const test__serialize_args = suite('serialize_args'); 13 | 14 | test__serialize_args('basic behavior', () => { 15 | const raw = ['a', '-i', '1', 'b', 'c', '-i', '-i', 'three']; 16 | const parsed = mri(raw); 17 | assert.equal(parsed, {_: ['a', 'b', 'c'], i: [1, true, 'three']}); 18 | const serialized = serialize_args(parsed); 19 | assert.equal(serialized, ['a', 'b', 'c', '-i', '1', '-i', '-i', 'three']); // sorted 20 | }); 21 | 22 | test__serialize_args.run(); 23 | 24 | const test__to_forwarded_args_by_command = suite('to_forwarded_args_by_command'); 25 | 26 | test__to_forwarded_args_by_command('basic behavior', () => { 27 | const raw_rest_args = to_raw_rest_args( 28 | ( 29 | 'gro taskname a b c --d -e 1 -- -- ' + 30 | 'eslint a --b c -- ' + 31 | 'gro a --a -- ' + 32 | 'tsc -b -- ' + 33 | 'gro b -t2 t2a --t2 t2b --t222 2 -- -- -- ' + 34 | 'groc --m --n nn -- ' + 35 | 'gro d -b a --c 4 -- ' + 36 | 'gro d -b a --c 5 -- ' 37 | ).split(' '), 38 | ); 39 | assert.equal(to_forwarded_args_by_command(raw_rest_args), { 40 | eslint: {_: ['a'], b: 'c'}, 41 | 'gro a': {a: true}, 42 | tsc: {b: true}, 43 | 'gro b': {'2': 't2a', t: true, t2: 't2b', t222: 2}, 44 | groc: {m: true, n: 'nn'}, 45 | 'gro d': {b: 'a', c: 5}, 46 | }); 47 | assert.equal(to_forwarded_args('gro b', raw_rest_args), { 48 | '2': 't2a', 49 | t: true, 50 | t2: 't2b', 51 | t222: 2, 52 | }); 53 | }); 54 | 55 | test__to_forwarded_args_by_command.run(); 56 | -------------------------------------------------------------------------------- /src/lib/build.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | import type {Task} from './task.ts'; 4 | import {Plugins} from './plugin.ts'; 5 | import {clean_fs} from './clean_fs.ts'; 6 | 7 | export const Args = z 8 | .object({ 9 | sync: z.boolean({description: 'dual of no-sync'}).default(true), 10 | 'no-sync': z.boolean({description: 'opt out of gro sync'}).default(false), 11 | install: z.boolean({description: 'dual of no-install'}).default(true), 12 | 'no-install': z // convenience, same as `gro build -- gro sync --no-install` but the latter takes precedence 13 | .boolean({description: 'opt out of installing packages before building'}) 14 | .default(false), 15 | }) 16 | .strict(); 17 | export type Args = z.infer; 18 | 19 | export const task: Task = { 20 | summary: 'build the project', 21 | Args, 22 | run: async (ctx): Promise => { 23 | const {args, invoke_task} = ctx; 24 | const {sync, install} = args; 25 | 26 | if (sync) { 27 | await invoke_task('sync', {install}); 28 | } 29 | 30 | // TODO possibly detect if the git workspace is clean, and ask for confirmation if not, 31 | // because we're not doing things like `gro gen` here because that's a dev/CI concern 32 | 33 | await clean_fs({build_dist: true}); 34 | 35 | const plugins = await Plugins.create({...ctx, dev: false, watch: false}); 36 | await plugins.setup(); 37 | await plugins.adapt(); 38 | await plugins.teardown(); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/changelog.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {Logger} from '@ryanatkn/belt/log.js'; 4 | import {readFile, writeFile} from 'node:fs/promises'; 5 | import type {Fetch_Value_Cache} from '@ryanatkn/belt/fetch.js'; 6 | 7 | import {update_changelog} from './changelog.ts'; 8 | import {load_from_env} from './env.ts'; 9 | 10 | const log = new Logger(); 11 | 12 | const token = load_from_env('SECRET_GITHUB_API_TOKEN'); 13 | if (!token) { 14 | log.warn('the env var SECRET_GITHUB_API_TOKEN was not found, so API calls with be unauthorized'); 15 | } 16 | 17 | const fixture_path = 'src/fixtures/changelog_example.md'; 18 | 19 | // TODO ideally this is just a ts file, but there's a problem where building outputs a `.d.ts` file 20 | // when importing from src/fixtures (fix in SvelteKit/Vite/tsconfig?) and I want to keep it in src/fixtures 21 | const changelog_cache_fixture: Fetch_Value_Cache = new Map( 22 | JSON.parse(await readFile('src/fixtures/changelog_cache.json', 'utf8')), 23 | ); 24 | 25 | test('update_changelog', async () => { 26 | const original = await readFile(fixture_path, 'utf8'); 27 | const result = await update_changelog( 28 | 'ryanatkn', 29 | 'gro', 30 | fixture_path, 31 | token, 32 | log, 33 | changelog_cache_fixture, 34 | ); 35 | const updated = await readFile(fixture_path, 'utf8'); 36 | await writeFile(fixture_path, original, 'utf8'); 37 | assert.ok(result); 38 | assert.is( 39 | updated, 40 | `# @ryanatkn/gro 41 | 42 | ## 0.6.0 43 | 44 | ### Minor Changes 45 | 46 | - duplicate 1 ([#429](https://github.com/ryanatkn/gro/pull/429)) 47 | - duplicate 2 ([#429](https://github.com/ryanatkn/gro/pull/429)) 48 | - abc ([#437](https://github.com/ryanatkn/gro/pull/437)) 49 | 50 | - 123 51 | - 123 52 | - 123 53 | 54 | ### Patch Changes 55 | 56 | - abc ([5e94cd4](https://github.com/ryanatkn/gro/commit/5e94cd4)) 57 | 58 | ## 0.5.2 59 | 60 | ### Patch Changes 61 | 62 | - abc ([e345eaa](https://github.com/ryanatkn/gro/commit/e345eaa)) 63 | 64 | ## 0.5.1 65 | 66 | ### Patch Changes 67 | 68 | - abc ([094279d](https://github.com/ryanatkn/gro/commit/094279d)) 69 | 70 | ## 0.5.0 71 | 72 | ### Minor Changes 73 | 74 | - abc ([f6133f7](https://github.com/ryanatkn/gro/commit/f6133f7)) 75 | 76 | ## 0.4.3 77 | 78 | ### Patch Changes 79 | 80 | - abc ([54b65ec](https://github.com/ryanatkn/gro/commit/54b65ec)) 81 | 82 | ## 0.4.2 83 | 84 | ### Patch Changes 85 | 86 | - abc ([80365d0](https://github.com/ryanatkn/gro/commit/80365d0)) 87 | 88 | ## 0.4.1 89 | 90 | ### Patch Changes 91 | 92 | - abc ([3d84dfd](https://github.com/ryanatkn/gro/commit/3d84dfd)) 93 | - abc ([fc64b77](https://github.com/ryanatkn/gro/commit/fc64b77)) 94 | 95 | ## 0.4.0 96 | 97 | ### Minor Changes 98 | 99 | - abc ([#434](https://github.com/ryanatkn/gro/pull/434)) 100 | - 123 101 | - 123 102 | 103 | ### Patch Changes 104 | 105 | - abc ([#434](https://github.com/ryanatkn/gro/pull/434)) 106 | 107 | ## 0.3.1 108 | 109 | - e 110 | 111 | ## 0.3.0 112 | 113 | - b2 114 | - c2 115 | - d2 116 | 117 | ## 0.2.0 118 | 119 | - a2 120 | 121 | ## 0.1.2 122 | 123 | - e 124 | 125 | ## 0.1.1 126 | 127 | - b 128 | - c 129 | - d 130 | 131 | ## 0.1.0 132 | 133 | - a 134 | `, 135 | ); 136 | }); 137 | 138 | test.run(); 139 | -------------------------------------------------------------------------------- /src/lib/changelog.ts: -------------------------------------------------------------------------------- 1 | import {readFile, writeFile} from 'node:fs/promises'; 2 | import {z} from 'zod'; 3 | import type {Logger} from '@ryanatkn/belt/log.js'; 4 | import type {Fetch_Value_Cache} from '@ryanatkn/belt/fetch.js'; 5 | 6 | import {github_fetch_commit_prs} from './github.ts'; 7 | 8 | /** 9 | * Updates a changelog produced by `@changesets/changelog-git` with better links and formatting. 10 | * It's similar to `@changesets/changelog-github` but doesn't require a token for light usage. 11 | * This may be better implemented as a standalone dependency 12 | * as an alternative to `@changesets/changelog-git`. 13 | * @returns boolean indicating if the changelog changed 14 | */ 15 | export const update_changelog = async ( 16 | owner: string, 17 | repo: string, 18 | path = 'CHANGELOG.md', 19 | token?: string, 20 | log?: Logger, 21 | cache: Fetch_Value_Cache = new Map(), // include a default cache to efficiently handle multiple changesets per commit 22 | ): Promise => { 23 | const contents = await readFile(path, 'utf8'); 24 | const parsed = parse_changelog(contents); 25 | const mapped = await map_changelog(parsed, owner, repo, token, log, cache); 26 | const updated = serialize_changelog(mapped); 27 | if (contents === updated) { 28 | return false; 29 | } 30 | await writeFile(path, updated, 'utf8'); 31 | return true; 32 | }; 33 | 34 | // keeping this really simple for now, no need to parse further for our current usecases 35 | const Parsed_Changelog = z.array(z.string()); 36 | type Parsed_Changelog = z.infer; 37 | const parse_changelog = (contents: string): Parsed_Changelog => contents.split('\n'); 38 | const serialize_changelog = (parsed: Parsed_Changelog): string => parsed.join('\n'); 39 | 40 | const LINE_WITH_SHA_MATCHER = /^- ([a-z0-9]{7,8}): /; 41 | 42 | const map_changelog = async ( 43 | parsed: Parsed_Changelog, 44 | owner: string, 45 | repo: string, 46 | token?: string, 47 | log?: Logger, 48 | cache?: Fetch_Value_Cache, 49 | ): Promise => { 50 | const mapped: Parsed_Changelog = []; 51 | for (const line of parsed) { 52 | const matches = LINE_WITH_SHA_MATCHER.exec(line); 53 | if (matches) { 54 | const commit_sha = matches[1]; 55 | const l = '- ' + line.substring(commit_sha.length + 4); 56 | const prs = await github_fetch_commit_prs(owner, repo, commit_sha, token, log, cache); // eslint-disable-line no-await-in-loop 57 | if (prs?.length) { 58 | mapped.push(`${l} (${prs.map((p) => `[#${p.number}](${p.html_url})`).join(', ')})`); 59 | } else { 60 | mapped.push( 61 | `${l} ([${commit_sha}](https://github.com/${owner}/${repo}/commit/${commit_sha}))`, 62 | ); 63 | } 64 | } else { 65 | mapped.push(line); 66 | } 67 | } 68 | return mapped; 69 | }; 70 | -------------------------------------------------------------------------------- /src/lib/changeset_helpers.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const CHANGESET_RESTRICTED_ACCESS = 'restricted'; 4 | export const CHANGESET_PUBLIC_ACCESS = 'public'; 5 | 6 | export const Changeset_Access = z.enum([CHANGESET_RESTRICTED_ACCESS, CHANGESET_PUBLIC_ACCESS]); 7 | 8 | export const CHANGESET_CLI = 'changeset'; 9 | 10 | export const CHANGESET_DIR = '.changeset'; 11 | 12 | export const Changeset_Bump = z.enum(['patch', 'minor', 'major']); 13 | export type Changeset_Bump = z.infer; 14 | -------------------------------------------------------------------------------- /src/lib/check.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import {spawn} from '@ryanatkn/belt/process.js'; 3 | import {styleText as st} from 'node:util'; 4 | 5 | import {Task_Error, type Task} from './task.ts'; 6 | import {git_check_clean_workspace} from './git.ts'; 7 | import {sync_package_json} from './package_json.ts'; 8 | 9 | export const Args = z 10 | .object({ 11 | typecheck: z.boolean({description: 'dual of no-typecheck'}).default(true), 12 | 'no-typecheck': z.boolean({description: 'opt out of typechecking'}).default(false), 13 | test: z.boolean({description: 'dual of no-test'}).default(true), 14 | 'no-test': z.boolean({description: 'opt out of running tests'}).default(false), 15 | gen: z.boolean({description: 'dual of no-gen'}).default(true), 16 | 'no-gen': z.boolean({description: 'opt out of gen check'}).default(false), 17 | format: z.boolean({description: 'dual of no-format'}).default(true), 18 | 'no-format': z.boolean({description: 'opt out of format check'}).default(false), 19 | package_json: z.boolean({description: 'dual of no-package_json'}).default(true), 20 | 'no-package_json': z.boolean({description: 'opt out of package.json check'}).default(false), 21 | lint: z.boolean({description: 'dual of no-lint'}).default(true), 22 | 'no-lint': z.boolean({description: 'opt out of linting'}).default(false), 23 | sync: z.boolean({description: 'dual of no-sync'}).default(true), 24 | 'no-sync': z.boolean({description: 'opt out of syncing'}).default(false), 25 | install: z.boolean({description: 'dual of no-install'}).default(true), 26 | 'no-install': z 27 | .boolean({description: 'opt out of installing packages when syncing'}) 28 | .default(false), // convenience, same as `gro check -- gro sync --no-install` but the latter takes precedence 29 | workspace: z 30 | .boolean({description: 'ensure a clean git workspace, useful for CI, also implies --no-sync'}) 31 | .default(false), 32 | }) 33 | .strict(); 34 | export type Args = z.infer; 35 | 36 | export const task: Task = { 37 | summary: 'check that everything is ready to commit', 38 | Args, 39 | run: async ({args, invoke_task, log, config}) => { 40 | const {typecheck, test, gen, format, package_json, lint, sync, install, workspace} = args; 41 | 42 | // When checking the workspace, which was added for CI, never sync. 43 | // Setup like installing packages and `sveltekit-sync` should be done in the CI setup. 44 | if (sync && !workspace) { 45 | await invoke_task('sync', {install, gen: false}); // never generate because `gro gen --check` runs below 46 | } 47 | 48 | if (typecheck) { 49 | await invoke_task('typecheck'); 50 | } 51 | 52 | if (test) { 53 | await invoke_task('test'); 54 | } 55 | 56 | if (gen) { 57 | await invoke_task('gen', {check: true}); 58 | } 59 | 60 | if (package_json && config.map_package_json) { 61 | const {changed} = await sync_package_json(config.map_package_json, log, true); 62 | if (changed) { 63 | throw new Task_Error('package.json is out of date, run `gro sync` to update it'); 64 | } else { 65 | log.info('check passed for package.json'); 66 | } 67 | } 68 | 69 | if (format) { 70 | await invoke_task('format', {check: true}); 71 | } 72 | 73 | // Run the linter last to surface every other kind of problem first. 74 | // It's not the ideal order when the linter would catch errors that cause failing tests, 75 | // but it's better for most usage. 76 | if (lint) { 77 | await invoke_task('lint'); 78 | } 79 | 80 | if (workspace) { 81 | const error_message = await git_check_clean_workspace(); 82 | if (error_message) { 83 | log.error(st('red', 'git status')); 84 | await spawn('git', ['status']); 85 | throw new Task_Error( 86 | 'Failed check for git_check_clean_workspace:' + 87 | error_message + 88 | ' - do you need to run `gro sync` or commit some files?', 89 | ); 90 | } 91 | } 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /src/lib/child_process_logging.ts: -------------------------------------------------------------------------------- 1 | import type {ChildProcess} from 'node:child_process'; 2 | import {strip_end} from '@ryanatkn/belt/string.js'; 3 | 4 | /** 5 | * Maps child process output through a transform function. 6 | */ 7 | export const map_child_process_output = ( 8 | child_process: ChildProcess, 9 | transform: (data: string) => string, 10 | ): void => { 11 | if (child_process.stdout) { 12 | child_process.stdout.on('data', (data) => { 13 | process.stdout.write(transform(data.toString())); 14 | }); 15 | } 16 | 17 | if (child_process.stderr) { 18 | child_process.stderr.on('data', (data) => { 19 | process.stderr.write(transform(data.toString())); 20 | }); 21 | } 22 | }; 23 | 24 | /** 25 | * Configures process output handling with path replacements while preserving ANSI colors. 26 | */ 27 | export const configure_colored_output_with_path_replacement = ( 28 | child_process: ChildProcess, 29 | replacement: string = '.', 30 | cwd: string = process.cwd(), 31 | ): void => { 32 | // Escape special characters in the cwd for regex safety 33 | const cwd_escaped = strip_end(cwd, '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 34 | const cwd_reg_exp = new RegExp(cwd_escaped, 'g'); 35 | 36 | // Use the generic mapper with a path replacement transform 37 | map_child_process_output(child_process, (data) => data.replace(cwd_reg_exp, replacement)); 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/clean.task.ts: -------------------------------------------------------------------------------- 1 | import {spawn} from '@ryanatkn/belt/process.js'; 2 | import {z} from 'zod'; 3 | 4 | import type {Task} from './task.ts'; 5 | import {clean_fs} from './clean_fs.ts'; 6 | import {Git_Origin} from './git.ts'; 7 | 8 | export const Args = z 9 | .object({ 10 | build_dev: z.boolean({description: 'delete the Gro build dev directory'}).default(false), 11 | build_dist: z.boolean({description: 'delete the Gro build dist directory'}).default(false), 12 | sveltekit: z 13 | .boolean({description: 'delete the SvelteKit directory and Vite cache'}) 14 | .default(false), 15 | nodemodules: z.boolean({description: 'delete the node_modules directory'}).default(false), 16 | git: z 17 | .boolean({ 18 | description: 19 | 'run "git remote prune" to delete local branches referencing nonexistent remote branches', 20 | }) 21 | .default(false), 22 | git_origin: Git_Origin.describe('the origin to "git remote prune"').default('origin'), 23 | }) 24 | .strict(); 25 | export type Args = z.infer; 26 | 27 | export const task: Task = { 28 | summary: 'remove temporary dev and build files, and optionally prune git branches', 29 | Args, 30 | run: async ({args}): Promise => { 31 | const {build_dev, build_dist, sveltekit, nodemodules, git, git_origin} = args; 32 | 33 | await clean_fs({ 34 | build: !build_dev && !build_dist, 35 | build_dev, 36 | build_dist, 37 | sveltekit, 38 | nodemodules, 39 | }); 40 | 41 | // lop off stale git branches 42 | if (git) { 43 | await spawn('git', ['remote', 'prune', git_origin]); 44 | } 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/clean_fs.ts: -------------------------------------------------------------------------------- 1 | import {rm} from 'node:fs/promises'; 2 | import {readdirSync, type RmOptions} from 'node:fs'; 3 | 4 | import {paths} from './paths.ts'; 5 | import { 6 | NODE_MODULES_DIRNAME, 7 | GRO_DIST_PREFIX, 8 | SVELTEKIT_DEV_DIRNAME, 9 | SVELTEKIT_BUILD_DIRNAME, 10 | SVELTEKIT_VITE_CACHE_PATH, 11 | SVELTEKIT_DIST_DIRNAME, 12 | } from './constants.ts'; 13 | 14 | export const clean_fs = async ( 15 | { 16 | build = false, 17 | build_dev = false, 18 | build_dist = false, 19 | sveltekit = false, 20 | nodemodules = false, 21 | }: { 22 | build?: boolean; 23 | build_dev?: boolean; 24 | build_dist?: boolean; 25 | sveltekit?: boolean; 26 | nodemodules?: boolean; 27 | }, 28 | rm_options: RmOptions = {force: true, recursive: true}, 29 | ): Promise => { 30 | const promises: Array> = []; 31 | 32 | if (build) { 33 | promises.push(rm(paths.build, rm_options)); 34 | } else if (build_dev) { 35 | promises.push(rm(paths.build_dev, rm_options)); 36 | } 37 | if (build || build_dist) { 38 | const paths = readdirSync('.').filter((p) => p.startsWith(GRO_DIST_PREFIX)); 39 | for (const path of paths) { 40 | promises.push(rm(path, rm_options)); 41 | } 42 | } 43 | if (sveltekit) { 44 | promises.push(rm(SVELTEKIT_DEV_DIRNAME, rm_options)); 45 | promises.push(rm(SVELTEKIT_BUILD_DIRNAME, rm_options)); 46 | promises.push(rm(SVELTEKIT_DIST_DIRNAME, rm_options)); 47 | promises.push(rm(SVELTEKIT_VITE_CACHE_PATH, rm_options)); 48 | } 49 | if (nodemodules) { 50 | promises.push(rm(NODE_MODULES_DIRNAME, rm_options)); 51 | } 52 | 53 | await Promise.all(promises); 54 | }; 55 | -------------------------------------------------------------------------------- /src/lib/cli.ts: -------------------------------------------------------------------------------- 1 | import {spawnSync, type SpawnOptions} from 'node:child_process'; 2 | import { 3 | spawn, 4 | spawn_process, 5 | type Spawn_Result, 6 | type Spawned_Process, 7 | } from '@ryanatkn/belt/process.js'; 8 | import {join} from 'node:path'; 9 | import {existsSync} from 'node:fs'; 10 | import {fileURLToPath, type URL} from 'node:url'; 11 | import type {Logger} from '@ryanatkn/belt/log.js'; 12 | 13 | import {NODE_MODULES_DIRNAME} from './constants.ts'; 14 | import type {Path_Id} from './path.ts'; 15 | import {print_command_args} from './args.ts'; 16 | 17 | // TODO maybe upstream to Belt? 18 | 19 | export type Cli = 20 | | {kind: 'local'; name: string; id: Path_Id} 21 | | {kind: 'global'; name: string; id: Path_Id}; 22 | 23 | /** 24 | * Searches the filesystem for the CLI `name`, first local to the cwd and then globally. 25 | * @returns `null` if not found locally or globally 26 | */ 27 | export const find_cli = ( 28 | name: string, 29 | cwd: string | URL = process.cwd(), 30 | options?: SpawnOptions, 31 | ): Cli | null => { 32 | const final_cwd = typeof cwd === 'string' ? cwd : fileURLToPath(cwd); 33 | const local_id = join(final_cwd, NODE_MODULES_DIRNAME, `.bin/${name}`); 34 | if (existsSync(local_id)) { 35 | return {name, id: local_id, kind: 'local'}; 36 | } 37 | const {stdout} = spawnSync('which', [name], options); 38 | const global_id = stdout.toString().trim(); 39 | if (!global_id) return null; 40 | return {name, id: global_id, kind: 'global'}; 41 | }; 42 | 43 | /** 44 | * Spawns a CLI if available using Belt's `spawn`. 45 | * If a string is provided for `name_or_cli`, it checks first local to the cwd and then globally. 46 | * @returns `undefined` if no CLI is found, or the spawn result 47 | */ 48 | export const spawn_cli = async ( 49 | name_or_cli: string | Cli, 50 | args: Array = [], 51 | log?: Logger, 52 | options?: SpawnOptions, 53 | ): Promise => { 54 | const cli = resolve_cli(name_or_cli, args, options?.cwd, log, options); 55 | if (!cli) return; 56 | return spawn(cli.id, args, options); 57 | }; 58 | 59 | /** 60 | * Spawns a CLI if available using Belt's `spawn_process`. 61 | * If a string is provided for `name_or_cli`, it checks first local to the cwd and then globally. 62 | * @returns `undefined` if no CLI is found, or the spawn result 63 | */ 64 | export const spawn_cli_process = ( 65 | name_or_cli: string | Cli, 66 | args: Array = [], 67 | log?: Logger, 68 | options?: SpawnOptions, 69 | ): Spawned_Process | undefined => { 70 | const cli = resolve_cli(name_or_cli, args, options?.cwd, log, options); 71 | if (!cli) return; 72 | return spawn_process(cli.id, args, options); 73 | }; 74 | 75 | export const resolve_cli = ( 76 | name_or_cli: string | Cli, 77 | args: Array = [], 78 | cwd: string | URL | undefined, 79 | log?: Logger, 80 | options?: SpawnOptions, 81 | ): Cli | undefined => { 82 | let final_cli; 83 | if (typeof name_or_cli === 'string') { 84 | const found = find_cli(name_or_cli, cwd, options); 85 | if (!found) return; 86 | final_cli = found; 87 | } else { 88 | final_cli = name_or_cli; 89 | } 90 | if (log) { 91 | log.info(print_command_args([final_cli.name].concat(args))); 92 | } 93 | return final_cli; 94 | }; 95 | 96 | export const to_cli_name = (cli: string | Cli): string => 97 | typeof cli === 'string' ? cli : cli.name; 98 | -------------------------------------------------------------------------------- /src/lib/commit.task.ts: -------------------------------------------------------------------------------- 1 | import {spawn} from '@ryanatkn/belt/process.js'; 2 | import {z} from 'zod'; 3 | 4 | import type {Task} from './task.ts'; 5 | import {Git_Origin, git_current_branch_name, git_push} from './git.ts'; 6 | 7 | export const Args = z 8 | .object({ 9 | _: z 10 | .array(z.string(), { 11 | description: 'the git commit message, the same as git commit -m or --message', 12 | }) 13 | .default([]), 14 | origin: Git_Origin.describe('git origin to commit to').default('origin'), 15 | }) 16 | .strict(); 17 | export type Args = z.infer; 18 | 19 | export const task: Task = { 20 | summary: 'commit and push to a new branch', 21 | Args, 22 | run: async ({args}): Promise => { 23 | const { 24 | _: [message], 25 | origin, 26 | } = args; 27 | 28 | const branch = await git_current_branch_name(); 29 | 30 | await spawn('git', ['commit', '-a', '-m', message]); 31 | await git_push(origin, branch, undefined, true); 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This module is intended to have no dependencies to avoid over-imports in the CLI and loader. 4 | If any of these become customizable from SvelteKit or Gro's configs, move them to `./paths.ts`. 5 | 6 | */ 7 | 8 | // TODO the slashes here are kinda gross - do we want to maintain the convention to have the trailing slash in most usage? 9 | 10 | export const SOURCE_DIRNAME = 'src'; 11 | export const GRO_DIRNAME = '.gro'; 12 | export const GRO_DIST_PREFIX = 'dist_'; // 13 | export const SERVER_DIST_PATH = 'dist_server'; // TODO should all of these be `_PATH` or should this be `DIRNAME`? also, add `_PLUGIN` to this name? 14 | export const GRO_DEV_DIRNAME = GRO_DIRNAME + '/dev'; 15 | /** @trailing_slash */ 16 | export const SOURCE_DIR = SOURCE_DIRNAME + '/'; 17 | /** @trailing_slash */ 18 | export const GRO_DIR = GRO_DIRNAME + '/'; 19 | /** @trailing_slash */ 20 | export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; 21 | export const GRO_CONFIG_PATH = 'gro.config.ts'; 22 | export const README_FILENAME = 'README.md'; 23 | export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; 24 | export const VITE_CONFIG_FILENAME = 'vite.config.ts'; 25 | export const NODE_MODULES_DIRNAME = 'node_modules'; 26 | export const LOCKFILE_FILENAME = 'package-lock.json'; 27 | export const SVELTEKIT_DEV_DIRNAME = '.svelte-kit'; // TODO use Svelte config value `outDir` 28 | export const SVELTEKIT_BUILD_DIRNAME = 'build'; 29 | export const SVELTEKIT_DIST_DIRNAME = 'dist'; 30 | export const SVELTEKIT_VITE_CACHE_PATH = NODE_MODULES_DIRNAME + '/.vite'; 31 | export const GITHUB_DIRNAME = '.github'; 32 | export const GIT_DIRNAME = '.git'; 33 | export const TSCONFIG_FILENAME = 'tsconfig.json'; 34 | 35 | export const TS_MATCHER = /\.(ts|tsx|mts|cts)$/; 36 | export const JS_MATCHER = /\.(js|jsx|mjs|cjs)$/; 37 | export const JSON_MATCHER = /\.json$/; 38 | export const SVELTE_MATCHER = /\.svelte$/; 39 | export const SVELTE_RUNES_MATCHER = /\.svelte\.(js|ts)$/; // TODO probably let `.svelte.` appear anywhere - https://github.com/sveltejs/svelte/issues/11536 40 | /** Extracts the script content from Svelte files. */ 41 | export const SVELTE_SCRIPT_MATCHER = /]*)?>([\s\S]*?)<\/script>/gim; // TODO maybe this shouldnt be global? or make a getter? 42 | export const EVERYTHING_MATCHER = /.*/; 43 | 44 | export const JS_CLI_DEFAULT = 'node'; 45 | export const PM_CLI_DEFAULT = 'npm'; 46 | export const PRETTIER_CLI_DEFAULT = 'prettier'; 47 | -------------------------------------------------------------------------------- /src/lib/dev.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | import type {Task} from './task.ts'; 4 | import {Plugins, type Plugin_Context} from './plugin.ts'; 5 | import {clean_fs} from './clean_fs.ts'; 6 | 7 | export const Args = z 8 | .object({ 9 | watch: z.boolean({description: 'dual of no-watch'}).default(true), 10 | 'no-watch': z 11 | .boolean({ 12 | description: 13 | 'opt out of running a long-lived process to watch files and rebuild on changes', 14 | }) 15 | .default(false), 16 | sync: z.boolean({description: 'dual of no-sync'}).default(true), 17 | 'no-sync': z.boolean({description: 'opt out of gro sync'}).default(false), 18 | install: z.boolean({description: 'dual of no-install'}).default(true), 19 | 'no-install': z // convenience, same as `gro dev -- gro sync --no-install` but the latter takes precedence 20 | .boolean({description: 'opt out of installing packages before starting the dev server'}) 21 | .default(false), 22 | }) 23 | .strict(); 24 | export type Args = z.infer; 25 | 26 | export type DevTask_Context = Plugin_Context; 27 | 28 | export const task: Task = { 29 | summary: 'start SvelteKit and other dev plugins', 30 | Args, 31 | run: async (ctx) => { 32 | const {args, invoke_task} = ctx; 33 | const {watch, sync, install} = args; 34 | 35 | await clean_fs({build_dev: true}); 36 | 37 | if (sync) { 38 | await invoke_task('sync', {install}); 39 | } 40 | 41 | const plugins = await Plugins.create({...ctx, dev: true, watch}); 42 | await plugins.setup(); 43 | if (!watch) { 44 | await plugins.teardown(); 45 | } 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import {resolve} from 'node:path'; 3 | import {existsSync, readFileSync} from 'node:fs'; 4 | 5 | export const load_env = ( 6 | dev: boolean, 7 | visibility: 'public' | 'private', 8 | public_prefix: string, 9 | private_prefix: string, 10 | env_dir?: string, 11 | env_files = ['.env', '.env.' + (dev ? 'development' : 'production')], 12 | ambient_env = process.env, 13 | ): Record => { 14 | const envs: Array> = env_files 15 | .map((path) => load(env_dir === undefined ? path : resolve(env_dir, path))) 16 | .filter((v) => v !== undefined); 17 | envs.push(ambient_env); 18 | return merge_envs(envs, visibility, public_prefix, private_prefix); 19 | }; 20 | 21 | const load = (path: string): Record | undefined => { 22 | if (!existsSync(path)) return; 23 | const loaded = readFileSync(path, 'utf8'); 24 | return dotenv.parse(loaded); 25 | }; 26 | 27 | export const merge_envs = ( 28 | envs: Array>, 29 | visibility: 'public' | 'private', 30 | public_prefix: string, 31 | private_prefix: string, 32 | ): Record => { 33 | const env: Record = {}; 34 | 35 | for (const e of envs) { 36 | for (const key in e) { 37 | if ( 38 | (visibility === 'private' && is_private_env(key, public_prefix, private_prefix)) || 39 | (visibility === 'public' && is_public_env(key, public_prefix, private_prefix)) 40 | ) { 41 | const value = e[key]; 42 | if (value !== undefined) env[key] = value; 43 | } 44 | } 45 | } 46 | 47 | return env; 48 | }; 49 | 50 | export const is_private_env = ( 51 | key: string, 52 | public_prefix: string, 53 | private_prefix: string, 54 | ): boolean => 55 | key.startsWith(private_prefix) && (public_prefix === '' || !key.startsWith(public_prefix)); 56 | 57 | export const is_public_env = ( 58 | key: string, 59 | public_prefix: string, 60 | private_prefix: string, 61 | ): boolean => 62 | key.startsWith(public_prefix) && (private_prefix === '' || !key.startsWith(private_prefix)); 63 | 64 | /** 65 | * Loads a single env value without merging it into `process.env`. 66 | * By default searches process.env, then a local `.env` if one exists, then `../.env` if it exists. 67 | */ 68 | export const load_from_env = (key: string, paths = ['.env', '../.env']): string | undefined => { 69 | if (process.env[key]) return process.env[key]; 70 | for (const path of paths) { 71 | const env = load(path); 72 | if (env?.[key]) return env[key]; 73 | } 74 | return undefined; 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/esbuild_helpers.ts: -------------------------------------------------------------------------------- 1 | import {styleText as st} from 'node:util'; 2 | import type {Logger} from '@ryanatkn/belt/log.js'; 3 | import type * as esbuild from 'esbuild'; 4 | 5 | import type {Parsed_Svelte_Config} from './svelte_config.ts'; 6 | 7 | export const print_build_result = (log: Logger, build_result: esbuild.BuildResult): void => { 8 | for (const error of build_result.errors) { 9 | log.error(st('red', 'esbuild error'), error); 10 | } 11 | for (const warning of build_result.warnings) { 12 | log.warn(st('yellow', 'esbuild warning'), warning); 13 | } 14 | }; 15 | 16 | // This concatenates weirdly to avoid a SvelteKit warning, 17 | // because SvelteKit detects usage as a string and not the AST. 18 | const import_meta_env = 'import.' + 'meta.env.'; // eslint-disable-line no-useless-concat 19 | 20 | /** 21 | * Creates an esbuild `define` shim for Vite's `import.meta\.env`. 22 | * @see https://esbuild.github.io/api/#define 23 | * @param dev 24 | * @param base_url - best-effort shim from SvelteKit's `base` to Vite's `import.meta\.env.BASE_URL` 25 | * @param ssr 26 | * @param mode 27 | * @returns 28 | */ 29 | export const to_define_import_meta_env = ( 30 | dev: boolean, 31 | base_url: Parsed_Svelte_Config['base_url'], 32 | ssr = true, 33 | mode = dev ? 'development' : 'production', 34 | ): Record => ({ 35 | // see `import_meta_env` for why this is defined weirdly instead of statically 36 | [import_meta_env + 'DEV']: JSON.stringify(dev), 37 | [import_meta_env + 'PROD']: JSON.stringify(!dev), 38 | [import_meta_env + 'SSR']: JSON.stringify(ssr), 39 | [import_meta_env + 'MODE']: JSON.stringify(mode), 40 | // it appears SvelteKit's `''` translates to Vite's `'/'`, so this intentionally falls back for falsy values, not just undefined 41 | [import_meta_env + 'BASE_URL']: JSON.stringify(base_url || '/'), 42 | }); 43 | 44 | export const default_ts_transform_options: esbuild.TransformOptions = { 45 | target: 'esnext', // TODO load local tsconfig 46 | format: 'esm', 47 | loader: 'ts', 48 | charset: 'utf8', 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_external_worker.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import type {Logger} from '@ryanatkn/belt/log.js'; 3 | import {basename} from 'node:path'; 4 | import type {CompileOptions, ModuleCompileOptions, PreprocessorGroup} from 'svelte/compiler'; 5 | 6 | import {print_build_result, to_define_import_meta_env} from './esbuild_helpers.ts'; 7 | import {resolve_specifier} from './resolve_specifier.ts'; 8 | import {esbuild_plugin_sveltekit_shim_alias} from './esbuild_plugin_sveltekit_shim_alias.ts'; 9 | import {esbuild_plugin_sveltekit_shim_env} from './esbuild_plugin_sveltekit_shim_env.ts'; 10 | import {esbuild_plugin_sveltekit_shim_app} from './esbuild_plugin_sveltekit_shim_app.ts'; 11 | import {esbuild_plugin_sveltekit_local_imports} from './esbuild_plugin_sveltekit_local_imports.ts'; 12 | import {esbuild_plugin_svelte} from './esbuild_plugin_svelte.ts'; 13 | import type {Parsed_Svelte_Config} from './svelte_config.ts'; 14 | import type {Path_Id} from './path.ts'; 15 | 16 | export interface Esbuild_Plugin_External_Worker_Options { 17 | dev: boolean; 18 | build_options: esbuild.BuildOptions; 19 | dir?: string; 20 | svelte_compile_options?: CompileOptions; 21 | svelte_compile_module_options?: ModuleCompileOptions; 22 | svelte_preprocessors?: PreprocessorGroup | Array; 23 | alias?: Record; 24 | base_url?: Parsed_Svelte_Config['base_url']; 25 | assets_url?: Parsed_Svelte_Config['assets_url']; 26 | public_prefix?: string; 27 | private_prefix?: string; 28 | env_dir?: string; 29 | env_files?: Array; 30 | ambient_env?: Record; 31 | log?: Logger; 32 | } 33 | 34 | export const esbuild_plugin_external_worker = ({ 35 | dev, 36 | build_options, 37 | dir = process.cwd(), 38 | svelte_compile_options, 39 | svelte_compile_module_options, 40 | svelte_preprocessors, 41 | alias, 42 | base_url, 43 | assets_url, 44 | public_prefix, 45 | private_prefix, 46 | env_dir, 47 | env_files, 48 | ambient_env, 49 | log, 50 | }: Esbuild_Plugin_External_Worker_Options): esbuild.Plugin => ({ 51 | name: 'external_worker', 52 | setup: (build) => { 53 | const builds: Map> = new Map(); 54 | const build_worker = async (path_id: Path_Id): Promise => { 55 | if (builds.has(path_id)) return builds.get(path_id)!; 56 | const building = esbuild.build({ 57 | entryPoints: [path_id], 58 | plugins: [ 59 | esbuild_plugin_sveltekit_shim_app({dev, base_url, assets_url}), 60 | esbuild_plugin_sveltekit_shim_env({ 61 | dev, 62 | public_prefix, 63 | private_prefix, 64 | env_dir, 65 | env_files, 66 | ambient_env, 67 | }), 68 | esbuild_plugin_sveltekit_shim_alias({dir, alias}), 69 | esbuild_plugin_svelte({ 70 | dev, 71 | base_url, 72 | dir, 73 | svelte_compile_options, 74 | svelte_compile_module_options, 75 | svelte_preprocessors, 76 | }), 77 | esbuild_plugin_sveltekit_local_imports(), 78 | ], 79 | define: to_define_import_meta_env(dev, base_url), 80 | ...build_options, 81 | }); 82 | builds.set(path_id, building); 83 | return building; 84 | }; 85 | 86 | build.onResolve({filter: /\.worker(|\.js|\.ts)$/}, async ({path, resolveDir}) => { 87 | const parsed = resolve_specifier(path, resolveDir); 88 | const {specifier, path_id, namespace} = parsed; 89 | const build_result = await build_worker(path_id); 90 | if (log) print_build_result(log, build_result); 91 | return {path: './' + basename(specifier), external: true, namespace}; 92 | }); 93 | }, 94 | }); 95 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_svelte.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import * as esbuild from 'esbuild'; 4 | import {readFile, rm} from 'node:fs/promises'; 5 | 6 | import {esbuild_plugin_svelte} from './esbuild_plugin_svelte.ts'; 7 | import {default_svelte_config} from './svelte_config.ts'; 8 | 9 | // TODO improve these tests to have automatic caching 10 | 11 | test('build for the client', async () => { 12 | const outfile = './src/fixtures/modules/some_test_server_bundle_DELETEME.js'; 13 | const built = await esbuild.build({ 14 | entryPoints: ['./src/fixtures/modules/some_test_server.ts'], 15 | plugins: [ 16 | esbuild_plugin_svelte({ 17 | dev: true, 18 | base_url: default_svelte_config.base_url, 19 | svelte_compile_options: {generate: 'client'}, 20 | }), 21 | ], 22 | outfile, 23 | format: 'esm', 24 | platform: 'node', 25 | packages: 'external', 26 | bundle: true, 27 | target: 'esnext', 28 | }); 29 | assert.is(built.errors.length, 0); 30 | assert.is(built.warnings.length, 0); 31 | 32 | const built_output = await readFile(outfile, 'utf8'); 33 | await rm(outfile); // TODO could be cleaner 34 | assert.is( 35 | built_output, 36 | `// src/fixtures/modules/some_test_svelte_ts.svelte.ts 37 | import * as $ from "svelte/internal/client"; 38 | var Some_Test_Svelte_Ts = class { 39 | #a = $.state("ok"); 40 | get a() { 41 | return $.get(this.#a); 42 | } 43 | set a(value) { 44 | $.set(this.#a, value, true); 45 | } 46 | }; 47 | 48 | // src/fixtures/modules/some_test_svelte_js.svelte.js 49 | import * as $2 from "svelte/internal/client"; 50 | var Some_Test_Svelte_Js = class { 51 | #a = $2.state("ok"); 52 | get a() { 53 | return $2.get(this.#a); 54 | } 55 | set a(value) { 56 | $2.set(this.#a, value, true); 57 | } 58 | }; 59 | 60 | // src/fixtures/modules/some_test_ts.ts 61 | var some_test_ts = ".ts"; 62 | 63 | // src/fixtures/modules/some_test_js.js 64 | var some_test_js = ".js"; 65 | 66 | // src/fixtures/modules/some_test_server.ts 67 | var some_test_server = "some_test_server"; 68 | export { 69 | Some_Test_Svelte_Js, 70 | Some_Test_Svelte_Ts, 71 | some_test_js, 72 | some_test_server, 73 | some_test_ts 74 | }; 75 | `, 76 | ); 77 | }); 78 | 79 | test('build for the server', async () => { 80 | const outfile = './src/fixtures/modules/some_test_client_bundle_DELETEME.js'; 81 | const built = await esbuild.build({ 82 | entryPoints: ['./src/fixtures/modules/some_test_server.ts'], 83 | plugins: [ 84 | esbuild_plugin_svelte({ 85 | dev: true, 86 | base_url: default_svelte_config.base_url, 87 | }), 88 | ], 89 | outfile, 90 | format: 'esm', 91 | platform: 'node', 92 | packages: 'external', 93 | bundle: true, 94 | target: 'esnext', 95 | }); 96 | assert.is(built.errors.length, 0); 97 | assert.is(built.warnings.length, 0); 98 | 99 | const built_output = await readFile(outfile, 'utf8'); 100 | await rm(outfile); // TODO could be cleaner 101 | assert.is( 102 | built_output, 103 | `// src/fixtures/modules/some_test_svelte_ts.svelte.ts 104 | import * as $ from "svelte/internal/server"; 105 | var Some_Test_Svelte_Ts = class { 106 | a = "ok"; 107 | }; 108 | 109 | // src/fixtures/modules/some_test_svelte_js.svelte.js 110 | import * as $2 from "svelte/internal/server"; 111 | var Some_Test_Svelte_Js = class { 112 | a = "ok"; 113 | }; 114 | 115 | // src/fixtures/modules/some_test_ts.ts 116 | var some_test_ts = ".ts"; 117 | 118 | // src/fixtures/modules/some_test_js.js 119 | var some_test_js = ".js"; 120 | 121 | // src/fixtures/modules/some_test_server.ts 122 | var some_test_server = "some_test_server"; 123 | export { 124 | Some_Test_Svelte_Js, 125 | Some_Test_Svelte_Ts, 126 | some_test_js, 127 | some_test_server, 128 | some_test_ts 129 | }; 130 | `, 131 | ); 132 | }); 133 | 134 | test.run(); 135 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_svelte.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import { 3 | compile, 4 | compileModule, 5 | preprocess, 6 | type CompileOptions, 7 | type ModuleCompileOptions, 8 | type PreprocessorGroup, 9 | } from 'svelte/compiler'; 10 | import {readFile} from 'node:fs/promises'; 11 | import {relative} from 'node:path'; 12 | 13 | import {to_define_import_meta_env, default_ts_transform_options} from './esbuild_helpers.ts'; 14 | import { 15 | default_svelte_config, 16 | to_default_compile_module_options, 17 | type Parsed_Svelte_Config, 18 | } from './svelte_config.ts'; 19 | import {TS_MATCHER, SVELTE_MATCHER, SVELTE_RUNES_MATCHER} from './constants.ts'; 20 | 21 | export interface Esbuild_Plugin_Svelte_Options { 22 | dev: boolean; 23 | base_url: Parsed_Svelte_Config['base_url']; 24 | dir?: string; 25 | svelte_compile_options?: CompileOptions; 26 | svelte_compile_module_options?: ModuleCompileOptions; 27 | svelte_preprocessors?: PreprocessorGroup | Array; 28 | ts_transform_options?: esbuild.TransformOptions; 29 | is_ts?: (filename: string) => boolean; 30 | } 31 | 32 | export const esbuild_plugin_svelte = (options: Esbuild_Plugin_Svelte_Options): esbuild.Plugin => { 33 | const { 34 | dev, 35 | base_url, 36 | dir = process.cwd(), 37 | svelte_compile_options = default_svelte_config.svelte_compile_options, 38 | svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options), 39 | svelte_preprocessors, 40 | ts_transform_options = default_ts_transform_options, 41 | is_ts = (f) => TS_MATCHER.test(f), 42 | } = options; 43 | 44 | const final_ts_transform_options: esbuild.TransformOptions = { 45 | ...ts_transform_options, 46 | define: to_define_import_meta_env(dev, base_url), 47 | sourcemap: 'inline', 48 | }; 49 | 50 | return { 51 | name: 'svelte', 52 | setup: (build) => { 53 | build.onLoad({filter: SVELTE_RUNES_MATCHER}, async ({path}) => { 54 | const source = await readFile(path, 'utf8'); 55 | try { 56 | const filename = relative(dir, path); 57 | const js_source = is_ts(filename) 58 | ? ( 59 | await esbuild.transform(source, { 60 | ...final_ts_transform_options, 61 | sourcefile: filename, 62 | }) 63 | ).code // TODO @many use warnings? handle not-inline sourcemaps? 64 | : source; 65 | const {js, warnings} = compileModule(js_source, { 66 | ...svelte_compile_module_options, 67 | filename, 68 | }); 69 | const contents = js.code + '//# sourceMappingURL=' + js.map.toUrl(); 70 | return { 71 | contents, 72 | warnings: warnings.map((w) => convert_svelte_message_to_esbuild(filename, source, w)), 73 | }; 74 | } catch (err) { 75 | return {errors: [convert_svelte_message_to_esbuild(path, source, err)]}; 76 | } 77 | }); 78 | 79 | build.onLoad({filter: SVELTE_MATCHER}, async ({path}) => { 80 | let source = await readFile(path, 'utf8'); 81 | try { 82 | const filename = relative(dir, path); 83 | const preprocessed = svelte_preprocessors 84 | ? await preprocess(source, svelte_preprocessors, {filename}) 85 | : null; 86 | if (preprocessed?.code) source = preprocessed.code; 87 | const {js, warnings} = compile(source, {...svelte_compile_options, filename}); 88 | const contents = js.code + '//# sourceMappingURL=' + js.map.toUrl(); 89 | return { 90 | contents, 91 | warnings: warnings.map((w) => convert_svelte_message_to_esbuild(filename, source, w)), 92 | }; 93 | } catch (err) { 94 | return {errors: [convert_svelte_message_to_esbuild(path, source, err)]}; 95 | } 96 | }); 97 | }, 98 | }; 99 | }; 100 | 101 | /** 102 | * Following the example in the esbuild docs: 103 | * https://esbuild.github.io/plugins/#svelte-plugin 104 | */ 105 | const convert_svelte_message_to_esbuild = ( 106 | path: string, 107 | source: string, 108 | {message, start, end}: SvelteError, 109 | ): esbuild.PartialMessage => { 110 | let location: esbuild.PartialMessage['location'] = null; 111 | if (start && end) { 112 | const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]; 113 | const lineEnd = start.line === end.line ? end.column : lineText.length; 114 | location = { 115 | file: path, 116 | line: start.line, 117 | lineText, 118 | column: start.column, 119 | length: lineEnd - start.column, 120 | }; 121 | } 122 | return {text: message, location}; 123 | }; 124 | 125 | // these are not exported by Svelte 126 | interface SvelteError { 127 | message: string; 128 | start?: LineInfo; 129 | end?: LineInfo; 130 | } 131 | interface LineInfo { 132 | line: number; 133 | column: number; 134 | } 135 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_sveltekit_local_imports.ts: -------------------------------------------------------------------------------- 1 | import type * as esbuild from 'esbuild'; 2 | import {readFile} from 'node:fs/promises'; 3 | import {dirname} from 'node:path'; 4 | 5 | import {resolve_specifier} from './resolve_specifier.ts'; 6 | import {EVERYTHING_MATCHER} from './constants.ts'; 7 | 8 | /** 9 | * Adds support for imports to both `.ts` and `.js`, 10 | * as well as imports without extensions that resolve to `.js` or `.ts`. 11 | * Prefers `.ts` over any `.js`, and falls back to `.ts` if no file is found. 12 | */ 13 | export const esbuild_plugin_sveltekit_local_imports = (): esbuild.Plugin => ({ 14 | name: 'sveltekit_local_imports', 15 | setup: (build) => { 16 | build.onResolve({filter: /^(\/|\.)/}, (args) => { 17 | const {path, importer} = args; 18 | if (!importer) return {path}; 19 | const {path_id, namespace} = resolve_specifier(path, dirname(importer)); 20 | return {path: path_id, namespace}; // `namespace` may be `undefined`, but esbuild needs the absolute path for json etc 21 | }); 22 | build.onLoad( 23 | {filter: EVERYTHING_MATCHER, namespace: 'sveltekit_local_imports_ts'}, 24 | async ({path}) => ({ 25 | contents: await readFile(path), 26 | loader: 'ts', 27 | resolveDir: dirname(path), 28 | }), 29 | ); 30 | build.onLoad( 31 | {filter: EVERYTHING_MATCHER, namespace: 'sveltekit_local_imports_js'}, 32 | async ({path}) => ({ 33 | contents: await readFile(path), 34 | resolveDir: dirname(path), 35 | }), 36 | ); 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_sveltekit_shim_alias.ts: -------------------------------------------------------------------------------- 1 | import type * as esbuild from 'esbuild'; 2 | import {escape_regexp} from '@ryanatkn/belt/regexp.js'; 3 | import {join} from 'node:path'; 4 | 5 | export interface Esbuild_Plugin_Sveltekit_Shim_Alias_Options { 6 | dir?: string; 7 | alias?: Record; 8 | } 9 | 10 | export const esbuild_plugin_sveltekit_shim_alias = ({ 11 | dir = process.cwd(), 12 | alias, 13 | }: Esbuild_Plugin_Sveltekit_Shim_Alias_Options): esbuild.Plugin => ({ 14 | name: 'sveltekit_shim_alias', 15 | setup: (build) => { 16 | const aliases: Record = {$lib: 'src/lib', ...alias}; 17 | // Create a Go-compatible regexp 18 | const filter = new RegExp(`^(?:${Object.keys(aliases).map(escape_regexp).join('|')})`); 19 | build.onResolve({filter}, async (args) => { 20 | const {path, ...rest} = args; 21 | // Find which alias prefix matches 22 | const prefix = Object.keys(aliases).find((key) => path.startsWith(key)); 23 | if (!prefix) return null; 24 | return build.resolve(join(dir, aliases[prefix] + path.substring(prefix.length)), rest); 25 | }); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_sveltekit_shim_app.ts: -------------------------------------------------------------------------------- 1 | import type * as esbuild from 'esbuild'; 2 | 3 | import { 4 | render_sveltekit_shim_app_environment, 5 | render_sveltekit_shim_app_paths, 6 | sveltekit_shim_app_specifiers, 7 | } from './sveltekit_shim_app.ts'; 8 | import type {Parsed_Svelte_Config} from './svelte_config.ts'; 9 | import {EVERYTHING_MATCHER} from './constants.ts'; 10 | 11 | export interface Esbuild_Plugin_Sveltekit_Shim_App_Options { 12 | dev: boolean; 13 | base_url: Parsed_Svelte_Config['base_url']; 14 | assets_url: Parsed_Svelte_Config['assets_url']; 15 | } 16 | 17 | export const esbuild_plugin_sveltekit_shim_app = ({ 18 | dev, 19 | base_url, 20 | assets_url, 21 | }: Esbuild_Plugin_Sveltekit_Shim_App_Options): esbuild.Plugin => ({ 22 | name: 'sveltekit_shim_app', 23 | setup: (build) => { 24 | build.onResolve({filter: /^\$app\/(forms|navigation|stores)$/}, ({path, ...rest}) => 25 | build.resolve(sveltekit_shim_app_specifiers.get(path)!, rest), 26 | ); 27 | build.onResolve({filter: /^\$app\/paths$/}, ({path}) => ({ 28 | path: sveltekit_shim_app_specifiers.get(path)!, 29 | namespace: 'sveltekit_shim_app_paths', 30 | })); 31 | build.onLoad({filter: EVERYTHING_MATCHER, namespace: 'sveltekit_shim_app_paths'}, () => ({ 32 | contents: render_sveltekit_shim_app_paths(base_url, assets_url), 33 | })); 34 | build.onResolve({filter: /^\$app\/environment$/}, ({path}) => ({ 35 | path: sveltekit_shim_app_specifiers.get(path)!, 36 | namespace: 'sveltekit_shim_app_environment', 37 | })); 38 | build.onLoad({filter: EVERYTHING_MATCHER, namespace: 'sveltekit_shim_app_environment'}, () => ({ 39 | contents: render_sveltekit_shim_app_environment(dev), 40 | })); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /src/lib/esbuild_plugin_sveltekit_shim_env.ts: -------------------------------------------------------------------------------- 1 | import type * as esbuild from 'esbuild'; 2 | 3 | import {render_env_shim_module} from './sveltekit_shim_env.ts'; 4 | import {EVERYTHING_MATCHER} from './constants.ts'; 5 | import {SVELTEKIT_ENV_MATCHER} from './sveltekit_helpers.ts'; 6 | 7 | export interface Esbuild_Plugin_Sveltekit_Shim_Env_Options { 8 | dev: boolean; 9 | public_prefix?: string; 10 | private_prefix?: string; 11 | env_dir?: string; 12 | env_files?: Array; 13 | ambient_env?: Record; 14 | } 15 | 16 | const namespace = 'sveltekit_shim_env'; 17 | 18 | export const esbuild_plugin_sveltekit_shim_env = ({ 19 | dev, 20 | public_prefix, 21 | private_prefix, 22 | env_dir, 23 | env_files, 24 | ambient_env, 25 | }: Esbuild_Plugin_Sveltekit_Shim_Env_Options): esbuild.Plugin => ({ 26 | name: 'sveltekit_shim_env', 27 | setup: (build) => { 28 | build.onResolve({filter: SVELTEKIT_ENV_MATCHER}, ({path}) => ({path, namespace})); 29 | build.onLoad({filter: EVERYTHING_MATCHER, namespace}, ({path}) => { 30 | const matches = SVELTEKIT_ENV_MATCHER.exec(path); 31 | const mode = matches![1] as 'static' | 'dynamic'; 32 | const visibility = matches![2] as 'public' | 'private'; 33 | return { 34 | loader: 'ts', 35 | contents: render_env_shim_module( 36 | dev, 37 | mode, 38 | visibility, 39 | public_prefix, 40 | private_prefix, 41 | env_dir, 42 | env_files, 43 | ambient_env, 44 | ), 45 | }; 46 | }); 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/lib/format.task.ts: -------------------------------------------------------------------------------- 1 | import {print_spawn_result} from '@ryanatkn/belt/process.js'; 2 | import {z} from 'zod'; 3 | 4 | import {Task_Error, type Task} from './task.ts'; 5 | import {format_directory} from './format_directory.ts'; 6 | import {paths} from './paths.ts'; 7 | 8 | export const Args = z 9 | .object({ 10 | check: z 11 | .boolean({description: 'exit with a nonzero code if any files are unformatted'}) 12 | .default(false), 13 | }) 14 | .strict(); 15 | export type Args = z.infer; 16 | 17 | export const task: Task = { 18 | summary: 'format source files', 19 | Args, 20 | run: async ({args, log, config}) => { 21 | const {check} = args; 22 | // TODO forward prettier args 23 | const format_result = await format_directory( 24 | log, 25 | paths.source, 26 | check, 27 | undefined, 28 | undefined, 29 | undefined, 30 | config.pm_cli, 31 | ); 32 | if (!format_result.ok) { 33 | throw new Task_Error( 34 | `Failed ${check ? 'formatting check' : 'to format'}. ${print_spawn_result(format_result)}`, 35 | ); 36 | } 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/format_directory.ts: -------------------------------------------------------------------------------- 1 | import type {Spawn_Result} from '@ryanatkn/belt/process.js'; 2 | import type {Logger} from '@ryanatkn/belt/log.js'; 3 | 4 | import {paths} from './paths.ts'; 5 | import { 6 | GITHUB_DIRNAME, 7 | README_FILENAME, 8 | SVELTE_CONFIG_FILENAME, 9 | VITE_CONFIG_FILENAME, 10 | TSCONFIG_FILENAME, 11 | GRO_CONFIG_PATH, 12 | PM_CLI_DEFAULT, 13 | PRETTIER_CLI_DEFAULT, 14 | } from './constants.ts'; 15 | import {serialize_args, to_forwarded_args} from './args.ts'; 16 | import {spawn_cli, to_cli_name, type Cli} from './cli.ts'; 17 | 18 | const EXTENSIONS_DEFAULT = 'ts,js,json,svelte,html,css,md,yml'; 19 | const ROOT_PATHS_DEFAULT = `${[ 20 | README_FILENAME, 21 | GRO_CONFIG_PATH, 22 | SVELTE_CONFIG_FILENAME, 23 | VITE_CONFIG_FILENAME, 24 | TSCONFIG_FILENAME, 25 | GITHUB_DIRNAME, 26 | ].join(',')}/**/*`; 27 | 28 | /** 29 | * Formats a directory on the filesystem. 30 | * If the source directory is given, it also formats all of the root directory files. 31 | * This is separated from `./format_file` to avoid importing all of the `prettier` code 32 | * inside modules that import this one. (which has a nontrivial cost) 33 | */ 34 | export const format_directory = async ( 35 | log: Logger, 36 | dir: string, 37 | check = false, 38 | extensions = EXTENSIONS_DEFAULT, 39 | root_paths = ROOT_PATHS_DEFAULT, 40 | prettier_cli: string | Cli = PRETTIER_CLI_DEFAULT, 41 | pm_cli: string = PM_CLI_DEFAULT, 42 | ): Promise => { 43 | const forwarded_args = to_forwarded_args(to_cli_name(prettier_cli)); 44 | forwarded_args[check ? 'check' : 'write'] = true; 45 | const serialized_args = serialize_args(forwarded_args); 46 | serialized_args.push(`${dir}**/*.{${extensions}}`); 47 | if (dir === paths.source) { 48 | serialized_args.push(`${paths.root}{${root_paths}}`); 49 | } 50 | const spawned = await spawn_cli(prettier_cli, serialized_args, log); 51 | if (!spawned) 52 | throw Error( 53 | `failed to find \`${to_cli_name(prettier_cli)}\` CLI locally or globally, do you need to run \`${pm_cli} install\`?`, 54 | ); 55 | return spawned; 56 | }; 57 | -------------------------------------------------------------------------------- /src/lib/format_file.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import {format_file} from './format_file.ts'; 5 | 6 | test('format ts', async () => { 7 | const ts_unformatted = 'hey (1)'; 8 | const ts_formatted = 'hey(1);\n'; 9 | assert.is(await format_file(ts_unformatted, {filepath: 'foo.ts'}), ts_formatted); 10 | assert.is(await format_file(ts_unformatted, {parser: 'typescript'}), ts_formatted); 11 | }); 12 | 13 | test('format svelte', async () => { 14 | const svelte_unformatted = ''; 15 | const svelte_formatted = '\n'; 16 | assert.is(await format_file(svelte_unformatted, {filepath: 'foo.svelte'}), svelte_formatted); 17 | assert.is(await format_file(svelte_unformatted, {parser: 'svelte'}), svelte_formatted); 18 | }); 19 | 20 | test.run(); 21 | -------------------------------------------------------------------------------- /src/lib/format_file.ts: -------------------------------------------------------------------------------- 1 | import prettier from 'prettier'; 2 | import {extname} from 'node:path'; 3 | 4 | import {load_package_json} from './package_json.ts'; 5 | 6 | let cached_base_options: prettier.Options | undefined; 7 | 8 | /** 9 | * Formats a file with Prettier. 10 | * @param content 11 | * @param options 12 | * @param base_options - defaults to the the cwd's package.json `prettier` value 13 | */ 14 | export const format_file = async ( 15 | content: string, 16 | options: prettier.Options, 17 | base_options: prettier.Options | null | undefined = cached_base_options, 18 | ): Promise => { 19 | const final_base_options = 20 | base_options !== undefined 21 | ? base_options 22 | : (cached_base_options = load_package_json().prettier as any); 23 | let final_options = options; 24 | if (options.filepath && !options.parser) { 25 | const {filepath, ...rest} = options; 26 | const parser = infer_parser(filepath); 27 | if (parser) final_options = {...rest, parser}; 28 | } 29 | try { 30 | return await prettier.format(content, {...final_base_options, ...final_options}); 31 | } catch (_err) { 32 | return content; 33 | } 34 | }; 35 | 36 | // This is just a simple convenience for callers so they can pass a file path. 37 | // They can provide the Prettier `options.parser` for custom extensions. 38 | const infer_parser = (path: string): string | null => { 39 | const extension = extname(path).substring(1); 40 | switch (extension) { 41 | case 'svelte': 42 | case 'xml': { 43 | return extension; 44 | } 45 | default: { 46 | return null; 47 | } 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/fs.ts: -------------------------------------------------------------------------------- 1 | import {rm} from 'node:fs/promises'; 2 | import {readdirSync, type RmOptions} from 'node:fs'; 3 | import {join} from 'node:path'; 4 | 5 | /** 6 | * Empties a directory with an optional `filter`. 7 | */ 8 | export const empty_dir = async ( 9 | dir: string, 10 | filter?: (path: string) => boolean, 11 | options?: RmOptions, 12 | ): Promise => { 13 | await Promise.all( 14 | readdirSync(dir).map((path) => 15 | filter && !filter(path) ? null : rm(join(dir, path), {...options, recursive: true}), 16 | ), 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/git.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import { 5 | git_check_clean_workspace, 6 | git_check_fully_staged_workspace, 7 | git_current_branch_first_commit_hash, 8 | git_current_branch_name, 9 | git_current_commit_hash, 10 | } from './git.ts'; 11 | 12 | test('git_current_branch_name', async () => { 13 | const branch_name = await git_current_branch_name(); 14 | assert.ok(branch_name); 15 | }); 16 | 17 | test('git_check_clean_workspace', async () => { 18 | await git_check_clean_workspace(); 19 | }); 20 | 21 | test('git_check_fully_staged_workspace', async () => { 22 | await git_check_fully_staged_workspace(); 23 | }); 24 | 25 | test('git_current_commit_hash', async () => { 26 | await git_current_commit_hash(); 27 | }); 28 | 29 | test('git_current_branch_first_commit_hash', async () => { 30 | const first_commit_hash = await git_current_branch_first_commit_hash(); 31 | assert.ok(first_commit_hash); 32 | }); 33 | 34 | test.run(); 35 | -------------------------------------------------------------------------------- /src/lib/github.ts: -------------------------------------------------------------------------------- 1 | // TODO if this grows at all, use `@octokit/request`, 2 | // for now it's just calling a single endpoint so we do it manually 3 | // and we specify just the types we need 4 | 5 | import {Fetch_Value_Cache, fetch_value} from '@ryanatkn/belt/fetch.js'; 6 | import type {Logger} from '@ryanatkn/belt/log.js'; 7 | import {z} from 'zod'; 8 | 9 | export const GITHUB_REPO_MATCHER = /.+github.com\/(.+)\/(.+)/; 10 | 11 | export const Github_Pull_Request = z.object({ 12 | url: z.string(), 13 | id: z.number(), 14 | html_url: z.string(), 15 | number: z.number(), 16 | user: z.object({ 17 | login: z.string(), 18 | }), 19 | }); 20 | export type Github_Pull_Request = z.infer; 21 | 22 | /** 23 | * @see https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-pull-requests-associated-with-a-commit 24 | */ 25 | export const github_fetch_commit_prs = async ( 26 | owner: string, 27 | repo: string, 28 | commit_sha: string, 29 | token?: string, 30 | log?: Logger, 31 | cache?: Fetch_Value_Cache, 32 | api_version?: string, 33 | ): Promise | null> => { 34 | const headers = api_version ? new Headers({'x-github-api-version': api_version}) : undefined; 35 | const url = `https://api.github.com/repos/${owner}/${repo}/commits/${commit_sha}/pulls`; 36 | const fetched = await fetch_value(url, { 37 | request: {headers}, 38 | parse: (v: Array) => v.map((p) => Github_Pull_Request.parse(p)), 39 | token, 40 | cache, 41 | return_early_from_cache: true, 42 | log, 43 | }); 44 | if (!fetched.ok) return null; 45 | return fetched.value; 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/gro.config.default.ts: -------------------------------------------------------------------------------- 1 | import {resolve} from 'node:path'; 2 | 3 | import type {Create_Gro_Config} from './gro_config.ts'; 4 | import {gro_plugin_sveltekit_library} from './gro_plugin_sveltekit_library.ts'; 5 | import {has_server, gro_plugin_server} from './gro_plugin_server.ts'; 6 | import {gro_plugin_sveltekit_app} from './gro_plugin_sveltekit_app.ts'; 7 | import {has_sveltekit_app, has_sveltekit_library} from './sveltekit_helpers.ts'; 8 | import {gro_plugin_gen} from './gro_plugin_gen.ts'; 9 | import {has_dep, load_package_json} from './package_json.ts'; 10 | import {find_first_existing_file} from './search_fs.ts'; 11 | 12 | // TODO hacky, maybe extract utils? 13 | 14 | /** 15 | * This is the default config that's passed to `gro.config.ts` 16 | * if it exists in the current project, and if not, this is the final config. 17 | * It looks at the SvelteKit config and filesystem and tries to do the right thing: 18 | * 19 | * - if `src/routes`, assumes a SvelteKit frontend - respects `KitConfig.kit.files.routes` 20 | * - if `src/lib`, assumes a Node library - respects `KitConfig.kit.files.lib` 21 | * - if `src/lib/server/server.ts`, assumes a Node server - needs config 22 | */ 23 | const config: Create_Gro_Config = async (cfg, svelte_config) => { 24 | const package_json = load_package_json(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? 25 | 26 | const [has_moss_dep, has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = 27 | await Promise.all([ 28 | has_dep('@ryanatkn/moss', package_json), 29 | has_server(), 30 | has_sveltekit_library(package_json, svelte_config), 31 | has_sveltekit_app(), 32 | ]); 33 | 34 | const local_moss_plugin_path = find_first_existing_file([ 35 | './src/lib/gro_plugin_moss.ts', 36 | './src/routes/gro_plugin_moss.ts', 37 | ]); 38 | 39 | // put things that generate files before SvelteKit so it can see them 40 | cfg.plugins = async () => 41 | [ 42 | // TODO probably belongs in the gen system 43 | local_moss_plugin_path 44 | ? (await import(resolve(local_moss_plugin_path))).gro_plugin_moss() 45 | : has_moss_dep 46 | ? (await import('@ryanatkn/moss/gro_plugin_moss.js')).gro_plugin_moss() 47 | : null, // lazy load to avoid errors if it's not installed 48 | gro_plugin_gen(), 49 | has_server_result.ok ? gro_plugin_server() : null, 50 | has_sveltekit_library_result.ok ? gro_plugin_sveltekit_library() : null, 51 | has_sveltekit_app_result.ok 52 | ? gro_plugin_sveltekit_app({host_target: has_server_result.ok ? 'node' : 'github_pages'}) 53 | : null, 54 | ].filter((v) => v !== null); 55 | 56 | return cfg; 57 | }; 58 | 59 | export default config; 60 | -------------------------------------------------------------------------------- /src/lib/gro.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --experimental-import-meta-resolve --experimental-strip-types --disable-warning=ExperimentalWarning 2 | 3 | // @sync Node options to `$lib/gro_helpers.ts` 4 | 5 | import {join} from 'node:path'; 6 | 7 | import {resolve_gro_module_path, spawn_with_loader} from './gro_helpers.ts'; 8 | 9 | /* 10 | 11 | This file is a loader for the Gro CLI. 12 | Its only purpose is to import the `invoke.js` script in the correct directory. 13 | By using `resolve_gro_module_path` it lets the global Gro CLI defer 14 | to a local installation of Gro if one is available, 15 | and it also provides special handling for the case 16 | where we're running Gro inside Gro's own repo for development. 17 | 18 | */ 19 | 20 | const invoke_path = resolve_gro_module_path('invoke.js'); 21 | 22 | const loader_path = join(invoke_path, '../loader.js'); 23 | 24 | const spawned = await spawn_with_loader(loader_path, invoke_path, process.argv.slice(2)); 25 | if (!spawned.ok) { 26 | process.exitCode = spawned.code || 1; 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/gro_config.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import {SEARCH_EXCLUDER_DEFAULT, load_gro_config} from './gro_config.ts'; 5 | 6 | test('load_gro_config', async () => { 7 | const config = await load_gro_config(); 8 | assert.ok(config); 9 | }); 10 | 11 | test('SEARCH_EXCLUDER_DEFAULT', () => { 12 | const assert_includes = (path: string, exclude: boolean) => { 13 | const m = `should ${exclude ? 'exclude' : 'include '}: ${path}`; 14 | const b = (v: boolean) => (exclude ? !v : v); 15 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`a/${path}/c`)), m); 16 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`a/${path}/c/d.js`)), m); 17 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`a/${path}/c/d.e.js`)), m); 18 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`a/${path}/`)), m); 19 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`a/${path}`)), m); 20 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/a/${path}/c`)), m); 21 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/a/${path}/c/d.js`)), m); 22 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/a/${path}/c/d.e.js`)), m); 23 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/a/${path}/`)), m); 24 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/a/${path}`)), m); 25 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/${path}/a`)), m); 26 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/${path}/a/b.js`)), m); 27 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/${path}/a/b.e.js`)), m); 28 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/${path}/`)), m); 29 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`/${path}`)), m); 30 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`./${path}/a`)), m); 31 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`./${path}/a/b.js`)), m); 32 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`./${path}/a/b.c.js`)), m); 33 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`./${path}/`)), m); 34 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`./${path}`)), m); 35 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`${path}/a`)), m); 36 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`${path}/a/b.js`)), m); 37 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`${path}/a/b.c.js`)), m); 38 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(`${path}/`)), m); 39 | assert.ok(b(SEARCH_EXCLUDER_DEFAULT.test(path)), m); 40 | }; 41 | 42 | assert_includes('node_modules', false); 43 | assert_includes('dist', false); 44 | assert_includes('build', false); 45 | assert_includes('.git', false); 46 | assert_includes('.gro', false); 47 | assert_includes('.svelte-kit', false); 48 | 49 | assert_includes('a', true); 50 | assert_includes('nodemodules', true); 51 | 52 | // Special exception for `gro/dist/`, but not `gro/build/` etc because they're not usecases. 53 | assert_includes('gro/build', false); 54 | assert_includes('gro/buildE', true); 55 | assert_includes('groE/build', false); 56 | assert_includes('gro/dist', true); 57 | assert_includes('node_modules/gro/dist', true); 58 | assert_includes('node_modules/@someuser/gro/dist', true); 59 | assert_includes('node_modules/@someuser/foo/gro/dist', false); 60 | assert_includes('gro/distE', true); 61 | assert_includes('groE/dist', false); 62 | assert_includes('Egro/dist', false); 63 | assert_includes('Ebuild', true); 64 | assert_includes('buildE', true); 65 | assert_includes('grobuild', true); 66 | assert_includes('distE', true); 67 | assert_includes('Edist', true); 68 | assert_includes('grodist', true); 69 | }); 70 | 71 | test.run(); 72 | -------------------------------------------------------------------------------- /src/lib/gro_helpers.ts: -------------------------------------------------------------------------------- 1 | import {realpathSync, existsSync} from 'node:fs'; 2 | import {join, resolve} from 'node:path'; 3 | import {fileURLToPath} from 'node:url'; 4 | import {spawn, type Spawn_Result} from '@ryanatkn/belt/process.js'; 5 | 6 | import {JS_CLI_DEFAULT, NODE_MODULES_DIRNAME, SVELTEKIT_DIST_DIRNAME} from './constants.ts'; 7 | 8 | /* 9 | 10 | This module is intended to have minimal dependencies to avoid over-imports in the CLI. 11 | 12 | */ 13 | 14 | /** 15 | * Resolves a path to an internal Gro file. 16 | * Prefers any local installation of Gro and falls back to the current CLI context. 17 | * 18 | * Uses heuristics to find `path`, so may fail in some rare corner cases. 19 | * Currently looks for `gro.js` as a sibling to the `path` arg for detection. 20 | * If this fails for your usecases, rename `gro.js` or open an issue/PR! 21 | * 22 | * Used by the CLI and `gro run`. 23 | * 24 | * case 1: 25 | * 26 | * We're in a directory that has a local installation of Gro at `node_modules/.bin/gro`. 27 | * Use this local version instead of the global. 28 | * 29 | * case 2: 30 | * 31 | * We're running Gro inside the Gro repo itself. 32 | * 33 | * In this case, we use the build directory instead of dist. 34 | * There's a paradox here for using Gro inside itself - 35 | * ideally we use the dist directory because that's what's shipped, 36 | * but the build directory has all of the tests, 37 | * and loading two instances of its modules causes problems 38 | * like `instanceof` checks failing. 39 | * For now we'll just run from build and see if it causes any problems. 40 | * There's probably a better design in here somewhere. 41 | * 42 | * case 3: 43 | * 44 | * Fall back to invoking Gro from wherever the CLI is being executed. 45 | * When using the global CLI, this uses the global Gro installation. 46 | * 47 | */ 48 | export const resolve_gro_module_path = (path = ''): string => { 49 | const gro_bin_path = resolve(NODE_MODULES_DIRNAME, '.bin/gro'); 50 | // case 1 51 | // Prefer any locally installed version of Gro. 52 | // This is really confusing if Gro is installed inside Gro itself, 53 | // so avoid that when developing Gro. 54 | if (existsSync(gro_bin_path)) { 55 | return join(realpathSync(gro_bin_path), '..', path); 56 | } 57 | // case 2 58 | // If running Gro inside its own repo, require the local dist. 59 | // If the local dist is not yet built it will fall back to the global. 60 | if ( 61 | existsSync(join(SVELTEKIT_DIST_DIRNAME, 'gro.js')) && 62 | existsSync(join(SVELTEKIT_DIST_DIRNAME, path)) 63 | ) { 64 | return resolve(SVELTEKIT_DIST_DIRNAME, path); 65 | } 66 | // case 3 67 | // Fall back to the version associated with the running CLI. 68 | const file_path = fileURLToPath(import.meta.url); 69 | return join(file_path, '..', path); 70 | }; 71 | 72 | /** 73 | * Runs a file using the Gro loader. 74 | * 75 | * Uses conditional exports to correctly set up `esm-env` as development by default, 76 | * so if you want production set `NODE_ENV=production`. 77 | * 78 | * @see https://nodejs.org/api/packages.html#conditional-exports 79 | * 80 | * @param loader_path path to loader 81 | * @param invoke_path path to file to spawn with `node` 82 | */ 83 | export const spawn_with_loader = ( 84 | loader_path: string, 85 | invoke_path: string, 86 | argv: Array, 87 | js_cli = JS_CLI_DEFAULT, // TODO source from config when possible 88 | ): Promise => { 89 | const args = [ 90 | '--import', 91 | // This does the same as `$lib/register.ts` but without the cost of importing another file. 92 | `data:text/javascript, 93 | import {register} from "node:module"; 94 | import {pathToFileURL} from "node:url"; 95 | register("${loader_path}", pathToFileURL("./"));`, 96 | // @sync Node options to `$lib/gro.ts` 97 | '--experimental-import-meta-resolve', // for `import.meta.resolve` 98 | '--experimental-strip-types', 99 | '--disable-warning', 100 | 'ExperimentalWarning', 101 | ]; 102 | // In almost all cases we want the exports condition to be `"development"`. Needed for `esm-env`. 103 | if (process.env.NODE_ENV !== 'production') { 104 | args.push('-C', 'development'); // same as `--conditions` 105 | } 106 | args.push(invoke_path, ...argv); 107 | return spawn(js_cli, args); 108 | }; 109 | -------------------------------------------------------------------------------- /src/lib/gro_plugin_gen.ts: -------------------------------------------------------------------------------- 1 | import {EMPTY_OBJECT} from '@ryanatkn/belt/object.js'; 2 | import {throttle} from '@ryanatkn/belt/throttle.js'; 3 | import {Unreachable_Error} from '@ryanatkn/belt/error.js'; 4 | 5 | import type {Plugin} from './plugin.ts'; 6 | import type {Args} from './args.ts'; 7 | import {paths} from './paths.ts'; 8 | import {find_genfiles, is_gen_path} from './gen.ts'; 9 | import {spawn_cli} from './cli.ts'; 10 | import {filter_dependents, type Cleanup_Watch} from './filer.ts'; 11 | 12 | const FLUSH_DEBOUNCE_DELAY = 500; 13 | 14 | export interface Task_Args extends Args { 15 | watch?: boolean; 16 | } 17 | 18 | export interface Gro_Plugin_Gen_Options { 19 | input_paths?: Array; 20 | root_dirs?: Array; 21 | flush_debounce_delay?: number; 22 | } 23 | 24 | export const gro_plugin_gen = ({ 25 | input_paths = [paths.source], 26 | root_dirs = [paths.source], 27 | flush_debounce_delay = FLUSH_DEBOUNCE_DELAY, 28 | }: Gro_Plugin_Gen_Options = EMPTY_OBJECT): Plugin => { 29 | let flushing_timeout: NodeJS.Timeout | undefined; 30 | const queued_files: Set = new Set(); 31 | const queue_gen = (gen_file_id: string) => { 32 | queued_files.add(gen_file_id); 33 | if (flushing_timeout === undefined) { 34 | flushing_timeout = setTimeout(() => { 35 | flushing_timeout = undefined; 36 | void flush_gen_queue(); 37 | }); // the timeout batches synchronously 38 | } 39 | }; 40 | const flush_gen_queue = throttle( 41 | async () => { 42 | const files = Array.from(queued_files); 43 | queued_files.clear(); 44 | await gen(files); 45 | }, 46 | {delay: flush_debounce_delay}, 47 | ); 48 | const gen = (files: Array = []) => spawn_cli('gro', ['gen', ...files]); 49 | 50 | let cleanup: Cleanup_Watch | undefined; 51 | 52 | return { 53 | name: 'gro_plugin_gen', 54 | setup: async ({watch, dev, log, config, filer}) => { 55 | // For production builds, we assume `gen` is already fresh, 56 | // which should be checked by CI via `gro check` which calls `gro gen --check`. 57 | if (!dev) return; 58 | 59 | // Do we need to just generate everything once and exit? 60 | if (!watch) { 61 | log.info('generating and exiting early'); 62 | 63 | // Run `gen`, first checking if there are any modules to avoid a console error. 64 | // Some parts of the build may have already happened, 65 | // making us miss `build` events for gen dependencies, 66 | // so we run `gen` here even if it's usually wasteful. 67 | const found = find_genfiles(input_paths, root_dirs, config); 68 | if (found.ok && found.value.resolved_input_files.length > 0) { 69 | await gen(); 70 | } 71 | return; 72 | } 73 | 74 | // When a file builds, check it and its tree of dependents 75 | // for any `.gen.` files that need to run. 76 | cleanup = await filer.watch((change, source_file) => { 77 | if (source_file.external) return; 78 | switch (change.type) { 79 | case 'add': 80 | case 'update': { 81 | if (is_gen_path(source_file.id)) { 82 | queue_gen(source_file.id); 83 | } 84 | const dependent_gen_file_ids = filter_dependents( 85 | source_file, 86 | filer.get_by_id, 87 | is_gen_path, 88 | undefined, 89 | undefined, 90 | log, 91 | ); 92 | for (const dependent_gen_file_id of dependent_gen_file_ids) { 93 | queue_gen(dependent_gen_file_id); 94 | } 95 | break; 96 | } 97 | case 'delete': { 98 | // TODO delete the generated file(s)? option? 99 | break; 100 | } 101 | default: 102 | throw new Unreachable_Error(change.type); 103 | } 104 | }); 105 | }, 106 | teardown: async () => { 107 | if (cleanup !== undefined) { 108 | await cleanup(); 109 | cleanup = undefined; 110 | } 111 | }, 112 | }; 113 | }; 114 | -------------------------------------------------------------------------------- /src/lib/gro_plugin_sveltekit_library.ts: -------------------------------------------------------------------------------- 1 | import {print_spawn_result, spawn} from '@ryanatkn/belt/process.js'; 2 | 3 | import type {Plugin} from './plugin.ts'; 4 | import {Task_Error} from './task.ts'; 5 | import {load_package_json} from './package_json.ts'; 6 | import { 7 | SVELTE_PACKAGE_CLI, 8 | run_svelte_package, 9 | type Svelte_Package_Options, 10 | } from './sveltekit_helpers.ts'; 11 | 12 | export interface Gro_Plugin_Sveltekit_Library_Options { 13 | /** 14 | * The options passed to the SvelteKit packaging CLI. 15 | * @see https://kit.svelte.dev/docs/packaging#options 16 | */ 17 | svelte_package_options?: Svelte_Package_Options; 18 | /** 19 | * The SvelteKit packaging CLI to use. Defaults to `svelte-package`. 20 | * @see https://kit.svelte.dev/docs/packaging 21 | */ 22 | svelte_package_cli?: string; 23 | } 24 | 25 | export const gro_plugin_sveltekit_library = ({ 26 | svelte_package_options, 27 | svelte_package_cli = SVELTE_PACKAGE_CLI, 28 | }: Gro_Plugin_Sveltekit_Library_Options = {}): Plugin => { 29 | const package_json = load_package_json(); 30 | 31 | return { 32 | name: 'gro_plugin_sveltekit_library', 33 | setup: async ({dev, log, config}) => { 34 | if (!dev) { 35 | await run_svelte_package( 36 | package_json, 37 | svelte_package_options, 38 | svelte_package_cli, 39 | log, 40 | config.pm_cli, 41 | ); 42 | } 43 | }, 44 | adapt: async ({log, timings, config}) => { 45 | // link the CLI binaries if they exist 46 | if (package_json.bin) { 47 | const timing_to_link = timings.start(`${config.pm_cli} link`); 48 | await Promise.all( 49 | Object.values(package_json.bin).map(async (bin_path) => { 50 | const chmod_result = await spawn('chmod', ['+x', bin_path]); 51 | if (!chmod_result.ok) 52 | log.error(`chmod on bin path ${bin_path} failed with code ${chmod_result.code}`); 53 | }), 54 | ); 55 | log.info(`linking`); 56 | const link_result = await spawn(config.pm_cli, ['link', '-f']); // TODO don't use `-f` unless necessary or at all? 57 | if (!link_result.ok) { 58 | throw new Task_Error(`Failed to link. ${print_spawn_result(link_result)}`); 59 | } 60 | timing_to_link(); 61 | } 62 | }, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/lib/hash.test.ts: -------------------------------------------------------------------------------- 1 | import {suite} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {webcrypto} from 'node:crypto'; 4 | 5 | import {to_hash} from './hash.ts'; 6 | 7 | const test__to_hash = suite('to_hash'); 8 | 9 | test__to_hash('turns a buffer into a string', async () => { 10 | assert.type(await to_hash(Buffer.from('hey')), 'string'); 11 | }); 12 | 13 | test__to_hash('returns the same value given the same input', async () => { 14 | assert.is(await to_hash(Buffer.from('hey')), await to_hash(Buffer.from('hey'))); 15 | }); 16 | 17 | test__to_hash('checks against an implementation copied from MDN', async () => { 18 | const data = Buffer.from('some_test_string'); 19 | assert.is(await to_hash_from_mdn_example(data), await to_hash(data)); 20 | }); 21 | 22 | test__to_hash.run(); 23 | 24 | /** 25 | * Copied from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest 26 | * and compared against our implementation for extra assurances, because cryptography. 27 | */ 28 | const to_hash_from_mdn_example = async (data: Buffer): Promise => 29 | Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', data))) 30 | .map((h) => h.toString(16).padStart(2, '0')) 31 | .join(''); 32 | -------------------------------------------------------------------------------- /src/lib/hash.ts: -------------------------------------------------------------------------------- 1 | import {webcrypto} from 'node:crypto'; 2 | 3 | const {subtle} = webcrypto; 4 | 5 | /** 6 | * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto 7 | */ 8 | export const to_hash = async ( 9 | data: Buffer, 10 | algorithm: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512' = 'SHA-256', 11 | ): Promise => { 12 | const digested = await subtle.digest(algorithm, data); 13 | const bytes = Array.from(new Uint8Array(digested)); 14 | let hex = ''; 15 | for (const h of bytes) { 16 | hex += h.toString(16).padStart(2, '0'); 17 | } 18 | return hex; 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export type {Gro_Config, Create_Gro_Config, Raw_Gro_Config} from './gro_config.ts'; 2 | export {type Plugin, replace_plugin} from './plugin.ts'; 3 | export type {Gen, Gen_Context} from './gen.ts'; 4 | export {type Task, type Task_Context, Task_Error} from './task.ts'; 5 | -------------------------------------------------------------------------------- /src/lib/invoke.ts: -------------------------------------------------------------------------------- 1 | import {attach_process_error_handlers} from '@ryanatkn/belt/process.js'; 2 | import {configure_log_colors} from '@ryanatkn/belt/log.js'; 3 | import {set_colors} from '@ryanatkn/belt/print.js'; 4 | import {styleText} from 'node:util'; 5 | 6 | import {invoke_task} from './invoke_task.ts'; 7 | import {to_task_args} from './args.ts'; 8 | import {load_gro_config} from './gro_config.ts'; 9 | import {sveltekit_sync_if_obviously_needed} from './sveltekit_helpers.ts'; 10 | 11 | /* 12 | 13 | This module invokes the Gro CLI which in turn invokes tasks. 14 | Tasks are the CLI's primary concept. 15 | To learn more about them, see `src/docs/task.md`. 16 | 17 | When the CLI is invoked it passes the first CLI arg as `task_name` to `invoke_task`, 18 | and the rest of the args are forwarded to the task's `run` function. 19 | 20 | */ 21 | 22 | // handle uncaught errors 23 | attach_process_error_handlers( 24 | (err) => (err.constructor.name === 'Task_Error' ? 'Task_Error' : null), 25 | (err) => (err.constructor.name === 'Silent_Error' ? '' : null), 26 | ); 27 | 28 | configure_log_colors(styleText); 29 | set_colors(styleText); 30 | 31 | await sveltekit_sync_if_obviously_needed(); 32 | 33 | const {task_name, args} = to_task_args(); 34 | await invoke_task(task_name, args, await load_gro_config()); 35 | -------------------------------------------------------------------------------- /src/lib/invoke_task.ts: -------------------------------------------------------------------------------- 1 | import {styleText as st} from 'node:util'; 2 | import {System_Logger} from '@ryanatkn/belt/log.js'; 3 | import {create_stopwatch, Timings} from '@ryanatkn/belt/timings.js'; 4 | import {print_ms, print_timings, print_log_label} from '@ryanatkn/belt/print.js'; 5 | 6 | import {to_forwarded_args, type Args} from './args.ts'; 7 | import {run_task} from './run_task.ts'; 8 | import {to_input_path, Raw_Input_Path} from './input_path.ts'; 9 | import {find_tasks, load_tasks, Silent_Error} from './task.ts'; 10 | import {load_gro_package_json} from './package_json.ts'; 11 | import {log_tasks, log_error_reasons} from './task_logging.ts'; 12 | import type {Gro_Config} from './gro_config.ts'; 13 | import {Filer} from './filer.ts'; 14 | 15 | /** 16 | * Invokes Gro tasks by name using the filesystem as the source. 17 | * 18 | * When a task is invoked, 19 | * Gro first searches for tasks in the current working directory. 20 | * and falls back to searching Gro's directory, if the two are different. 21 | * See `src/lib/input_path.ts` for info about what "task_name" can refer to. 22 | * If it matches a directory, all of the tasks within it are logged, 23 | * both in the current working directory and Gro. 24 | * 25 | * This code is particularly hairy because 26 | * we're accepting a wide range of user input 27 | * and trying to do the right thing. 28 | * Precise error messages are especially difficult and 29 | * there are some subtle differences in the complex logical branches. 30 | * The comments describe each condition. 31 | * 32 | * @param task_name - The name of the task to invoke. 33 | * @param args - The CLI args to pass to the task. 34 | * @param config - The Gro configuration. 35 | * @param initial_timings - The timings to use for the top-level task, `null` for composed tasks. 36 | */ 37 | export const invoke_task = async ( 38 | task_name: Raw_Input_Path, 39 | args: Args | undefined, 40 | config: Gro_Config, 41 | initial_filer?: Filer, 42 | initial_timings?: Timings | null, 43 | ): Promise => { 44 | const log = new System_Logger(print_log_label(task_name || 'gro')); 45 | log.info('invoking', task_name ? st('cyan', task_name) : 'gro'); 46 | 47 | const filer = initial_filer ?? new Filer({log}); 48 | 49 | const timings = initial_timings ?? new Timings(); 50 | 51 | const total_timing = create_stopwatch(); 52 | const finish = () => { 53 | if (!initial_timings) return; // print timings only for the top-level task 54 | print_timings(timings, log); 55 | log.info(`🕒 ${print_ms(total_timing())}`); 56 | }; 57 | 58 | // Check if the caller just wants to see the version. 59 | if (!task_name && (args?.version || args?.v)) { 60 | const gro_package_json = load_gro_package_json(); 61 | log.info(`${st('gray', 'v')}${st('cyan', gro_package_json.version)}`); 62 | finish(); 63 | return; 64 | } 65 | 66 | // Resolve the input path for the provided task name. 67 | const input_path = to_input_path(task_name); 68 | 69 | const {task_root_dirs} = config; 70 | 71 | // Find the task or directory specified by the `input_path`. 72 | // Fall back to searching the Gro directory as well. 73 | const found = find_tasks([input_path], task_root_dirs, config); 74 | if (!found.ok) { 75 | log_error_reasons(log, found.reasons); 76 | throw new Silent_Error(); 77 | } 78 | 79 | // Found a match either in the current working directory or Gro's directory. 80 | const found_tasks = found.value; 81 | const {resolved_input_files} = found_tasks; 82 | 83 | // Load the task module. 84 | const loaded = await load_tasks(found_tasks); 85 | if (!loaded.ok) { 86 | log_error_reasons(log, loaded.reasons); 87 | throw new Silent_Error(); 88 | } 89 | const loaded_tasks = loaded.value; 90 | 91 | if (resolved_input_files.length > 1 || resolved_input_files[0].resolved_input_path.is_directory) { 92 | // The input path matches a directory. Log the tasks but don't run them. 93 | log_tasks(log, loaded_tasks); 94 | finish(); 95 | return; 96 | } 97 | 98 | // The input path matches a file that's presumable a task, so load and run it. 99 | if (loaded_tasks.modules.length !== 1) throw Error('expected one loaded task'); // run only one task at a time 100 | const task = loaded_tasks.modules[0]; 101 | log.info( 102 | `→ ${st('cyan', task.name)} ${(task.mod.task.summary && st('gray', task.mod.task.summary)) ?? ''}`, 103 | ); 104 | 105 | const timing_to_run_task = timings.start('run task ' + task_name); 106 | const result = await run_task( 107 | task, 108 | {...args, ...to_forwarded_args(`gro ${task.name}`)}, 109 | invoke_task, 110 | config, 111 | filer, 112 | timings, 113 | ); 114 | timing_to_run_task(); 115 | if (!result.ok) { 116 | log.info(`${st('red', '🞩')} ${st('cyan', task.name)}`); 117 | log_error_reasons(log, [result.reason]); 118 | throw result.error; 119 | } 120 | log.info(`✓ ${st('cyan', task.name)}`); 121 | 122 | finish(); 123 | }; 124 | -------------------------------------------------------------------------------- /src/lib/lint.task.ts: -------------------------------------------------------------------------------- 1 | import {print_spawn_result} from '@ryanatkn/belt/process.js'; 2 | import {z} from 'zod'; 3 | 4 | import {Task_Error, type Task} from './task.ts'; 5 | import {serialize_args, to_forwarded_args} from './args.ts'; 6 | import {find_cli, spawn_cli} from './cli.ts'; 7 | 8 | const ESLINT_CLI = 'eslint'; 9 | 10 | export const Args = z 11 | .object({ 12 | _: z.array(z.string(), {description: 'paths to serve'}).default([]), 13 | eslint_cli: z.string({description: 'the ESLint CLI to use'}).default(ESLINT_CLI), 14 | }) 15 | .strict(); 16 | export type Args = z.infer; 17 | 18 | export const task: Task = { 19 | summary: 'run eslint', 20 | Args, 21 | run: async ({log, args}): Promise => { 22 | const {_, eslint_cli} = args; 23 | 24 | const found_eslint_cli = find_cli(eslint_cli); 25 | if (!found_eslint_cli) { 26 | // TODO maybe make this an option? 27 | log.info('ESLint is not installed; skipping linting'); 28 | return; 29 | } 30 | 31 | const forwarded_args = {_, 'max-warnings': 0, ...to_forwarded_args(eslint_cli)}; 32 | const serialized_args = serialize_args(forwarded_args); 33 | const eslintResult = await spawn_cli(found_eslint_cli, serialized_args, log); 34 | if (!eslintResult?.ok) { 35 | throw new Task_Error(`ESLint found some problems. ${print_spawn_result(eslintResult!)}`); 36 | } 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/loader.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve} from 'node:path'; 4 | import {readFileSync} from 'node:fs'; 5 | 6 | const JSON_FIXTURE = 'src/fixtures/modules/some_test_json.json'; 7 | const JSON_WITHOUT_EXTENSION_FIXTURE = 'src/fixtures/modules/some_test_json_without_extension'; 8 | 9 | test('import .js', async () => { 10 | const imported = await import(resolve('src/fixtures/modules/some_test_ts.js')); 11 | assert.ok(imported); 12 | assert.is(imported.a, 'ok'); 13 | }); 14 | 15 | test('import .ts', async () => { 16 | const imported = await import(resolve('src/fixtures/modules/some_test_ts.ts')); 17 | assert.ok(imported); 18 | assert.is(imported.a, 'ok'); 19 | }); 20 | 21 | test('import raw .ts', async () => { 22 | const path = resolve('src/fixtures/modules/some_test_ts.ts'); 23 | const imported = await import(path + '?raw'); 24 | assert.ok(imported); 25 | assert.equal(imported.default, readFileSync(path, 'utf8')); 26 | }); 27 | 28 | test('import .json', async () => { 29 | const path = resolve(JSON_FIXTURE); 30 | const imported = await import(path, {with: {type: 'json'}}); // import attribute is required 31 | assert.ok(imported); 32 | assert.is(imported.default.a, 'ok'); 33 | assert.equal(imported.default, JSON.parse(readFileSync(path, 'utf8'))); 34 | }); 35 | 36 | test('import json that doesnt end with .json', async () => { 37 | const path = resolve(JSON_WITHOUT_EXTENSION_FIXTURE); 38 | const imported = await import(path, {with: {type: 'json'}}); // import attribute means `.json` is not required 39 | assert.ok(imported); 40 | assert.ok(imported.default.some_test_json_without_extension); 41 | assert.equal(imported.default, JSON.parse(readFileSync(path, 'utf8'))); 42 | }); 43 | 44 | test('fail to import .json without the import attribute', async () => { 45 | let imported; 46 | let err; 47 | try { 48 | imported = await import(resolve(JSON_FIXTURE)); // intentionally missing the import attribute and expecting failure 49 | } catch (error) { 50 | err = error; 51 | } 52 | assert.ok(err); 53 | assert.not.ok(imported); 54 | }); 55 | 56 | test('import raw .css', async () => { 57 | const path = resolve('src/fixtures/modules/some_test_css.css'); 58 | const imported = await import(path); 59 | assert.is(typeof imported.default, 'string'); 60 | assert.equal(imported.default, readFileSync(path, 'utf8')); 61 | }); 62 | 63 | test('import .svelte', async () => { 64 | const imported = await import(resolve('src/fixtures/modules/Some_Test_Svelte.svelte')); 65 | assert.ok(imported); 66 | assert.is(imported.a, 'ok'); 67 | }); 68 | 69 | test('import raw .svelte', async () => { 70 | const path = resolve('src/fixtures/modules/Some_Test_Svelte.svelte'); 71 | const imported = await import(path + '?raw'); 72 | assert.ok(imported); 73 | assert.equal(imported.default, readFileSync(path, 'utf8')); 74 | }); 75 | 76 | test('import .svelte.js', async () => { 77 | const imported = await import(resolve('src/fixtures/modules/some_test_svelte_js.svelte.js')); 78 | assert.ok(imported.Some_Test_Svelte_Js); 79 | const instance = new imported.Some_Test_Svelte_Js(); 80 | assert.is(instance.a, 'ok'); 81 | }); 82 | 83 | test('import .svelte.ts', async () => { 84 | const imported = await import(resolve('src/fixtures/modules/some_test_svelte_ts.svelte.ts')); 85 | assert.ok(imported.Some_Test_Svelte_Ts); 86 | const instance = new imported.Some_Test_Svelte_Ts(); 87 | assert.is(instance.a, 'ok'); 88 | }); 89 | 90 | test.run(); 91 | -------------------------------------------------------------------------------- /src/lib/module.test.ts: -------------------------------------------------------------------------------- 1 | import {suite} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import {is_external_module} from './module.ts'; 5 | 6 | const test__is_external_module = suite('is_external_module'); 7 | 8 | test__is_external_module('internal browser module patterns', () => { 9 | assert.is(is_external_module('./foo'), false); 10 | assert.is(is_external_module('./foo.js'), false); 11 | assert.is(is_external_module('../foo'), false); 12 | assert.is(is_external_module('../foo.js'), false); 13 | assert.is(is_external_module('../../../foo'), false); 14 | assert.is(is_external_module('../../../foo.js'), false); 15 | assert.is(is_external_module('/foo'), false); 16 | assert.is(is_external_module('/foo.js'), false); 17 | assert.is(is_external_module('src/foo'), false); 18 | assert.is(is_external_module('src/foo.js'), false); 19 | assert.is(is_external_module('$lib/foo'), false); 20 | assert.is(is_external_module('$lib/foo.js'), false); 21 | assert.is(is_external_module('./foo/bar/baz'), false); 22 | assert.is(is_external_module('./foo/bar/baz.js'), false); 23 | assert.is(is_external_module('../foo/bar/baz'), false); 24 | assert.is(is_external_module('../foo/bar/baz.js'), false); 25 | assert.is(is_external_module('../../../foo/bar/baz'), false); 26 | assert.is(is_external_module('../../../foo/bar/baz.js'), false); 27 | assert.is(is_external_module('/foo/bar/baz'), false); 28 | assert.is(is_external_module('/foo/bar/baz.js'), false); 29 | assert.is(is_external_module('src/foo/bar/baz'), false); 30 | assert.is(is_external_module('src/foo/bar/baz.js'), false); 31 | assert.is(is_external_module('$lib/foo/bar/baz'), false); 32 | assert.is(is_external_module('$lib/foo/bar/baz.js'), false); 33 | }); 34 | 35 | test__is_external_module('external browser module patterns', () => { 36 | assert.is(is_external_module('foo'), true); 37 | assert.is(is_external_module('foo.js'), true); 38 | assert.is(is_external_module('foo/bar/baz'), true); 39 | assert.is(is_external_module('foo/bar/baz.js'), true); 40 | assert.is(is_external_module('@foo/bar/baz'), true); 41 | assert.is(is_external_module('@foo/bar/baz.js'), true); 42 | }); 43 | 44 | test__is_external_module.run(); 45 | -------------------------------------------------------------------------------- /src/lib/module.ts: -------------------------------------------------------------------------------- 1 | import {LIB_DIRNAME} from './paths.ts'; 2 | import {SOURCE_DIR, SOURCE_DIRNAME} from './constants.ts'; 3 | 4 | export const MODULE_PATH_SRC_PREFIX = SOURCE_DIR; 5 | export const MODULE_PATH_LIB_PREFIX = `$${LIB_DIRNAME}/`; 6 | 7 | const INTERNAL_MODULE_MATCHER = new RegExp( 8 | `^(\\.?\\.?|${SOURCE_DIRNAME}|\\$${LIB_DIRNAME})\\/`, 9 | 'u', 10 | ); 11 | 12 | export const is_external_module = (module_name: string): boolean => 13 | !INTERNAL_MODULE_MATCHER.test(module_name); 14 | -------------------------------------------------------------------------------- /src/lib/modules.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve} from 'node:path'; 4 | 5 | import {load_module} from './modules.ts'; 6 | 7 | // TODO if we import directly, svelte-package generates types in `src/fixtures` 8 | /* eslint-disable no-useless-concat */ 9 | const mod_test1 = await import('../fixtures/' + 'test1.foo.js'); 10 | 11 | test('load_module basic behavior', async () => { 12 | const id = resolve('src/fixtures/test1.foo.js'); 13 | let validated_mod; 14 | const result = await load_module(id, (mod): mod is any => { 15 | validated_mod = mod; 16 | return true; 17 | }); 18 | assert.ok(result.ok); 19 | assert.is(result.id, id); 20 | assert.is(result.mod, validated_mod); 21 | assert.is(result.mod, mod_test1); 22 | }); 23 | 24 | test('load_module without validation', async () => { 25 | const id = resolve('src/fixtures/test1.foo.js'); 26 | const result = await load_module(id); 27 | assert.ok(result.ok); 28 | assert.is(result.id, id); 29 | assert.is(result.mod, mod_test1); 30 | }); 31 | 32 | test('load_module fails validation', async () => { 33 | const id = resolve('src/fixtures/test1.foo.js'); 34 | let validated_mod; 35 | const test_validation = (mod: Record) => { 36 | validated_mod = mod; 37 | return false; 38 | }; 39 | const result = await load_module(id, test_validation as any); 40 | assert.ok(!result.ok); 41 | if (result.type === 'failed_validation') { 42 | assert.is(result.validation, test_validation.name); 43 | assert.is(result.id, id); 44 | assert.is(result.mod, validated_mod); 45 | assert.is(result.mod, mod_test1); 46 | } else { 47 | throw Error('Should be invalid'); 48 | } 49 | }); 50 | 51 | test('load_module fails to import', async () => { 52 | const id = resolve('foo/test/failure'); 53 | const result = await load_module(id); 54 | assert.ok(!result.ok); 55 | if (result.type === 'failed_import') { 56 | assert.is(result.id, id); 57 | assert.ok(result.error instanceof Error); 58 | } else { 59 | throw Error('Should fail to import'); 60 | } 61 | }); 62 | 63 | test.run(); 64 | -------------------------------------------------------------------------------- /src/lib/modules.ts: -------------------------------------------------------------------------------- 1 | import type {Timings} from '@ryanatkn/belt/timings.js'; 2 | import {Unreachable_Error} from '@ryanatkn/belt/error.js'; 3 | import type {Result} from '@ryanatkn/belt/result.js'; 4 | import {print_error} from '@ryanatkn/belt/print.js'; 5 | 6 | import type {Resolved_Input_File} from './input_path.ts'; 7 | import {print_path} from './paths.ts'; 8 | import type {Path_Id} from './path.ts'; 9 | 10 | export interface Module_Meta = Record> { 11 | id: Path_Id; 12 | mod: T_Module; 13 | } 14 | 15 | export type Load_Module_Result = Result< 16 | {id: Path_Id; mod: T_Module}, 17 | Load_Module_Failure 18 | >; 19 | export type Load_Module_Failure = 20 | | {ok: false; type: 'failed_import'; id: Path_Id; error: Error} 21 | | { 22 | ok: false; 23 | type: 'failed_validation'; 24 | id: Path_Id; 25 | mod: Record; 26 | validation: string; 27 | }; 28 | 29 | export const load_module = async >( 30 | id: Path_Id, 31 | validate?: (mod: Record) => mod is T_Module, 32 | ): Promise> => { 33 | let mod; 34 | try { 35 | mod = await import(id); 36 | } catch (err) { 37 | return {ok: false, type: 'failed_import', id, error: err}; 38 | } 39 | if (validate && !validate(mod)) { 40 | return {ok: false, type: 'failed_validation', id, mod, validation: validate.name}; 41 | } 42 | return {ok: true, id, mod}; 43 | }; 44 | 45 | export interface Load_Modules_Failure { 46 | type: 'load_module_failures'; 47 | load_module_failures: Array; 48 | reasons: Array; 49 | // still return the modules and timings, deferring to the caller 50 | modules: Array; 51 | } 52 | 53 | export type Load_Modules_Result = Result< 54 | { 55 | modules: Array; 56 | }, 57 | Load_Modules_Failure 58 | >; 59 | 60 | // TODO parallelize and sort afterwards 61 | export const load_modules = async < 62 | T_Module extends Record, 63 | T_Module_Meta extends Module_Meta, 64 | >( 65 | resolved_input_files: Array, 66 | validate: (mod: any) => mod is T_Module, 67 | map_module_meta: (resolved_input_file: Resolved_Input_File, mod: T_Module) => T_Module_Meta, 68 | timings?: Timings, 69 | ): Promise> => { 70 | const timing_to_load_modules = timings?.start('load modules'); 71 | const modules: Array = []; 72 | const load_module_failures: Array = []; 73 | const reasons: Array = []; 74 | for (const resolved_input_file of resolved_input_files.values()) { 75 | const {id, input_path} = resolved_input_file; 76 | const result = await load_module(id, validate); // eslint-disable-line no-await-in-loop 77 | if (result.ok) { 78 | modules.push(map_module_meta(resolved_input_file, result.mod)); 79 | } else { 80 | load_module_failures.push(result); 81 | switch (result.type) { 82 | case 'failed_import': { 83 | reasons.push( 84 | `Module import ${print_path(id)} failed from input ${print_path( 85 | input_path, 86 | )}: ${print_error(result.error)}`, 87 | ); 88 | break; 89 | } 90 | case 'failed_validation': { 91 | reasons.push(`Module ${print_path(id)} failed validation '${result.validation}'.`); 92 | break; 93 | } 94 | default: 95 | throw new Unreachable_Error(result); 96 | } 97 | } 98 | } 99 | timing_to_load_modules?.(); 100 | 101 | if (load_module_failures.length) { 102 | return { 103 | ok: false, 104 | type: 'load_module_failures', 105 | load_module_failures, 106 | reasons, 107 | modules, 108 | }; 109 | } 110 | 111 | return {ok: true, modules}; 112 | }; 113 | -------------------------------------------------------------------------------- /src/lib/package.gen.ts: -------------------------------------------------------------------------------- 1 | import type {Gen} from './gen.ts'; 2 | import {load_package_json} from './package_json.ts'; 3 | import {IS_THIS_GRO} from './paths.ts'; 4 | import {create_src_json} from './src_json.ts'; 5 | 6 | // TODO rename? `Package_Json + Src_Json = package.ts` currently, idk 7 | 8 | // TODO consider an api that uses magic imports like SvelteKit's `$app`, like `$repo/package.json` 9 | 10 | /** 11 | * A convenience `gen` file that outputs `$lib/package.ts`, 12 | * which mirrors `package.json` but in TypeScript, 13 | * allowing apps to import typesafe data from their own `package.json`. 14 | */ 15 | export const gen: Gen = ({origin_path}) => { 16 | const package_json = load_package_json(); 17 | const src_json = create_src_json(package_json, undefined); 18 | 19 | return ` 20 | // generated by ${origin_path} 21 | 22 | import type {Package_Json} from '${ 23 | IS_THIS_GRO ? './package_json.ts' : '@ryanatkn/gro/package_json.js' 24 | }'; 25 | import type {Src_Json} from '${IS_THIS_GRO ? './src_json.ts' : '@ryanatkn/gro/src_json.js'}'; 26 | 27 | export const package_json = ${JSON.stringify(package_json)} satisfies Package_Json; 28 | 29 | export const src_json = ${JSON.stringify(src_json)} satisfies Src_Json; 30 | 31 | // generated by ${origin_path} 32 | `; 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/package_meta.ts: -------------------------------------------------------------------------------- 1 | import {strip_start, strip_end, ensure_end} from '@ryanatkn/belt/string.js'; 2 | 3 | import type {Package_Json, Url} from './package_json.ts'; 4 | import type {Src_Json} from './src_json.ts'; 5 | 6 | // TODO needs refactoring, more clarity 7 | export interface Package_Meta { 8 | repo_url: Url; // 'https://github.com/ryanatkn/fuz' 9 | package_json: Package_Json; 10 | src_json: Src_Json; 11 | name: string; // '@ryanatkn/fuz_library' 12 | repo_name: string; // fuz_library 13 | /** 14 | * The github user/org. 15 | */ 16 | owner_name: string | null; // 'fuz-dev' 17 | homepage_url: Url | null; // 'https://www.fuz.dev/' 18 | logo_url: Url | null; // 'https://www.fuz.dev/logo.svg' falling back to 'https://www.fuz.dev/favicon.png' 19 | logo_alt: string; // 'icon for gro' 20 | npm_url: Url | null; // 'https://npmjs.com/package/@ryanatkn/fuz_library' 21 | changelog_url: Url | null; 22 | published: boolean; 23 | } 24 | 25 | export const parse_package_meta = ( 26 | package_json: Package_Json, 27 | src_json: Src_Json, 28 | ): Package_Meta => { 29 | const {name} = package_json; 30 | 31 | // TODO hacky 32 | const parse_repo = (r: string | null | undefined) => { 33 | if (!r) return null; 34 | return strip_end(strip_start(strip_end(r, '.git'), 'git+'), '/'); 35 | }; 36 | 37 | const repo_url = parse_repo( 38 | package_json.repository 39 | ? typeof package_json.repository === 'string' 40 | ? package_json.repository 41 | : package_json.repository.url 42 | : null, 43 | ); 44 | if (!repo_url) { 45 | throw Error('failed to parse package_meta - `repo_url` is required in package_json'); 46 | } 47 | 48 | const homepage_url = package_json.homepage ?? null; 49 | 50 | const published = 51 | !package_json.private && !!package_json.exports && package_json.version !== '0.0.1'; 52 | 53 | // TODO generic registries 54 | const npm_url = published ? 'https://www.npmjs.com/package/' + package_json.name : null; 55 | 56 | const changelog_url = published && repo_url ? repo_url + '/blob/main/CHANGELOG.md' : null; 57 | 58 | const repo_name = parse_repo_name(name); 59 | 60 | const owner_name = repo_url ? strip_start(repo_url, 'https://github.com/').split('/')[0] : null; 61 | 62 | const logo_url = homepage_url 63 | ? ensure_end(homepage_url, '/') + 64 | (package_json.logo ? strip_start(package_json.logo, '/') : 'favicon.png') 65 | : null; 66 | 67 | const logo_alt = package_json.logo_alt ?? `logo for ${repo_name}`; 68 | 69 | return { 70 | package_json, 71 | src_json, 72 | name, 73 | repo_name, 74 | repo_url, 75 | owner_name, 76 | homepage_url, 77 | logo_url, 78 | logo_alt, 79 | npm_url, 80 | changelog_url, 81 | published, 82 | }; 83 | }; 84 | 85 | // TODO proper parsing 86 | export const parse_repo_name = (name: string): string => 87 | name[0] === '@' ? name.split('/')[1] : name; 88 | 89 | export const parse_org_url = (pkg: Package_Meta): string | null => { 90 | const {repo_name, repo_url} = pkg; 91 | if (!repo_url) return null; 92 | const suffix = '/' + repo_name; 93 | if (repo_url.endsWith(suffix)) { 94 | return strip_end(repo_url, suffix); 95 | } 96 | return null; 97 | }; 98 | -------------------------------------------------------------------------------- /src/lib/parse_exports.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import {extname} from 'node:path'; 3 | import type {Flavored} from '@ryanatkn/belt/types.js'; 4 | import type {Logger} from '@ryanatkn/belt/log.js'; 5 | 6 | import type {Path_Id} from './path.ts'; 7 | import {TS_MATCHER} from './constants.ts'; 8 | import {Parse_Exports_Context} from './parse_exports_context.ts'; 9 | 10 | export type Declaration_Kind = 11 | | 'type' 12 | | 'function' 13 | | 'variable' // TODO maybe expand this to have literals/primitives? 14 | | 'class' 15 | | 'component' 16 | | 'json' 17 | | 'css'; 18 | 19 | export interface Declaration { 20 | name: string; 21 | kind: Declaration_Kind | null; 22 | } 23 | 24 | export type Export_Declaration = Flavored; 25 | 26 | /** 27 | * Parse exports from a file based on its file type and content. 28 | */ 29 | export const parse_exports = ( 30 | id: Path_Id, 31 | program?: ts.Program, 32 | declarations: Array = [], 33 | log?: Logger, 34 | ): Array => { 35 | // First, infer declarations based on file extension 36 | infer_declarations_from_file_type(id, declarations); 37 | 38 | // For TypeScript files with program, perform detailed export analysis 39 | if (TS_MATCHER.test(id) && program) { 40 | const source_file = program.getSourceFile(id); 41 | if (!source_file) return declarations; 42 | 43 | const checker = program.getTypeChecker(); 44 | 45 | // Get the exports of the source file (module) 46 | const symbol = checker.getSymbolAtLocation(source_file); 47 | if (!symbol) return declarations; 48 | 49 | // Get the module exports 50 | const exports = checker.getExportsOfModule(symbol); 51 | 52 | // Process TypeScript declarations 53 | const export_context = new Parse_Exports_Context(program, log); 54 | export_context.analyze_source_file(source_file); 55 | export_context.process_exports(source_file, exports, declarations); 56 | } 57 | 58 | return declarations; 59 | }; 60 | 61 | // TODO temporary until proper type inference 62 | export const infer_declarations_from_file_type = ( 63 | file_path: Path_Id, 64 | declarations: Array = [], 65 | ): Array => { 66 | const extension = extname(file_path).toLowerCase(); 67 | 68 | switch (extension) { 69 | case '.svelte': { 70 | declarations.push({ 71 | name: 'default', 72 | kind: 'component', 73 | }); 74 | break; 75 | } 76 | case '.css': { 77 | declarations.push({ 78 | name: 'default', 79 | kind: 'css', 80 | }); 81 | break; 82 | } 83 | case '.json': { 84 | declarations.push({ 85 | name: 'default', 86 | kind: 'json', 87 | }); 88 | break; 89 | } 90 | } 91 | 92 | return declarations; 93 | }; 94 | 95 | /** 96 | * Process TypeScript exports, identifying their kinds. 97 | */ 98 | export const process_ts_exports = ( 99 | source_file: ts.SourceFile, 100 | program: ts.Program, 101 | exports: Array, 102 | declarations: Array = [], 103 | log?: Logger, 104 | ): Array => { 105 | const export_context = new Parse_Exports_Context(program, log); 106 | export_context.analyze_source_file(source_file); 107 | return export_context.process_exports(source_file, exports, declarations); 108 | }; 109 | -------------------------------------------------------------------------------- /src/lib/path.ts: -------------------------------------------------------------------------------- 1 | import {fileURLToPath, type URL} from 'node:url'; 2 | import type {Flavored} from '@ryanatkn/belt/types.js'; 3 | 4 | /** 5 | * An absolute path on the filesystem. Named "id" to be consistent with Rollup. 6 | */ 7 | export type Path_Id = Flavored; 8 | 9 | export interface Path_Info { 10 | id: Path_Id; 11 | is_directory: boolean; 12 | } 13 | 14 | export interface Resolved_Path extends Path_Info { 15 | path: string; 16 | } 17 | 18 | export type Path_Filter = (path: string, is_directory: boolean) => boolean; 19 | 20 | export type File_Filter = (path: string) => boolean; 21 | 22 | export const to_file_path = (path_or_url: string | URL): string => 23 | typeof path_or_url === 'string' ? path_or_url : fileURLToPath(path_or_url.href); 24 | -------------------------------------------------------------------------------- /src/lib/paths.test.ts: -------------------------------------------------------------------------------- 1 | import {suite} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve, join} from 'node:path'; 4 | 5 | import { 6 | create_paths, 7 | paths, 8 | gro_paths, 9 | is_gro_id, 10 | to_root_path, 11 | path_id_to_base_path, 12 | base_path_to_path_id, 13 | } from './paths.ts'; 14 | 15 | const test__create_paths = suite('create_paths'); 16 | 17 | test__create_paths('basic behavior', () => { 18 | const root = resolve('../fake'); 19 | const p = create_paths(root); 20 | assert.is(p.root, join(root, '/')); 21 | assert.is(p.source, join(root, 'src/')); 22 | }); 23 | 24 | test__create_paths('paths object has the same identity as the gro_paths object', () => { 25 | assert.is(paths, gro_paths); // because we're testing inside the Gro project 26 | }); 27 | 28 | test__create_paths.run(); 29 | 30 | const test__is_gro_id = suite('is_gro_id'); 31 | 32 | test__is_gro_id('basic behavior', () => { 33 | assert.ok(is_gro_id(resolve(paths.root))); 34 | assert.ok(is_gro_id(resolve(paths.root.slice(0, -1)))); 35 | assert.ok(is_gro_id(resolve(paths.source).slice(0, -1))); 36 | assert.ok(!is_gro_id(resolve('../fake/src'))); 37 | assert.ok(!is_gro_id(resolve('../fake/src/'))); 38 | assert.ok(!is_gro_id(resolve('../gro_fake'))); 39 | assert.ok(!is_gro_id(resolve('../gro_fake/'))); 40 | assert.ok(!is_gro_id(resolve('../gro_fake/src'))); 41 | assert.ok(!is_gro_id(resolve('../gro_fake/src/'))); 42 | }); 43 | 44 | test__is_gro_id.run(); 45 | 46 | const test__to_root_path = suite('to_root_path'); 47 | 48 | test__to_root_path('basic behavior', () => { 49 | assert.is(to_root_path(resolve('foo/bar')), 'foo/bar'); 50 | assert.is(to_root_path(resolve('./')), './'); 51 | assert.is(to_root_path(resolve('./')), './'); 52 | }); 53 | 54 | test__to_root_path.run(); 55 | 56 | const test__path_id_to_base_path = suite('path_id_to_base_path'); 57 | 58 | test__path_id_to_base_path('basic behavior', () => { 59 | assert.is(path_id_to_base_path(resolve('src/foo/bar/baz.ts')), 'foo/bar/baz.ts'); 60 | }); 61 | 62 | test__path_id_to_base_path.run(); 63 | 64 | const test__base_path_to_path_id = suite('base_path_to_path_id'); 65 | 66 | test__base_path_to_path_id('basic behavior', () => { 67 | assert.is(base_path_to_path_id('foo/bar/baz.ts'), resolve('src/foo/bar/baz.ts')); 68 | }); 69 | 70 | test__base_path_to_path_id('does not change extension', () => { 71 | assert.is(base_path_to_path_id('foo/bar/baz.js'), resolve('src/foo/bar/baz.js')); 72 | }); 73 | 74 | test__base_path_to_path_id.run(); 75 | -------------------------------------------------------------------------------- /src/lib/paths.ts: -------------------------------------------------------------------------------- 1 | import {join, extname, relative, basename} from 'node:path'; 2 | import {fileURLToPath} from 'node:url'; 3 | import {ensure_end, strip_end} from '@ryanatkn/belt/string.js'; 4 | import {styleText as st} from 'node:util'; 5 | 6 | import { 7 | GRO_CONFIG_PATH, 8 | GRO_DEV_DIR, 9 | GRO_DIR, 10 | SOURCE_DIR, 11 | SVELTEKIT_DIST_DIRNAME, 12 | } from './constants.ts'; 13 | import {default_svelte_config} from './svelte_config.ts'; 14 | import type {Path_Id} from './path.ts'; 15 | 16 | /* 17 | 18 | A path `id` is an absolute path to the source/.gro/dist directory. 19 | It's the same name that Rollup uses. 20 | 21 | */ 22 | 23 | export const LIB_DIRNAME = basename(default_svelte_config.lib_path); 24 | export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; 25 | /** @trailing_slash */ 26 | export const LIB_DIR = LIB_PATH + '/'; 27 | export const ROUTES_DIRNAME = basename(default_svelte_config.routes_path); 28 | 29 | export interface Paths { 30 | /** @trailing_slash */ 31 | root: string; 32 | /** @trailing_slash */ 33 | source: string; 34 | /** @trailing_slash */ 35 | lib: string; 36 | /** @trailing_slash */ 37 | build: string; 38 | /** @trailing_slash */ 39 | build_dev: string; 40 | config: string; 41 | } 42 | 43 | export const create_paths = (root_dir: string): Paths => { 44 | // TODO remove reliance on trailing slash towards windows support 45 | const root = ensure_end(root_dir, '/'); 46 | return { 47 | root, 48 | source: root + SOURCE_DIR, 49 | lib: root + LIB_DIR, 50 | build: root + GRO_DIR, 51 | build_dev: root + GRO_DEV_DIR, 52 | config: root + GRO_CONFIG_PATH, 53 | }; 54 | }; 55 | 56 | export const infer_paths = (id: Path_Id): Paths => (is_gro_id(id) ? gro_paths : paths); 57 | 58 | export const is_gro_id = (id: Path_Id): boolean => 59 | id.startsWith(gro_paths.root) || gro_paths.root === ensure_end(id, '/'); 60 | 61 | // '/home/me/app/src/foo/bar/baz.ts' → 'src/foo/bar/baz.ts' 62 | export const to_root_path = (id: Path_Id, p = infer_paths(id)): string => 63 | relative(p.root, id) || './'; 64 | 65 | // '/home/me/app/src/foo/bar/baz.ts' → 'foo/bar/baz.ts' 66 | export const path_id_to_base_path = (path_id: Path_Id, p = infer_paths(path_id)): string => 67 | relative(p.source, path_id); 68 | 69 | // TODO base_path is an obsolete concept, it was a remnant from forcing `src/` 70 | // 'foo/bar/baz.ts' → '/home/me/app/src/foo/bar/baz.ts' 71 | export const base_path_to_path_id = (base_path: string, p = infer_paths(base_path)): Path_Id => 72 | join(p.source, base_path); 73 | 74 | export const print_path = (path: string, p = infer_paths(path)): string => { 75 | let final_path = 76 | strip_end(path, '/') === strip_end(GRO_DIST_DIR, '/') ? 'gro' : to_root_path(path, p); 77 | final_path = 78 | final_path === 'gro' ? final_path : final_path[0] === '.' ? final_path : './' + final_path; 79 | return st('gray', final_path); 80 | }; 81 | 82 | export const replace_extension = (path: string, new_extension: string): string => { 83 | const {length} = extname(path); 84 | return (length === 0 ? path : path.substring(0, path.length - length)) + new_extension; 85 | }; 86 | 87 | /** 88 | * Paths for the user repo. 89 | */ 90 | export const paths = create_paths(process.cwd()); 91 | 92 | /** @trailing_slash */ 93 | export const GRO_PACKAGE_DIR = 'gro/'; 94 | // TODO document these conditions with comments 95 | // TODO there's probably a more robust way to do this 96 | const filename = fileURLToPath(import.meta.url); 97 | const gro_package_dir_path = join( 98 | filename, 99 | filename.includes('/gro/src/lib/') 100 | ? '../../../' 101 | : filename.includes('/gro/dist/') 102 | ? '../../' 103 | : '../', 104 | ); 105 | export const IS_THIS_GRO = gro_package_dir_path === paths.root; 106 | /** 107 | * Paths for the Gro package being used by the user repo. 108 | */ 109 | export const gro_paths = IS_THIS_GRO ? paths : create_paths(gro_package_dir_path); 110 | /** @trailing_slash */ 111 | export const GRO_DIST_DIR = gro_paths.root + SVELTEKIT_DIST_DIRNAME + '/'; 112 | -------------------------------------------------------------------------------- /src/lib/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import {replace_plugin} from './plugin.ts'; 5 | 6 | test('replace_plugin', () => { 7 | const a = {name: 'a'}; 8 | const b = {name: 'b'}; 9 | const c = {name: 'c'}; 10 | const plugins = [a, b, c]; 11 | const a2 = {name: 'a'}; 12 | const b2 = {name: 'b'}; 13 | const c2 = {name: 'c'}; 14 | let p = plugins; 15 | p = replace_plugin(p, a2); 16 | assert.is(p[0], a2); 17 | assert.is(p[1], b); 18 | assert.is(p[2], c); 19 | p = replace_plugin(p, b2); 20 | assert.is(p[0], a2); 21 | assert.is(p[1], b2); 22 | assert.is(p[2], c); 23 | // allows duplicate names in the array 24 | p = replace_plugin(p, c2, 'a'); 25 | assert.is(p[0], c2); 26 | assert.is(p[1], b2); 27 | assert.is(p[2], c); 28 | p = replace_plugin(p, a2, 'c'); 29 | assert.is(p[0], a2); 30 | assert.is(p[1], b2); 31 | assert.is(p[2], c); 32 | p = replace_plugin(p, c2); 33 | assert.is(p[0], a2); 34 | assert.is(p[1], b2); 35 | assert.is(p[2], c2); 36 | }); 37 | 38 | test('replace_plugin without an array', () => { 39 | const a = {name: 'a'}; 40 | const a2 = {name: 'a'}; 41 | const p = replace_plugin([a], a2); 42 | assert.is(p[0], a2); 43 | }); 44 | 45 | test('replace_plugin throws if it cannot find the given name', () => { 46 | const a = {name: 'a'}; 47 | const plugins = [a]; 48 | let err; 49 | try { 50 | replace_plugin(plugins, {name: 'b'}); 51 | } catch (_err) { 52 | err = _err; 53 | } 54 | if (!err) assert.unreachable('should have failed'); 55 | }); 56 | 57 | test.run(); 58 | -------------------------------------------------------------------------------- /src/lib/plugin.ts: -------------------------------------------------------------------------------- 1 | import type {Task_Context} from './task.ts'; 2 | 3 | /** 4 | * Gro `Plugin`s enable custom behavior during `gro dev` and `gro build`. 5 | * In contrast, `Adapter`s use the results of `gro build` to produce final artifacts. 6 | */ 7 | export interface Plugin { 8 | name: string; 9 | setup?: (ctx: T_Plugin_Context) => void | Promise; 10 | adapt?: (ctx: T_Plugin_Context) => void | Promise; 11 | teardown?: (ctx: T_Plugin_Context) => void | Promise; 12 | } 13 | 14 | export type Create_Config_Plugins = ( 15 | ctx: T_Plugin_Context, 16 | ) => Array> | Promise>>; 17 | 18 | export interface Plugin_Context extends Task_Context { 19 | dev: boolean; 20 | watch: boolean; 21 | } 22 | 23 | /** See `Plugins.create` for a usage example. */ 24 | export class Plugins { 25 | readonly ctx: T_Plugin_Context; 26 | readonly instances: Array>; 27 | 28 | constructor(ctx: T_Plugin_Context, instances: Array) { 29 | this.ctx = ctx; 30 | this.instances = instances; 31 | } 32 | 33 | static async create( 34 | ctx: T_Plugin_Context, 35 | ): Promise> { 36 | const {timings} = ctx; 37 | const timing_to_create = timings.start('plugins.create'); 38 | const instances: Array = await ctx.config.plugins(ctx); 39 | const plugins = new Plugins(ctx, instances); 40 | timing_to_create(); 41 | return plugins; 42 | } 43 | 44 | async setup(): Promise { 45 | const {ctx, instances} = this; 46 | if (!instances.length) return; 47 | const {timings, log} = ctx; 48 | const timing_to_setup = timings.start('plugins.setup'); 49 | for (const plugin of instances) { 50 | if (!plugin.setup) continue; 51 | log.debug('setup plugin', plugin.name); 52 | const timing = timings.start(`setup:${plugin.name}`); 53 | await plugin.setup(ctx); // eslint-disable-line no-await-in-loop 54 | timing(); 55 | } 56 | timing_to_setup(); 57 | } 58 | 59 | async adapt(): Promise { 60 | const {ctx, instances} = this; 61 | const {timings} = ctx; 62 | const timing_to_run_adapters = timings.start('plugins.adapt'); 63 | for (const plugin of instances) { 64 | if (!plugin.adapt) continue; 65 | const timing = timings.start(`adapt:${plugin.name}`); 66 | await plugin.adapt(ctx); // eslint-disable-line no-await-in-loop 67 | timing(); 68 | } 69 | timing_to_run_adapters(); 70 | } 71 | 72 | async teardown(): Promise { 73 | const {ctx, instances} = this; 74 | if (!instances.length) return; 75 | const {timings, log} = ctx; 76 | const timing_to_teardown = timings.start('plugins.teardown'); 77 | for (const plugin of instances) { 78 | if (!plugin.teardown) continue; 79 | log.debug('teardown plugin', plugin.name); 80 | const timing = timings.start(`teardown:${plugin.name}`); 81 | await plugin.teardown(ctx); // eslint-disable-line no-await-in-loop 82 | timing(); 83 | } 84 | timing_to_teardown(); 85 | } 86 | } 87 | 88 | /** 89 | * Replaces a plugin by name in `plugins` without mutating the param. 90 | * Throws if the plugin name cannot be found. 91 | * @param plugins - accepts the same types as the return value of `Create_Config_Plugins` 92 | * @param new_plugin 93 | * @param name - @default new_plugin.name 94 | * @returns `plugins` with `new_plugin` at the index of the plugin with `name` 95 | */ 96 | export const replace_plugin = ( 97 | plugins: Array, 98 | new_plugin: Plugin, 99 | name = new_plugin.name, 100 | ): Array => { 101 | const index = plugins.findIndex((p) => p.name === name); 102 | if (index === -1) throw Error('Failed to find plugin to replace: ' + name); 103 | const replaced = plugins.slice(); 104 | replaced[index] = new_plugin; 105 | return replaced; 106 | }; 107 | -------------------------------------------------------------------------------- /src/lib/register.ts: -------------------------------------------------------------------------------- 1 | import {register} from 'node:module'; 2 | 3 | register('./loader.js', import.meta.url); 4 | -------------------------------------------------------------------------------- /src/lib/reinstall.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import {spawn} from '@ryanatkn/belt/process.js'; 3 | import {rm} from 'node:fs/promises'; 4 | 5 | import {Task_Error, type Task} from './task.ts'; 6 | import {LOCKFILE_FILENAME, NODE_MODULES_DIRNAME} from './constants.ts'; 7 | 8 | export const Args = z.object({}).strict(); 9 | export type Args = z.infer; 10 | 11 | export const task: Task = { 12 | summary: `refreshes ${LOCKFILE_FILENAME} with the latest and cleanest deps`, 13 | Args, 14 | run: async ({log, config}): Promise => { 15 | log.info(`running the initial \`${config.pm_cli} install\``); 16 | const initial_install_result = await spawn(config.pm_cli, ['install']); 17 | if (!initial_install_result.ok) { 18 | throw new Task_Error(`Failed initial \`${config.pm_cli} install\``); 19 | } 20 | 21 | // Deleting both the lockfile and node_modules upgrades to the latest minor/patch versions. 22 | await Promise.all([rm(LOCKFILE_FILENAME), rm(NODE_MODULES_DIRNAME, {recursive: true})]); 23 | log.info( 24 | `running \`${config.pm_cli} install\` after deleting ${LOCKFILE_FILENAME} and ${NODE_MODULES_DIRNAME}, this can take a while...`, 25 | ); 26 | const second_install_result = await spawn(config.pm_cli, ['install']); 27 | if (!second_install_result.ok) { 28 | throw new Task_Error( 29 | `Failed \`${config.pm_cli} install\` after deleting ${LOCKFILE_FILENAME} and ${NODE_MODULES_DIRNAME}`, 30 | ); 31 | } 32 | 33 | // Deleting the lockfile and reinstalling cleans the lockfile of unnecessary dep noise, 34 | // like esbuild's many packages for each platform. 35 | await rm(LOCKFILE_FILENAME); 36 | log.info(`running \`${config.pm_cli} install\` one last time to clean ${LOCKFILE_FILENAME}`); 37 | const final_install_result = await spawn(config.pm_cli, ['install']); 38 | if (!final_install_result.ok) { 39 | throw new Task_Error(`Failed \`${config.pm_cli} install\``); 40 | } 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/lib/release.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | import type {Task} from './task.ts'; 4 | import {has_sveltekit_library, has_sveltekit_app} from './sveltekit_helpers.ts'; 5 | import {load_package_json} from './package_json.ts'; 6 | 7 | export const Args = z.object({}).strict(); 8 | export type Args = z.infer; 9 | 10 | export const task: Task = { 11 | summary: 'publish and deploy', 12 | Args, 13 | run: async ({invoke_task}) => { 14 | const package_json = load_package_json(); 15 | 16 | const publish = has_sveltekit_library(package_json).ok; 17 | if (publish) { 18 | await invoke_task('publish', {optional: true}); 19 | } 20 | if (has_sveltekit_app().ok) { 21 | await invoke_task('deploy', {build: !publish}); 22 | } 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/resolve.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import {styleText as st} from 'node:util'; 3 | 4 | import {TASK_FILE_SUFFIXES, type Task} from './task.ts'; 5 | import {resolve_input_paths, to_input_paths} from './input_path.ts'; 6 | 7 | export const Args = z 8 | .object({ 9 | _: z.array(z.string(), {description: 'the input paths to resolve'}).default(['']), 10 | verbose: z.boolean({description: 'log diagnostics'}).default(false), 11 | }) 12 | .strict(); 13 | export type Args = z.infer; 14 | 15 | export const task: Task = { 16 | summary: 'diagnostic that logs resolved filesystem info for the given input paths', 17 | Args, 18 | run: ({args, config, log}): void => { 19 | const {_, verbose} = args; 20 | 21 | if (verbose) log.info('raw input paths:', _); 22 | 23 | const input_paths = to_input_paths(_); 24 | if (verbose) log.info('input paths:', input_paths); 25 | 26 | const {task_root_dirs} = config; 27 | if (verbose) log.info('task root paths:', task_root_dirs); 28 | 29 | const {resolved_input_paths, possible_paths_by_input_path, unmapped_input_paths} = 30 | resolve_input_paths(input_paths, task_root_dirs, TASK_FILE_SUFFIXES); 31 | if (verbose) log.info('resolved_input_paths:', resolved_input_paths); 32 | if (verbose) log.info('possible_paths_by_input_path:', possible_paths_by_input_path); 33 | if (verbose) log.info('unmapped_input_paths:', unmapped_input_paths); 34 | 35 | for (const p of resolved_input_paths) { 36 | log.info('resolved:', st('green', p.id)); 37 | } 38 | 39 | if (!resolved_input_paths.length) { 40 | log.warn(st('yellow', 'no input paths were resolved')); 41 | } 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/lib/resolve_specifier.ts: -------------------------------------------------------------------------------- 1 | import {extname, isAbsolute, join, relative} from 'node:path'; 2 | import {existsSync} from 'node:fs'; 3 | 4 | import {replace_extension} from './paths.ts'; 5 | import type {Path_Id} from './path.ts'; 6 | 7 | export interface Resolved_Specifier { 8 | /** 9 | * The resolved filesystem path for the specifier. 10 | */ 11 | path_id: Path_Id; 12 | /** 13 | * Same as `path_id` but includes `?raw` and other querystrings. (currently none) 14 | */ 15 | path_id_with_querystring: string; 16 | specifier: string; 17 | mapped_specifier: string; 18 | namespace: undefined | 'sveltekit_local_imports_ts' | 'sveltekit_local_imports_js'; 19 | raw: boolean; 20 | } 21 | 22 | /** 23 | * Maps an import `specifier` relative to `dir`, 24 | * and infer the correct extension following Vite conventions. 25 | * If no `.js` file is found for the specifier on the filesystem, it assumes `.ts`. 26 | */ 27 | export const resolve_specifier = (specifier: string, dir: string): Resolved_Specifier => { 28 | const raw = specifier.endsWith('?raw'); // TODO more robust detection? other values? 29 | const final_specifier = raw ? specifier.substring(0, specifier.length - 4) : specifier; 30 | const absolute_path = isAbsolute(final_specifier) ? final_specifier : join(dir, final_specifier); 31 | 32 | let mapped_path; 33 | let path_id; 34 | let namespace: Resolved_Specifier['namespace']; 35 | 36 | const ext = extname(absolute_path); 37 | const is_js = ext === '.js'; 38 | const is_ts = ext === '.ts'; 39 | 40 | if (!is_js && !is_ts && existsSync(absolute_path)) { 41 | // unrecognized extension and the file exists 42 | mapped_path = absolute_path; 43 | path_id = absolute_path; 44 | } else if (is_ts) { 45 | // explicitly ts 46 | mapped_path = replace_extension(absolute_path, '.js'); 47 | path_id = absolute_path; 48 | namespace = 'sveltekit_local_imports_ts'; 49 | } else { 50 | // extensionless, or js that points to ts, or just js 51 | const js_id = is_js ? absolute_path : absolute_path + '.js'; 52 | const ts_id = is_js ? replace_extension(absolute_path, '.ts') : absolute_path + '.ts'; 53 | if (!existsSync(ts_id) && existsSync(js_id)) { 54 | mapped_path = js_id; 55 | path_id = js_id; 56 | namespace = 'sveltekit_local_imports_js'; 57 | } else { 58 | mapped_path = js_id; 59 | path_id = ts_id; 60 | namespace = 'sveltekit_local_imports_ts'; 61 | } 62 | } 63 | 64 | let mapped_specifier = relative(dir, mapped_path); 65 | if (mapped_specifier[0] !== '.') mapped_specifier = './' + mapped_specifier; 66 | 67 | return { 68 | path_id, 69 | path_id_with_querystring: raw ? path_id + '?raw' : path_id, 70 | raw, 71 | specifier, 72 | mapped_specifier, 73 | namespace, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/run.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import {styleText as st} from 'node:util'; 3 | import {existsSync} from 'node:fs'; 4 | 5 | import {Task_Error, type Task} from './task.ts'; 6 | import {resolve_gro_module_path, spawn_with_loader} from './gro_helpers.ts'; 7 | 8 | export const Args = z 9 | .object({ 10 | _: z 11 | .array(z.string(), {description: 'the file path to run and other node CLI args'}) 12 | .default([]), 13 | }) 14 | .strict(); 15 | export type Args = z.infer; 16 | 17 | export const task: Task = { 18 | summary: 'execute a file with the loader, like `node` but works for TypeScript', 19 | Args, 20 | run: async ({args, log}) => { 21 | const { 22 | _: [path, ...argv], 23 | } = args; 24 | 25 | if (!path) { 26 | log.info(st('green', '\n\nUsage: ') + st('cyan', 'gro run path/to/file.ts [...node_args]\n')); 27 | return; 28 | } 29 | 30 | if (!existsSync(path)) { 31 | throw new Task_Error('Cannot find file to run at path: ' + path); 32 | } 33 | 34 | const loader_path = resolve_gro_module_path('loader.js'); 35 | 36 | const spawned = await spawn_with_loader(loader_path, path, argv); 37 | if (!spawned.ok) { 38 | throw new Task_Error(`\`gro run ${path}\` failed with exit code ${spawned.code}`); 39 | } 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/run_gen.ts: -------------------------------------------------------------------------------- 1 | import {styleText as st} from 'node:util'; 2 | import {print_error} from '@ryanatkn/belt/print.js'; 3 | import type {Timings} from '@ryanatkn/belt/timings.js'; 4 | import type {Logger} from '@ryanatkn/belt/log.js'; 5 | 6 | import { 7 | type Gen_Results, 8 | type Genfile_Module_Result, 9 | type Gen_Context, 10 | type Genfile_Module_Meta, 11 | to_gen_result, 12 | type Raw_Gen_Result, 13 | } from './gen.ts'; 14 | import {print_path, to_root_path} from './paths.ts'; 15 | import type {format_file as base_format_file} from './format_file.ts'; 16 | import type {Gro_Config} from './gro_config.ts'; 17 | import {default_svelte_config} from './svelte_config.ts'; 18 | 19 | export const GEN_NO_PROD_MESSAGE = 'gen runs only during development'; 20 | 21 | export const run_gen = async ( 22 | gen_modules: Array, 23 | config: Gro_Config, 24 | log: Logger, 25 | timings: Timings, 26 | format_file?: typeof base_format_file, 27 | ): Promise => { 28 | let input_count = 0; 29 | let output_count = 0; 30 | const timing_for_run_gen = timings.start('run_gen'); 31 | const results = await Promise.all( 32 | gen_modules.map(async (module_meta): Promise => { 33 | input_count++; 34 | const {id} = module_meta; 35 | const timing_for_module = timings.start(id); 36 | 37 | // Perform code generation by calling `gen` on the module. 38 | const gen_ctx: Gen_Context = { 39 | config, 40 | svelte_config: default_svelte_config, 41 | origin_id: id, 42 | origin_path: to_root_path(id), 43 | log, 44 | }; 45 | let raw_gen_result: Raw_Gen_Result; 46 | try { 47 | raw_gen_result = await module_meta.mod.gen(gen_ctx); 48 | } catch (err) { 49 | return { 50 | ok: false, 51 | id, 52 | error: err, 53 | reason: st('red', `Error generating ${print_path(id)}`), 54 | elapsed: timing_for_module(), 55 | }; 56 | } 57 | 58 | // Convert the module's return value to a normalized form. 59 | const gen_result = to_gen_result(id, raw_gen_result); 60 | 61 | // Format the files if needed. 62 | const files = format_file 63 | ? await Promise.all( 64 | gen_result.files.map(async (file) => { 65 | if (!file.format) return file; 66 | try { 67 | return {...file, content: await format_file(file.content, {filepath: file.id})}; 68 | } catch (err) { 69 | log.error( 70 | st('red', `Error formatting ${print_path(file.id)} via ${print_path(id)}`), 71 | print_error(err), 72 | ); 73 | return file; 74 | } 75 | }), 76 | ) 77 | : gen_result.files; 78 | 79 | output_count += files.length; 80 | return { 81 | ok: true, 82 | id, 83 | files, 84 | elapsed: timing_for_module(), 85 | }; 86 | }), 87 | ); 88 | return { 89 | results, 90 | successes: results.filter((r) => r.ok), 91 | failures: results.filter((r) => !r.ok), 92 | input_count, 93 | output_count, 94 | elapsed: timing_for_run_gen(), 95 | }; 96 | }; 97 | -------------------------------------------------------------------------------- /src/lib/run_task.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {Timings} from '@ryanatkn/belt/timings.js'; 4 | 5 | import {run_task} from './run_task.ts'; 6 | import {load_gro_config} from './gro_config.ts'; 7 | import {Filer} from './filer.ts'; 8 | 9 | test('passes args and returns output', async () => { 10 | const args = {a: 1, _: []}; 11 | const result = await run_task( 12 | { 13 | name: 'testTask', 14 | id: 'foo/testTask', 15 | mod: { 16 | task: { 17 | run: ({args}) => Promise.resolve(args), 18 | }, 19 | }, 20 | }, 21 | args, 22 | () => Promise.resolve(), 23 | await load_gro_config(), 24 | new Filer(), 25 | new Timings(), 26 | ); 27 | assert.ok(result.ok); 28 | assert.is(result.output, args); 29 | }); 30 | 31 | test('invokes a sub task', async () => { 32 | const args = {a: 1, _: []}; 33 | let invoked_task_name; 34 | let invoked_args; 35 | const result = await run_task( 36 | { 37 | name: 'testTask', 38 | id: 'foo/testTask', 39 | mod: { 40 | task: { 41 | run: async ({args, invoke_task}) => { 42 | await invoke_task('bar/testTask', args); 43 | return args; 44 | }, 45 | }, 46 | }, 47 | }, 48 | args, 49 | (invoking_task_name, invoking_args) => { 50 | invoked_task_name = invoking_task_name; 51 | invoked_args = invoking_args; 52 | return Promise.resolve(); 53 | }, 54 | await load_gro_config(), 55 | new Filer(), 56 | new Timings(), 57 | ); 58 | assert.ok(result.ok); 59 | assert.is(invoked_task_name, 'bar/testTask'); 60 | assert.is(invoked_args, args); 61 | assert.is(result.output, args); 62 | }); 63 | 64 | test('failing task', async () => { 65 | let err; 66 | const result = await run_task( 67 | { 68 | name: 'testTask', 69 | id: 'foo/testTask', 70 | mod: { 71 | task: { 72 | run: () => { 73 | err = Error(); 74 | throw err; 75 | }, 76 | }, 77 | }, 78 | }, 79 | {_: []}, 80 | async () => {}, // eslint-disable-line @typescript-eslint/no-empty-function 81 | await load_gro_config(), 82 | new Filer(), 83 | new Timings(), 84 | ); 85 | assert.ok(!result.ok); 86 | assert.ok(result.reason); 87 | assert.is(result.error, err); 88 | }); 89 | 90 | test.run(); 91 | -------------------------------------------------------------------------------- /src/lib/run_task.ts: -------------------------------------------------------------------------------- 1 | import {styleText as st} from 'node:util'; 2 | import {print_log_label} from '@ryanatkn/belt/print.js'; 3 | import {System_Logger} from '@ryanatkn/belt/log.js'; 4 | import type {Timings} from '@ryanatkn/belt/timings.js'; 5 | 6 | import {parse_args, type Args} from './args.ts'; 7 | import type {invoke_task as base_invoke_task} from './invoke_task.ts'; 8 | import {log_task_help} from './task_logging.ts'; 9 | import type {Gro_Config} from './gro_config.ts'; 10 | import {Task_Error, type Task_Module_Meta} from './task.ts'; 11 | import {default_svelte_config} from './svelte_config.ts'; 12 | import type {Filer} from './filer.ts'; 13 | 14 | export type Run_Task_Result = 15 | | { 16 | ok: true; 17 | output: unknown; 18 | } 19 | | { 20 | ok: false; 21 | reason: string; 22 | error: Error; 23 | }; 24 | 25 | export const run_task = async ( 26 | task_meta: Task_Module_Meta, 27 | unparsed_args: Args, 28 | invoke_task: typeof base_invoke_task, 29 | config: Gro_Config, 30 | filer: Filer, 31 | timings: Timings, 32 | ): Promise => { 33 | const {task} = task_meta.mod; 34 | const log = new System_Logger(print_log_label(task_meta.name)); 35 | 36 | if (unparsed_args.help) { 37 | log_task_help(log, task_meta); 38 | return {ok: true, output: null}; 39 | } 40 | 41 | // Parse and validate args. 42 | let args = unparsed_args; 43 | if (task.Args) { 44 | const parsed = parse_args(unparsed_args, task.Args); 45 | if (!parsed.success) { 46 | log.error(st('red', `Args validation failed:`), '\n', parsed.error.format()); 47 | throw new Task_Error(`Task args failed validation`); 48 | } 49 | args = parsed.data; 50 | } 51 | 52 | // Run the task. 53 | let output: unknown; // TODO generic 54 | try { 55 | output = await task.run({ 56 | args, 57 | config, 58 | svelte_config: default_svelte_config, 59 | filer, 60 | log, 61 | timings, 62 | invoke_task: (invoked_task_name, invoked_args, invoked_config) => 63 | invoke_task(invoked_task_name, invoked_args, invoked_config ?? config, filer, timings), 64 | }); 65 | } catch (err) { 66 | return { 67 | ok: false, 68 | reason: st( 69 | 'red', 70 | err?.constructor?.name === 'Task_Error' 71 | ? (err.message as string) 72 | : `Unexpected error running task ${st( 73 | 'cyan', 74 | task_meta.name, 75 | )}. If this is unexpected try running \`${config.pm_cli} install\` and \`gro clean\`.`, 76 | ), 77 | error: err, 78 | }; 79 | } 80 | return {ok: true, output}; 81 | }; 82 | -------------------------------------------------------------------------------- /src/lib/search_fs.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve} from 'node:path'; 4 | 5 | import {search_fs} from './search_fs.ts'; 6 | 7 | test('search_fs basic behavior', () => { 8 | const ignored_path = 'test1.foo.ts'; 9 | let has_ignored_path = false; 10 | const result = search_fs('./src/fixtures', { 11 | filter: (path) => { 12 | if (!has_ignored_path) has_ignored_path = path.endsWith(ignored_path); 13 | return !path.endsWith(ignored_path); 14 | }, 15 | sort: (a, b) => a.path.localeCompare(b.path) * -1, 16 | }); 17 | assert.ok(has_ignored_path); // makes sure the test isn't wrong 18 | const expected_files = [ 19 | 'test2.foo.ts', 20 | 'test_ts.ts', 21 | 'test_task_module.task_fixture.ts', 22 | 'test_sveltekit_env.ts', 23 | 'test_js.js', 24 | 'test_invalid_task_module.ts', 25 | 'test_file.other.ext', 26 | 'test_failing_task_module.ts', 27 | 'some_test_side_effect.ts', 28 | 'some_test_exports3.ts', 29 | 'some_test_exports2.ts', 30 | 'some_test_exports.ts', 31 | 'modules/src_json_sample_exports.ts', 32 | 'modules/some_test_ts.ts', 33 | 'modules/Some_Test_Svelte.svelte', 34 | 'modules/some_test_svelte_ts.svelte.ts', 35 | 'modules/some_test_svelte_js.svelte.js', 36 | 'modules/some_test_server.ts', 37 | 'modules/some_test_script.ts', 38 | 'modules/some_test_json.json', 39 | 'modules/some_test_json_without_extension', 40 | 'modules/some_test_js.js', 41 | 'modules/some_test_css.css', 42 | 'changelog_example.md', 43 | 'changelog_cache.json', 44 | 'baz2/test2.baz.ts', 45 | 'baz1/test1.baz.ts', 46 | 'bar2/test2.bar.ts', 47 | 'bar1/test1.bar.ts', 48 | ]; 49 | assert.equal( 50 | result.map((f) => f.path), 51 | expected_files, 52 | ); 53 | assert.equal( 54 | result.map((f) => f.id), 55 | expected_files.map((f) => resolve(`src/fixtures/${f}`)), 56 | ); 57 | }); 58 | 59 | test.run(); 60 | -------------------------------------------------------------------------------- /src/lib/search_fs.ts: -------------------------------------------------------------------------------- 1 | import {EMPTY_OBJECT} from '@ryanatkn/belt/object.js'; 2 | import {to_array} from '@ryanatkn/belt/array.js'; 3 | import {ensure_end} from '@ryanatkn/belt/string.js'; 4 | import {isAbsolute, join} from 'node:path'; 5 | import {existsSync, readdirSync} from 'node:fs'; 6 | 7 | import type {File_Filter, Resolved_Path, Path_Filter} from './path.ts'; 8 | 9 | export interface Search_Fs_Options { 10 | /** 11 | * One or more filter functions, any of which can short-circuit the search by returning `false`. 12 | */ 13 | filter?: Path_Filter | Array; 14 | /** 15 | * One or more file filter functions. Every filter must pass for a file to be included. 16 | */ 17 | file_filter?: File_Filter | Array; 18 | /** 19 | * Pass `null` or `false` to speed things up at the cost of volatile ordering. 20 | */ 21 | sort?: boolean | null | ((a: Resolved_Path, b: Resolved_Path) => number); 22 | /** 23 | * Set to `true` to include directories. Defaults to `false`. 24 | */ 25 | include_directories?: boolean; 26 | /** 27 | * Sets the cwd for `dir` unless it's an absolute path or `null`. 28 | */ 29 | cwd?: string | null; 30 | } 31 | 32 | export const search_fs = ( 33 | dir: string, 34 | options: Search_Fs_Options = EMPTY_OBJECT, 35 | ): Array => { 36 | const { 37 | filter, 38 | file_filter, 39 | sort = default_sort, 40 | include_directories = false, 41 | cwd = process.cwd(), 42 | } = options; 43 | 44 | const final_dir = ensure_end(cwd && !isAbsolute(dir) ? join(cwd, dir) : dir, '/'); 45 | 46 | const filters = 47 | !filter || (Array.isArray(filter) && !filter.length) ? undefined : to_array(filter); 48 | const file_filters = 49 | !file_filter || (Array.isArray(file_filter) && !file_filter.length) 50 | ? undefined 51 | : to_array(file_filter); 52 | 53 | if (!existsSync(final_dir)) return []; 54 | 55 | const paths: Array = []; 56 | crawl(final_dir, paths, filters, file_filters, include_directories, null); 57 | 58 | return sort ? paths.sort(typeof sort === 'boolean' ? default_sort : sort) : paths; 59 | }; 60 | 61 | const default_sort = (a: Resolved_Path, b: Resolved_Path): number => a.path.localeCompare(b.path); 62 | 63 | const crawl = ( 64 | dir: string, 65 | paths: Array, 66 | filters: Array | undefined, 67 | file_filter: Array | undefined, 68 | include_directories: boolean, 69 | base_dir: string | null, 70 | ): Array => { 71 | // This sync version is significantly faster than using the `fs/promises` version - 72 | // it doesn't parallelize but that's not the common case in Gro. 73 | const dirents = readdirSync(dir, {withFileTypes: true}); 74 | for (const dirent of dirents) { 75 | const {name, parentPath} = dirent; 76 | const is_directory = dirent.isDirectory(); 77 | const id = parentPath + name; 78 | const include = !filters || filters.every((f) => f(id, is_directory)); 79 | if (!include) continue; 80 | const path = base_dir === null ? name : base_dir + '/' + name; 81 | if (is_directory) { 82 | const dir_id = id + '/'; 83 | if (include_directories) { 84 | paths.push({path, id: dir_id, is_directory: true}); 85 | } 86 | crawl(dir_id, paths, filters, file_filter, include_directories, path); 87 | } else if (!file_filter || file_filter.every((f) => f(id))) { 88 | paths.push({path, id, is_directory: false}); 89 | } 90 | } 91 | return paths; 92 | }; 93 | 94 | export const find_first_existing_file = (paths: Array): string | null => { 95 | for (const path of paths) { 96 | if (existsSync(path)) { 97 | return path; 98 | } 99 | } 100 | return null; 101 | }; 102 | -------------------------------------------------------------------------------- /src/lib/src_json.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | 4 | import {to_src_modules} from './src_json.ts'; 5 | import {paths} from './paths.ts'; 6 | 7 | test('to_src_modules handles simple cases and omits `declarations` when empty', () => { 8 | const exports = { 9 | './fixtures/modules/some_test_script.js': { 10 | import: './dist/some_test_script.js', 11 | types: './dist/some_test_script.d.ts', 12 | }, 13 | './fixtures/modules/some_test_ts.js': { 14 | import: './dist/some_test_ts.js', 15 | types: './dist/some_test_ts.d.ts', 16 | }, 17 | }; 18 | 19 | const result = to_src_modules(exports, paths.source); 20 | 21 | assert.ok(result, 'result should be defined'); 22 | assert.ok(result['./fixtures/modules/some_test_script.js'], 'module should be processed'); 23 | 24 | assert.equal(result, { 25 | './fixtures/modules/some_test_script.js': { 26 | path: 'fixtures/modules/some_test_script.ts', 27 | // `declarations` should be omitted when empty 28 | }, 29 | './fixtures/modules/some_test_ts.js': { 30 | path: 'fixtures/modules/some_test_ts.ts', 31 | declarations: [ 32 | {name: 'a', kind: 'variable'}, 33 | {name: 'some_test_ts', kind: 'variable'}, 34 | {name: 'some_test_fn', kind: 'function'}, 35 | {name: 'Some_Test_Type', kind: 'type'}, 36 | {name: 'Some_Test_Interface', kind: 'type'}, 37 | {name: 'Some_Test_Class', kind: 'class'}, 38 | ], 39 | }, 40 | }); 41 | }); 42 | 43 | test('to_src_modules identifies all export kinds correctly', () => { 44 | const exports = { 45 | './fixtures/modules/src_json_sample_exports.js': { 46 | import: './dist/src_json_sample_exports.js', 47 | types: './dist/src_json_sample_exports.d.ts', 48 | }, 49 | }; 50 | 51 | const result = to_src_modules(exports, paths.source); 52 | 53 | assert.ok(result, 'result should be defined'); 54 | assert.ok(result['./fixtures/modules/src_json_sample_exports.js'], 'module should be processed'); 55 | 56 | assert.equal(result, { 57 | './fixtures/modules/src_json_sample_exports.js': { 58 | path: 'fixtures/modules/src_json_sample_exports.ts', 59 | declarations: [ 60 | {name: 'direct_function', kind: 'function'}, 61 | {name: 'direct_variable', kind: 'variable'}, 62 | {name: 'direct_arrow_function', kind: 'function'}, 63 | {name: 'Direct_Type', kind: 'type'}, 64 | {name: 'Direct_Interface', kind: 'type'}, 65 | {name: 'Direct_Class', kind: 'class'}, 66 | {name: 'simple_variable', kind: 'variable'}, 67 | {name: 'arrow_function', kind: 'function'}, 68 | {name: 'multi_line_arrow', kind: 'function'}, 69 | {name: 'declared_function', kind: 'function'}, 70 | {name: 'Simple_Class', kind: 'class'}, 71 | {name: 'class_expression', kind: 'class'}, 72 | {name: 'object_value', kind: 'variable'}, 73 | {name: 'numeric_value', kind: 'variable'}, 74 | {name: 'renamed_variable', kind: 'variable'}, 75 | {name: 'renamed_function', kind: 'function'}, 76 | {name: 'Renamed_Class', kind: 'class'}, 77 | {name: 'Renamed_Type', kind: 'type'}, 78 | {name: 'Simple_Type', kind: 'type'}, 79 | {name: 'Simple_Interface', kind: 'type'}, 80 | {name: 'Variable_Type', kind: 'type'}, 81 | {name: 'extra_variable', kind: 'variable'}, 82 | {name: 'Explicit_Type', kind: 'type'}, 83 | {name: 'default', kind: 'function'}, 84 | {name: 'dual_purpose', kind: 'variable'}, 85 | {name: 'dual_purpose_type', kind: 'type'}, 86 | ], 87 | }, 88 | }); 89 | }); 90 | 91 | test('to_src_modules handles empty or undefined exports gracefully', () => { 92 | // Undefined exports 93 | assert.equal( 94 | to_src_modules(undefined, paths.source), 95 | undefined, 96 | 'undefined exports should return undefined', 97 | ); 98 | 99 | // Empty exports 100 | assert.equal(to_src_modules({}, paths.source), {}, 'empty exports should return empty object'); 101 | }); 102 | 103 | test.run(); 104 | -------------------------------------------------------------------------------- /src/lib/svelte_config.ts: -------------------------------------------------------------------------------- 1 | import type {Config as SvelteConfig} from '@sveltejs/kit'; 2 | import type {CompileOptions, ModuleCompileOptions, PreprocessorGroup} from 'svelte/compiler'; 3 | import {join} from 'node:path'; 4 | import {EMPTY_OBJECT} from '@ryanatkn/belt/object.js'; 5 | 6 | import {SVELTE_CONFIG_FILENAME} from './constants.ts'; 7 | 8 | /* 9 | 10 | This module is intended to have minimal dependencies to avoid over-imports in the CLI. 11 | 12 | */ 13 | 14 | /** 15 | * Loads a SvelteKit config at `dir`. 16 | * @returns `null` if no config is found 17 | */ 18 | export const load_svelte_config = async ({ 19 | dir = process.cwd(), 20 | config_filename = SVELTE_CONFIG_FILENAME, 21 | }: {dir?: string; config_filename?: string} = EMPTY_OBJECT): Promise => { 22 | try { 23 | return (await import(join(dir, config_filename))).default; 24 | } catch (_err) { 25 | return null; 26 | } 27 | }; 28 | 29 | /** 30 | * A subset of SvelteKit's config in a form that Gro uses 31 | * because SvelteKit doesn't expose its config resolver. 32 | * Flattens things out to keep them simple and easy to pass around, 33 | * and doesn't deal with most properties, but includes the full `svelte_config`. 34 | * The `base` and `assets` in particular are renamed for clarity with Gro's internal systems, 35 | * so these properties become first-class vocabulary inside Gro. 36 | */ 37 | export interface Parsed_Svelte_Config { 38 | // TODO probably fill these out with defaults 39 | svelte_config: SvelteConfig | null; 40 | alias: Record; 41 | base_url: '' | `/${string}` | undefined; 42 | assets_url: '' | `http://${string}` | `https://${string}` | undefined; 43 | 44 | // TODO others, but maybe replace with a Zod schema? https://kit.svelte.dev/docs/configuration 45 | /** 46 | * Same as the SvelteKit `files.assets`. 47 | */ 48 | assets_path: string; 49 | /** 50 | * Same as the SvelteKit `files.lib`. 51 | */ 52 | lib_path: string; 53 | /** 54 | * Same as the SvelteKit `files.routes`. 55 | */ 56 | routes_path: string; 57 | 58 | env_dir: string | undefined; 59 | private_prefix: string | undefined; 60 | public_prefix: string | undefined; 61 | svelte_compile_options: CompileOptions; 62 | svelte_compile_module_options: ModuleCompileOptions; 63 | svelte_preprocessors: PreprocessorGroup | Array | undefined; 64 | } 65 | 66 | // TODO currently incomplete and hack - maybe rethink 67 | /** 68 | * Returns Gro-relevant properties of a SvelteKit config 69 | * as a convenience wrapper around `load_svelte_config`. 70 | * Needed because SvelteKit doesn't expose its config resolver. 71 | */ 72 | export const parse_svelte_config = async ({ 73 | dir_or_config = process.cwd(), // TODO maybe not the best API, maybe a type union? `({svelte_config} | {dir}) & {config_filename}` 74 | config_filename = SVELTE_CONFIG_FILENAME, 75 | }: { 76 | dir_or_config?: string | SvelteConfig; 77 | config_filename?: string; 78 | } = EMPTY_OBJECT): Promise => { 79 | const svelte_config = 80 | typeof dir_or_config === 'string' 81 | ? await load_svelte_config({dir: dir_or_config, config_filename}) 82 | : dir_or_config; 83 | 84 | const kit = svelte_config?.kit; 85 | 86 | const alias = {$lib: 'src/lib', ...kit?.alias}; 87 | 88 | const base_url = kit?.paths?.base; 89 | const assets_url = kit?.paths?.assets; 90 | 91 | // TODO probably a Zod schema instead 92 | const assets_path = kit?.files?.assets ?? 'static'; 93 | const lib_path = kit?.files?.lib ?? 'src/lib'; 94 | const routes_path = kit?.files?.routes ?? 'src/routes'; 95 | 96 | const env_dir = kit?.env?.dir; 97 | const private_prefix = kit?.env?.privatePrefix; 98 | const public_prefix = kit?.env?.publicPrefix; 99 | 100 | const svelte_compile_options: CompileOptions = svelte_config?.compilerOptions ?? {}; 101 | // Change the default to `generate: 'server'`, 102 | // because SvelteKit handles the client in the normal cases. 103 | if (svelte_compile_options.generate === undefined) { 104 | svelte_compile_options.generate = 'server'; 105 | } 106 | const svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options); // TODO will kit have these separately? 107 | const svelte_preprocessors = svelte_config?.preprocess; 108 | 109 | return { 110 | svelte_config, 111 | alias, 112 | base_url, 113 | assets_url, 114 | assets_path, 115 | lib_path, 116 | routes_path, 117 | env_dir, 118 | private_prefix, 119 | public_prefix, 120 | svelte_compile_options, 121 | svelte_compile_module_options, 122 | svelte_preprocessors, 123 | }; 124 | }; 125 | 126 | export const to_default_compile_module_options = ({ 127 | dev, 128 | generate, 129 | filename, 130 | rootDir, 131 | warningFilter, 132 | }: CompileOptions): ModuleCompileOptions => ({dev, generate, filename, rootDir, warningFilter}); 133 | 134 | /** 135 | * The parsed SvelteKit config for the cwd, cached globally at the module level. 136 | */ 137 | export const default_svelte_config = await parse_svelte_config(); // always load it to keep things simple ahead 138 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app.ts: -------------------------------------------------------------------------------- 1 | import type {Parsed_Svelte_Config} from './svelte_config.ts'; 2 | 3 | export const SVELTEKIT_SHIM_APP_PATHS_MATCHER = /\/util\/sveltekit_shim_app_paths\.js$/; 4 | export const SVELTEKIT_SHIM_APP_ENVIRONMENT_MATCHER = /\/util\/sveltekit_shim_app_environment\.js$/; 5 | 6 | /** 7 | * Maps SvelteKit `$app` specifiers to their Gro shims. 8 | * @see https://kit.svelte.dev/docs/modules 9 | */ 10 | export const sveltekit_shim_app_specifiers = new Map([ 11 | ['$app/environment', '@ryanatkn/gro/sveltekit_shim_app_environment.js'], 12 | ['$app/forms', '@ryanatkn/gro/sveltekit_shim_app_forms.js'], 13 | ['$app/navigation', '@ryanatkn/gro/sveltekit_shim_app_navigation.js'], 14 | ['$app/paths', '@ryanatkn/gro/sveltekit_shim_app_paths.js'], 15 | ['$app/state', '@ryanatkn/gro/sveltekit_shim_app_state.js'], 16 | ]); 17 | 18 | export const render_sveltekit_shim_app_paths = ( 19 | base_url: Parsed_Svelte_Config['base_url'] = '', 20 | assets_url: Parsed_Svelte_Config['assets_url'] = '', 21 | ): string => `// shim for $app/paths 22 | // @see https://github.com/sveltejs/kit/issues/1485 23 | 24 | export const assets = ${JSON.stringify(assets_url)}; 25 | export const base = ${JSON.stringify(base_url)}; 26 | `; 27 | 28 | // TODO improve support 29 | // `dev` is not guaranteed to be the same as `MODE` - https://kit.svelte.dev/docs/modules#$app-environment-dev 30 | // `version` is `config.kit.version.name` but I couldn't see how to load a SvelteKit `ValidatedConfig` 31 | // `building` is just being hardcoded, might be better (but still not correct) to be `!dev` 32 | export const render_sveltekit_shim_app_environment = ( 33 | dev: boolean, 34 | ): string => `// shim for $app/environment 35 | // @see https://github.com/sveltejs/kit/issues/1485 36 | 37 | export const browser = false; 38 | export const building = false; 39 | export const dev = ${JSON.stringify(dev)}; 40 | export const version = 'TODO'; 41 | `; 42 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app_environment.ts: -------------------------------------------------------------------------------- 1 | // shim for $app/environment 2 | // @see https://github.com/sveltejs/kit/issues/1485 3 | 4 | /** 5 | * This file is created dynamically by `render_sveltekit_shim_app_environment` 6 | * but exists here for the sake of the Node loader. 7 | * There may be a cleaner workaround but I couldn't find it. 8 | * @see https://github.com/nodejs/loaders for details about the forthcoming virtual file support 9 | */ 10 | 11 | export const browser = false; 12 | export const building = false; 13 | export const dev = true; 14 | export const version = 'TODO'; 15 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app_forms.ts: -------------------------------------------------------------------------------- 1 | // shim for $app/forms 2 | // @see https://github.com/sveltejs/kit/issues/1485 3 | 4 | import type { 5 | applyAction as base_applyAction, 6 | deserialize as base_deserialize, 7 | enhance as base_enhance, 8 | } from '$app/forms'; 9 | import {noop, noop_async} from '@ryanatkn/belt/function.js'; 10 | 11 | export const applyAction: typeof base_applyAction = noop_async; 12 | export const deserialize: typeof base_deserialize = () => ({}) as any; 13 | export const enhance: typeof base_enhance = () => ({destroy: noop}); 14 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app_navigation.ts: -------------------------------------------------------------------------------- 1 | // shim for $app/navigation 2 | // @see https://github.com/sveltejs/kit/issues/1485 3 | 4 | import type { 5 | afterNavigate as base_afterNavigate, 6 | beforeNavigate as base_beforeNavigate, 7 | disableScrollHandling as base_disableScrollHandling, 8 | goto as base_goto, 9 | invalidate as base_invalidate, 10 | invalidateAll as base_invalidateAll, 11 | preloadCode as base_preloadCode, 12 | preloadData as base_preloadData, 13 | } from '$app/navigation'; 14 | import {noop, noop_async} from '@ryanatkn/belt/function.js'; 15 | 16 | export const afterNavigate: typeof base_afterNavigate = noop; 17 | export const beforeNavigate: typeof base_beforeNavigate = noop; 18 | export const disableScrollHandling: typeof base_disableScrollHandling = noop; 19 | export const goto: typeof base_goto = noop_async; 20 | export const invalidate: typeof base_invalidate = noop_async; 21 | export const invalidateAll: typeof base_invalidateAll = noop_async; 22 | export const preloadCode: typeof base_preloadCode = noop_async; 23 | export const preloadData: typeof base_preloadData = noop_async; 24 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app_paths.ts: -------------------------------------------------------------------------------- 1 | // shim for $app/paths 2 | // @see https://github.com/sveltejs/kit/issues/1485 3 | 4 | /** 5 | * This file is created dynamically by `render_sveltekit_shim_app_paths` 6 | * but exists here for the sake of the Node loader. 7 | * There may be a cleaner workaround but I couldn't find it. 8 | * @see https://github.com/nodejs/loaders for details about the forthcoming virtual file support 9 | */ 10 | 11 | import type {resolveRoute as base_resolveRoute} from '$app/paths'; 12 | import {noop} from '@ryanatkn/belt/function.js'; 13 | 14 | export const assets = ''; 15 | export const base = ''; 16 | export const resolveRoute: typeof base_resolveRoute = noop; 17 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_app_state.ts: -------------------------------------------------------------------------------- 1 | // shim for $app/state 2 | // @see https://github.com/sveltejs/kit/issues/1485 3 | 4 | import type { 5 | navigating as base_navigating, 6 | page as base_page, 7 | updated as base_updated, 8 | } from '$app/state'; 9 | 10 | export const navigating: typeof base_navigating = { 11 | from: null, 12 | to: null, 13 | type: null, 14 | willUnload: null, 15 | delta: null, 16 | complete: null, 17 | }; 18 | 19 | export const page: typeof base_page = { 20 | data: {}, 21 | form: null, 22 | error: null, 23 | params: {}, 24 | route: {id: null}, 25 | state: {}, 26 | status: -1, 27 | url: new URL('https://github.com/ryanatkn/gro'), 28 | }; 29 | 30 | export const updated: typeof base_updated = { 31 | current: false, 32 | check: () => { 33 | throw Error('Can only call updated.check() in the browser'); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_env.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve} from 'node:path'; 4 | import {init_test_env} from './test_helpers.ts'; 5 | 6 | init_test_env(); 7 | 8 | const VALUE = 'SOME_PUBLIC_ENV_VAR'; 9 | 10 | // dynamic import paths are needed to avoid building .d.ts and .d.ts.map files, could be fixed in the build process 11 | 12 | test('shims static SvelteKit $env imports', async () => { 13 | const mod = await import(resolve('src/fixtures/test_sveltekit_env.ts')); 14 | assert.is(mod.exported_env_static_public, VALUE); 15 | }); 16 | 17 | test('shims dynamic SvelteKit $env imports', async () => { 18 | const mod = await import('$env/static/public'); 19 | // @ts-ignore 20 | assert.is(mod.PUBLIC_SOME_PUBLIC_ENV_VAR, VALUE); 21 | }); 22 | 23 | test.run(); 24 | -------------------------------------------------------------------------------- /src/lib/sveltekit_shim_env.ts: -------------------------------------------------------------------------------- 1 | import {load_env} from './env.ts'; 2 | 3 | // TODO might want to do more escaping and validation 4 | 5 | /** 6 | * Generates a module shim for SvelteKit's `$env` imports. 7 | */ 8 | export const render_env_shim_module = ( 9 | dev: boolean, 10 | mode: 'static' | 'dynamic', 11 | visibility: 'public' | 'private', 12 | public_prefix = 'PUBLIC_', 13 | private_prefix = '', 14 | env_dir?: string, 15 | env_files?: Array, 16 | ambient_env?: Record, 17 | ): string => { 18 | const env = load_env( 19 | dev, 20 | visibility, 21 | public_prefix, 22 | private_prefix, 23 | env_dir, 24 | env_files, 25 | ambient_env, 26 | ); 27 | if (mode === 'static') { 28 | return `// shim for $env/static/${visibility} 29 | // @see https://github.com/sveltejs/kit/issues/1485 30 | ${Object.entries(env) 31 | .map(([k, v]) => `export let ${k} = ${JSON.stringify(v)};`) 32 | .join('\n')} 33 | `; 34 | } else { 35 | return `// shim for $env/dynamic/${visibility} 36 | // @see https://github.com/sveltejs/kit/issues/1485 37 | import {load_env} from '@ryanatkn/gro/env.js'; 38 | export const env = load_env(${dev}, ${JSON.stringify(visibility)}, ${JSON.stringify( 39 | public_prefix, 40 | )}, ${JSON.stringify(private_prefix)}, ${JSON.stringify(env_dir)}, ${JSON.stringify( 41 | env_files, 42 | )}, ${JSON.stringify(ambient_env)}); 43 | `; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/lib/sync.task.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import {spawn} from '@ryanatkn/belt/process.js'; 3 | 4 | import {Task_Error, type Task} from './task.ts'; 5 | import {sync_package_json} from './package_json.ts'; 6 | import {sveltekit_sync} from './sveltekit_helpers.ts'; 7 | 8 | export const Args = z 9 | .object({ 10 | sveltekit: z.boolean({description: 'dual of no-sveltekit'}).default(true), 11 | 'no-sveltekit': z.boolean({description: 'opt out of svelte-kit sync'}).default(false), 12 | package_json: z.boolean({description: 'dual of no-package_json'}).default(true), 13 | 'no-package_json': z.boolean({description: 'opt out of package.json sync'}).default(false), 14 | gen: z.boolean({description: 'dual of no-gen'}).default(true), 15 | 'no-gen': z.boolean({description: 'opt out of running gen'}).default(false), 16 | install: z.boolean({description: 'dual of no-install'}).default(true), 17 | 'no-install': z.boolean({description: 'opt out of installing packages'}).default(false), 18 | }) 19 | .strict(); 20 | export type Args = z.infer; 21 | 22 | export const task: Task = { 23 | summary: 'run `gro gen`, update `package.json`, and optionally install packages to sync up', 24 | Args, 25 | run: async ({args, invoke_task, config, log}): Promise => { 26 | const {sveltekit, package_json, gen, install} = args; 27 | 28 | if (install) { 29 | const result = await spawn(config.pm_cli, ['install']); 30 | if (!result.ok) { 31 | throw new Task_Error(`Failed \`${config.pm_cli} install\``); 32 | } 33 | } 34 | 35 | if (sveltekit) { 36 | await sveltekit_sync(undefined, config.pm_cli); 37 | log.info('synced SvelteKit'); 38 | } 39 | 40 | if (package_json && config.map_package_json) { 41 | await sync_package_json(config.map_package_json, log); 42 | } 43 | 44 | if (gen) { 45 | await invoke_task('gen'); 46 | } 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/task.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {resolve} from 'node:path'; 4 | import {noop} from '@ryanatkn/belt/function.js'; 5 | 6 | import {is_task_path, to_task_name, validate_task_module, find_tasks, load_tasks} from './task.ts'; 7 | import * as actual_test_task_module from './test.task.ts'; 8 | import {create_empty_gro_config} from './gro_config.ts'; 9 | import {GRO_DIST_DIR} from './paths.ts'; 10 | 11 | test('is_task_path basic behavior', () => { 12 | assert.ok(is_task_path('foo.task.ts')); 13 | assert.ok(is_task_path('foo.task.js')); 14 | assert.ok(!is_task_path('foo.ts')); 15 | assert.ok(!is_task_path('foo.js')); 16 | assert.ok(is_task_path('bar/baz/foo.task.ts')); 17 | assert.ok(is_task_path('bar/baz/foo.task.js')); 18 | assert.ok(!is_task_path('bar/baz/foo.ts')); 19 | assert.ok(!is_task_path('bar/baz/foo.js')); 20 | }); 21 | 22 | test('to_task_name basic behavior', () => { 23 | assert.is(to_task_name('foo.task.ts', process.cwd(), '', ''), 'foo'); 24 | assert.is(to_task_name('foo.task.js', process.cwd(), '', ''), 'foo'); 25 | assert.is(to_task_name('bar/baz/foo.task.ts', process.cwd(), '', ''), 'bar/baz/foo'); 26 | assert.is(to_task_name('a/b/c/foo.task.ts', 'a/b/c', '', ''), 'foo'); 27 | assert.is(to_task_name('a/b/c/foo.task.ts', 'a', '', ''), 'b/c/foo'); 28 | assert.is(to_task_name('a/b/c/foo.task.ts', 'a/b', '', ''), 'c/foo'); 29 | assert.is(to_task_name('/a/b/c/foo.task.ts', '/a/b', '/a/b', '/a/b/d'), '../c/foo'); 30 | assert.is(to_task_name('/a/b/c/foo.task.ts', '/a/b', '/a/b', '/a/b'), 'c/foo'); 31 | assert.is(to_task_name('/a/b/c/foo.task.ts', '/a/b', '/a/b', '/a/b/c'), 'foo'); 32 | assert.is(to_task_name('/a/b/d/foo.task.js', '/a/b/d', '/a/b/d/foo', '/a/c'), '../b/d/foo'); 33 | assert.is( 34 | to_task_name( 35 | GRO_DIST_DIR + 'foo.task.js', 36 | GRO_DIST_DIR.slice(0, -1), 37 | GRO_DIST_DIR + 'foo', 38 | '/a', 39 | ), 40 | 'foo', 41 | ); 42 | assert.is( 43 | to_task_name( 44 | GRO_DIST_DIR + 'foo.task.js', 45 | GRO_DIST_DIR, // same as above but adds a trailing slash here 46 | GRO_DIST_DIR + 'foo', 47 | '/a', 48 | ), 49 | 'foo', 50 | ); 51 | assert.is( 52 | to_task_name(resolve('a/b'), resolve('b'), '', ''), 53 | resolve('a/b'), 54 | 'falls back to the id when unresolved', 55 | ); 56 | }); 57 | 58 | test('validate_task_module basic behavior', async () => { 59 | // TODO if we import directly, svelte-package generates types in `src/fixtures` 60 | const test_task_module_js = await import('../fixtures/' + 'test_task_module.task_fixture.js'); // eslint-disable-line no-useless-concat 61 | const test_task_module_ts = await import('../fixtures/' + 'test_task_module.task_fixture.ts'); // eslint-disable-line no-useless-concat 62 | const test_invalid_task_module_js = await import('../fixtures/' + 'test_invalid_task_module.js'); // eslint-disable-line no-useless-concat 63 | const test_invalid_task_module_ts = await import('../fixtures/' + 'test_invalid_task_module.ts'); // eslint-disable-line no-useless-concat 64 | assert.ok(validate_task_module(test_task_module_js)); 65 | assert.ok(validate_task_module(test_task_module_ts)); 66 | assert.ok(!validate_task_module(test_invalid_task_module_js)); 67 | assert.ok(!validate_task_module(test_invalid_task_module_ts)); 68 | // demonstrating values: 69 | assert.ok(validate_task_module({task: {run: noop}})); 70 | assert.ok(!validate_task_module({task: {run: {}}})); 71 | }); 72 | 73 | test('load_tasks basic behavior', async () => { 74 | const found = find_tasks( 75 | [resolve('src/lib/test'), resolve('src/lib/test.task.ts')], 76 | [resolve('src/lib')], 77 | create_empty_gro_config(), 78 | ); 79 | assert.ok(found.ok); 80 | const result = await load_tasks(found.value); 81 | assert.ok(result.ok); 82 | assert.is(result.value.modules.length, 1); 83 | assert.is(result.value.modules[0].mod, actual_test_task_module); 84 | }); 85 | 86 | test.run(); 87 | -------------------------------------------------------------------------------- /src/lib/test.task.ts: -------------------------------------------------------------------------------- 1 | import {styleText as st} from 'node:util'; 2 | import {z} from 'zod'; 3 | 4 | import {Task_Error, type Task} from './task.ts'; 5 | import {paths} from './paths.ts'; 6 | import {find_cli} from './cli.ts'; 7 | 8 | export const Args = z 9 | .object({ 10 | _: z.array(z.string(), {description: 'file patterns to test'}).default([`\\.test\\.ts$`]), // TODO maybe use uvu's default instead of being restrictive? 11 | bail: z 12 | .boolean({description: 'the bail option to uvu run, exit immediately on failure'}) 13 | .default(false), 14 | cwd: z.string({description: 'the cwd option to uvu parse'}).optional(), 15 | ignore: z 16 | .union([z.string(), z.array(z.string())], {description: 'the ignore option to uvu parse'}) 17 | .optional(), 18 | }) 19 | .strict(); 20 | export type Args = z.infer; 21 | 22 | export const task: Task = { 23 | summary: 'run tests with uvu', 24 | Args, 25 | run: async ({args, log}): Promise => { 26 | const {_: patterns, bail, cwd, ignore} = args; 27 | 28 | if (!find_cli('uvu')) { 29 | log.warn(st('yellow', 'uvu is not installed, skipping tests')); 30 | return; 31 | } 32 | 33 | const [{run}, {parse}] = await Promise.all([import('uvu/run'), import('uvu/parse')]); 34 | 35 | // uvu doesn't work with esm loaders and TypeScript files, 36 | // so we use its `parse` and `run` APIs directly instead of its CLI. 37 | // To avoid surprises, we allow any number of patterns in the rest args, 38 | // so we call `parse` multiple times because it supports only one. 39 | const suites = []; 40 | for (const pattern of patterns) { 41 | const parsed = await parse(paths.source, pattern, {cwd, ignore}); // eslint-disable-line no-await-in-loop 42 | suites.push(...parsed.suites); 43 | } 44 | await run(suites, {bail}); 45 | 46 | if (process.exitCode) { 47 | throw new Task_Error('Tests failed.'); 48 | } 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/lib/typecheck.task.ts: -------------------------------------------------------------------------------- 1 | import {print_spawn_result} from '@ryanatkn/belt/process.js'; 2 | import {z} from 'zod'; 3 | 4 | import {Task_Error, type Task} from './task.ts'; 5 | import {serialize_args, to_forwarded_args} from './args.ts'; 6 | import {find_cli, spawn_cli, spawn_cli_process} from './cli.ts'; 7 | import {SVELTE_CHECK_CLI, sveltekit_sync_if_available} from './sveltekit_helpers.ts'; 8 | import {configure_colored_output_with_path_replacement} from './child_process_logging.ts'; 9 | import {paths} from './paths.ts'; 10 | 11 | export const Args = z 12 | .object({ 13 | svelte_check_cli: z 14 | .string({description: 'the svelte-check CLI to use'}) 15 | .default(SVELTE_CHECK_CLI), 16 | typescript_cli: z 17 | .string({description: 'the TypeScript CLI to use as a fallback to svelte-check'}) 18 | .default('tsc'), 19 | path_replacement: z 20 | .string({description: 'replacement string for current working directory in output'}) 21 | .default('.'), 22 | cwd: z.string({description: 'current working directory'}).default(paths.root), 23 | }) 24 | .strict(); 25 | export type Args = z.infer; 26 | 27 | export const task: Task = { 28 | summary: 'run tsc on the project without emitting any files', 29 | Args, 30 | run: async ({args, log}): Promise => { 31 | const {svelte_check_cli, typescript_cli, path_replacement, cwd} = args; 32 | 33 | await sveltekit_sync_if_available(); 34 | 35 | // Prefer svelte-check if available. 36 | const found_svelte_check_cli = find_cli(svelte_check_cli); 37 | if (found_svelte_check_cli) { 38 | const serialized = serialize_args(to_forwarded_args(svelte_check_cli)); 39 | const spawned = spawn_cli_process(found_svelte_check_cli, serialized, undefined, { 40 | stdio: ['inherit', 'pipe', 'pipe'], 41 | env: {...process.env, FORCE_COLOR: '1'}, // Needed for colors (maybe make an option) 42 | }); 43 | 44 | const svelte_check_process = spawned?.child; 45 | if (svelte_check_process) { 46 | // Configure process output with path replacement while preserving colors 47 | configure_colored_output_with_path_replacement(svelte_check_process, path_replacement, cwd); 48 | 49 | const svelte_check_result = await spawned.closed; 50 | 51 | if (!svelte_check_result.ok) { 52 | throw new Task_Error(`Failed to typecheck. ${print_spawn_result(svelte_check_result)}`); 53 | } 54 | } 55 | 56 | return; 57 | } 58 | 59 | // Fall back to tsc. 60 | const found_typescript_cli = find_cli(typescript_cli); 61 | if (found_typescript_cli) { 62 | const forwarded = to_forwarded_args(typescript_cli); 63 | if (!forwarded.noEmit) forwarded.noEmit = true; 64 | const serialized = serialize_args(forwarded); 65 | const svelte_check_result = await spawn_cli(found_typescript_cli, serialized, log); 66 | if (!svelte_check_result?.ok) { 67 | throw new Task_Error(`Failed to typecheck. ${print_spawn_result(svelte_check_result!)}`); 68 | } 69 | return; 70 | } 71 | 72 | throw new Task_Error( 73 | `Failed to typecheck because neither \`${svelte_check_cli}\` nor \`${typescript_cli}\` was found`, 74 | ); 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /src/lib/watch_dir.ts: -------------------------------------------------------------------------------- 1 | import {watch, type ChokidarOptions, type FSWatcher} from 'chokidar'; 2 | import {relative} from 'node:path'; 3 | import {statSync} from 'node:fs'; 4 | import {create_deferred, type Deferred} from '@ryanatkn/belt/async.js'; 5 | 6 | import type {Path_Filter} from './path.ts'; 7 | 8 | // TODO pretty hacky 9 | 10 | export interface Watch_Node_Fs { 11 | init: () => Promise; 12 | close: () => Promise; 13 | } 14 | 15 | export interface Watcher_Change { 16 | type: Watcher_Change_Type; 17 | path: string; 18 | is_directory: boolean; 19 | } 20 | export type Watcher_Change_Type = 'add' | 'update' | 'delete'; 21 | export type Watcher_Change_Callback = (change: Watcher_Change) => void; 22 | 23 | export interface Watch_Dir_Options { 24 | dir: string; 25 | on_change: Watcher_Change_Callback; 26 | filter?: Path_Filter | null | undefined; 27 | chokidar?: ChokidarOptions; 28 | /** 29 | * When `false`, returns the `path` relative to `dir`. 30 | * @default true 31 | */ 32 | absolute?: boolean; 33 | } 34 | 35 | /** 36 | * Watch for changes on the filesystem using chokidar. 37 | */ 38 | export const watch_dir = ({ 39 | dir, 40 | on_change, 41 | filter, 42 | absolute = true, 43 | chokidar, 44 | }: Watch_Dir_Options): Watch_Node_Fs => { 45 | let watcher: FSWatcher | undefined; 46 | let initing: Deferred | undefined; 47 | 48 | return { 49 | init: async () => { 50 | if (initing) return initing.promise; 51 | initing = create_deferred(); 52 | watcher = watch(dir, {...chokidar}); 53 | watcher.on('add', (path) => { 54 | const final_path = absolute ? path : relative(dir, path); 55 | if (filter && !filter(final_path, false)) return; 56 | on_change({type: 'add', path: final_path, is_directory: false}); 57 | }); 58 | watcher.on('addDir', (path) => { 59 | const final_path = absolute ? path : relative(dir, path); 60 | if (filter && !filter(final_path, true)) return; 61 | on_change({type: 'add', path: final_path, is_directory: true}); 62 | }); 63 | watcher.on('change', (path, s) => { 64 | const stats = s ?? statSync(path); 65 | const final_path = absolute ? path : relative(dir, path); 66 | if (filter && !filter(final_path, stats.isDirectory())) return; 67 | on_change({type: 'update', path: final_path, is_directory: stats.isDirectory()}); 68 | }); 69 | watcher.on('unlink', (path) => { 70 | const final_path = absolute ? path : relative(dir, path); 71 | if (filter && !filter(final_path, false)) return; 72 | on_change({type: 'delete', path: final_path, is_directory: false}); 73 | }); 74 | watcher.on('unlinkDir', (path) => { 75 | const final_path = absolute ? path : relative(dir, path); 76 | if (filter && !filter(final_path, true)) return; 77 | on_change({type: 'delete', path: final_path, is_directory: true}); 78 | }); 79 | // wait until ready 80 | watcher.once('ready', () => initing?.resolve()); 81 | await initing.promise; 82 | }, 83 | close: async () => { 84 | initing = undefined; 85 | if (!watcher) return; 86 | await watcher.close(); 87 | }, 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | @ryanatkn/gro 18 | 19 | 20 | 21 | {@render children()} 22 | 23 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | import {DEV} from 'esm-env'; 2 | 3 | export const prerender = true; 4 | export const ssr = DEV; 5 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 |
23 |
24 |

gro

25 | 26 | 27 | 28 | 33 |
34 |
35 | 42 | {#if show_detail} 43 |
44 | 45 |
46 | {:else} 47 |
48 | 49 |
50 | {/if} 51 |
52 |
53 | 54 | {#snippet logo_header()}about{/snippet} 55 | 56 | 57 |
58 |
59 |
60 | 61 | 79 | -------------------------------------------------------------------------------- /src/routes/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 |
18 |

{pkg.repo_name}

19 |
20 | 🧶 21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 |
29 | 32 | 33 |
34 |
35 | 36 | 52 | -------------------------------------------------------------------------------- /src/routes/history/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

7 | Gro previously had a dev server with an experimental frontend for visualizing and working with 8 | build data. And then SvelteKit and Vite came along! 9 |

10 |

11 | It was removed in PR #321 and is archived 12 | here: 13 | https://github.com/spiderspace/gro/tree/archive/devserver 16 |

17 |

Vite plugins should be used going forward.

18 |
19 | 20 | 25 | -------------------------------------------------------------------------------- /src/routes/moss.css: -------------------------------------------------------------------------------- 1 | /* generated by gro_plugin_moss */ 2 | 3 | .box { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | 10 | /* can be used to override the direction of a `.box` */ 11 | .row { 12 | display: flex; 13 | flex-direction: row; 14 | align-items: center; 15 | } 16 | 17 | .width_md { 18 | width: 100%; 19 | max-width: var(--distance_md); 20 | } 21 | 22 | /* A panel is a box embedded into the page, useful for visually isolating content. */ 23 | .panel { 24 | border-radius: var(--border_radius_xs); 25 | background-color: var(--panel_bg, var(--fg_1)); 26 | } 27 | 28 | /* TODO other button variants? */ 29 | /* TODO this is slightly strange that it doesn't use --icon_size */ 30 | /* These are used as modifiers to buttons, and so they use `:where` so they cascade. */ 31 | .icon_button { 32 | width: var(--input_height); 33 | height: var(--input_height); 34 | min-width: var(--input_height); 35 | min-height: var(--input_height); 36 | flex-shrink: 0; 37 | line-height: 1; 38 | font-weight: 900; 39 | padding: 0; 40 | } 41 | 42 | /* TODO maybe this belongs with the reset, like `selected`? or does `selected` belong here? */ 43 | .plain:not(:hover) { 44 | --border_color: transparent; 45 | box-shadow: none; 46 | --button_fill: transparent; 47 | } 48 | .plain:hover, 49 | .plain:active { 50 | --border_color: transparent; 51 | } 52 | 53 | .chip { 54 | font-weight: 600; 55 | padding-left: var(--space_xs); 56 | padding-right: var(--space_xs); 57 | background-color: var(--fg_1); 58 | border-radius: var(--border_radius_xs); 59 | } 60 | a.chip { 61 | font-weight: 700; 62 | } 63 | 64 | .position_relative { 65 | position: relative; 66 | } 67 | /** Same as `display: inline flow-root`. */ 68 | .display_inline_block { 69 | display: inline-block; 70 | } 71 | /** Same as `display: block flex`. */ 72 | .display_flex { 73 | display: flex; 74 | } 75 | .flex_1 { 76 | flex: 1; 77 | } 78 | .text_align_center { 79 | text-align: center; 80 | } 81 | .shadow_inset_xs { 82 | box-shadow: var(--shadow_inset_xs) 83 | color-mix(in hsl, var(--shadow_color) var(--shadow_alpha, var(--shadow_alpha_1)), transparent); 84 | } 85 | .w_100 { 86 | width: 100%; 87 | } 88 | .h_100 { 89 | height: 100%; 90 | } 91 | .p_md { 92 | padding: var(--space_md); 93 | } 94 | .p_lg { 95 | padding: var(--space_lg); 96 | } 97 | .px_xl { 98 | padding-left: var(--space_xl); 99 | padding-right: var(--space_xl); 100 | } 101 | .mt_0 { 102 | margin-top: 0; 103 | } 104 | .mt_xl3 { 105 | margin-top: var(--space_xl3); 106 | } 107 | .mr_xs { 108 | margin-right: var(--space_xs); 109 | } 110 | .mb_xs { 111 | margin-bottom: var(--space_xs); 112 | } 113 | .mb_lg { 114 | margin-bottom: var(--space_lg); 115 | } 116 | .mb_xl3 { 117 | margin-bottom: var(--space_xl3); 118 | } 119 | 120 | /* generated by gro_plugin_moss */ 121 | -------------------------------------------------------------------------------- /static/CNAME: -------------------------------------------------------------------------------- 1 | gro.ryanatkn.com -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryanatkn/gro/aa4af6f4811b17586d180c332c6ae7c399d0a533/static/favicon.png -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 15 | 19 | 23 | 27 | 31 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import {vitePreprocess} from '@sveltejs/vite-plugin-svelte'; 2 | import adapter from '@sveltejs/adapter-static'; 3 | import {create_csp_directives} from '@ryanatkn/fuz/csp.js'; 4 | import {csp_trusted_sources_of_ryanatkn} from '@ryanatkn/fuz/csp_of_ryanatkn.js'; 5 | 6 | /** @type {import('@sveltejs/kit').Config} */ 7 | export default { 8 | preprocess: [vitePreprocess()], 9 | compilerOptions: {runes: true}, 10 | vitePlugin: {inspector: true}, 11 | kit: { 12 | adapter: adapter(), 13 | paths: {relative: false}, // use root-absolute paths: https://kit.svelte.dev/docs/configuration#paths 14 | alias: {$routes: 'src/routes'}, 15 | csp: { 16 | directives: create_csp_directives({ 17 | trusted_sources: csp_trusted_sources_of_ryanatkn, 18 | }), 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@sveltejs/kit"], 5 | "module": "nodenext", 6 | "moduleResolution": "nodenext", 7 | "strict": true, 8 | "useUnknownInCatchVariables": false, 9 | "exactOptionalPropertyTypes": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noImplicitOverride": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "resolveJsonModule": true, 17 | "sourceMap": true, 18 | "declaration": true, 19 | "declarationMap": true, 20 | "sourceRoot": "../src/lib/", 21 | "importHelpers": true, 22 | "skipLibCheck": true, 23 | "allowJs": true, 24 | "checkJs": true, 25 | "erasableSyntaxOnly": true, 26 | "allowImportingTsExtensions": true, 27 | "rewriteRelativeImportExtensions": true 28 | }, 29 | "include": [ 30 | "*.js", 31 | "*.ts", 32 | ".svelte-kit/ambient.d.ts", 33 | ".svelte-kit/non-ambient.d.ts", 34 | ".svelte-kit/types/**/$types.d.ts", 35 | "src/**/*.js", 36 | "src/**/*.ts", 37 | "src/**/*.svelte" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite'; 2 | import {sveltekit} from '@sveltejs/kit/vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | resolve: { 7 | // this is a hack but it's only to build Gro's website 8 | alias: [{find: '@ryanatkn/gro/package_meta.js', replacement: './src/lib/package_meta.ts'}], 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------