diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ca7b808 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Bash(node --test:*)", + "Bash(git checkout:*)", + "Bash(npm test:*)", + "Bash(node -e:*)", + "Bash(npm install:*)", + "Bash(node --input-type=module:*)", + "Bash(npm run test:node-test:*)", + "Bash(npm run test:neostandard:*)", + "WebFetch(domain:github.com)", + "Bash(npx eslint:*)", + "Bash(git -C /Users/bret/Developer/domstack branch:*)", + "Bash(git -C /Users/bret/Developer/domstack-2 branch --show-current)", + "Bash(npx neostandard:*)", + "Bash(ls:*)", + "Bash(git -C /Users/bret/Developer/domstack log --format=\"%s%n%n%b\" b15def119ce084a6ffa0f5d65deaac483132b9a4 -1)", + "Bash(git -C /Users/bret/Developer/domstack log --format=\"%s%n%n%b\" 90ad05fb08faee843ea2ef451cacd726e3ac6012 -1)", + "Bash(gh pr view:*)", + "Bash(gh api:*)", + "Bash(git -C /Users/bret/Developer/domstack log --oneline master..HEAD -- README.md)", + "Bash(git -C /Users/bret/Developer/domstack diff master -- README.md)" + ] + } +} diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json deleted file mode 100644 index 2651faa..0000000 --- a/.dependency-cruiser.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "forbidden": [ - { - "name": "not-to-test", - "comment": "Don't allow dependencies from outside the test folder to test", - "severity": "error", - "from": { - "pathNot": "^(test|spec)" - }, - "to": { - "path": "^(test|spec)" - } - }, - { - "name": "not-to-spec", - "comment": "Don't allow dependencies to (typescript/ javascript/ coffeescript) spec files", - "severity": "error", - "from": {}, - "to": { - "path": "\\.spec\\.(js|ts|ls|coffee|litcoffee|coffee\\.md)$" - } - }, - { - "name": "no-circular", - "severity": "warn", - "comment": "Warn in case there's circular dependencies", - "from": {}, - "to": { - "circular": true - } - }, - { - "name": "no-deprecated-core", - "comment": "Warn about dependencies on deprecated core modules.", - "severity": "warn", - "from": {}, - "to": { - "dependencyTypes": [ - "core" - ], - "path": "^(punycode|domain|constants|sys|_linklist)$" - } - }, - { - "name": "no-deprecated-npm", - "comment": "These npm modules are deprecated - find an alternative.", - "severity": "warn", - "from": {}, - "to": { - "dependencyTypes": [ - "deprecated" - ] - } - }, - { - "name": "not-to-unresolvable", - "comment": "Don't allow dependencies on modules dependency-cruiser can't resolve to files on disk (which probably means they don't exist)", - "severity": "error", - "from": {}, - "to": { - "couldNotResolve": true - } - }, - { - "name": "not-to-dev-dep", - "severity": "error", - "comment": "Don't allow dependencies from src/app/lib to a development only package", - "from": { - "path": "^(src|app|lib)", - "pathNot": "\\.spec\\.(js|ts|ls|coffee|litcoffee|coffee\\.md)$" - }, - "to": { - "dependencyTypes": [ - "npm-dev" - ] - } - }, - { - "name": "no-non-package-json", - "severity": "error", - "comment": "Don't allow dependencies to packages not in package.json (except from within node_modules)", - "from": { - "pathNot": "^node_modules" - }, - "to": { - "dependencyTypes": [ - "unknown", - "undetermined", - "npm-no-pkg", - "npm-unknown" - ] - } - }, - { - "name": "optional-deps-used", - "severity": "info", - "comment": "nothing serious - but just check you have some serious try/ catches around the import/ requires of these", - "from": {}, - "to": { - "dependencyTypes": [ - "npm-optional" - ] - } - }, - { - "name": "peer-deps-used", - "comment": "Warn about the use of a peer dependency (peer dependencies are deprecated with the advent of npm 3 - and probably gone with version 4).", - "severity": "warn", - "from": {}, - "to": { - "dependencyTypes": [ - "npm-peer" - ] - } - }, - { - "name": "no-duplicate-dep-types", - "comment": "Warn if a dependency you're actually using occurs in your package.json more than once (technically: has more than one dependency type)", - "severity": "warn", - "from": {}, - "to": { - "moreThanOneDependencyType": true - } - } - ], - "options": { - "doNotFollow": "^node_modules" - } -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 31ae44d..152df60 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,31 +5,157 @@ version: 2 updates: # Enable version updates for npm - package-ecosystem: "npm" - # Look for `package.json` and `lock` files in the `root` directory + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + preact: + patterns: + - "preact" + - "preact-render-to-string" + - "htm" directory: "/" - # Check the npm registry for updates every day (weekdays) schedule: interval: "daily" - package-ecosystem: "npm" directory: "/examples/basic" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/css-modules/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" schedule: interval: "daily" - package-ecosystem: "npm" directory: "/examples/default-layout/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/esbuild-settings" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/markdown-settings/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" schedule: interval: "daily" - package-ecosystem: "npm" directory: "/examples/nested-dest/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/preact-isomorphic/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" schedule: interval: "daily" - package-ecosystem: "npm" - directory: "/examples/preact/" + directory: "/examples/react/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + react: + patterns: + - "react" + - "react-dom" + - "@types/react" + - "@types/react-dom" schedule: interval: "daily" - package-ecosystem: "npm" directory: "/examples/string-layouts/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/tailwind/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/type-stripping/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/uhtml-isomorphic/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" + schedule: + interval: "daily" + - package-ecosystem: "npm" + directory: "/examples/worker-examples/" + groups: + typescript: + patterns: + - "@voxpelli/tsconfig" + - "@types/node" + - "typescript" schedule: interval: "daily" + # Enable version updates for pnpm # Enable updates to github actions - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/neocities-old.yml b/.github/workflows/neocities-old.yml new file mode 100644 index 0000000..ebb1477 --- /dev/null +++ b/.github/workflows/neocities-old.yml @@ -0,0 +1,36 @@ +name: Deploy old website to neociteis + +on: + push: + branches: + - top-bun + +env: + FORCE_COLOR: 1 + +concurrency: # prevent concurrent deploys doing starnge things + group: deploy-to-neocities-old + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + check-latest: true + - run: npm i + - run: npm run build + + - name: Deploy to neocities + uses: bcomnes/deploy-to-neocities@v3 + with: + api_key: ${{ secrets.NEOCITIES_API_TOKEN }} + cleanup: true + neocities_supporter: true + preview_before_deploy: true diff --git a/.github/workflows/neocities.yml b/.github/workflows/neocities.yml index c2db337..e4b3c23 100644 --- a/.github/workflows/neocities.yml +++ b/.github/workflows/neocities.yml @@ -1,4 +1,4 @@ -name: Deploy to neociteis +name: Deploy domstack website to neociteis on: push: @@ -17,10 +17,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json check-latest: true @@ -30,7 +30,7 @@ jobs: - name: Deploy to neocities uses: bcomnes/deploy-to-neocities@v3 with: - api_token: ${{ secrets.NEOCITIES_API_TOKEN }} + api_key: ${{ secrets.NEOCITIES_DOMSTACK_API_TOKEN }} cleanup: true - neocoties_supporter: true + neocities_supporter: true preview_before_deploy: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ec483a..5e776f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,12 +14,12 @@ jobs: version_and_release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # fetch full history so things like auto-changelog work properly fetch-depth: 0 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json check-latest: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d3e1dd..bde822b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,9 +15,9 @@ jobs: node-version: [lts/*, '23'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} check-latest: true @@ -34,7 +34,7 @@ jobs: uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - files: .tap/report/lcov.info + files: coverage/lcov.info parallel: true coverage: diff --git a/.gitignore b/.gitignore index dae330e..e7a894f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,11 @@ package-lock.json public coverage .tap +.tmp-* # Generated types *.d.ts *.d.ts.map +!types/**/*.d.ts +!types/**/*.d.ts.map diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7d515..6cd6a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -## [v10.5.3](https://github.com/bcomnes/top-bun/compare/v10.5.2...v10.5.3) +## [v11.0.0](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.7...v11.0.0) + +### Merged + +- Bump chokidar from 4.0.3 to 5.0.0 [`#211`](https://github.com/bcomnes/top-bun/pull/211) +- Bump read-pkg from 9.0.1 to 10.0.0 [`#208`](https://github.com/bcomnes/top-bun/pull/208) +- Bump esbuild from 0.25.12 to 0.27.1 [`#212`](https://github.com/bcomnes/top-bun/pull/212) +- Bump actions/checkout from 5 to 6 [`#210`](https://github.com/bcomnes/top-bun/pull/210) +- Bump actions/setup-node from 5 to 6 [`#207`](https://github.com/bcomnes/top-bun/pull/207) +- Bump actions/setup-node from 4 to 5 [`#205`](https://github.com/bcomnes/top-bun/pull/205) +- Bump the react group across 1 directory with 3 updates [`#202`](https://github.com/bcomnes/top-bun/pull/202) + +### Commits + +- Update website deploy [`273c5e5`](https://github.com/bcomnes/top-bun/commit/273c5e5bfdaebb1bd9812b00d3181a6c14e682d7) +- Merge pull request #219 from bcomnes/blog-example [`c3a7d61`](https://github.com/bcomnes/top-bun/commit/c3a7d61c1caaea7b488927332630624846255d61) +- Implement progressive watching [`e2ec56e`](https://github.com/bcomnes/top-bun/commit/e2ec56e4006445982f6a23f3002ca5792fa47e12) + +## [v11.0.0-beta.7](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.6...v11.0.0-beta.7) - 2025-08-17 + +### Commits + +- Add Async type variants for the various layout and page functions [`4bd6bdb`](https://github.com/bcomnes/top-bun/commit/4bd6bdbc10f595a80205e5928a63120292230835) + +## [v11.0.0-beta.6](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.5...v11.0.0-beta.6) - 2025-08-17 + +### Merged + +- Bump the typescript group with 2 updates [`#200`](https://github.com/bcomnes/top-bun/pull/200) + +### Commits + +- Dependabot groups [`9c68781`](https://github.com/bcomnes/top-bun/commit/9c68781c2e371dd8f628f51a8c3abc3eed422668) +- Remove @async tag [`518fe7e`](https://github.com/bcomnes/top-bun/commit/518fe7e7fef398ede48b65250c255447c775c115) +- More update groups [`14062fb`](https://github.com/bcomnes/top-bun/commit/14062fb8b297f1a0cdc82b1d5e559a4cc106d822) + +## [v11.0.0-beta.5](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.4...v11.0.0-beta.5) - 2025-08-16 + +### Commits + +- Fix children types on LayoutFunction [`33b93a3`](https://github.com/bcomnes/top-bun/commit/33b93a3382a4f20ff198268d80e0bb535890245a) + +## [v11.0.0-beta.4](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.3...v11.0.0-beta.4) - 2025-08-16 + +### Commits + +- Rename template variables [`9a9f3d3`](https://github.com/bcomnes/top-bun/commit/9a9f3d34f6f66e975aef640265259cf475cd492d) +- Remove logging [`e6717c5`](https://github.com/bcomnes/top-bun/commit/e6717c561a0e5dc6262c9d74fd7ba465550cb73d) + +## [v11.0.0-beta.3](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.2...v11.0.0-beta.3) - 2025-08-16 + +### Commits + +- Update docs [`5aa5778`](https://github.com/bcomnes/top-bun/commit/5aa5778621190e4147b39e3084222325f0ea0c77) + +## [v11.0.0-beta.2](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.1...v11.0.0-beta.2) - 2025-08-16 + +### Commits + +- Allow customization of the PageFunction return type [`eae282a`](https://github.com/bcomnes/top-bun/commit/eae282a5f547289a30bbc1b08b1ceb1af15f5aea) +- Enable customization of LayoutFunction types [`0e1939a`](https://github.com/bcomnes/top-bun/commit/0e1939a78f37620fcd93705a9df0b848e93ddb6d) + +## [v11.0.0-beta.1](https://github.com/bcomnes/top-bun/compare/v11.0.0-beta.0...v11.0.0-beta.1) - 2025-08-16 + +### Merged + +- Bump @types/react from 18.3.23 to 19.1.10 in /examples/react [`#199`](https://github.com/bcomnes/top-bun/pull/199) +- Bump actions/checkout from 4 to 5 [`#196`](https://github.com/bcomnes/top-bun/pull/196) + +### Commits + +- Fix warning [`e60b67e`](https://github.com/bcomnes/top-bun/commit/e60b67e139490495ad30acf3550f473865cfc7ba) +- Set up new website action [`a8b0563`](https://github.com/bcomnes/top-bun/commit/a8b0563b805ed9528f99e6faa19b11a37bb8d9c8) +- Allow for sync PageFunctions [`701c42e`](https://github.com/bcomnes/top-bun/commit/701c42e163050f8d9213368fba378095cf97ab1c) + +## [v11.0.0-beta.0](https://github.com/bcomnes/top-bun/compare/v10.5.3...v11.0.0-beta.0) - 2025-07-20 + +### Commits + +- Switch examples over to use preact and general improvements [`289719d`](https://github.com/bcomnes/top-bun/commit/289719d53a69b3e3d7798794eca541628fdd59cd) +- More docs improvements [`0f16d6a`](https://github.com/bcomnes/top-bun/commit/0f16d6ae23930092f5a3fc82601152fb127c328d) +- Move to @import syntax [`debc2ae`](https://github.com/bcomnes/top-bun/commit/debc2aec4b81d19bac9aa91d689ec7fe6a6c2627) + +## [v10.5.3](https://github.com/bcomnes/top-bun/compare/v10.5.2...v10.5.3) - 2025-07-20 ### Merged diff --git a/README.md b/README.md index e4621ae..15f4f08 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,44 @@ -# πŸ₯ top-bun -[![npm version](https://img.shields.io/npm/v/top-bun.svg)](https://npmjs.org/package/top-bun) -[![Actions Status](https://github.com/bcomnes/top-bun/workflows/tests/badge.svg)](https://github.com/bcomnes/top-bun/actions) -[![Coverage Status](https://coveralls.io/repos/github/bcomnes/top-bun/badge.svg?branch=master)](https://coveralls.io/github/bcomnes/top-bun?branch=master) +# domstack +[![npm version](https://img.shields.io/npm/v/@domstack/static.svg)](https://npmjs.org/package/@domstack/static) +[![Actions Status](https://github.com/bcomnes/domstack/workflows/tests/badge.svg)](https://github.com/bcomnes/domstack/actions) +[![Coverage Status](https://coveralls.io/repos/github/bcomnes/domstack/badge.svg?branch=master)](https://coveralls.io/github/bcomnes/domstack?branch=master) [![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)](https://github.com/voxpelli/types-in-js) -[![Neocities][neocities-img]](https://top-bun.org) +[![Neocities][neocities-img]](https://domstack.net) -`top-bun`: a traditional web bakery made with html, md, css and js. +`domstack`: Cut the [gordian knot](https://en.wikipedia.org/wiki/Gordian_Knot) of modern web development and build websites with a stack of html, md, css, ts, tsx, (and/or js/jsx). -(A bakery themed static site generator that's as fun as making bread.) +DOMStack provides a few project conventions around esbuild ande Node.js that lets you quickly, cleanly and easily build websites and web apps using all of your favorite technolgies without any framework specific impurities, unlocking the web platform as a freeform canvas. + +It's fast to learn, quick to build with, and performs better than you are used to. + +`domstack` currently ships a static site generator tool which is great for building static wesbites, and static PWA/MPAs. +There is an experimental fastify plugin in the works that will unlock dynamic hypermedia webapps using the same project structure. ```console -npm install top-bun +npm install @domstack/static ``` -- 🌎 [`top-bun` docs website](https://top-bun.org) +- 🌎 [domstack docs website](https://domstack.net) - πŸ’¬ [Discord Chat](https://discord.gg/AVTsPRGeR9) +- πŸ“’ [v11 - top-bun is now domstack](https://bret.io/blog/2023/top-bun-is-now-domstack/) - πŸ“’ [v7 Announcement](https://bret.io/blog/2023/reintroducing-top-bun/) - πŸ“˜ [Full TypeScript Support](#typescript-support) +## Migrating from top-bun + +domstack v11 is a major release that renames the project from `top-bun` to `@domstack/static`. The full migration guide is at [docs/v11-migration.md](docs/v11-migration.md). Key changes at a glance: + +- **Package**: `top-bun` β†’ `@domstack/static` +- **CLI**: `top-bun`/`tb` β†’ `domstack`/`dom` +- **Programmatic API**: `TopBun` class β†’ `DomStack`, all `TopBun*` types/errors/warnings renamed to `DomStack*` +- **`postVars` removed**: migrate `postVars` exports from `page.vars.js` files to a single `global.data.js` with a default export +- **New reserved filenames**: `global.data.js`, `markdown-it.settings.js`, `page.md`, `*.worker.{js,ts}` are now special β€” rename any colliding files +- **Default layout**: switched from `uhtml-isomorphic` to `preact`; add `uhtml-isomorphic` to your own deps if you import it directly +- **Output paths**: `top-bun-esbuild-meta.json` β†’ `dom-stack-esbuild-meta.json`, `top-bun-defaults/` β†’ `dom-stack-defaults/` +- **Conflict now throws**: using both `browser` in `global.vars.js` and `define` in `esbuild.settings.js` is now a hard error + +See [docs/v11-migration.md](docs/v11-migration.md) for the complete migration guide with code examples. + ## Table of Contents [[toc]] @@ -25,87 +46,94 @@ npm install top-bun ## Usage ```console -$ top-bun --help -Usage: top-bun [options] +$ domstack --help +Usage: domstack [options] - Example: top-bun --src website --dest public + Example: domstack --src website --dest public --src, -s path to source directory (default: "src") --dest, -d path to build destination directory (default: "public") --ignore, -i comma separated gitignore style ignore string - --drafts Build draft pages with the `.draft.{md,js,html}` page suffix. - --target, -t comma separated target strings for esbuild - --noEsbuildMeta skip writing the esbuild metafile to disk - --eject, -e eject the top bun default layout, style and client into the src flag directory + --drafts Build draft pages with the `.draft.{md,js,ts,html}` page suffix. + --eject, -e eject the DOMStack default layout, style and client into the src flag directory --watch, -w build, watch and serve the site build --watch-only watch and build the src folder without serving --copy path to directories to copy into dist; can be used multiple times --help, -h show help --version, -v show version information -top-bun (v10.5.1) +domstack (v11.0.0) ``` -`top-bun` builds a `src` directory into a `dest` directory (default: `public`). -`top-bun` is also aliased to a `tb` bin. +`domstack` builds a `src` directory into a `dest` directory (default: `public`). +`domstack` is also aliased to a `dom` bin. -- Running `top-bun` will result in a `build` by default. -- Running `top-bun --watch` or `top-bun -w` will build the site and start an auto-reloading development web-server that watches for changes. -- Running `top-bun --eject` or `top-bun -e` will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json. +- Running `domstack` will result in a `build` by default. +- Running `domstack --watch` or `domstack -w` will build the site and start an auto-reloading development web-server that watches for changes (provided by [Browsersync](https://browsersync.io)). +- Running `domstack --eject` or `domstack -e` will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json. -`top-bun` is primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. +`domstack` is primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. It can be used outside of this context, but it works best within it. ## Core Concepts -`top-bun` builds a website from "pages" in a `src` directory, nearly 1:1 into a `dest` directory. +`domstack` is a static site generator that builds a website from "pages" in a `src` directory, nearly 1:1 into a `dest` directory. +By building "pages" from their `src` location to the `dest` destination, the directory structure inside of `src` becomes a "filesystem router" naturally, without any additional moving systems or structures. + A `src` directory tree might look something like this: ```bash src % tree . β”œβ”€β”€ md-page -β”‚ β”œβ”€β”€ README.md # directories with README.md in them turn into /md-page/index.html. -β”‚ β”œβ”€β”€ client.js # Every page can define its own client.js script that loads only with it. +β”‚ β”œβ”€β”€ page.md # page.md (or README.md) in a directory turns into /md-page/index.html. page.md takes precedence. +β”‚ β”œβ”€β”€ client.ts # Every page can define its own client.ts script that loads only with it. β”‚ β”œβ”€β”€ style.css # Every page can define its own style.css style that loads only with it. β”‚ β”œβ”€β”€ loose-md-page.md # loose markdown get built in place, but lacks some page features. β”‚ └── nested-page # pages are built in place and can nest. -β”‚ β”œβ”€β”€ README.md # This page is accessed at /md-page/nested-page/. -β”‚ β”œβ”€β”€ client.js # nested pages are just pages, so they also can have a page scoped client and style. -β”‚ └── style.css +β”‚ β”œβ”€β”€ README.md # This page is accessed at /md-page/nested-page/. (page.md works here too) +β”‚ β”œβ”€β”€ style.css # nested pages are just pages, so they also can have a page scoped client and style. +β”‚ └── client.js # Anywhere JS loads, you can use .js or .ts β”œβ”€β”€ html-page -β”‚ β”œβ”€β”€ client.jsx # client bundles can also be written in .jsx/.tsx +β”‚ β”œβ”€β”€ client.tsx # client bundles can also be written in .jsx/.tsx β”‚ β”œβ”€β”€ page.html # Raw html pages are also supported. They support handlebars template blocks. -β”‚ β”œβ”€β”€ page.vars.js # pages can define page variables in a page.vars.js. +β”‚ β”œβ”€β”€ page.vars.ts # pages can define page variables in a page.vars.ts β”‚ └── style.css β”œβ”€β”€ js-page -β”‚ └── page.js # A page can also just be a plain javascript function that returns content +β”‚ └── page.js # A page can also just be a plain javascript function that returns content. They can also be type checked. β”œβ”€β”€ ts-page -β”‚ β”œβ”€β”€ client.ts # client bundles can be written in typescript via type stripping -β”‚ β”œβ”€β”€ page.vars.ts # pages can define page variables in a page.vars.js. -β”‚ └── page.ts # Anywhere you can use js in top-bun, you can also use typescript files. They compile via speedy type stripping. +β”‚ β”œβ”€β”€ client.ts # domstack provides type-stripping via Node.JS and esbuild +β”‚ β”œβ”€β”€ page.vars.ts # use tsc to run typechecking +β”‚ └── page.ts β”œβ”€β”€ feeds -β”‚ └── feeds.template.js # Templates let you generate any file you want from variables and page data. +β”‚ └── feeds.template.ts # Templates let you generate any file you want from variables and page data. +β”œβ”€β”€ page-with-workers +β”‚ β”œβ”€β”€ client.ts +β”‚ └── page.ts +β”‚ β”œβ”€β”€ counter.worker.ts # Web workers use a .worker.{ts,js} naming convention and are auto-bundled +β”‚ └── analytics.worker.js β”œβ”€β”€ layouts # layouts can live anywhere. The inner content of your page is slotted into your layout. -β”‚ β”œβ”€β”€ blog.layout.js # pages specify which layout they want by setting a `layout` page variable. +β”‚ β”œβ”€β”€ blog.layout.ts # pages specify which layout they want by setting a `layout` page variable. β”‚ β”œβ”€β”€ blog.layout.css # layouts can define an additional layout style. -β”‚ β”œβ”€β”€ blog.layout.client.js # layouts can also define a layout client. -β”‚ β”œβ”€β”€ article.layout.js # layouts can extend other layouts, since they are just functions. -β”‚ β”œβ”€β”€ typescript.layout.ts # layouts can also be written in typescript -β”‚ └── root.layout.js # the default layout is called root. +β”‚ β”œβ”€β”€ blog.layout.client.ts # layouts can also define a layout client. +β”‚ β”œβ”€β”€ article.layout.ts # layouts can extend other layouts, since they are just functions. +β”‚ β”œβ”€β”€ javascript.layout.js # layouts can also be written in javascript +β”‚ └── root.layout.ts # the default layout is called "root" β”œβ”€β”€ globals # global assets can live anywhere. Here they are in a folder called globals. -β”‚ β”œβ”€β”€ global.client.js # you can define a global js client that loads on every page. +β”‚ β”œβ”€β”€ global.client.ts # you can define a global client that loads on every page. β”‚ β”œβ”€β”€ global.css # you can define a global css file that loads on every page. -β”‚ β”œβ”€β”€ global.vars.js # site wide variables get defined in global.vars.js. -β”‚ └── esbuild.settings.js # You can even customize the build settings passed to esbuild! -β”œβ”€β”€ README.md # This is just a top level page built from a README.md file. -β”œβ”€β”€ client.js # the top level page can define a page scoped js client. -β”œβ”€β”€ style.js # the top level page can define a page scoped Css style. +β”‚ β”œβ”€β”€ global.vars.ts # site wide variables get defined in global.vars.ts +β”‚ β”œβ”€β”€ global.data.ts # optional file to derive and aggregate data from all pages before rendering +β”‚ β”œβ”€β”€ markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown +β”‚ └── esbuild.settings.ts # You can even customize the build settings passed to esbuild +β”œβ”€β”€ page.md # The top level page can also be a page.md (or README.md) file. +β”œβ”€β”€ client.ts # the top level page can define a page scoped js client. +β”œβ”€β”€ style.css # the top level page can define a page scoped css style. └── favicon-16x16.png # static assets can live anywhere. Anything other than JS, CSS and HTML get copied over automatically. ``` -The core idea of `top-bun` is that a `src` directory of markdown, html and js "inner" documents will be transformed into layout wrapped html documents in the `dest` directory, along with page scoped js and css bundles, as well as a global stylesheet and global js bundle. +The core idea of `domstack` is that a `src` directory of markdown, html and ts/js "inner" documents will be transformed into layout wrapped html documents in the `dest` directory, along with page scoped js and css bundles, as well as a global stylesheet and global js bundle. -It ships with sane defaults so that you can point `top-bun` at a standard [markdown documented repository](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github) and have it build a website with near-zero preparation. +It ships with sane defaults so that you can point `domstack` at a standard [markdown documented repository](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github) and have it build a website with near-zero preparation. ## Examples @@ -113,23 +141,26 @@ A collection of examples can be found in the [`./examples`](./examples) folder. To run examples: -```console -$ git clone git@github.com:bcomnes/top-bun.git -$ cd top-bun +```bash +$ git clone git@github.com:bcomnes/domstack.git +$ cd domstack +# install the top level deps $ npm i -$ npm run example:{example-name} +$ cd example:{example-name} +# install the example deps $ npm i +# start the example $ npm start ``` ### Additional examples -Here are some additional external examples of larger top-bun projects. -If you have a project that uses top-bun and could act as a nice example, please PR it to the list! +Here are some additional external examples of larger domstack projects. +If you have a project that uses domstack and could act as a nice example, please PR it to the list! -- [Blog Example](https://github.com/bcomnes/bret.io/) -- [Isomorphic Static/Client App](https://github.com/hifiwi-fi/breadcrum.net/tree/master/packages/web/client) -- [Zero-Conf Markdown Docs](https://github.com/bcomnes/deploy-to-neocities/blob/70b264bcb37fca5b21e45d6cba9265f97f6bfa6f/package.json#L38) +- [Blog Example](https://github.com/bcomnes/bret.io/) - A personal blog written with DOMStack +- [Isomorphic Static/Client App](https://github.com/hifiwi-fi/breadcrum.net/tree/master/packages/web/client) - Pages build from client templates and hydrate on load. +- [Zero-Conf Markdown Docs](https://github.com/bcomnes/deploy-to-neocities/blob/70b264bcb37fca5b21e45d6cba9265f97f6bfa6f/package.json#L38) - A npm package with markdown docs, transformed into a website without any any configuration ## Pages @@ -137,26 +168,28 @@ Pages are a named directories inside of `src`, with **one of** the following pag - `md` pages are [CommonMark](https://commonmark.org) markdown pages, with an optional [YAML](https://yaml.org) front-matter block. - `html` pages are an inner [html](https://developer.mozilla.org/en-US/docs/Web/HTML) fragment that get inserted into the page layout. -- `js` pages are a [js](https://developer.mozilla.org/en-US/docs/Web/JavaScript) file that exports a default function that resolves into an inner-html fragment that is inserted into the page layout. +- `ts`/`js` pages are a [ts](https://developer.mozilla.org/en-US/docs/Glossary/TypeScript)/[js](https://developer.mozilla.org/en-US/docs/Web/JavaScript) file that exports a default function that resolves into an inner-html fragment that is inserted into the page layout. -Variables are available in all pages. `md` and `html` pages support variable access via [handlebars][hb] template blocks. `js` pages receive variables as part of the argument passed to them. See the [Variables](#variables) section for more info. +Variables are available in all pages. `md` and `html` pages support variable access via [handlebars][hb] template blocks. `ts`/`js` pages receive variables as part of the argument passed to them. See the [Variables](#variables) section for more info. -A special variable called `layout` determines which layout the page is rendered into. +Pages can define a special variable called `layout` determines which layout the page is rendered into. -Because pages are just directories, they nest and structure naturally. Directories in the `src` folder that lack one of these special page files can exist along side page directories and can be used to store co-located code or static assets without conflict. +Because pages are just directories, they nest and structure naturally as a filesystem router. Directories in the `src` folder that lack one of these special page files can exist along side page directories and can be used to store co-located code or static assets without conflict. ### `md` pages -A `md` page looks like this: +A `md` page looks like this on the filesystem: ```bash +src/page-name/page.md +# or src/page-name/README.md # or src/page-name/loose-md.md ``` -- `md` pages have two types: a `README.md` in a folder, or a loose `whatever-name-you-want.md` file. -- `README.md` files transform to an `index.html` at the same path, and `whatever-name-you-want.md` loose markdown files transform into `whatever-name-you-want.html` files at the same path in the `dest` directory. +- `md` pages have three types: a `page.md`, a `README.md`, or a loose `whatever-name-you-want.md` file. +- `page.md` and `README.md` files transform to an `index.html` at the same path. When both exist in the same directory, `page.md` takes precedence over `README.md`. `whatever-name-you-want.md` loose markdown files transform into `whatever-name-you-want.html` files at the same path in the `dest` directory. - `md` pages can have YAML frontmatter, with variables that are accessible to the page layout and handlebars template blocks when building. - You can include html in markdown files, so long as you adhere to the allowable markdown syntax around html tags. - `md` pages support [handlebars][hb] template placeholders. @@ -167,15 +200,15 @@ An example of a `md` page: ```md --- -title: A title for my markdown -favoriteBread: 'Baguette' +title: A title for a markdown page +favoriteColor: 'Blue' --- -Just writing about baking. +Just writing about web development. -## Favorite breads +## Favorite colors -My favorite bread is \{{ vars.favoriteBread }}. +My favorite color is {{ vars.favoriteColor }}. ``` ### `html` pages @@ -187,7 +220,7 @@ src/page-name/page.html ``` - `html` pages are named `page.html` inside an associated page folder. -- `html` pages are the simplest page type in `top-bun`. They let you build with raw html for when you don't want that page to have access to markdown features. Some pages are better off with just raw `html`. +- `html` pages are the simplest page type in `domstack`. They let you build with raw html for when you don't want that page to have access to markdown features. Some pages are better off with just raw `html`, and the rules with building `html` in a real `html` file are much more flexible than inside of a `md` file. - `html` page variables can only be set in a `page.vars.js` file inside the page directory. - `html` pages support [handlebars][hb] template placeholders. - You can disable `html` page [handlebars][hb] processing by setting the `handlebars` variable to `false`. @@ -195,35 +228,44 @@ src/page-name/page.html An example `html` page: ```html -

Favorite breads

+

Favorite frameworks

``` -### `js` pages +### `ts`/`js` pages -A `js` page looks like this: +A `ts`/`js` page looks like this: ```bash +src/page-name/page.ts +# or src/page-name/page.js ``` -- `js` pages consist of a named directory with a `page.js` inside of it, that exports a default function that returns the contents of the inner page. -- a `js` page needs to `export default` a function (async or sync) that accepts a variables argument and returns a string of the inner html of the page, or any other type that your layout can accept. -- A `js` page can export a `vars` object or function (async or sync) that takes highest variable precedence when rendering the page. `export vars` is similar to a `md` page's front matter. -- A `js` page receives the standard `top-bun` [Variables](#variables) set. -- There is no built in handlebars support in `js` pages, however you are free to use any template library that you can import. -- `js` pages are run in a Node.js context only. +- `js`/`ts` pages consist of a named directory with a `page.js` or `page.ts` inside of it, that exports a default function that returns the contents of the inner page. +- a `js`/`ts` page needs to `export default` a function (async or sync) that accepts a variables argument and returns a string of the inner html of the page, or any other type that your layout can accept. +- You can specify the return type using `PageFunction` where `T` is the variables type and `U` is the return type (defaults to `any`). +- A `js`/`ts` page can export a `vars` object or function (async or sync) that takes highest variable precedence when rendering the page. `export vars` is similar to a `md` page's front matter. +- A `js`/`ts` page receives the standard `domstack` [Variables](#variables) set. +- There is no built in handlebars support in `js`/`ts` pages, however you are free to use any template library that you can import. +- `js`/`ts` pages are run in a Node.js context only. -An example `js` page: +An example TypeScript page: -```js -export default async ({ +```typescript +import type { PageFunction } from '@domstack/static' + +export const vars = { + favoriteCookie: 'Chocolate Chip with Sea Salt' +} + +const page: PageFunction = async ({ vars }) => { return /* html */`
@@ -232,35 +274,29 @@ export default async ({
` } -export const vars = { - favoriteCookie: 'Chocolate Chip with Sea Salt' -} +export default page ``` -It is it's recommended to use some level of template processing over raw string templates so that html is well formed and you default escape variable values. Here is a more realistic `js` example that uses [`uhtml`](https://github.com/WebReflection/uhtml) and [types-in-js](https://github.com/voxpelli/types-in-js) and `top-bun` page introspection. +It is recommended to use some level of template processing over raw string templates so that HTML is well-formed and variable values are properly escaped. Here is a more realistic TypeScript example that uses [`preact`](https://preactjs.com/) with [`htm`](https://github.com/developit/htm) and `domstack` page introspection. -```js -// @ts-ignore -import { html } from 'uhtml-isomorphic' +```typescript +import { html } from 'htm/preact' import { dirname, basename } from 'node:path' +import type { PageFunction } from '@domstack/static' -/** - * @template T - * @typedef {import('top-bun').LayoutFunction} LayoutFunction - */ - -/** - * @type {LayoutFunction<{ - * favoriteCake: string - * }>} - */ -export default async function blogIndex ({ - vars: { - favoriteCake - }, +type BlogVars = { + favoriteCake: string +} + +export const vars = { + favoriteCake: 'Chocolate Cloud Cake' +} + +const blogIndex: PageFunction = async ({ + vars: { favoriteCake }, pages -}) { +}) => { const yearPages = pages.filter(page => dirname(page.pageInfo.path) === 'blog') return html`

I love ${favoriteCake}!!

@@ -270,9 +306,7 @@ export default async function blogIndex ({
` } -export const vars = { - favoriteCake: 'Chocolate Cloud Cake' -} +export default blogIndex ``` ### Page Styles @@ -301,17 +335,17 @@ An example of a page `style.css` file: ### Page JS Bundles -You can create a `client.js` file in any page folder. +You can create a `client.ts` or `client.js` file in any page folder. Page bundles are client side JS bundles that are loaded on that one page only. -You can import common code and modules from relative paths, or `npm` modules. -The `client.js` page bundles are bundle-split with every other client-side js entry-point, so importing common chunks of code are loaded in a maximally efficient way. +You can import common code and modules from relative paths, or `npm` modules out of `node_modules`. +The `client.js` page bundles are bundle-split with every other client-side js/ts entry-point, so importing common chunks of code are loaded in a maximally efficient way. Page bundles are run in a browser context only, however they can share carefully crafted code that also runs in a Node.js or layout context. -`js` page bundles are bundled using [`esbuild`][esbuild]. +`ts`/`js` page bundles are bundled using [`esbuild`][esbuild]. An example of a page `client.js` file: -```js -/* /some-page/client.js */ +```typescript +/* /some-page/client.ts */ import { funnyLibrary } from 'funny-library' import { someHelper } from '../helpers/foo.js' @@ -321,13 +355,14 @@ await funnyLibrary() #### .tsx/.jsx -Client bundles support .jsx and .tsx. They default to preact, so if you want mainlain recat, customize your esbuild settings to load that instead. +Client bundles support .jsx and .tsx. They default to preact, so if you want mainlain react, customize your esbuild settings to load that instead. +See the [react](./examples/react/) example for more details. ### Page variable files -Each page can also have a `page.vars.js` file that exports a `default` function or object that contains page specific variables. +Each page can also have a `page.vars.ts` or `page.vars.js` file that exports a `default` sync/async function or object that contains page specific variables. -```js +```typescript // export an object export default { my: 'vars' @@ -344,11 +379,11 @@ export default async () => { } ``` -Page variable files have higher precedent than `global.vars.js` variables, but lower precedent than frontmatter or `vars` page exports. +Page variable files have higher precedent than `global.vars.ts` variables, but lower precedent than frontmatter or `vars` ts/js page exports. ### Draft pages -If you add a `.draft.{md,html,js}` to any of the page types, the page is considered a draft page. +If you add a `.draft.{md,html,ts,js}` to any of the page types, the page is considered a draft page. Draft pages are not built by default. If you pass the `--drafts` flag when building or watching, the draft pages will be built. When draft pages are omitted, they are completely ignored. @@ -359,6 +394,50 @@ It is a good idea to display something indicating the page is a draft in your te Any static assets near draft pages will still be copied because static assets are processed in parallel from page generation (to keep things fast). If you have an idea on how to relate static assets to a draft page for omission, please open a discussion issue. +Draft pages let you work on pages before they are ready and easily omit them from a build when deploying pages that are ready. + +## Web Workers + +You can easily write [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) for a page by adding a file called `${name}.worker.ts` or `${name}.worker.js` where `name` becomes the name of the worker filename in the `workers.json` file. +DOMStack will build these similarly to page `client.ts` bundles, and will even bundle split their contents with the rest of your site. + +``` +page-directory/ + β”œβ”€β”€ page.js + β”œβ”€β”€ client.js + β”œβ”€β”€ counter.worker.js # Worker with counter functionality + └── data.worker.js # Worker for data processing +``` + +To use a woker, load in a `./workers.json` file that is generated along with the worker bundle to get the final name of the worker entrypoint and then create a worker with that filename. + +```typescript +// First, fetch the workers.json to get worker paths in your client.ts +async function initializeWorkers() { + const response = await fetch('./workers.json'); + const workersData = await response.json(); + + // Initialize workers with the correct hashed filenames + const counterWorker = new Worker( + new URL(`./${workersData.counter}`, import.meta.url), + { type: 'module' } + ); + + // Use the worker + counterWorker.postMessage({ action: 'increment' }); + + counterWorker.onmessage = (e) => { + console.log(e.data); + }; + + return counterWorker; +} + +const worker = await initializeWorkers(); +``` + +See the [Web Workers Example](https://github.com/domstack/domstack/tree/master/examples/worker-example) for a complete implementation. + ## Layouts Layouts are "outer page templates" that pages get rendered into. @@ -367,6 +446,11 @@ You can define as many as you want, and they can live anywhere in the `src` dire Layouts are named `${layout-name}.layout.js` where `${layout-name}` becomes the name of the layout. Layouts should have a unique name, and layouts with duplicate name will result in a build error. +Layouts can be typed using `LayoutFunction` where: +- `T` is the variables type +- `U` is the type of content received from pages (defaults to `any`) +- `V` is the layout's return type (defaults to `string` for HTML output) + Example layout file names: ```bash @@ -374,7 +458,7 @@ src/layouts/root.layout.js # this layout is references as 'root' src/other-layouts/article.layout.js # this layout is references as 'article' ``` -At a minimum, your site requires a `root` layout (a file named `root.layout.js`), though `top-bun` ships a default `root` layout so defining one in your `src` directory is optional, though recommended. +At a minimum, your site requires a `root` layout (a file named `root.layout.js`), though `domstack` ships a default `root` layout so defining one in your `src` directory is optional, though recommended. All pages have a `layout` variable that defaults to `root`. If you set the `layout` variable to a different name, pages will build with a layout matching the name you set to that variable. @@ -393,7 +477,7 @@ A page referencing a layout name that doesn't have a matching layout file will r ### The default `root.layout.js` -A layout is a js file that `export default`'s an async or sync function that implements an outer-wrapper html template that will house the inner content from the page (`children`) being rendered. Think of the bread in a sandwich. That's a layout. πŸ₯ͺ +A layout is a `ts`/`js` file that `export default`'s an async or sync function that implements an outer-wrapper html template that will house the inner content from the page (`children`) being rendered. Think of the frame around a picture. That's a layout. πŸ–ΌοΈ It is always passed a single object argument with the following entries: @@ -404,104 +488,110 @@ It is always passed a single object argument with the following entries: - `pages`: An array of page data that you can use to generate index pages with, or any other page-introspection based content that you desire. - `page`: An object with metadata and other facts about the current page being rendered into the template. This will also be found somewhere in the `pages` array. -The default `root.layout.js` is featured below, and is implemented with [`uhtml`][uhtml], though it could just be done with a template literal or any other template system. - -`root.layout.js` can live anywhere in the `src` directory. - -```js -// @ts-ignore -import { html, render } from 'uhtml-isomorphic' - -/** - * @template {Record} T - * @typedef {import('top-bun').LayoutFunction} LayoutFunction - */ - -/** - * Build all of the bundles using esbuild. - * - * @type {LayoutFunction<{ - * title: string, - * siteName: string, - * defaultStyle: boolean - * }>} - */ -export default function defaultRootLayout ({ +The default `root.layout.ts` is featured below, and is implemented with [`preact`](https://preactjs.com/) and [`htm`](https://github.com/developit/htm), though it could just be done with a template literal or any other template system that runs in Node.js. + +`root.layout.ts` can live anywhere in the `src` directory. + +```typescript +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' +import type { LayoutFunction } from '@domstack/static' + +type RootLayoutVars = { + title: string, + siteName: string, + defaultStyle: boolean, + basePath?: string +} + +const defaultRootLayout: LayoutFunction = ({ vars: { title, - siteName = 'TopBun' + siteName = 'Domstack', + basePath, /* defaultStyle = true Set this to false in global or page vars to disable the default style in the default layout */ }, scripts, styles, - children - /* pages */ - /* page */ -}) { - return render(String, html` + children, + pages, + page, +}) => { + return /* html */` - - - ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} - - ${scripts - ? scripts.map(script => html``) - : null} - ${styles - ? styles.map(style => html``) - : null} - - -
- ${typeof children === 'string' ? html([children]) : children /* Support both uhtml and string children. Optional. */} -
- + ${render(html` + + + ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + + ${scripts + ? scripts.map(script => html``)} - - - ${children} - - - `) -} +// Page returns VDOM +const page: PageFunction<{title: string}, VDOMNode> = ({ vars }) => ({ + type: 'h1', + props: {}, + children: [vars.title] +}) -export default layout +// Layout accepts VDOM, returns HTML string +const layout: LayoutFunction<{site: string}, VDOMNode, string> = ({ children }) => { + const html = renderVDOM(children) // Convert VDOM to HTML + return `${html}` +} ``` ## Design Goals @@ -1132,11 +1296,11 @@ export default layout - Library agnostic. Strings are the interchange format. - Pages are shallow apps. New page, new blank canvas. - Just a program. `js` pages and layouts are just JavaScript programs. This provides an escape hatch to do anything. Use any template language want, but probably just use tagged template literals. -- Steps remain orthogonal. Static file copying, css and js bundling, are mere optimizations on top of the `src` folder. The `src` folder should essentially run in the browser. Each step in a `top-bun` build should work independent of the others. This allows for maximal parallelism when building. -- Standardized entrypoints. Every page in a `top-bun` site has a natural and obvious entrypoint. There is no magic redirection to learn about. +- Steps remain orthogonal. Static file copying, css and js bundling, are mere optimizations on top of the `src` folder. The `src` folder should essentially run in the browser. Each step in a `domstack` build should work independent of the others. This allows for maximal parallelism when building. +- Standardized entrypoints. Every page in a `domstack` site has a natural and obvious entrypoint. There is no magic redirection to learn about. - Pages build into `index.html` files inside of named directories. This allows for naturally colocated assets next to the page, pretty URLs and full support for relative URLs. - No parallel directory structures. You should never be forced to have two directories with identical layouts to put files next to each other. Everything should be colocatable. -- Markdown entrypoints are named README.md. This allows for the `src` folder to be fully navigable in GitHub and other git repo hosting providing a natural hosted CMS UI. +- Markdown entrypoints are named `page.md` or `README.md`. `README.md` allows for the `src` folder to be fully navigable in GitHub and other git repo hosting providing a natural hosted CMS UI. `page.md` is preferred when GitHub navigability is not a concern. - Real TC39 ESM from the start. - Garbage in, garbage out. Don't over-correct bad input. - Conventions + standards. Vanilla file types. No new file extensions. No weird syntax to learn. Language tools should just work because you aren't doing anything weird or out of band. @@ -1146,31 +1310,208 @@ export default layout ## FAQ -Top-**Bun**? Like the JS runtime? +Why DOMStack? -: No, like the bakery from Wallace and Gromit in ["A Matter of Loaf and Death"](https://www.youtube.com/watch?v=zXBmZLmfQZ4s) +: DOMStack is named after the DOM (Document Object Model) and the concept of stacking technologies together to build websites. It represents the layering of HTML, CSS, and JavaScript in a cohesive build system. -How does `top-bun` relate to [`sitedown`](https://ghub.io/sitedown) +How does `domstack` relate to [`sitedown`](https://ghub.io/sitedown) -: `top-bun` used to be called `siteup` which is sort of like "markup", which is related to "markdown", which inspired the project `sitedown` to which `top-bun` is a spiritual off-shot of. Put a folder of web documents in your `top-bun` oven, and bake a website. +: `domstack` used to be called `siteup` which is sort of like "markup", which is related to "markdown", which inspired the project `sitedown` to which `domstack` is a spiritual off-shoot of. Put a folder of web documents in your `domstack` build system, and generate a website. ## Examples -Look at [examples](./examples/) and `top-bun` [dependents](https://github.com/bcomnes/top-bun/network/dependents) for some examples how `top-bun` can work. +Look at [examples](./examples/) and `domstack` [dependents](https://github.com/bcomnes/domstack/network/dependents) for some examples how `domstack` can work. ## Implementation -`top-bun` bundles the best tools for every technology in the stack: +`domstack` bundles the best tools for every technology in the stack: - `js` and `css` is bundled with [`esbuild`](https://github.com/evanw/esbuild). - `md` is processed with [markdown-it](https://github.com/markdown-it/markdown-it). - static files are processed with [cpx2](https://github.com/bcomnes/cpx2). +- `ts` support via native typestripping in Node.js and esbuild. +- `jsx/tsx` support via esbuild. These tools are treated as implementation details, but they may be exposed more in the future. The idea is that they can be swapped out for better tools in the future if they don't make it. +### Build Process Flow + +The following diagram illustrates the DomStack build process: + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ START β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ identifyPages() β”‚ + β”‚ β”‚ + β”‚ β€’ Find pages β”‚ + β”‚ β€’ Find layouts β”‚ + β”‚ β€’ Find templates β”‚ + β”‚ β€’ Find globals β”‚ + β”‚ β€’ Find settings β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ buildEsbuild() β”‚ β”‚ buildStatic() β”‚ β”‚ buildCopy() β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β€’ Bundle JS/CSS β”‚ β”‚ β€’ Copy static β”‚ β”‚ β€’ Copy extra β”‚ +β”‚ β€’ Generate β”‚ β”‚ files β”‚ β”‚ directories β”‚ +β”‚ metafile β”‚ β”‚ (if enabled) β”‚ β”‚ from opts β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ buildPages() β”‚ + β”‚ β”‚ + β”‚ β€’ Process HTML β”‚ + β”‚ β€’ Process MD β”‚ + β”‚ β€’ Process JS β”‚ + β”‚ β€’ Apply layouts β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Return Results β”‚ + β”‚ β”‚ + β”‚ β€’ siteData β”‚ + β”‚ β€’ esbuildResults β”‚ + β”‚ β€’ staticResults β”‚ + β”‚ β€’ copyResults β”‚ + β”‚ β€’ pageResults β”‚ + β”‚ β€’ warnings β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The build process follows these key steps: + +1. **Page identification** - Scans the source directory to identify all pages, layouts, templates, and global assets +2. **Destination preparation** - Ensures the destination directory is ready for the build output +3. **Parallel asset processing** - Three operations run concurrently: + - JavaScript and CSS bundling via esbuild + - Static file copying (when enabled) + - Additional directory copying (from `--copy` options) +4. **Page building** - Processes all pages, applying layouts and generating final HTML + +This architecture allows for efficient parallel processing of independent tasks while maintaining the correct build order dependencies. + +#### buildPages() Detail + +The `buildPages()` step processes pages in parallel with a concurrency limit: + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ buildPages() β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Resolve Once: β”‚ + β”‚ β€’ Global vars β”‚ + β”‚ β€’ All layouts β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Parallel Page Init β”‚ + β”‚(Concurrency: min(CPUs, 24))β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MD Page Task β”‚ β”‚ HTML Page Task β”‚ β”‚ JS Page Task β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚1. Parse MD β”‚ β”‚ β”‚ β”‚1. Read .htmlβ”‚ β”‚ β”‚ β”‚1. Import .jsβ”‚ β”‚ +β”‚ β”‚ frontmatter β”‚ β”‚ β”‚ β”‚ file β”‚ β”‚ β”‚ β”‚ module β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚2. Variable β”‚ β”‚ β”‚ β”‚2. Variable β”‚ β”‚ β”‚ β”‚2. Variable β”‚ β”‚ +β”‚ β”‚ Resolution β”‚ β”‚ β”‚ β”‚ Resolution β”‚ β”‚ β”‚ β”‚ Resolution β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ builder + β”‚ β”‚ β”‚ β”‚page.vars.js β”‚ β”‚ β”‚ β”‚ Exported β”‚ β”‚ +β”‚ β”‚ page.vars.jsβ”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + page.varsβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ global.data.js runs β”‚ + β”‚ (receives PageData[])β”‚ + β”‚ stamps vars on pages β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Parallel Render + Write β”‚ + β”‚ (Concurrency: min(CPUs, 24)) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Variable Resolution Layers: +- **Global vars** - Site-wide variables from `global.vars.js` (resolved once) +- **Layout vars** - Layout-specific variables from layout functions (resolved once) +- **Page-specific vars** vary by type: + - **MD pages**: page.vars.js + builder vars (from frontmatter) + - **HTML pages**: page.vars.js + - **JS pages**: exported vars β†’ page.vars.js +- **Global data** - Derived variables from `global.data.js`, stamped onto every page after all pages initialize (resolved once, after page init) + +### Watch Mode + +When you run `domstack --watch` (or `domstack -w`), domstack performs an initial build and then watches for changes, rebuilding only what's necessary. Watch mode uses two independent watch loops: + +**esbuild watch** β€” JS and CSS bundles are handled by esbuild's native `context.watch()`. In watch mode, output filenames are stable (no content hashes), so bundle changes never require a page HTML rebuild. Browser-sync detects the updated files on disk and reloads the browser directly. + +**chokidar watch** β€” Page files, layouts, templates, and config files are watched by chokidar. When a file changes, domstack determines the minimal set of pages to rebuild using dependency tracking maps built at startup. + +#### What triggers what + +| Change | Rebuild scope | +|---|---| +| `page.js`, `page.ts`, `page.html`, `page.md`, or `page.vars.*` | Only that page | +| A file imported by a `page.js` or `page.vars.*` | Only the pages that import it (transitively) | +| A layout file (`*.layout.js`) | Only the pages using that layout | +| A file imported by a layout | Only the pages using the affected layout(s) | +| A template file (`*.template.js`) | Only that template | +| A file imported by a template | Only the affected template(s) | +| `markdown-it.settings.*` | All `.md` pages | +| `global.data.*` | All pages and templates | +| `global.vars.*` or `esbuild.settings.*` | Full rebuild (esbuild restart + all pages) | +| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*` | esbuild handles it β€” no page rebuild | +| Adding or removing an esbuild entry point (e.g. creating a new `client.js`) | esbuild restart + only the affected page(s) | +| Adding or removing any other file | Full rebuild | + +#### Dependency tracking + +domstack uses [`@11ty/dependency-tree-typescript`](https://github.com/11ty/dependency-tree-typescript) to statically analyze ESM imports in page files, layout files, and template files. This means if your `page.js` imports a shared utility module, changing that module will only rebuild the pages that depend on it β€” not the entire site. + +esbuild tracks its own entry point dependencies independently. Changing a file imported by `client.js` will trigger an esbuild rebundle but will not trigger a page rebuild, since watch mode uses stable filenames. + +#### Stable filenames + +In watch mode, esbuild uses `[dir]/[name]` output patterns instead of `[dir]/[name]-[hash]`. This means the ``) - : null} - ${styles - ? styles.map(style => html``) - : null} - - -
- ${typeof children === 'string' ? html([children]) : children /* Support both uhtml and string children. Optional. */} -
- - -`) -} diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts new file mode 100644 index 0000000..1ef49db --- /dev/null +++ b/examples/basic/src/layouts/root.layout.ts @@ -0,0 +1,58 @@ +// The root.layout.ts file must return the rendered page. +// It must implement the following variables: +// +// - children: the string or type that the page returns that represents the inner-content of the page +// - scripts: an array of urls that should be injected into the page as script tags, type module +// - styles: an array of urls that should be injected into the page as link rel="stylesheet" tags. +// +// All other variables are set on a page level basis, either by hand or by data extraction from the page type. + +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' +import type { LayoutFunction } from '@domstack/static' + +export interface PageVars { + title: string; + siteName: string; + basePath?: string; +} + +const RootLayout: LayoutFunction = async ({ + vars: { + title, + siteName, + basePath + }, + scripts, + styles, + children, +}) => { + return /* html */` + + + ${render(html` + + + ${siteName}${title ? ` | ${title}` : ''} + + ${scripts + ? scripts.map(script => html``)} + + + +`)} +${render(html` + + +
+ ${typeof children === 'string' + ? html`
` + : children + } +
+ + +`)} +` +} + +export default rootLayout diff --git a/examples/blog/src/layouts/year-index.layout.ts b/examples/blog/src/layouts/year-index.layout.ts new file mode 100644 index 0000000..a4c721c --- /dev/null +++ b/examples/blog/src/layouts/year-index.layout.ts @@ -0,0 +1,53 @@ +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' +import { dirname } from 'node:path' +import type { LayoutFunction } from '@domstack/static' +import rootLayout from './root.layout.ts' +import type { RootVars } from './root.layout.ts' + +export type YearIndexVars = RootVars + +/** + * Auto-index layout: lists all direct child pages of the current page's + * folder, sorted newest-first by publishDate. Use on year/section index + * pages β€” just set `layout: year-index` in frontmatter, no page.ts needed. + */ +const yearIndexLayout: LayoutFunction = (args) => { + const { children, page, pages, ...rest } = args + + const childPages = pages + .filter(p => dirname(p.pageInfo.path) === page.path && p.vars.publishDate) + .sort((a, b) => new Date(b.vars.publishDate).getTime() - new Date(a.vars.publishDate).getTime()) + + const wrappedChildren = render(html` +
+

${args.vars.title}

+
    + ${childPages.map(p => { + const date = new Date(p.vars.publishDate) + return html` +
  • +

    + ${p.vars.title} +

    +

    + +

    + ${p.vars.description ? html`

    ${p.vars.description}

    ` : null} +
  • + ` + })} +
+ ${typeof children === 'string' && children.trim() + ? html`
` + : null + } +
+ `) + + return rootLayout({ ...rest, page, pages, children: wrappedChildren }) +} + +export default yearIndexLayout diff --git a/examples/blog/src/library.ts b/examples/blog/src/library.ts new file mode 100644 index 0000000..21ec276 --- /dev/null +++ b/examples/blog/src/library.ts @@ -0,0 +1 @@ +export const foo = 'bar' diff --git a/examples/blog/tsconfig.json b/examples/blog/tsconfig.json new file mode 100644 index 0000000..431d757 --- /dev/null +++ b/examples/blog/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@voxpelli/tsconfig/node20.json", + "compilerOptions": { + "skipLibCheck": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "include": [ + "**/*" + ], + "exclude": [ + "**/*.js", + "node_modules", + "public", + ] +} diff --git a/examples/css-modules/package.json b/examples/css-modules/package.json index a8a55d7..7a9b8eb 100644 --- a/examples/css-modules/package.json +++ b/examples/css-modules/package.json @@ -1,12 +1,12 @@ { - "name": "@top-bun/preact-example", + "name": "@domstack/preact-example", "version": "0.0.0", "type": "module", "scripts": { "start": "npm run watch", - "build": "npm run clean && top-bun", + "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", - "watch": "npm run clean && tb --watch" + "watch": "npm run clean && dom --watch" }, "author": "Bret Comnes (https://bret.io/)", "license": "MIT", @@ -17,7 +17,7 @@ "mine.css": "^9.0.1", "preact": "^10.24.0", "preact-render-to-string": "^6.5.11", - "top-bun": "../../." + "@domstack/static": "file:../../." }, "devDependencies": { "npm-run-all2": "^6.0.0" diff --git a/examples/css-modules/src/README.md b/examples/css-modules/src/README.md index f026f45..2b755bc 100644 --- a/examples/css-modules/src/README.md +++ b/examples/css-modules/src/README.md @@ -1,5 +1,34 @@ -# Preact example +# CSS Modules Example -This is a preact example. +This example demonstrates how to use CSS modules with DOMStack, providing component-scoped styling that avoids global namespace conflicts. -[Isomorphic Component Rendering](./modules/) +CSS module support is provided by esbuild, so for more information on what is and isn't supported read the esbuild CSS module docs: + +- [esbuild.github.io/content-types/#local-css](https://esbuild.github.io/content-types/#local-css) +- [github.com/css-modules/css-modules](https://github.com/css-modules/css-modules) + +CSS modules are NOT supported in Node.js natively, so you need to import a loader to support them, or only reference them in client bundles. + +## How It Works + +1. Create a CSS file with the `.module.css` extension +2. Import the styles in your JavaScript components that get loaded in your client bundles. +3. Use the imported class names as object properties on the javascript side. + +## Project Structure + +``` +src/ +β”œβ”€β”€ globals/ # Global styles and scripts +β”œβ”€β”€ layouts/ # Layout templates +β”œβ”€β”€ modules/ # Components with CSS modules +β”‚ β”œβ”€β”€ app.module.css # Module-scoped CSS +β”‚ β”œβ”€β”€ client.js # Client-side hydration +β”‚ β”œβ”€β”€ page.js # Server-side component +β”‚ └── style.css # Regular CSS +└── README.md # This file (becomes index.html) +``` + +## Example + +Check out the [CSS Modules in a Preact Component](./modules/) example to see CSS modules in action. diff --git a/examples/css-modules/src/globals/global.client.js b/examples/css-modules/src/globals/global.client.js index 4000282..20121a7 100644 --- a/examples/css-modules/src/globals/global.client.js +++ b/examples/css-modules/src/globals/global.client.js @@ -1,4 +1,2 @@ -// @ts-ignore import { toggleTheme } from 'mine.css' -// @ts-ignore window.toggleTheme = toggleTheme diff --git a/examples/css-modules/src/layouts/root.layout.js b/examples/css-modules/src/layouts/root.layout.js index 7b1e3d7..f96d678 100644 --- a/examples/css-modules/src/layouts/root.layout.js +++ b/examples/css-modules/src/layouts/root.layout.js @@ -1,34 +1,30 @@ -// @ts-ignore +/** + * @import { LayoutFunction } from '@domstack/static' + */ + import { html } from 'htm/preact' import { render } from 'preact-render-to-string' /** - * @template {Record} T - * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + * @typedef {{ + * title: string, + * siteName: string, + * basePath?: string, + }} PageVars */ /** - * Build all of the bundles using esbuild. - * - * @type {LayoutFunction<{ - * title: string, - * siteName: string, - * defaultStyle: boolean, - * basePath: string - * }>} - */ -export default function defaultRootLayout ({ + * @type {LayoutFunction} + */ +export default async function RootLayout ({ vars: { title, - siteName = 'TopBun', - basePath, - /* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */ + siteName, + basePath }, scripts, styles, children, - /* pages */ - /* page */ }) { return /* html */` @@ -36,7 +32,7 @@ export default function defaultRootLayout ({ ${render(html` - ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + ${siteName}${title ? ` | ${title}` : ''} ${scripts ? scripts.map(script => html``).join('\n ')} + +` +} diff --git a/examples/nested-dest/README.md b/examples/nested-dest/README.md index 9d50829..ce23b59 100644 --- a/examples/nested-dest/README.md +++ b/examples/nested-dest/README.md @@ -1,13 +1,53 @@ -# Nested dest +# Nested Destination Example -One of the design goals of `top-bun` was to allow you to point `top-bun` at a generic -library repository, and render out all of the markdown inside of it, into a `dest` folder. +## Overview -The issue with this is that `dest` folder essentially lives inside of the `src` folder, -so it was important to support sane ignore patterns by default so you don't fall into a recursive render -loop, or try to render out `node_modules` etc. +This example demonstrates one of DOMStack's key features: the ability to build a documentation site from an existing repository structure without reorganizing it. -This example site points `top-bun` at the root of the example `/`, and builds into `/public`, so its a good -example how hoe flexible top-bun can be. +## How It Works -boop beep +DOMStack can use any directory as its source, including the root of your project. This allows you to: + +1. Generate documentation directly from your project's existing markdown files +2. Keep source files in their original locations +3. Build the site into a separate destination directory + +## Key Concepts + +### Source and Destination Paths + +In this example: +- Source: The root directory (`/`) +- Destination: A subdirectory (`/public`) + +### Avoiding Recursive Processing + +When your destination folder is inside your source tree, DOMStack needs to avoid: +- Processing files in the destination folder (recursive loop) +- Processing unwanted directories like `node_modules` + +This is handled through intelligent ignore patterns: +- Default ignore patterns for common directories +- Custom ignore patterns through the `--ignore` flag + +## Example Configuration + +The build command in this example uses: + +```bash +domstack --src . --ignore ignore +``` + +This tells DOMStack to: +- Use the current directory (`.`) as the source +- Explicitly ignore the `ignore` directory +- Apply default ignore patterns for `node_modules`, etc. + +## Try It Yourself + +1. Examine this project's structure - notice how files are in the root +2. Look at the built output in `/public` to see how files are processed +3. Check `package.json` to see how the build command is configured + +- [CHANGELOG.md](./CHANGELOG.md) +- [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/examples/nested-dest/global.vars.js b/examples/nested-dest/global.vars.js index 467289d..2f20898 100644 --- a/examples/nested-dest/global.vars.js +++ b/examples/nested-dest/global.vars.js @@ -1,4 +1,4 @@ export default { defaultStyle: false, - siteName: 'nested top-bun example', + siteName: 'nested depscan example', } diff --git a/examples/nested-dest/package.json b/examples/nested-dest/package.json index 97e954a..5b42a6a 100644 --- a/examples/nested-dest/package.json +++ b/examples/nested-dest/package.json @@ -5,22 +5,19 @@ "type": "module", "scripts": { "start": "npm run watch", - "build": "npm run clean && top-bun --src . --ignore ignore", + "build": "npm run clean && domstack --src . --ignore ignore", "clean": "rm -rf public && mkdir -p public", "watch": "npm run clean && run-p watch:*", - "watch:serve": "browser-sync start --server 'public' --files 'public'", - "watch:top-bun": "npm run build -- --watch" + "watch:domstack": "npm run build -- --watch" }, "keywords": [], "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "dependencies": { - "top-bun": "../../.", - "mine.css": "^9.0.1", - "uhtml-isomorphic": "^2.1.0" + "@domstack/static": "file:../../.", + "mine.css": "^9.0.1" }, "devDependencies": { - "browser-sync": "^2.26.7", "npm-run-all2": "^6.0.0" } } diff --git a/examples/preact-isomorphic/package.json b/examples/preact-isomorphic/package.json new file mode 100644 index 0000000..f5ce358 --- /dev/null +++ b/examples/preact-isomorphic/package.json @@ -0,0 +1,27 @@ +{ + "name": "@domstack/preact-example", + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "npm run watch", + "build": "npm run clean && domstack", + "clean": "rm -rf public && mkdir -p public", + "watch": "npm run clean && dom --watch" + }, + "author": "Bret Comnes (https://bret.io/)", + "license": "MIT", + "dependencies": { + "@preact/signals": "^2.0.0", + "highlight.js": "^11.9.0", + "htm": "^3.1.1", + "mine.css": "^9.0.1", + "preact": "^10.24.0", + "preact-render-to-string": "^6.5.11", + "@domstack/static": "file:../../." + }, + "devDependencies": { + "@voxpelli/tsconfig": "^15.0.0", + "npm-run-all2": "^6.0.0", + "typescript": "~5.8.2" + } +} diff --git a/examples/preact-isomorphic/src/README.md b/examples/preact-isomorphic/src/README.md new file mode 100644 index 0000000..634d084 --- /dev/null +++ b/examples/preact-isomorphic/src/README.md @@ -0,0 +1,41 @@ +# Preact Isomorphic Rendering Example + +This example demonstrates how to implement isomorphic rendering with Preact in DOMStack. Isomorphic rendering means the same components can be rendered on both the server and client, providing benefits of server-side rendering (SSR) with client-side interactivity. + +## What is Isomorphic Rendering? + +Isomorphic (or universal) rendering combines: + +1. **Server-side rendering** - Components are rendered to HTML on the server first +2. **Client-side hydration** - JavaScript takes over in the browser to add interactivity +3. **Shared component code** - The same components work in both environments + +## Benefits + +- **Performance**: Faster initial page loads and time-to-content +- **SEO**: Search engines see fully rendered content +- **Accessibility**: Content is available without JavaScript +- **User Experience**: No flash of unstyled content or layout shifts + +## Examples in This Project + +- [Isomorphic Component Rendering](./isomorphic/) - Complete todo app rendered both server and client-side +- [JSX Client Mounting](./jsx-page/) - Example of client-side JSX rendering into static HTML + +## Implementation Approach + +This example uses: + +- **Preact** - A lightweight alternative to React +- **HTM** - JSX alternative using tagged template literals +- **Signals** - For reactive state management +- **preact-render-to-string** - For server-side rendering + +## How It Works + +1. The server renders components to HTML using `preact-render-to-string` +2. The HTML is sent to the browser with linked JavaScript +3. In the browser, the same components hydrate the existing HTML +4. Interactivity is enabled without replacing the DOM structure + +Learn more about these techniques in the examples! diff --git a/examples/preact-isomorphic/src/globals/global.client.ts b/examples/preact-isomorphic/src/globals/global.client.ts new file mode 100644 index 0000000..20121a7 --- /dev/null +++ b/examples/preact-isomorphic/src/globals/global.client.ts @@ -0,0 +1,2 @@ +import { toggleTheme } from 'mine.css' +window.toggleTheme = toggleTheme diff --git a/examples/preact/src/globals/global.css b/examples/preact-isomorphic/src/globals/global.css similarity index 100% rename from examples/preact/src/globals/global.css rename to examples/preact-isomorphic/src/globals/global.css diff --git a/examples/preact-isomorphic/src/isomorphic/client.ts b/examples/preact-isomorphic/src/isomorphic/client.ts new file mode 100644 index 0000000..026ab90 --- /dev/null +++ b/examples/preact-isomorphic/src/isomorphic/client.ts @@ -0,0 +1,260 @@ +/** + * Preact Isomorphic Example + * + * This file demonstrates a todo application that works with both: + * 1. Server-side rendering (when imported by page.js) + * 2. Client-side hydration (when loaded in the browser) + * + * It uses the same component code for both environments. + */ +import { html, Component } from 'htm/preact'; +import { render } from 'preact'; +import { useCallback } from 'preact/hooks'; +import { useSignal, useComputed } from '@preact/signals'; +import type { ComponentChildren, JSX } from 'preact'; + +/** + * Header component props + */ +interface HeaderProps { + name: string; + subtitle?: string; +} + +/** + * App Header Component + * Displays the title of the application + */ +const Header = ({ name, subtitle }: HeaderProps): JSX.Element => html` +
+

${name}

+ ${subtitle && html`

${subtitle}

`} +
+`; + +/** + * Todo item props + */ +interface TodoItemProps { + text: string; + completed: boolean; + onToggle: () => void; + onDelete: () => void; +} + +/** + * Todo Item Component + * Renders a single todo item with completion toggle + */ +const TodoItem = ({ text, completed, onToggle, onDelete }: TodoItemProps): JSX.Element => html` +
  • + + +
  • +`; + +/** + * Counter Component using Signals + * Demonstrates Preact Signals for reactive state management + */ +const Counter = (): JSX.Element => { + // Create a signal for the count value + const count = useSignal(0); + + // Derived state that automatically updates when count changes + const doubled = useComputed(() => count.value * 2); + const isEven = useComputed(() => count.value % 2 === 0); + + // Event handlers + const increment = useCallback(() => { count.value++; }, []); + const decrement = useCallback(() => { count.value > 0 && count.value--; }, []); + const reset = useCallback(() => { count.value = 0; }, []); + + return html` +
    +

    Signal-based Counter

    +
    + Count: ${count} + Doubled: ${doubled} +
    +
    + + + +
    +
    + `; +}; + +/** + * Todo item structure + */ +interface Todo { + id: number; + text: string; + completed: boolean; +} + +/** + * Todo app props + */ +interface TodoAppProps { + title?: string; +} + +/** + * Todo app state + */ +interface TodoAppState { + todos: Todo[]; + newTodo: string; +} + +/** + * Todo Application Component + * Manages a list of todos with add/toggle/delete functionality + */ +class TodoApp extends Component { + constructor(props: TodoAppProps) { + super(props); + // Initialize with example todos + this.state = { + todos: [ + { id: 1, text: 'Learn about SSR', completed: true }, + { id: 2, text: 'Build isomorphic apps', completed: false }, + { id: 3, text: 'Deploy to production', completed: false } + ], + newTodo: '' + }; + } + + // Update the new todo input value + updateNewTodo = (e: JSX.TargetedEvent): void => { + this.setState({ newTodo: e.currentTarget.value }); + }; + + // Add a new todo item + addTodo = (e: JSX.TargetedEvent): void => { + e.preventDefault(); + const { todos, newTodo } = this.state; + + if (newTodo.trim()) { + this.setState({ + todos: [ + ...todos, + { + id: Date.now(), + text: newTodo, + completed: false + } + ], + newTodo: '' + }); + } + }; + + // Toggle a todo's completion status + toggleTodo = (id: number): void => { + const { todos } = this.state; + this.setState({ + todos: todos.map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + ) + }); + }; + + // Delete a todo item + deleteTodo = (id: number): void => { + const { todos } = this.state; + this.setState({ + todos: todos.filter(todo => todo.id !== id) + }); + }; + + render({ title }: TodoAppProps, { todos, newTodo }: TodoAppState): JSX.Element { + const remaining = todos.filter(todo => !todo.completed).length; + + return html` +
    + <${Header} + name=${title || 'Todo App'} + subtitle="Server + Client Rendering Example" + /> + +
    + + +
    + +
      + ${todos.map(todo => html` + <${TodoItem} + key=${todo.id} + text=${todo.text} + completed=${todo.completed} + onToggle=${() => this.toggleTodo(todo.id)} + onDelete=${() => this.deleteTodo(todo.id)} + /> + `)} +
    + +
    + ${remaining} item${remaining !== 1 ? 's' : ''} remaining +
    + + <${Counter} /> +
    + `; + } +} + +/** + * Main page export for both server and client rendering + * This is what gets rendered in both environments + */ +export const page = (): JSX.Element => html` +
    + <${TodoApp} title="Isomorphic Todo App" /> + +
    +

    How This Works

    +

    + This page is rendered on the server first, then hydrated on the client. + The same component code runs in both environments. +

    +

    + Try adding todos and toggling them. These interactions are handled + by client-side JavaScript, but the initial HTML comes from the server. +

    +
    +
    +`; + +/** + * Client-side only code + * This code only runs in the browser, not during server rendering + */ +if (typeof window !== 'undefined') { + // Find the container that was server-rendered + const renderTarget = document.querySelector('.app-main'); + + // Hydrate the existing HTML with interactive components + if (renderTarget) { + render(page(), renderTarget); + console.log('βœ… Preact isomorphic app successfully hydrated'); + } +} diff --git a/examples/preact-isomorphic/src/isomorphic/page.ts b/examples/preact-isomorphic/src/isomorphic/page.ts new file mode 100644 index 0000000..6ce0773 --- /dev/null +++ b/examples/preact-isomorphic/src/isomorphic/page.ts @@ -0,0 +1,6 @@ +import { page } from './client.ts'; +import type { JSX } from 'preact'; + +export default function(): JSX.Element { + return page(); +} diff --git a/examples/preact-isomorphic/src/isomorphic/style.css b/examples/preact-isomorphic/src/isomorphic/style.css new file mode 100644 index 0000000..f3c7d43 --- /dev/null +++ b/examples/preact-isomorphic/src/isomorphic/style.css @@ -0,0 +1,167 @@ +/* ===== Isomorphic Example Styles ===== */ + +.isomorphic-container { + max-width: 800px; + margin: 0 auto; + padding: 1rem; +} + +.app-header { + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--accent-midground); +} + +.app-header h1 { + margin-bottom: 0.5rem; +} + +.app-header .subtitle { + font-style: italic; +} + +.todo-app { + background-color: var(--layer-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 1.5rem; + margin-bottom: 2rem; +} + +.todo-form { + display: flex; + margin-bottom: 1rem; +} + +.todo-form input { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--accent-midground); + border-radius: 4px 0 0 4px; + background-color: var(--background); + color: var(--text); +} + +.todo-form button { + padding: 0.5rem 1rem; + background-color: #4a8fe7; + color: white; + border: none; + border-radius: 0 4px 4px 0; + cursor: pointer; +} + +.todo-form button:hover { + background-color: #3a7fd7; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 0 0 1rem 0; +} + +.todo-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + border-bottom: 1px solid var(--accent-midground); +} + +.todo-item:last-child { + border-bottom: none; +} + +.todo-item.completed .todo-text { + text-decoration: line-through; + opacity: 0.6; +} + +.todo-label { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.delete-btn { + background-color: #ff5252; + color: white; + border: none; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; +} + +.delete-btn:hover { + background-color: #ff0000; +} + +.todo-stats { + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.counter-widget { + background-color: var(--accent-background); + border-radius: 6px; + padding: 1rem; + margin-top: 1rem; +} + +.counter-widget h3 { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.1rem; +} + +.counter-display { + display: flex; + justify-content: space-between; + padding: 0.5rem; + margin-bottom: 0.5rem; + background-color: var(--background); + border-radius: 4px; + transition: background-color 0.2s; +} + +.counter-display.even { + background-color: rgba(173, 216, 230, 0.2); +} + +.counter-display.odd { + background-color: rgba(255, 228, 181, 0.2); +} + +.counter-controls { + display: flex; + gap: 0.5rem; +} + +.counter-controls button { + flex: 1; + padding: 0.5rem; + border: none; + border-radius: 4px; + background-color: #4a8fe7; + color: white; + cursor: pointer; +} + +.counter-controls button:hover { + background-color: #3a7fd7; +} + +.info-panel { + background-color: var(--accent-background); + border-left: 4px solid #4a8fe7; + padding: 1rem; + border-radius: 0 4px 4px 0; +} + +.info-panel h2 { + margin-top: 0; + font-size: 1.2rem; +} diff --git a/examples/preact-isomorphic/src/jsx-page/client.jsx b/examples/preact-isomorphic/src/jsx-page/client.jsx new file mode 100644 index 0000000..2d4707f --- /dev/null +++ b/examples/preact-isomorphic/src/jsx-page/client.jsx @@ -0,0 +1,121 @@ +import { render } from 'preact' +import { useState, useEffect } from 'preact/hooks' + +/** + * Simple JSX Client-Side Component Example + * + * This demonstrates a Preact component using JSX syntax that runs + * exclusively on the client-side (browser). Unlike the isomorphic example, + * this component is not pre-rendered on the server. + */ + +// User profile card component +const ProfileCard = ({ name, role, avatar, isActive }) => ( +
    +
    + {`${name}'s + +
    +
    +

    {name}

    +

    {role}

    +
    +
    +) + +// Main application component +export const Page = () => { + // State for the counter + const [count, setCount] = useState(0) + + // State for theme toggling + const [darkMode, setDarkMode] = useState(false) + + // State for user profiles + const [users, setUsers] = useState([ + { id: 1, name: 'Alex Johnson', role: 'Developer', isActive: true }, + { id: 2, name: 'Sam Taylor', role: 'Designer', isActive: false }, + { id: 3, name: 'Jordan Casey', role: 'Product Manager', isActive: true } + ]) + + // Effect to demonstrate client-side lifecycle + useEffect(() => { + console.log('Component mounted in the browser') + + // Update document title when count changes + document.title = `Count: ${count}` + + return () => { + console.log('Component will unmount') + } + }, [count]) + + // Toggle a user's active status + const toggleUserStatus = (userId) => { + setUsers(users.map(user => + user.id === userId + ? { ...user, isActive: !user.isActive } + : user + )) + } + + return ( +
    +

    Client-Side JSX Rendering

    + +
    + +
    + +
    +

    Interactive Counter: {count}

    +
    + + + +
    +
    + +
    +

    User Profiles

    +

    Click on a profile to toggle active status:

    +
    + {users.map(user => ( +
    toggleUserStatus(user.id)}> + +
    + ))} +
    +
    + +
    +

    How This Works

    +

    Unlike the isomorphic example, this component:

    +
      +
    • Renders entirely on the client-side
    • +
    • Uses native JSX syntax instead of HTM
    • +
    • Mounts to an empty container in the HTML
    • +
    +
    +
    + ) +} + +// Mount the component to the DOM +const renderTarget = document.querySelector('.jsx-app') +if (renderTarget) { + console.log({ renderTarget }) + render(, renderTarget) +} diff --git a/examples/preact-isomorphic/src/jsx-page/page.html b/examples/preact-isomorphic/src/jsx-page/page.html new file mode 100644 index 0000000..28c1a88 --- /dev/null +++ b/examples/preact-isomorphic/src/jsx-page/page.html @@ -0,0 +1,38 @@ +
    +

    Client-Side JSX Rendering Example

    + +
    +

    + This example demonstrates how Preact JSX components can be mounted to static HTML pages. + Unlike isomorphic rendering, the component below is rendered entirely on the client-side. +

    + +
    +

    Key Concepts:

    +
      +
    • Static HTML page with an empty container
    • +
    • JSX syntax for component definition
    • +
    • Client-side only rendering lifecycle
    • +
    • No server-side pre-rendering
    • +
    +
    +
    + +
    +

    Live Demo:

    + +
    +
    + +
    +

    How It Works:

    +

    + The empty div.jsx-app above serves as a mount point. When the page loads, + the Preact component defined in client.jsx is rendered into this container. +

    +

    + This approach is useful when adding interactive components to otherwise static pages, + or when you want to isolate complex UI logic to the client-side only. +

    +
    +
    diff --git a/examples/preact-isomorphic/src/jsx-page/style.css b/examples/preact-isomorphic/src/jsx-page/style.css new file mode 100644 index 0000000..08e2a6b --- /dev/null +++ b/examples/preact-isomorphic/src/jsx-page/style.css @@ -0,0 +1,234 @@ +/* ===== JSX Page Styles ===== */ + +.jsx-page-container { + max-width: 800px; + margin: 0 auto; + padding: 1rem; +} + +.explanation-section, +.demo-section, +.code-reference { + margin-bottom: 2rem; +} + +.key-points { + background-color: var(--accent-background); + padding: 1rem; + border-radius: 6px; + margin: 1rem 0; +} + +.key-points h3 { + margin-top: 0; +} + +.key-points ul { + margin-bottom: 0; +} + +.jsx-demo { + background-color: var(--layer-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 1.5rem; + transition: background-color 0.3s, color 0.3s; +} + +/* Custom dark theme for the demo component - separate from the system dark mode */ +.jsx-demo.dark-theme { + background-color: #222; + color: #eee; +} + +.theme-toggle { + margin-bottom: 1.5rem; + text-align: right; +} + +.theme-toggle button { + background-color: #4a8fe7; + color: white; + border: none; + border-radius: 4px; + padding: 0.5rem 1rem; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.theme-toggle button:hover { + background-color: #3a7fd7; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.theme-toggle button:active { + transform: translateY(1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.jsx-demo.dark-theme .theme-toggle button { + background-color: #f1c40f; + color: #222; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.jsx-demo.dark-theme .theme-toggle button:hover { + background-color: #f4d03f; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +/* Counter section button styles */ +.counter-section .counter-controls { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +.counter-section .counter-controls button { + padding: 0.5rem 1rem; + background-color: #4a8fe7; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + flex: 1; +} + +.counter-section .counter-controls button:hover:not([disabled]) { + background-color: #3a7fd7; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.counter-section .counter-controls button:active:not([disabled]) { + transform: translateY(1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.counter-section .counter-controls button:disabled { + background-color: var(--accent-midground); + cursor: not-allowed; + opacity: 0.7; +} + +.jsx-demo.dark-theme .counter-section .counter-controls button { + background-color: #4a8fe7; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + color: white; +} + +.jsx-demo.dark-theme .counter-section .counter-controls button:hover:not([disabled]) { + background-color: #3a7fd7; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.jsx-demo.dark-theme .counter-section .counter-controls button:disabled { + background-color: #555; +} + +.counter-section, +.profiles-section, +.explanation { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--accent-midground); +} + +.jsx-demo.dark-theme .counter-section, +.jsx-demo.dark-theme .profiles-section, +.jsx-demo.dark-theme .explanation { + border-bottom-color: #444; +} + +.counter-section h3, +.profiles-section h3, +.explanation h3 { + margin-top: 0; + margin-bottom: 1rem; + font-weight: 600; +} + +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.profile-card { + background-color: var(--accent-background); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + margin-bottom: 0.5rem; + height: 100%; +} + +.jsx-demo.dark-theme .profile-card { + background-color: #333; +} + +.profile-card:hover { + transform: translateY(-3px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.profile-header { + padding: 1rem; + position: relative; + display: flex; + justify-content: center; + background-color: var(--accent-midground); +} + +.jsx-demo.dark-theme .profile-header { + background-color: #444; +} + +.avatar { + width: 64px; + height: 64px; + border-radius: 50%; + object-fit: cover; +} + +.status-indicator { + position: absolute; + bottom: 1rem; + right: calc(50% - 40px); + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--background); +} + +.status-indicator.active { + background-color: #4caf50; +} + +.status-indicator.inactive { + background-color: #ccc; +} + +.profile-info { + padding: 1rem; + text-align: center; +} + +.profile-info h3 { + margin: 0 0 0.25rem 0; + font-size: 1.1rem; +} + +.profile-info .role { + margin: 0; + font-size: 0.9rem; +} diff --git a/examples/preact/src/layouts/root.layout.js b/examples/preact-isomorphic/src/layouts/root.layout.ts similarity index 62% rename from examples/preact/src/layouts/root.layout.js rename to examples/preact-isomorphic/src/layouts/root.layout.ts index 7b1e3d7..e4a03d6 100644 --- a/examples/preact/src/layouts/root.layout.js +++ b/examples/preact-isomorphic/src/layouts/root.layout.ts @@ -1,26 +1,41 @@ -// @ts-ignore -import { html } from 'htm/preact' -import { render } from 'preact-render-to-string' +import { html } from 'htm/preact'; +import { render } from 'preact-render-to-string'; +import type { VNode } from 'preact'; /** - * @template {Record} T - * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + * Page variables that can be passed to the layout */ +export interface PageVars { + title?: string; + siteName?: string; + defaultStyle?: boolean; + basePath?: string; +} + +/** + * Props for layout functions + */ +export interface LayoutProps { + vars: T; + scripts?: string[]; + styles?: string[]; + children: string | VNode; + pages?: unknown[]; + page?: unknown; +} + +/** + * Type definition for layout functions + */ +export type LayoutFunction = (props: LayoutProps) => string; /** * Build all of the bundles using esbuild. - * - * @type {LayoutFunction<{ - * title: string, - * siteName: string, - * defaultStyle: boolean, - * basePath: string - * }>} */ -export default function defaultRootLayout ({ +export default function defaultRootLayout({ vars: { title, - siteName = 'TopBun', + siteName = 'Domstack', basePath, /* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */ }, @@ -29,7 +44,7 @@ export default function defaultRootLayout ({ children, /* pages */ /* page */ -}) { +}: LayoutProps): string { return /* html */` @@ -49,11 +64,11 @@ export default function defaultRootLayout ({ ${render(html` ${typeof children === 'string' - ? html`
    ` + ? html`
    ` : html`
    ${children}
    ` } `)} - ` + `; } diff --git a/examples/preact-isomorphic/tsconfig.json b/examples/preact-isomorphic/tsconfig.json new file mode 100644 index 0000000..c0a6886 --- /dev/null +++ b/examples/preact-isomorphic/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@voxpelli/tsconfig/node20.json", + "compilerOptions": { + "skipLibCheck": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "include": [ + "**/*", + ], + "exclude": [ + "**/*.js", + "node_modules", + "coverage", + ".github" + ] +} diff --git a/examples/preact/src/README.md b/examples/preact/src/README.md deleted file mode 100644 index 93af148..0000000 --- a/examples/preact/src/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Preact example - -This is a preact example. - -- [Isomorphic Component Rendering](./isomorphic/) -- [JSX-page](./jsx-page/) diff --git a/examples/preact/src/isomorphic/client.js b/examples/preact/src/isomorphic/client.js deleted file mode 100644 index cbd8fed..0000000 --- a/examples/preact/src/isomorphic/client.js +++ /dev/null @@ -1,55 +0,0 @@ -import { html, Component } from 'htm/preact' -import { render } from 'preact' -import { useCallback } from 'preact/hooks' -import { useSignal, useComputed } from '@preact/signals' - -const Header = ({ name }) => html`

    ${name} List

    ` - -const Footer = props => { - const count = useSignal(0) - const double = useComputed(() => count.value * 2) - - const handleClick = useCallback(() => { - count.value++ - }, [count]) - - return html`
    - ${count} - ${double} - ${props.children} - -
    ` -} - -class App extends Component { - addTodo () { - const { todos = [] } = this.state - this.setState({ todos: todos.concat(`Item ${todos.length}`) }) - } - - render ({ page }, { todos = [] }) { - return html` -
    - <${Header} name="ToDo's (${page})" /> -
      - ${todos.map(todo => html` -
    • ${todo}
    • - `)} -
    - - <${Footer}>footer content here -
    - ` - } -} - -export const page = () => html` - <${App} page="Isomorphic"/> - <${Footer}>footer content here - <${Footer}>footer content here - ` - -if (typeof window !== 'undefined') { - const renderTarget = document.querySelector('.app-main') - render(page(), renderTarget) -} diff --git a/examples/preact/src/isomorphic/page.js b/examples/preact/src/isomorphic/page.js deleted file mode 100644 index d1d1ea9..0000000 --- a/examples/preact/src/isomorphic/page.js +++ /dev/null @@ -1,5 +0,0 @@ -import { page } from './client.js' - -export default () => { - return page() -} diff --git a/examples/preact/src/jsx-page/client.jsx b/examples/preact/src/jsx-page/client.jsx deleted file mode 100644 index fa47236..0000000 --- a/examples/preact/src/jsx-page/client.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render } from 'preact' - -export const page = () => { - return ( -
    - look ma, client side jsx! -
    - ) -} - -const renderTarget = document.querySelector('.jsx-app') -render(page(), renderTarget) diff --git a/examples/preact/src/jsx-page/page.html b/examples/preact/src/jsx-page/page.html deleted file mode 100644 index bf0074d..0000000 --- a/examples/preact/src/jsx-page/page.html +++ /dev/null @@ -1,4 +0,0 @@ -
    -

    This is an html page, with a client.jsx that mounts onto it

    -
    -
    diff --git a/examples/react/package.json b/examples/react/package.json new file mode 100644 index 0000000..a4d0666 --- /dev/null +++ b/examples/react/package.json @@ -0,0 +1,29 @@ +{ + "name": "@domstack/react-typescript-example", + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "npm run watch", + "build": "npm run clean && domstack", + "clean": "rm -rf public && mkdir -p public", + "watch": "npm run clean && domstack --watch", + "typecheck": "tsc --noEmit" + }, + "author": "Bret Comnes (https://bret.io/)", + "license": "MIT", + "dependencies": { + "@domstack/static": "file:../../.", + "htm": "^3.1.1", + "mine.css": "^9.0.1", + "react": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@voxpelli/tsconfig": "^15.0.0", + "npm-run-all2": "^6.0.0", + "react-dom": "^19.1.1", + "typescript": "~5.8.2", + "highlight.js": "^11.9.0" + } +} diff --git a/examples/react/src/README.md b/examples/react/src/README.md new file mode 100644 index 0000000..ad7a608 --- /dev/null +++ b/examples/react/src/README.md @@ -0,0 +1,53 @@ +# React with TypeScript in DOMStack + +This example demonstrates how to use React with TypeScript in DOMStack for client-side rendering. Unlike the Preact examples that come with DOMStack by default, this example shows how to override the default JSX configuration to use React with TypeScript instead. + +## What This Example Shows + +- How to configure ESBuild to use React instead of Preact for TSX +- Client-side rendering with React components written in TypeScript +- Type-safe React hooks for state management +- TypeScript interfaces and type definitions +- Integration with DOMStack's build system + +## Key Components + +1. **ESBuild Configuration**: Custom `esbuild.settings.ts` that overrides the default Preact JSX settings +2. **React Components**: Client-side components with typed hooks and state +3. **Static HTML Mount Points**: HTML pages with mount points for React components +4. **TypeScript Interfaces**: Type definitions for props, state, and functions + +## Example Structure + +- `globals/esbuild.settings.ts` - Configuration to use React instead of Preact +- [`react-page/`](./react-page/) - Client-side React component example with TypeScript +- `layouts/` - Basic layout structure + +## How It Works + +Unlike isomorphic examples with Preact, this example focuses on client-side rendering only. The workflow is: + +1. The HTML is served with empty containers +2. React components are loaded and mounted to these containers +3. All rendering happens in the browser + +## Getting Started + +Run the following commands: + +```bash +npm install +npm run build +``` + +To watch for changes during development: + +```bash +npm run watch +``` + +## React with TypeScript vs. Preact in DOMStack + +DOMStack uses Preact by default because it's smaller and has a compatible API with React. This example shows how to use React with TypeScript instead if you prefer or need specific React features with type safety. + +The key difference is in the `esbuild.settings.ts` file, which configures ESBuild to use React's TSX transformer and runtime, along with TypeScript support. diff --git a/examples/react/src/globals/esbuild.settings.ts b/examples/react/src/globals/esbuild.settings.ts new file mode 100644 index 0000000..da74b2c --- /dev/null +++ b/examples/react/src/globals/esbuild.settings.ts @@ -0,0 +1,21 @@ +/** + * Custom ESBuild Settings for React with TypeScript + * + * This file overrides the default DOMStack ESBuild configuration + * to replace Preact with React for TSX transformation and runtime. + */ +import type { BuildOptions } from 'esbuild' + +/** + * Configure ESBuild settings for React with TypeScript support + * + * @param esbuildSettings - The default ESBuild configuration + * @returns The modified ESBuild configuration + */ +export default async function esbuildSettingsOverride(esbuildSettings: BuildOptions): Promise { + // Override the JSX settings to use React instead of Preact + esbuildSettings.jsx = 'automatic' + esbuildSettings.jsxImportSource = 'react' + + return esbuildSettings +} diff --git a/examples/react/src/globals/global.client.ts b/examples/react/src/globals/global.client.ts new file mode 100644 index 0000000..782a44d --- /dev/null +++ b/examples/react/src/globals/global.client.ts @@ -0,0 +1,76 @@ +/** + * Global client-side TypeScript for React example + * + * This file is loaded on all pages before any page-specific TypeScript. + * It's a good place to add global event listeners, polyfills, or + * other initialization code that should run on every page. + */ + +// Define interfaces for our global utilities +interface DomstackUtils { + formatDate(date: Date): string; + getRandomItem(array: T[]): T; +} + +// Extend the Window interface to include our global utilities +interface Window { + domstackUtils: DomstackUtils; +} + +console.log('React example global client TypeScript loaded'); + +// Add a class to indicate JavaScript is enabled +document.documentElement.classList.add('js-enabled'); + +// Basic example of a global utility function +window.domstackUtils = { + /** + * Format a date in a human-readable format + * @param date - The date to format + * @returns Formatted date string + */ + formatDate: (date: Date): string => { + return new Intl.DateTimeFormat('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }).format(date); + }, + + /** + * Get a random item from an array + * @param array - The array to get a random item from + * @returns A random item from the array + */ + getRandomItem: (array: T[]): T => { + if (array.length === 0) { + throw new Error("Cannot get random item from empty array"); + } + const index = Math.floor(Math.random() * array.length); + // This assertion is safe because we've checked that array is not empty + return array[index] as T; + } +}; + +// Add dark mode detection +const prefersDarkMode: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); +if (prefersDarkMode.matches) { + document.body.classList.add('dark-mode-preferred'); +} + +// Listen for dark mode changes +prefersDarkMode.addEventListener('change', (e: MediaQueryListEvent): void => { + if (e.matches) { + document.body.classList.add('dark-mode-preferred'); + } else { + document.body.classList.remove('dark-mode-preferred'); + } +}); + +// Example of measuring and logging performance +const pageLoadTime: number = performance.now(); +window.addEventListener('load', (): void => { + const totalLoadTime: number = performance.now() - pageLoadTime; + console.log(`Page fully loaded in ${totalLoadTime.toFixed(2)}ms`); +}); \ No newline at end of file diff --git a/examples/react/src/globals/global.css b/examples/react/src/globals/global.css new file mode 100644 index 0000000..06985a0 --- /dev/null +++ b/examples/react/src/globals/global.css @@ -0,0 +1,120 @@ +@import 'mine.css/dist/mine.css'; +@import 'mine.css/dist/layout.css'; +@import 'highlight.js/styles/github-dark-dimmed.css'; + +/* Custom React Example Styles */ +:root { + --primary-color: #61dafb; + --secondary-color: #282c34; + --text-color: #333; + --background-color: #f5f5f5; + --card-background: #fff; + --success-color: #4caf50; + --warning-color: #ff9800; + --error-color: #f44336; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + color: var(--text-color); + background-color: var(--background-color); + line-height: 1.6; +} + +.app-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.react-demo { + background-color: var(--card-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 1.5rem; + margin-bottom: 2rem; +} + +.react-header { + display: flex; + align-items: center; + margin-bottom: 1.5rem; +} + +.react-logo { + animation: spin 10s linear infinite; + height: 40px; + margin-right: 1rem; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.button { + background-color: var(--primary-color); + color: var(--secondary-color); + border: none; + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s, transform 0.1s; +} + +.button:hover { + background-color: #4ac0e0; + transform: translateY(-1px); +} + +.button:active { + transform: translateY(1px); +} + +.button-group { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} + +.card { + border-radius: 6px; + border: 1px solid #eee; + padding: 1rem; + margin-bottom: 1rem; + transition: box-shadow 0.2s; +} + +.card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.info-panel { + background-color: rgba(97, 218, 251, 0.1); + border-left: 4px solid var(--primary-color); + padding: 1rem; + margin: 1rem 0; + border-radius: 0 4px 4px 0; +} + +/* Form elements */ +input, select, textarea { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + width: 100%; + margin-bottom: 1rem; +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(97, 218, 251, 0.2); +} diff --git a/examples/react/src/layouts/root.layout.ts b/examples/react/src/layouts/root.layout.ts new file mode 100644 index 0000000..a06208e --- /dev/null +++ b/examples/react/src/layouts/root.layout.ts @@ -0,0 +1,62 @@ +import { html } from 'htm/react' +import { renderToStaticMarkup } from 'react-dom/server' + +// Define TypeScript interfaces for layout props +interface LayoutVars { + title?: string; + siteName?: string; + basePath?: string; +} + +interface LayoutProps { + vars: LayoutVars; + scripts?: string[]; + styles?: string[]; + children: string | any; +} + +/** + * Basic layout for React with TypeScript example + * + * This layout is only used for the initial HTML page structure. + * React components will be mounted client-side after the page loads. + * Uses React DOM Server for server-side rendering. + */ +export default function rootLayout({ + vars: { + title, + siteName = 'React TypeScript Example', + basePath, + }, + scripts, + styles, + children, +}: LayoutProps): string { + return /* html */` + + + ${renderToStaticMarkup(html` + + + ${`${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName}`} + + + ${scripts + ? scripts.map(script => html``) - : ''} - ${styles - ? styles.map(style => /* html */``) - : ''} - - - ${children} - + + + ${siteName || ''}${title ? ` | ${title}` : ''} + + ${scripts + ? scripts.map(script => /* html */``).join('\n ') + : ''} + ${styles + ? styles.map(style => /* html */``).join('\n ') + : ''} + + +
    + ${children} +
    + -` + ` } diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json index 70657e9..56d6590 100644 --- a/examples/tailwind/package.json +++ b/examples/tailwind/package.json @@ -1,23 +1,25 @@ { - "name": "@top-bun/preact-example", + "name": "@domstack/preact-example", "version": "0.0.0", "type": "module", "scripts": { "start": "npm run watch", - "build": "npm run clean && top-bun", + "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", - "watch": "npm run clean && tb --watch" + "watch": "npm run clean && dom --watch" }, "author": "Bret Comnes (https://bret.io/)", "license": "MIT", - "dependencies": {}, + "dependencies": { + "@tailwindcss/typography": "^0.5.16" + }, "devDependencies": { - "npm-run-all2": "^6.0.0", + "@domstack/static": "file:../../.", "@preact/signals": "^2.0.0", + "esbuild-plugin-tailwindcss": "2.1.0", "htm": "^3.1.1", + "npm-run-all2": "^6.0.0", "preact": "^10.24.0", - "preact-render-to-string": "^6.5.11", - "top-bun": "../../.", - "esbuild-plugin-tailwindcss": "2.0.1" + "preact-render-to-string": "^6.5.11" } } diff --git a/examples/tailwind/src/README.md b/examples/tailwind/src/README.md index 7febf99..069373d 100644 --- a/examples/tailwind/src/README.md +++ b/examples/tailwind/src/README.md @@ -1,5 +1,40 @@ -# Preact example +# Tailwind CSS in DOMStack -This is a preact example WITH TAILWIND! +This example demonstrates how to integrate Tailwind CSS with DOMStack, providing a powerful utility-first CSS framework for your websites and applications. -[Isomorphic Component Rendering](./isomorphic/) +## What is Tailwind CSS? + +Tailwind CSS is a utility-first CSS framework that allows you to build custom designs without leaving your HTML. Instead of pre-designed components, Tailwind provides low-level utility classes that let you build completely custom designs. + +## How This Example Works + +This example shows how to: + +1. Configure ESBuild to process Tailwind CSS +2. Import Tailwind into your global CSS +3. Use Tailwind classes in Preact components +4. Create responsive, utility-based designs + +## Key Files + +- `globals/esbuild.settings.js` - Configures the Tailwind plugin +- `globals/global.css` - Imports the Tailwind framework +- `isomorphic/client.js` - Demonstrates Tailwind classes in components + +## Example Component + +The example includes a Todo application with Tailwind styling for: +- Responsive layouts +- Spacing utilities +- Flexbox components +- Colors and shadows +- Interactive states (hover effects) + +## Getting Started + +Explore the [Isomorphic Component Rendering](./isomorphic/) example to see Tailwind in action. + +## Learn More + +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) +- [DOMStack Documentation](https://github.com/bcomnes/domstack) diff --git a/examples/tailwind/src/globals/esbuild.settings.js b/examples/tailwind/src/globals/esbuild.settings.js index 7c0ba4e..171da22 100644 --- a/examples/tailwind/src/globals/esbuild.settings.js +++ b/examples/tailwind/src/globals/esbuild.settings.js @@ -1,8 +1,26 @@ +/** + * Tailwind CSS Integration for DOMStack + * + * This file configures ESBuild to process Tailwind CSS in your project. + * It enables utility-first CSS classes that can be used directly in your HTML and components. + */ import tailwindPlugin from 'esbuild-plugin-tailwindcss' +/** + * Configure ESBuild settings to include Tailwind CSS processing + * + * @param {import('esbuild').BuildOptions} esbuildSettings - The default ESBuild configuration + * @return {Promise} - The modified ESBuild configuration + */ export default async function esbuildSettingsOverride (esbuildSettings) { + // Add the Tailwind plugin to the ESBuild configuration esbuildSettings.plugins = [ tailwindPlugin(), ] + + // You can also add other ESBuild settings as needed + // esbuildSettings.minify = true; + // esbuildSettings.sourcemap = true; + return esbuildSettings } diff --git a/examples/tailwind/src/globals/global.css b/examples/tailwind/src/globals/global.css index bdb109c..3fbea23 100644 --- a/examples/tailwind/src/globals/global.css +++ b/examples/tailwind/src/globals/global.css @@ -1,2 +1,3 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; diff --git a/examples/tailwind/src/layouts/root.layout.js b/examples/tailwind/src/layouts/root.layout.js index 3a6b6dc..7616783 100644 --- a/examples/tailwind/src/layouts/root.layout.js +++ b/examples/tailwind/src/layouts/root.layout.js @@ -20,7 +20,7 @@ import { render } from 'preact-render-to-string' export default function defaultRootLayout ({ vars: { title, - siteName = 'TopBun', + siteName = 'Domstack', basePath, }, scripts, @@ -44,7 +44,7 @@ export default function defaultRootLayout ({ `)} ${render(html` - + ${typeof children === 'string' ? html`
    ` : html`
    ${children}
    ` diff --git a/examples/type-stripping/package.json b/examples/type-stripping/package.json index 3da3e9e..f5ce358 100644 --- a/examples/type-stripping/package.json +++ b/examples/type-stripping/package.json @@ -1,12 +1,12 @@ { - "name": "@top-bun/preact-example", + "name": "@domstack/preact-example", "version": "0.0.0", "type": "module", "scripts": { "start": "npm run watch", - "build": "npm run clean && top-bun", + "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", - "watch": "npm run clean && tb --watch" + "watch": "npm run clean && dom --watch" }, "author": "Bret Comnes (https://bret.io/)", "license": "MIT", @@ -17,7 +17,7 @@ "mine.css": "^9.0.1", "preact": "^10.24.0", "preact-render-to-string": "^6.5.11", - "top-bun": "../../." + "@domstack/static": "file:../../." }, "devDependencies": { "@voxpelli/tsconfig": "^15.0.0", diff --git a/examples/type-stripping/src/README.md b/examples/type-stripping/src/README.md index f922c8c..52bd0b3 100644 --- a/examples/type-stripping/src/README.md +++ b/examples/type-stripping/src/README.md @@ -1,6 +1,50 @@ -# Preact example +# TypeScript Support in DOMStack -This is a preact example. +This example demonstrates how DOMStack handles TypeScript files by automatically stripping types during the build process, allowing you to use TypeScript without additional configuration. -- [Isomorphic Component Rendering](./isomorphic/) -- [tsx-client]('./isomorphic/') +## What is Type Stripping? + +Type stripping is the process of removing TypeScript type annotations during compilation to produce standard JavaScript. DOMStack performs this automatically using ESBuild, giving you: + +- Full TypeScript type checking during development +- Clean JavaScript output without runtime type overhead +- No need for separate TypeScript build steps + +## Features Demonstrated + +This example showcases: + +- `.ts` files for standard TypeScript +- `.tsx` files for JSX with TypeScript +- Type imports and exports +- Interface definitions +- Automatic handling of type annotations + +## Examples in This Project + +- [Isomorphic Component Rendering](./isomorphic/) - TypeScript with Preact +- [TSX Client Example](./tsx-page/) - TypeScript JSX components + +## How It Works + +1. Write your code using full TypeScript syntax +2. DOMStack detects `.ts` and `.tsx` file extensions +3. ESBuild automatically strips type annotations during bundling +4. Your pages and components work exactly like JavaScript versions + +## Benefits of TypeScript in DOMStack + +- **Developer Experience**: Get IDE autocompletion and type checking +- **Error Prevention**: Catch type errors before runtime +- **Documentation**: Types serve as self-documenting code +- **Zero Runtime Cost**: All types are removed in the final output + +## Getting Started with TypeScript + +To use TypeScript in your DOMStack project: + +1. Create files with `.ts` or `.tsx` extensions +2. Write TypeScript code normally +3. DOMStack will handle the rest automatically + +No additional setup required! diff --git a/examples/type-stripping/src/isomorphic/client.ts b/examples/type-stripping/src/isomorphic/client.ts index cbd8fed..78a66bb 100644 --- a/examples/type-stripping/src/isomorphic/client.ts +++ b/examples/type-stripping/src/isomorphic/client.ts @@ -1,55 +1,427 @@ -import { html, Component } from 'htm/preact' +import { html } from 'htm/preact' import { render } from 'preact' -import { useCallback } from 'preact/hooks' -import { useSignal, useComputed } from '@preact/signals' +import { useState, useCallback, useEffect } from 'preact/hooks' -const Header = ({ name }) => html`

    ${name} List

    ` -const Footer = props => { - const count = useSignal(0) - const double = useComputed(() => count.value * 2) +// ===== Type Definitions ===== - const handleClick = useCallback(() => { - count.value++ - }, [count]) +interface Task { + id: string; + title: string; + completed: boolean; + priority: 'low' | 'medium' | 'high'; + createdAt: Date; +} + +interface TaskFilterState { + status: 'all' | 'active' | 'completed'; + priority: 'all' | 'low' | 'medium' | 'high'; +} + +interface TaskStats { + total: number; + active: number; + completed: number; + byPriority: { + low: number; + medium: number; + high: number; + }; +} + +// Component Props +interface TaskItemProps { + task: Task; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} - return html`
    - ${count} - ${double} - ${props.children} - -
    ` +interface TaskFilterProps { + filters: TaskFilterState; + onFilterChange: (filters: Partial) => void; + stats: TaskStats; } -class App extends Component { - addTodo () { - const { todos = [] } = this.state - this.setState({ todos: todos.concat(`Item ${todos.length}`) }) +interface TaskFormProps { + onAddTask: (task: Omit) => void; +} + +interface TaskListProps { + tasks: Task[]; + onToggleTask: (id: string) => void; + onDeleteTask: (id: string) => void; +} + +// ===== Helper Functions ===== + +// Generate a unique ID (simplified version) +const generateId = (): string => { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 5); +}; + +// Calculate task statistics +const calculateTaskStats = (tasks: Task[]): TaskStats => { + const completed = tasks.filter(t => t.completed).length; + + return { + total: tasks.length, + active: tasks.length - completed, + completed, + byPriority: { + low: tasks.filter(t => t.priority === 'low').length, + medium: tasks.filter(t => t.priority === 'medium').length, + high: tasks.filter(t => t.priority === 'high').length + } + }; +}; + +// Filter tasks based on current filters +const filterTasks = (tasks: Task[], filters: TaskFilterState): Task[] => { + return tasks.filter(task => { + // Filter by status + if (filters.status === 'active' && task.completed) return false; + if (filters.status === 'completed' && !task.completed) return false; + + // Filter by priority + if (filters.priority !== 'all' && task.priority !== filters.priority) return false; + + return true; + }); +}; + +// ===== Components ===== + +// Individual Task Item Component +const TaskItem = ({ task, onToggle, onDelete }: TaskItemProps) => { + const getPriorityClass = (priority: Task['priority']): string => { + switch (priority) { + case 'high': return 'task-priority-high'; + case 'medium': return 'task-priority-medium'; + case 'low': return 'task-priority-low'; + default: return ''; + } + }; + + return html` +
  • +
    + onToggle(task.id)} + /> + ${task.title} + ${task.priority} +
    + +
  • + `; +}; + +// Task Filter Component +const TaskFilter = ({ filters, onFilterChange, stats }: TaskFilterProps) => { + return html` +
    +
    +

    Filter by Status

    +
    + + + +
    +
    + +
    +

    Filter by Priority

    +
    + + + + +
    +
    +
    + `; +}; + +// Task Form Component +const TaskForm = ({ onAddTask }: TaskFormProps) => { + const [error, setError] = useState(null); + + // Simple approach using form submit + const handleSubmit = (e: Event): void => { + e.preventDefault(); + + // Get form inputs directly + const form = e.target as HTMLFormElement; + const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement; + const prioritySelect = form.querySelector('select[name="priority"]') as HTMLSelectElement; + + // Validate + if (!titleInput || !titleInput.value.trim()) { + setError('Task title cannot be empty'); + return; + } + + // Add task + onAddTask({ + title: titleInput.value.trim(), + completed: false, + priority: prioritySelect.value as Task['priority'] + }); + + // Reset form + titleInput.value = ''; + prioritySelect.value = 'medium'; + setError(null); + }; + + return html` +
    +
    + + + + + +
    + + ${error ? html`

    ${error}

    ` : null} +
    + `; +}; + +// Task List Component +const TaskList = ({ tasks, onToggleTask, onDeleteTask }: TaskListProps) => { + if (tasks.length === 0) { + return html`

    No tasks to display

    `; } - render ({ page }, { todos = [] }) { - return html` -
    - <${Header} name="ToDo's (${page})" /> -
      - ${todos.map(todo => html` -
    • ${todo}
    • - `)} -
    - - <${Footer}>footer content here -
    - ` + return html` +
      + ${tasks.map(task => html` + <${TaskItem} + key=${task.id} + task=${task} + onToggle=${onToggleTask} + onDelete=${onDeleteTask} + /> + `)} +
    + `; +}; + +// ===== Main Application ===== + +// Sample initial tasks +const initialTasks: Task[] = [ + { + id: 'task-1', + title: 'Learn TypeScript', + completed: true, + priority: 'high', + createdAt: new Date() + }, + { + id: 'task-2', + title: 'Build isomorphic app with DOMStack', + completed: false, + priority: 'medium', + createdAt: new Date() + }, + { + id: 'task-3', + title: 'Deploy application', + completed: false, + priority: 'low', + createdAt: new Date() } -} +]; + +// Main TaskManager component +const TaskManager = () => { + // TypeScript typed state + const [tasks, setTasks] = useState(initialTasks); + const [filters, setFilters] = useState({ + status: 'all', + priority: 'all' + }); + + // Memoized task stats + const [stats, setStats] = useState(calculateTaskStats(tasks)); + + // Update stats when tasks change + useEffect(() => { + setStats(calculateTaskStats(tasks)); + }, [tasks]); + + // Filter tasks based on current filters + const filteredTasks = filterTasks(tasks, filters); + + // Event handlers with TypeScript types + const handleAddTask = useCallback((newTask: Omit): void => { + const task: Task = { + ...newTask, + id: generateId(), + createdAt: new Date() + }; + + setTasks(prevTasks => [...prevTasks, task]); + }, []); -export const page = () => html` - <${App} page="Isomorphic"/> - <${Footer}>footer content here - <${Footer}>footer content here - ` + const handleToggleTask = useCallback((id: string): void => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === id ? { ...task, completed: !task.completed } : task + ) + ); + }, []); + const handleDeleteTask = useCallback((id: string): void => { + setTasks(prevTasks => prevTasks.filter(task => task.id !== id)); + }, []); + + const handleFilterChange = useCallback((newFilters: Partial): void => { + setFilters(prevFilters => ({ ...prevFilters, ...newFilters })); + }, []); + + const handleClearCompleted = useCallback((): void => { + setTasks(prevTasks => prevTasks.filter(task => !task.completed)); + }, []); + + // Data persistence - example of useEffect with TypeScript + useEffect((): void => { + // Only run in browser context + if (typeof window !== 'undefined') { + const savedTasks = localStorage.getItem('isomorphic-tasks'); + if (savedTasks) { + try { + // Parse and restore dates properly + const parsedTasks = JSON.parse(savedTasks, (key, value) => { + return key === 'createdAt' ? new Date(value) : value; + }); + setTasks(parsedTasks); + } catch (err) { + console.error('Failed to parse saved tasks:', err); + } + } + } + }, []); + + useEffect((): (() => void) | void => { + // Only run in browser context + if (typeof window !== 'undefined') { + const handleSave = (): void => { + localStorage.setItem('isomorphic-tasks', JSON.stringify(tasks)); + }; + + // Save on page unload + window.addEventListener('beforeunload', handleSave); + + // Clean up event listener + return (): void => { + window.removeEventListener('beforeunload', handleSave); + }; + } + }, [tasks]); + + return html` +
    +
    +

    Isomorphic Task Manager

    +

    Demonstrating TypeScript with DOMStack

    +
    + + <${TaskForm} onAddTask=${handleAddTask} /> + + <${TaskFilter} + filters=${filters} + onFilterChange=${handleFilterChange} + stats=${stats} + /> + + <${TaskList} + tasks=${filteredTasks} + onToggleTask=${handleToggleTask} + onDeleteTask=${handleDeleteTask} + /> + +
    + +
    +
    + `; +}; + +// Export for isomorphic rendering +export const page = (): any => html`<${TaskManager} />`; + +// Client-side rendering if (typeof window !== 'undefined') { - const renderTarget = document.querySelector('.app-main') - render(page(), renderTarget) + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const renderTarget = document.querySelector('.app-main'); + if (renderTarget) { + render(page(), renderTarget); + } + }); + } else { + const renderTarget = document.querySelector('.app-main'); + if (renderTarget) { + render(page(), renderTarget); + } + } } diff --git a/examples/type-stripping/src/isomorphic/page.ts b/examples/type-stripping/src/isomorphic/page.ts index a348ef6..150913e 100644 --- a/examples/type-stripping/src/isomorphic/page.ts +++ b/examples/type-stripping/src/isomorphic/page.ts @@ -1,5 +1,8 @@ import { page } from './client.ts' +// This demonstrates the isomorphic nature of the application: +// - In server context: renders the initial HTML +// - In browser context: hydrates with interactive functionality export default () => { return page() } diff --git a/examples/type-stripping/src/isomorphic/style.css b/examples/type-stripping/src/isomorphic/style.css new file mode 100644 index 0000000..8d1715c --- /dev/null +++ b/examples/type-stripping/src/isomorphic/style.css @@ -0,0 +1,275 @@ +/* Isomorphic Task Manager Styles */ + +.task-manager { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.app-header { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eaeaea; +} + +.app-header h1 { + margin: 0 0 0.5rem; + color: #333; +} + +.app-header p { + margin: 0; + color: #666; + font-size: 1rem; +} + +/* Task Form */ +.task-form { + margin-bottom: 2rem; +} + +.form-group { + display: flex; + gap: 0.5rem; +} + +.task-form input { + flex: 1; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.task-form input.error { + border-color: #e74c3c; +} + +.task-form select { + width: 150px; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; +} + +.task-form button { + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + padding: 0.75rem 1.5rem; + cursor: pointer; + font-weight: 600; +} + +.task-form button:hover { + background-color: #2980b9; +} + +.task-form button:disabled { + background-color: #95a5a6; + cursor: not-allowed; +} + +.error-message { + color: #e74c3c; + margin-top: 0.5rem; + font-size: 0.875rem; +} + +/* Task Filters */ +.task-filters { + margin-bottom: 2rem; + display: flex; + gap: 2rem; + flex-wrap: wrap; +} + +.filter-group h3 { + font-size: 1rem; + margin-top: 0; + margin-bottom: 0.5rem; + color: #555; +} + +.btn-group { + display: flex; + gap: 0.5rem; +} + +.btn-group button { + background-color: #f1f1f1; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-group button:hover { + background-color: #e7e7e7; +} + +.btn-group button.active { + background-color: #2c3e50; + color: white; + border-color: #2c3e50; +} + +/* Task List */ +.task-list { + list-style: none; + padding: 0; + margin: 0 0 2rem; +} + +.task-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border: 1px solid #eee; + border-radius: 4px; + margin-bottom: 0.5rem; + transition: all 0.2s ease; +} + +.task-item:hover { + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.task-item.completed { + background-color: #f9f9f9; + opacity: 0.8; +} + +.task-item.completed .task-title { + text-decoration: line-through; + color: #7f8c8d; +} + +.task-content { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.task-title { + font-size: 1rem; + margin-right: 1rem; +} + +.task-priority { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.task-priority-high { + border-left: 4px solid #e74c3c; +} + +.task-priority-high .task-priority { + background-color: #fdedec; + color: #e74c3c; +} + +.task-priority-medium { + border-left: 4px solid #f39c12; +} + +.task-priority-medium .task-priority { + background-color: #fef5e7; + color: #f39c12; +} + +.task-priority-low { + border-left: 4px solid #3498db; +} + +.task-priority-low .task-priority { + background-color: #ebf5fb; + color: #3498db; +} + +.delete-btn { + background-color: transparent; + color: #7f8c8d; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.375rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; +} + +.delete-btn:hover { + background-color: #e74c3c; + color: white; + border-color: #e74c3c; +} + +.no-tasks { + text-align: center; + color: #7f8c8d; + font-style: italic; + padding: 2rem; + border: 1px dashed #ddd; + border-radius: 4px; +} + +/* Actions */ +.actions { + display: flex; + justify-content: center; +} + +.clear-completed { + background-color: #ecf0f1; + color: #34495e; + border: none; + border-radius: 4px; + padding: 0.75rem 1.5rem; + cursor: pointer; + font-weight: 600; +} + +.clear-completed:hover:not(:disabled) { + background-color: #bdc3c7; +} + +.clear-completed:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .form-group { + flex-direction: column; + } + + .task-filters { + flex-direction: column; + gap: 1rem; + } + + .task-item { + flex-direction: column; + align-items: flex-start; + } + + .task-content { + margin-bottom: 0.5rem; + width: 100%; + } + + .delete-btn { + align-self: flex-end; + } +} diff --git a/examples/type-stripping/src/layouts/root.layout.ts b/examples/type-stripping/src/layouts/root.layout.ts index 474a685..33c6571 100644 --- a/examples/type-stripping/src/layouts/root.layout.ts +++ b/examples/type-stripping/src/layouts/root.layout.ts @@ -1,7 +1,7 @@ import { html } from 'htm/preact' import { render } from 'preact-render-to-string' -import type { LayoutFunction } from 'top-bun' +import type { LayoutFunction } from '@domstack/static' interface Vars { title?: string @@ -15,7 +15,7 @@ type DefaultRootLayout = LayoutFunction const defaultRootLayout: DefaultRootLayout = ({ vars: { title, - siteName = 'TopBun', + siteName = 'Domstack', basePath, }, scripts, diff --git a/examples/type-stripping/src/tsx-page/client.tsx b/examples/type-stripping/src/tsx-page/client.tsx index 497ef69..73a7573 100644 --- a/examples/type-stripping/src/tsx-page/client.tsx +++ b/examples/type-stripping/src/tsx-page/client.tsx @@ -1,14 +1,133 @@ import { render } from 'preact' +import { useState, useEffect } from 'preact/hooks' + +// TypeScript interfaces for our component props +interface ButtonProps { + onClick: () => void; + variant: 'primary' | 'secondary' | 'danger'; + children: any; + disabled?: boolean; +} + +interface UserCardProps { + id: number; + name: string; + email: string; + role?: string; +} + +// Styled button component with TypeScript props +const Button = ({ onClick, variant, children, disabled = false }: ButtonProps) => { + // Compute classes based on variant + const getButtonClass = (): string => { + switch (variant) { + case 'primary': + return 'bg-blue-500 hover:bg-blue-700 text-white' + case 'secondary': + return 'bg-gray-500 hover:bg-gray-700 text-white' + case 'danger': + return 'bg-red-500 hover:bg-red-700 text-white' + default: + return 'bg-blue-500 hover:bg-blue-700 text-white' + } + } -export const page = () => { return ( -
    - look ma, client side jsx! + + ) +} + +// User card component with TypeScript props +const UserCard = ({ id, name, email, role = 'User' }: UserCardProps) => ( +
    +

    {name}

    +

    ID: {id}

    +

    Email: {email}

    +

    Role: {role}

    +
    +) + +// Main application component with state management +export const Page = () => { + // TypeScript typed state + const [users, setUsers] = useState([ + { id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' } + ]) + + const [count, setCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + + // TypeScript void return type + const incrementCounter = (): void => { + setCount(prev => prev + 1) + } + + // Add a new user with TypeScript type safety + const addUser = (): void => { + setIsLoading(true) + + // Simulate API call + setTimeout(() => { + const newUser: UserCardProps = { + id: users.length + 1, + name: `User ${users.length + 1}`, + email: `user${users.length + 1}@example.com` + } + + setUsers([...users, newUser]) + setIsLoading(false) + }, 500) + } + + // TypeScript with useEffect + useEffect((): void => { + document.title = `${count} clicks` + }, [count]) + + return ( +
    +

    TypeScript JSX Example

    + +
    +

    Counter: {count}

    + +
    + +
    +

    Users

    + {users.map(user => ( + + ))} + +
    + +
    +
    + +
    ) } +// TypeScript DOM null check const renderTarget = document.querySelector('.jsx-app') if (renderTarget) { - render(page(), renderTarget) + render(, renderTarget) } diff --git a/examples/type-stripping/src/tsx-page/page.html b/examples/type-stripping/src/tsx-page/page.html index bf0074d..4557f50 100644 --- a/examples/type-stripping/src/tsx-page/page.html +++ b/examples/type-stripping/src/tsx-page/page.html @@ -1,4 +1,30 @@
    -

    This is an html page, with a client.jsx that mounts onto it

    -
    +

    TypeScript JSX Integration Example

    +

    This example demonstrates how DOMStack can use TypeScript JSX (.tsx) files for client-side rendering with automatic type stripping.

    + +
    +

    Features Demonstrated:

    +
      +
    • TypeScript interfaces for component props
    • +
    • Strongly typed state management
    • +
    • Type-safe event handlers
    • +
    • Component composition with TypeScript
    • +
    +
    + +
    +

    Live Demo:

    +
    +
    + +
    +

    How It Works:

    +

    The client.tsx file contains TypeScript JSX code that:

    +
      +
    1. Defines interfaces for component props
    2. +
    3. Creates typed state variables
    4. +
    5. Uses type-safe functions
    6. +
    7. Gets compiled to JavaScript automatically by DOMStack
    8. +
    +
    diff --git a/examples/uhtml-isomorphic/README.md b/examples/uhtml-isomorphic/README.md new file mode 100644 index 0000000..a089390 --- /dev/null +++ b/examples/uhtml-isomorphic/README.md @@ -0,0 +1,110 @@ +# uhtml-isomorphic Example + +This example demonstrates how to use [uhtml-isomorphic](https://github.com/WebReflection/uhtml-isomorphic) with DOMStack for isomorphic component rendering. + +## Overview + +uhtml-isomorphic is a lightweight library that provides the same API for both server and client rendering, making it easy to build components that work in both environments. This approach offers several benefits: + +- Write components once, use them everywhere +- Server-side rendering for fast initial page loads +- Client-side hydration for interactivity +- No JSX compilation required +- Efficient DOM updates + +## Getting Started + +### Prerequisites + +- Node.js 22.x or higher + +### Installation + +```bash +# Install dependencies +npm install +``` + +### Building the Example + +```bash +# Build the site +npm run build + +# Watch for changes during development +npm run watch +``` + +The built site will be in the `public` directory. + +## Project Structure + +``` +src/ +β”œβ”€β”€ isomorphic/ # Isomorphic component example +β”‚ β”œβ”€β”€ client.js # Client-side hydration code +β”‚ └── page.js # Server-side rendering code +β”œβ”€β”€ html-mount/ # HTML mount example +β”‚ β”œβ”€β”€ client.js # Client mounting code +β”‚ └── page.html # Static HTML template +β”œβ”€β”€ layouts/ # Layout templates +β”‚ └── root.layout.js # Root layout using uhtml-isomorphic +└── README.md # Main content (becomes index.html) +``` + +## Key Features Demonstrated + +### 1. Isomorphic Components + +The isomorphic example shows how to: +- Create components that render the same way on server and client +- Share code between environments +- Add client-side interactivity via hydration + +### 2. HTML Mounting + +The HTML mount example demonstrates: +- Starting with static HTML content +- Using uhtml-isomorphic to enhance it with dynamic client-side features +- Mounting components to specific DOM elements + +### 3. uhtml-isomorphic Layout + +The root layout shows how to: +- Build a complete HTML document structure +- Insert dynamic content +- Handle scripts and styles + +## How uhtml-isomorphic Works + +uhtml-isomorphic uses tagged template literals to define components: + +```js +import { html, render } from 'uhtml-isomorphic' + +// Create a component +const myComponent = (name) => html` +
    +

    Hello, ${name}!

    +

    Welcome to uhtml-isomorphic

    +
    +` + +// Server-side rendering +const output = render(String, myComponent('World')) + +// Client-side rendering +const container = document.querySelector('.app') +render(container, myComponent('World')) +``` + +## Learn More + +- [uhtml-isomorphic Documentation](https://github.com/WebReflection/uhtml-isomorphic) +- [DOMStack Documentation](https://github.com/bcomnes/domstack) + +## Related Examples + +Check out these other DOMStack examples: +- basic - Core features demonstration +- string-layouts - Simple template string layouts \ No newline at end of file diff --git a/examples/preact/package.json b/examples/uhtml-isomorphic/package.json similarity index 64% rename from examples/preact/package.json rename to examples/uhtml-isomorphic/package.json index a8a55d7..51826e5 100644 --- a/examples/preact/package.json +++ b/examples/uhtml-isomorphic/package.json @@ -1,23 +1,20 @@ { - "name": "@top-bun/preact-example", + "name": "@domstack/uhtml-isomorphic-example", "version": "0.0.0", "type": "module", "scripts": { "start": "npm run watch", - "build": "npm run clean && top-bun", + "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", "watch": "npm run clean && tb --watch" }, "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "dependencies": { - "@preact/signals": "^2.0.0", "highlight.js": "^11.9.0", - "htm": "^3.1.1", "mine.css": "^9.0.1", - "preact": "^10.24.0", - "preact-render-to-string": "^6.5.11", - "top-bun": "../../." + "uhtml-isomorphic": "^2.1.0", + "@domstack/static": "../../." }, "devDependencies": { "npm-run-all2": "^6.0.0" diff --git a/examples/uhtml-isomorphic/src/README.md b/examples/uhtml-isomorphic/src/README.md new file mode 100644 index 0000000..1cc6663 --- /dev/null +++ b/examples/uhtml-isomorphic/src/README.md @@ -0,0 +1,24 @@ +# uhtml-isomorphic Example + +This example demonstrates using [uhtml-isomorphic](https://github.com/WebReflection/uhtml-isomorphic) for building isomorphic components with DOMStack. + +## Features + +- Server-side rendering with the same syntax as client-side code +- Hydration of server-rendered components +- Pure JavaScript approach (no JSX required) +- Lightweight and efficient DOM updates + +## Examples + +- [Isomorphic Component Rendering](./isomorphic/) - Full isomorphic rendering with hydration +- [HTML Mount Example](./html-mount/) - Client-side mounting to HTML pages + +## How It Works + +uhtml-isomorphic provides a unified API for both server and client rendering, allowing you to write components once and use them everywhere. This example shows how to: + +1. Create components using tagged template literals +2. Render on the server with DOMStack +3. Hydrate in the browser for interactivity +4. Use the same component code in both environments diff --git a/examples/preact/src/globals/global.client.js b/examples/uhtml-isomorphic/src/globals/global.client.js similarity index 100% rename from examples/preact/src/globals/global.client.js rename to examples/uhtml-isomorphic/src/globals/global.client.js diff --git a/examples/uhtml-isomorphic/src/globals/global.css b/examples/uhtml-isomorphic/src/globals/global.css new file mode 100644 index 0000000..84d5e65 --- /dev/null +++ b/examples/uhtml-isomorphic/src/globals/global.css @@ -0,0 +1,3 @@ +@import 'mine.css/dist/mine.css'; +@import 'mine.css/dist/layout.css'; +@import 'highlight.js/styles/github-dark-dimmed.css'; diff --git a/examples/uhtml-isomorphic/src/html-mount/client.js b/examples/uhtml-isomorphic/src/html-mount/client.js new file mode 100644 index 0000000..2d02d43 --- /dev/null +++ b/examples/uhtml-isomorphic/src/html-mount/client.js @@ -0,0 +1,12 @@ +import { html, render } from 'uhtml-isomorphic' + +export const page = () => { + return html` +
    + look ma, client side uhtml-isomorphic! +
    + ` +} + +const renderTarget = document.querySelector('.uhtml-app') +render(renderTarget, page()) diff --git a/examples/uhtml-isomorphic/src/html-mount/page.html b/examples/uhtml-isomorphic/src/html-mount/page.html new file mode 100644 index 0000000..f984c64 --- /dev/null +++ b/examples/uhtml-isomorphic/src/html-mount/page.html @@ -0,0 +1,4 @@ +
    +

    This is an html page, with a client.js that mounts onto it

    +
    +
    diff --git a/examples/uhtml-isomorphic/src/isomorphic/client.js b/examples/uhtml-isomorphic/src/isomorphic/client.js new file mode 100644 index 0000000..8438b7a --- /dev/null +++ b/examples/uhtml-isomorphic/src/isomorphic/client.js @@ -0,0 +1,41 @@ +// Simple counter state +let counter = 0 + +// Function to update counter display +function updateCounter () { + const counterElement = document.querySelector('.counter-value') + if (counterElement) { + counterElement.textContent = counter + } +} + +// Initialize client-side interactivity +function initializeCounter () { + const incrementButton = document.querySelector('.increment-button') + const decrementButton = document.querySelector('.decrement-button') + + if (incrementButton) { + incrementButton.addEventListener('click', () => { + counter++ + updateCounter() + }) + } + + if (decrementButton) { + decrementButton.addEventListener('click', () => { + counter-- + updateCounter() + }) + } +} + +// Hydrate the component when in browser +if (typeof window !== 'undefined') { + // Wait for DOM to be ready + window.addEventListener('DOMContentLoaded', () => { + // Initialize counter interactivity + initializeCounter() + + console.log('uhtml-isomorphic component hydrated!') + }) +} diff --git a/examples/uhtml-isomorphic/src/isomorphic/page.js b/examples/uhtml-isomorphic/src/isomorphic/page.js new file mode 100644 index 0000000..dab2183 --- /dev/null +++ b/examples/uhtml-isomorphic/src/isomorphic/page.js @@ -0,0 +1,17 @@ +import { html } from 'uhtml-isomorphic' + +export default () => { + return html` +
    +

    uhtml-isomorphic Example

    +

    This page is rendered using uhtml-isomorphic, which provides isomorphic rendering capabilities.

    +

    The client-side JavaScript will hydrate this component.

    +
    +

    Interactive Counter

    +

    Counter value: 0

    + + +
    +
    + ` +} diff --git a/examples/uhtml-isomorphic/src/layouts/root.layout.js b/examples/uhtml-isomorphic/src/layouts/root.layout.js new file mode 100644 index 0000000..72cf92a --- /dev/null +++ b/examples/uhtml-isomorphic/src/layouts/root.layout.js @@ -0,0 +1,53 @@ +import { html, render } from 'uhtml-isomorphic' + +/** + * @template {Record} T + * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + */ + +/** + * Build all of the bundles using esbuild. + * + * @type {LayoutFunction<{ + * title: string, + * siteName: string, + * defaultStyle: boolean, + * basePath: string + * }>} + */ +export default function defaultRootLayout ({ + vars: { + title, + siteName = 'Domstack', + basePath, + /* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */ + }, + scripts, + styles, + children, + /* pages */ + /* page */ +}) { + return render(String, html` + + + + + ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + + ${scripts + ? scripts.map(script => html``) + : null} + ${styles + ? styles.map(style => html``) + : null} + + + ${typeof children === 'string' + ? html`
    ${html([children])}
    ` + : html`
    ${children}
    ` + } + + + `) +} diff --git a/examples/worker-example/README.md b/examples/worker-example/README.md new file mode 100644 index 0000000..0617b51 --- /dev/null +++ b/examples/worker-example/README.md @@ -0,0 +1,121 @@ +# DOMStack Web Workers Example + +This example demonstrates how to use web workers in a DOMStack project. + +## Overview + +Web workers provide a way to run JavaScript in background threads, allowing for resource-intensive operations without blocking the main thread. In this example, we demonstrate: + +1. A simple counter worker that maintains state +2. A Fibonacci calculator worker that performs computationally intensive operations + +## Installation & Running + +```bash +# Navigate to the example directory +cd examples/worker-example + +# Install dependencies +npm install + +# Build and serve +npm start +``` + +This will start a development server and open the example in your browser. + +## How It Works + +DOMStack supports web workers through a simple naming convention: + +- Create files with the pattern `{name}.worker.js` in your page directories +- During build, DOMStack generates a `meta.json` file with worker filename mappings +- Access the workers in your client code using this metadata + +## Implementation Details + +### 1. Worker Files + +The example includes two web worker files: + +- `counter.worker.js` - A worker that maintains a counter state and supports multiple operations +- `fibonacci.worker.js` - A worker that performs CPU-intensive Fibonacci calculations + +### 2. Using Workers in Pages + +The worker code is separated into dedicated client-side files (`client.js`). DOMStack generates a `meta.json` file with hashed worker paths: + +```js +// First, fetch the meta.json to get worker paths +async function initializeWorkers() { + const response = await fetch('./meta.json'); + const meta = await response.json(); + + // Initialize workers with the correct hashed filenames + const counterWorker = new Worker( + new URL(`./${meta.workers.counter}`, import.meta.url), + { type: 'module' } + ); + + // Use the workers + counterWorker.postMessage({ action: 'increment' }); + + counterWorker.onmessage = (e) => { + counterElement.textContent = e.data.count; + }; +} +``` + +## What You'll Learn + +- How to create web worker files in DOMStack +- How web workers are automatically bundled by the build system +- How to use the `meta.json` file to access worker paths +- Practical patterns for worker communication +- Keeping the UI responsive during heavy computations + +## Project Structure + +``` +worker-example/ +β”œβ”€β”€ package.json # Project dependencies and scripts +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ globals/ # Global styles and variables +β”‚ β”œβ”€β”€ layouts/ # Page layouts +β”‚ β”œβ”€β”€ README.md # Home page content +β”‚ └── worker-page/ # Web worker example page +β”‚ β”œβ”€β”€ page.js # Main page template +β”‚ β”œβ”€β”€ client.js # Client-side code for worker interaction +β”‚ β”œβ”€β”€ counter.worker.js # Counter worker implementation +β”‚ β”œβ”€β”€ fibonacci.worker.js # Fibonacci calculator worker +β”‚ └── style.css # Page-specific styles +``` + +## Build Output + +When you build the project, DOMStack: + +1. Bundles each worker file with a unique hash in the filename +2. Creates a `meta.json` file in each page directory that contains workers +3. Maps the original worker names to their hashed filenames + +``` +public/worker-page/ +β”œβ”€β”€ index.html +β”œβ”€β”€ client-XXXX.js +β”œβ”€β”€ counter.worker-XXXX.js # Hashed worker filename +β”œβ”€β”€ fibonacci.worker-XXXX.js # Hashed worker filename +β”œβ”€β”€ meta.json # Contains worker path mappings +└── style-XXXX.css +``` + +## Benefits of Web Workers + +- **Performance** - Run CPU-intensive tasks without blocking the UI +- **Responsiveness** - Keep your app responsive during heavy computations +- **Isolation** - Workers run in a separate context with their own memory + +## Learn More + +- [MDN Web Workers API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) +- [Using Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) \ No newline at end of file diff --git a/examples/worker-example/package.json b/examples/worker-example/package.json new file mode 100644 index 0000000..44d8b27 --- /dev/null +++ b/examples/worker-example/package.json @@ -0,0 +1,22 @@ +{ + "name": "@domstack/worker-example", + "version": "0.0.0", + "description": "DOMStack Web Workers Example", + "type": "module", + "scripts": { + "start": "npm run watch", + "build": "npm run clean && domstack", + "clean": "rm -rf public && mkdir -p public", + "watch": "npm run clean && dom --watch" + }, + "keywords": ["domstack", "web-workers", "static-site-generator"], + "author": "", + "license": "MIT", + "dependencies": { + "mine.css": "^9.0.1", + "@domstack/static": "file:../../." + }, + "devDependencies": { + "npm-run-all2": "^6.0.0" + } +} diff --git a/examples/worker-example/src/README.md b/examples/worker-example/src/README.md new file mode 100644 index 0000000..a5563e7 --- /dev/null +++ b/examples/worker-example/src/README.md @@ -0,0 +1,83 @@ +# DOMStack Web Workers Example + +This example demonstrates how to use web workers in a DOMStack project. + +## Features + +- **Counter Worker**: A simple worker that maintains state and responds to messages +- **Fibonacci Worker**: A worker that performs computationally intensive operations +- **Integration with DOMStack**: Shows how workers are bundled and made available to pages + +## [Worker Page Example](/worker-page/) + +Visit the [Worker Example page](/worker-page/) to see the web workers in action. The page demonstrates: + +1. How to create and structure web workers in DOMStack +2. How to communicate with workers using messages +3. How to handle responses from workers + +## How It Works + +DOMStack supports web workers through a special naming convention: + +- Create files with the pattern `{name}.worker.js` in your page directories +- DOMStack generates a `workers.json` file with worker filename mappings during build +- Use this metadata to initialize workers in your client.js files + +For example, with a file structure like: + +``` +page-directory/ + β”œβ”€β”€ page.js + β”œβ”€β”€ client.js + β”œβ”€β”€ counter.worker.js + └── fibonacci.worker.js +``` + +After building, DOMStack generates: + +``` +page-directory/ + β”œβ”€β”€ index.html + β”œβ”€β”€ client-XXXX.js + β”œβ”€β”€ counter.worker-XXXX.js + β”œβ”€β”€ fibonacci.worker-XXXX.js + └── workers.json # Contains worker path mappings +``` + +You can initialize the workers in your client.js: + +```js +// In client.js +async function initializeWorkers() { + // Fetch the workers.json to get the hashed worker filenames + const response = await fetch('./workers.json'); + const workersData = await response.json(); + + // Initialize workers with the correct hashed filenames + const counterWorker = new Worker( + new URL(`./${workersData.counter}`, import.meta.url), + { type: 'module' } + ); + + // Send messages to the worker + counterWorker.postMessage({ action: 'increment' }); + + // Receive messages from the worker + counterWorker.onmessage = (e) => { + console.log(e.data.count); + }; + + return counterWorker; +} + +// Initialize workers when the page loads +const counterWorker = await initializeWorkers(); +``` + +## Technical Details + +- Workers are bundled using esbuild during the build process +- Each worker gets its own bundle with proper hashing for cache busting +- Workers are loaded as ES modules by default +- The `workers.json` file helps client code find the correct hashed worker files \ No newline at end of file diff --git a/examples/worker-example/src/client.js b/examples/worker-example/src/client.js new file mode 100644 index 0000000..c4eee47 --- /dev/null +++ b/examples/worker-example/src/client.js @@ -0,0 +1,110 @@ +// Client-side script for the home page +// This script adds some dynamic elements to the home page + +document.addEventListener('DOMContentLoaded', () => { + // Add a status indicator to show if the browser supports web workers + const supportsWorkers = typeof Worker !== 'undefined' + + // Create the status element + const statusContainer = document.createElement('div') + statusContainer.className = 'worker-status' + statusContainer.innerHTML = ` +

    Web Worker Support

    +
    + ${supportsWorkers ? 'βœ“' : 'βœ—'} + + ${supportsWorkers + ? 'Your browser supports Web Workers!' + : 'Your browser does not support Web Workers'} + +
    + ${supportsWorkers + ? '

    You can run all the examples in this demo.

    ' + : '

    You need a modern browser to run these examples.

    '} + ` + + // Insert the status after the first section + const firstSection = document.querySelector('h2') + if (firstSection && firstSection.parentNode) { + firstSection.parentNode.insertBefore(statusContainer, firstSection.nextSibling) + } + + // Add a little animation for page links + document.querySelectorAll('a[href="/worker-page/"]').forEach(link => { + link.addEventListener('mouseenter', () => { + link.innerHTML = 'Web Worker Example β†’' + }) + + link.addEventListener('mouseleave', () => { + link.innerHTML = 'Web Worker Example' + }) + }) + + // Add some styles for our dynamic elements + const style = document.createElement('style') + style.textContent = ` + .worker-status { + margin: 2rem 0; + padding: 1.5rem; + border-radius: 8px; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + } + + .worker-status h3 { + margin-top: 0; + margin-bottom: 1rem; + } + + .status { + display: flex; + align-items: center; + margin-bottom: 1rem; + } + + .status-icon { + display: inline-block; + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 50%; + margin-right: 10px; + font-weight: bold; + } + + .supported .status-icon { + background-color: #28a745; + color: white; + } + + .unsupported .status-icon { + background-color: #dc3545; + color: white; + } + + .status-text { + font-weight: bold; + } + + .supported .status-text { + color: #28a745; + } + + .unsupported .status-text { + color: #dc3545; + } + + .link-icon { + display: inline-block; + transition: transform 0.2s; + margin-left: 5px; + } + + a:hover .link-icon { + transform: translateX(3px); + } + ` + + document.head.appendChild(style) +}) diff --git a/examples/worker-example/src/globals/global.css b/examples/worker-example/src/globals/global.css new file mode 100644 index 0000000..ddd247b --- /dev/null +++ b/examples/worker-example/src/globals/global.css @@ -0,0 +1,68 @@ +@import 'mine.css/dist/mine.css'; +@import 'mine.css/dist/layout.css'; + +/* Global styles for the worker example */ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: #333; + background-color: #f8f9fa; +} + +h1, h2, h3 { + color: #0d6efd; + margin-top: 1rem; + margin-bottom: 1rem; +} + +button { + background-color: #0d6efd; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; +} + +button:hover { + background-color: #0b5ed7; +} + +button:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +.mine-layout { + padding: 2rem; + max-width: 1000px; + margin: 0 auto; +} + +a { + color: #0d6efd; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.navigation { + margin-bottom: 2rem; +} + +.navigation a { + margin-right: 1rem; +} + +.footer { + margin-top: 3rem; + padding-top: 1rem; + border-top: 1px solid #dee2e6; + text-align: center; + color: #6c757d; +} \ No newline at end of file diff --git a/examples/worker-example/src/globals/global.vars.js b/examples/worker-example/src/globals/global.vars.js new file mode 100644 index 0000000..45d5596 --- /dev/null +++ b/examples/worker-example/src/globals/global.vars.js @@ -0,0 +1,27 @@ +// global.vars.js file exports default variables that are available to all pages +// These have the lowest precedence in the variable resolution order + +export default { + siteName: 'DOMStack Web Workers', + description: 'Examples of using Web Workers with DOMStack', + author: 'DOMStack Team', + defaultLayout: 'root' +} + +// Alternatively, you can use an async function that returns an object: +// export default async function() { +// return { +// siteName: 'DOMStack Web Workers', +// description: 'Examples of using Web Workers with DOMStack', +// author: 'DOMStack Team', +// defaultLayout: 'root' +// } +// } + +// The browser variable is special and is exposed to client JS +export const browser = { + environment: 'browser', + features: { + webWorkers: 'available' + } +} diff --git a/examples/worker-example/src/layouts/root.layout.js b/examples/worker-example/src/layouts/root.layout.js new file mode 100644 index 0000000..afe650e --- /dev/null +++ b/examples/worker-example/src/layouts/root.layout.js @@ -0,0 +1,47 @@ +/** + * Root layout for the worker example + * + * This layout file determines the outer HTML structure of all pages. + * It receives variables, children content, scripts, and styles from the pages. + */ +export default function rootLayout ({ + vars: { + siteName, + title, + description, + }, + children, + scripts = [], + styles = [], +}) { + return ` + + + + + + ${title ? `${title} | ${siteName}` : siteName} + ${styles.map(style => ``).join('\n ')} + + +
    +
    + +
    + +
    + ${children} +
    + +
    +

    DOMStack Web Workers Example © ${new Date().getFullYear()}

    +
    +
    + + ${scripts.map(script => ``).join('\n ')} + +` +} diff --git a/examples/worker-example/src/style.css b/examples/worker-example/src/style.css new file mode 100644 index 0000000..7b1af51 --- /dev/null +++ b/examples/worker-example/src/style.css @@ -0,0 +1,100 @@ +/* Styles for the home page */ +h1 { + color: #0d6efd; + border-bottom: 2px solid #0d6efd; + padding-bottom: 0.5rem; + margin-bottom: 1.5rem; +} + +h2 { + margin-top: 2rem; + color: #495057; +} + +ul { + margin-left: 1.5rem; + line-height: 1.6; +} + +code { + background-color: #f8f9fa; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + border: 1px solid #e9ecef; + color: #dc3545; +} + +pre { + background-color: #f8f9fa; + padding: 1rem; + border-radius: 4px; + border: 1px solid #dee2e6; + overflow-x: auto; + margin: 1.5rem 0; +} + +pre code { + background-color: transparent; + padding: 0; + border: none; + color: #212529; +} + +.features { + margin: 2rem 0; +} + +.example-link { + display: inline-block; + margin: 1rem 0; + background-color: #0d6efd; + color: white; + padding: 0.75rem 1.5rem; + border-radius: 4px; + font-weight: bold; + text-decoration: none; + transition: background-color 0.2s; +} + +.example-link:hover { + background-color: #0b5ed7; + text-decoration: none; +} + +.code-block { + position: relative; + margin: 2rem 0; +} + +.code-block::before { + content: attr(data-title); + position: absolute; + top: -12px; + left: 10px; + background-color: #fff; + padding: 0 0.5rem; + font-size: 0.85rem; + color: #6c757d; + border: 1px solid #dee2e6; + border-radius: 3px; +} + +.file-structure { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.9rem; + line-height: 1.5; + margin: 1.5rem 0; +} + +.technical-details { + background-color: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + border: 1px solid #e9ecef; + margin: 2rem 0; +} + +.technical-details h2 { + margin-top: 0; +} \ No newline at end of file diff --git a/examples/worker-example/src/worker-page/client.js b/examples/worker-example/src/worker-page/client.js new file mode 100644 index 0000000..f596713 --- /dev/null +++ b/examples/worker-example/src/worker-page/client.js @@ -0,0 +1,152 @@ +// Web worker client-side script + +// Helper function to load workers using workers.json for path resolution +async function initializeWorkers () { + try { + // Fetch the workers.json file to get worker paths + const response = await fetch('./workers.json') + + if (!response.ok) { + console.error('Failed to load workers.json:', response.status) + return { error: true } + } + + const workersData = await response.json() + + if (!workersData.counter || !workersData.fibonacci) { + console.error('Invalid workers.json format:', workersData) + return { error: true } + } + + // Initialize workers with the correct hashed filenames + const counterWorker = new Worker( + new URL(`./${workersData.counter}`, import.meta.url), + { type: 'module' } + ) + + const fibWorker = new Worker( + new URL(`./${workersData.fibonacci}`, import.meta.url), + { type: 'module' } + ) + + return { counterWorker, fibWorker } + } catch (err) { + console.error('Error initializing workers:', err) + return { error: true } + } +} + +// Initialize UI elements when the DOM is ready +document.addEventListener('DOMContentLoaded', async () => { + // Counter example elements + const counterElement = document.getElementById('counter') + const lastOperationElement = document.getElementById('lastOperation') + const incrementButton = document.getElementById('increment') + const decrementButton = document.getElementById('decrement') + const resetButton = document.getElementById('reset') + const multiplyButton = document.getElementById('multiply') + + // Fibonacci example elements + const fibInput = document.getElementById('fibInput') + const calculateButton = document.getElementById('calculate') + const resultElement = document.getElementById('fibResult') + const computationTimeElement = document.getElementById('computationTime') + + // Initialize workers + const { counterWorker, fibWorker, error } = await initializeWorkers() + + if (error) { + // Show error message if workers couldn't be initialized + document.querySelectorAll('.demo-container').forEach(container => { + container.innerHTML = ` +
    +

    Failed to initialize workers. Please check the console for details.

    +

    This can happen if the workers.json file is missing or malformed.

    +
    + ` + }) + return + } + + // Set up counter worker + counterWorker.onmessage = (e) => { + counterElement.textContent = e.data.count + + // Display last operation if available + if (e.data.lastOperation) { + const op = e.data.lastOperation + lastOperationElement.textContent = `Last action: ${op.type} (${op.oldValue} β†’ ${op.newValue})` + } + } + + // Button event listeners for counter + incrementButton.addEventListener('click', () => { + counterWorker.postMessage({ action: 'increment' }) + }) + + decrementButton.addEventListener('click', () => { + counterWorker.postMessage({ action: 'decrement' }) + }) + + resetButton.addEventListener('click', () => { + counterWorker.postMessage({ action: 'reset' }) + }) + + multiplyButton.addEventListener('click', () => { + counterWorker.postMessage({ action: 'multiply', value: 2 }) + }) + + // Set up Fibonacci worker + let startTime + + fibWorker.onmessage = (e) => { + const endTime = performance.now() + const computationTime = endTime - startTime + + if (e.data.error) { + resultElement.textContent = `Error: ${e.data.error}` + } else { + resultElement.textContent = e.data.result + } + + computationTimeElement.textContent = + `Computation time: ${computationTime.toFixed(2)}ms` + + calculateButton.disabled = false + fibInput.disabled = false + } + + // Calculate button event listener + calculateButton.addEventListener('click', () => { + const n = parseInt(fibInput.value, 10) + + if (isNaN(n) || n < 0) { + resultElement.textContent = 'Please enter a valid positive number' + return + } + + startTime = performance.now() + resultElement.textContent = 'Calculating...' + computationTimeElement.textContent = '' + calculateButton.disabled = true + fibInput.disabled = true + + // Show the UI is still responsive during calculation + const dots = ['', '.', '..', '...'] + let dotIndex = 0 + + const dotAnimation = setInterval(() => { + resultElement.textContent = 'Calculating' + dots[dotIndex] + dotIndex = (dotIndex + 1) % dots.length + }, 300) + + // Send message to worker + fibWorker.postMessage({ n }) + + // Cleanup animation when worker responds + fibWorker.addEventListener('message', function clearAnimation () { + clearInterval(dotAnimation) + fibWorker.removeEventListener('message', clearAnimation) + }, { once: true }) + }) +}) diff --git a/examples/worker-example/src/worker-page/counter.worker.ts b/examples/worker-example/src/worker-page/counter.worker.ts new file mode 100644 index 0000000..4f2b1ef --- /dev/null +++ b/examples/worker-example/src/worker-page/counter.worker.ts @@ -0,0 +1,100 @@ +/** + * Counter Web Worker Example + * + * This worker maintains a counter state and responds to various actions: + * - increment: Adds 1 to the counter + * - decrement: Subtracts 1 from the counter + * - reset: Sets the counter back to zero + * - set: Sets the counter to a specific value + * - multiply: Multiplies the counter by a value + */ + +// Initialize counter state +let count = 0 +const operationHistory = [] +const MAX_HISTORY = 10 + +// Listen for messages from the main thread +self.onmessage = (event) => { + const { action, value } = event.data + const oldCount = count + const timestamp = new Date().toISOString() + + switch (action) { + case 'increment': + // Increment the counter + count++ + recordOperation('increment', oldCount, count, timestamp) + break + + case 'decrement': + // Decrement the counter + count = Math.max(0, count - 1) // Prevent negative values + recordOperation('decrement', oldCount, count, timestamp) + break + + case 'reset': + // Reset the counter to zero + count = 0 + recordOperation('reset', oldCount, count, timestamp) + break + + case 'set': + // Set the counter to a specific value + if (typeof value === 'number' && !isNaN(value)) { + count = Math.max(0, value) // Ensure non-negative + recordOperation('set', oldCount, count, timestamp) + } + break + + case 'multiply': + // Multiply the counter by a value + if (typeof value === 'number' && !isNaN(value)) { + count = Math.floor(count * value) + recordOperation('multiply', oldCount, count, timestamp, value) + } + break + + case 'getHistory': + // Return operation history + self.postMessage({ count, history: operationHistory }) + return + + default: + // Unknown action + console.warn(`Unknown action: ${action}`) + } + + // Send the current count back to the main thread + self.postMessage({ count, lastOperation: operationHistory[0] }) +} + +/** + * Records an operation in the history + * + * @param {string} type - The type of operation + * @param {number} oldValue - The value before the operation + * @param {number} newValue - The value after the operation + * @param {string} timestamp - When the operation occurred + * @param {number} [param] - Optional parameter for the operation + */ +function recordOperation (type, oldValue, newValue, timestamp, param) { + const operation = { + type, + oldValue, + newValue, + timestamp, + param + } + + // Add to the beginning of the array + operationHistory.unshift(operation) + + // Trim history to maximum size + if (operationHistory.length > MAX_HISTORY) { + operationHistory.pop() + } +} + +// Send initial count on startup +self.postMessage({ count, history: operationHistory }) diff --git a/examples/worker-example/src/worker-page/fibonacci.worker.js b/examples/worker-example/src/worker-page/fibonacci.worker.js new file mode 100644 index 0000000..c813ac5 --- /dev/null +++ b/examples/worker-example/src/worker-page/fibonacci.worker.js @@ -0,0 +1,89 @@ +/** + * Fibonacci Web Worker Example + * + * This worker calculates Fibonacci numbers using different methods + * based on the input size to demonstrate various computation strategies. + */ + +// Listen for messages from the main thread +self.onmessage = (event) => { + const { n } = event.data + + if (typeof n !== 'number' || n < 0) { + self.postMessage({ + error: 'Invalid input. Please provide a positive number.' + }) + return + } + + // Simulate intense computation by adding a small delay for demonstration + // This helps show the benefits of using web workers + const startTime = performance.now() + + // Choose algorithm based on input size + let result + if (n <= 40) { + // For smaller numbers, use the iterative approach + result = fibonacciIterative(n) + } else { + // For larger numbers, we'd use a more optimized approach + // but still simulate the longer computation time + simulateHeavyComputation() + result = fibonacciIterative(n) + } + + const computationTime = performance.now() - startTime + console.log(`Fibonacci(${n}) took ${computationTime.toFixed(2)}ms to calculate`) + + // Send the result back to the main thread + self.postMessage({ result }) +} + +/** + * Calculate the nth Fibonacci number using iteration + * This is more efficient for larger numbers + * + * @param {number} n - The position in the Fibonacci sequence (0-based) + * @return {number} The nth Fibonacci number + */ +function fibonacciIterative (n) { + // Handle edge cases + if (n === 0) return 0 + if (n === 1) return 1 + + let a = 0 + let b = 1 + let temp + + // Iterative calculation + for (let i = 2; i <= n; i++) { + temp = a + b + a = b + b = temp + } + + return b +} + +/** + * Recursive implementation (not used for large numbers due to performance) + * Included for educational purposes only + */ +// function fibonacciRecursive (n) { +// if (n <= 1) return n +// return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2) +// } + +/** + * Simulate a heavy computation by performing unnecessary work + * This is just to demonstrate the benefit of web workers for UI responsiveness + */ +function simulateHeavyComputation () { + // Simulate intensive computation with a deliberate delay + const start = performance.now() + while (performance.now() - start < 1000) { + // Busy wait to simulate CPU-intensive work + // eslint-disable-next-line no-unused-expressions + Math.random() * Math.random() + } +} diff --git a/examples/worker-example/src/worker-page/page.html b/examples/worker-example/src/worker-page/page.html new file mode 100644 index 0000000..a139a35 --- /dev/null +++ b/examples/worker-example/src/worker-page/page.html @@ -0,0 +1,90 @@ +
    +

    {{ vars.title }}

    +

    + This page demonstrates how to use web workers in DOMStack for offloading + tasks from the main thread to maintain a responsive user interface. +

    + +
    +

    About Web Workers

    +

    + Web Workers run JavaScript in background threads, allowing you to perform + computations without blocking the main UI thread. This example shows two + worker implementations: +

    +
      +
    • Counter Worker: A simple worker that maintains state
    • +
    • Fibonacci Worker: A computationally intensive worker
    • +
    +
    + +
    +

    Counter Worker Example

    +

    This worker maintains a state and updates it based on messages:

    +
    +
    +

    Counter value: 0

    +

    +
    +
    + + + + +
    +
    +
    + +
    +

    Fibonacci Calculator Worker

    +

    + This worker performs CPU-intensive calculations in a background thread. + Try entering a large number (30-45) to see how the worker prevents UI blocking. +

    +
    +
    + + +
    +
    + +
    +
    +

    Result: -

    +

    +
    +
    +
    + +
    +

    Implementation

    +

    Worker files use the {name}.worker.js naming convention. DOMStack generates a workers.json file with worker path mappings:

    +
    // First, fetch the workers.json to get worker paths
    +async function initializeWorkers() {
    +  const response = await fetch('./workers.json');
    +  const workersData = await response.json();
    +
    +  // Initialize workers with the correct hashed filenames
    +  const counterWorker = new Worker(
    +    new URL(`./${workersData.counter}`, import.meta.url),
    +    { type: 'module' }
    +  );
    +  const fibWorker = new Worker(
    +    new URL(`./${workersData.fibonacci}`, import.meta.url),
    +    { type: 'module' }
    +  );
    +
    +  return { counterWorker, fibWorker };
    +}
    +

    This handles the hashed filenames that get generated during the build process.

    +

    Send messages to workers:

    +
    // Send data to the worker
    +counterWorker.postMessage({ action: 'increment' });
    +fibWorker.postMessage({ n: 42 });
    +

    Receive responses from workers:

    +
    // Listen for worker responses
    +counterWorker.onmessage = (e) => {
    +  console.log('New count:', e.data.count);
    +};
    +
    +
    \ No newline at end of file diff --git a/examples/worker-example/src/worker-page/page.vars.js b/examples/worker-example/src/worker-page/page.vars.js new file mode 100644 index 0000000..ddcfaf1 --- /dev/null +++ b/examples/worker-example/src/worker-page/page.vars.js @@ -0,0 +1,11 @@ +/** + * Variables for the worker example page + * + * These variables are available to the page template and can be used to + * customize the page content and metadata. + */ +export default { + title: 'Web Worker Example', + description: 'Learn how to use web workers in DOMStack for background processing', + layout: 'root' +} diff --git a/examples/worker-example/src/worker-page/style.css b/examples/worker-example/src/worker-page/style.css new file mode 100644 index 0000000..032ef51 --- /dev/null +++ b/examples/worker-example/src/worker-page/style.css @@ -0,0 +1,109 @@ +/* Worker example page styles */ +.worker-example { + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + padding: 2rem; + margin-bottom: 2rem; +} + +.worker-example h1 { + color: #0d6efd; + border-bottom: 2px solid #0d6efd; + padding-bottom: 0.5rem; + margin-bottom: 1.5rem; +} + +.worker-example h2 { + color: #495057; + margin-top: 1.5rem; + margin-bottom: 1rem; +} + +.explanation { + margin-bottom: 2rem; +} + +.explanation ul { + margin-left: 1.5rem; +} + +.demo-section { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + margin: 2rem 0; + border: 1px solid #e9ecef; +} + +.demo-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.counter-display, .result { + background-color: white; + padding: 1rem; + border-radius: 4px; + border: 1px solid #dee2e6; + position: relative; +} + +.input-group { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.controls { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.computation-time { + font-size: 0.9rem; + color: #6c757d; + margin-top: 0.5rem; +} + +input { + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 1rem; + width: 80px; +} + +#counter, #fibResult { + font-weight: bold; + color: #0d6efd; + font-size: 1.2rem; +} + +.last-operation { + font-size: 0.85rem; + color: #6c757d; + margin-top: 0.5rem; + font-style: italic; +} + +.code-example { + margin: 2rem 0; +} + +.code-example pre { + background-color: #f8f9fa; + padding: 1rem; + border-radius: 4px; + border: 1px solid #dee2e6; + overflow-x: auto; + margin-bottom: 1.5rem; +} + +.code-example code { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} \ No newline at end of file diff --git a/fonts/ds-weiss-gotisch/1001fonts-ds-weiss-gotisch-eula.txt b/fonts/ds-weiss-gotisch/1001fonts-ds-weiss-gotisch-eula.txt new file mode 100644 index 0000000..1f4f2b6 --- /dev/null +++ b/fonts/ds-weiss-gotisch/1001fonts-ds-weiss-gotisch-eula.txt @@ -0,0 +1,31 @@ +1001Fonts Free For Commercial Use License (FFC) + +Preamble +In this license, 'DS Weiss-Gotisch' refers to the given .zip file, which may contain one or numerous fonts. These fonts can be of any type (.ttf, .otf, ...) and together they form a 'font family' or in short a 'typeface'. + +1. Copyright +DS Weiss-Gotisch is the intellectual property of its respective author, provided it is original, and is protected by copyright laws in many parts of the world. + +2. Usage +DS Weiss-Gotisch may be downloaded and used free of charge for both personal and commercial use, as long as the usage is not racist or illegal. Personal use refers to all usage that does not generate financial income in a business manner, for instance: + + - personal scrapbooking for yourself + - recreational websites and blogs for friends and family + - prints such as flyers, posters, t-shirts for churches, charities, and non-profit organizations + +Commercial use refers to usage in a business environment, including: + + - business cards, logos, advertising, websites for companies + - t-shirts, books, apparel that will be sold for money + - flyers, posters for events that charge admission + - freelance graphic design work + - anything that will generate direct or indirect income + +3. Modification +DS Weiss-Gotisch may not be modified, altered, adapted or built upon without written permission by its respective author. This pertains all files within the downloadable font zip-file. + +4. Distribution +While DS Weiss-Gotisch may freely be copied and passed along to other individuals for private use as its original downloadable zip-file, it may not be sold or published without written permission by its respective author. + +5. Disclaimer +DS Weiss-Gotisch is offered 'as is' without any warranty. 1001fonts.com and the respective author of DS Weiss-Gotisch shall not be liable for any damage derived from using this typeface. By using DS Weiss-Gotisch you agree to the terms of this license. \ No newline at end of file diff --git a/fonts/ds-weiss-gotisch/DSWeiss-Gotisch.ttf b/fonts/ds-weiss-gotisch/DSWeiss-Gotisch.ttf new file mode 100644 index 0000000..77665a0 Binary files /dev/null and b/fonts/ds-weiss-gotisch/DSWeiss-Gotisch.ttf differ diff --git a/fonts/ds-weiss-gotisch/DSWeiss-GotischAlt.ttf b/fonts/ds-weiss-gotisch/DSWeiss-GotischAlt.ttf new file mode 100644 index 0000000..a5505b9 Binary files /dev/null and b/fonts/ds-weiss-gotisch/DSWeiss-GotischAlt.ttf differ diff --git a/index.js b/index.js index 0a44f38..b7eed3f 100644 --- a/index.js +++ b/index.js @@ -1,52 +1,106 @@ +/** + * @import { DomStackOpts as DomStackOpts, Results, SiteData } from './lib/builder.js' + * @import { Stats } from 'node:fs' + * @import { FSWatcher } from 'chokidar' + * @import { AsyncLayoutFunction, LayoutFunction } from './lib/build-pages/page-data.js' + * @import { PageFunction, AsyncPageFunction } from './lib/build-pages/page-builders/page-writer.js' + * @import { TemplateFunction } from './lib/build-pages/page-builders/template-builder.js' + * @import { TemplateAsyncIterator } from './lib/build-pages/page-builders/template-builder.js' + * @import { TemplateOutputOverride } from './lib/build-pages/page-builders/template-builder.js' + * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult } from './lib/build-pages/index.js' + * @import { BuildOptions, BuildContext } from 'esbuild' + * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js' +*/ import { once } from 'events' import assert from 'node:assert' import chokidar from 'chokidar' -import { basename, relative, resolve } from 'node:path' -// @ts-ignore +import { basename, dirname, relative, resolve } from 'node:path' +// @ts-expect-error import makeArray from 'make-array' import ignore from 'ignore' -// @ts-ignore +// @ts-expect-error import cpx from 'cpx2' import { inspect } from 'util' import browserSync from 'browser-sync' +import { find } from '@11ty/dependency-tree-typescript' import { getCopyGlob } from './lib/build-static/index.js' import { getCopyDirs } from './lib/build-copy/index.js' import { builder } from './lib/builder.js' -import { TopBunAggregateError } from './lib/helpers/top-bun-aggregate-error.js' +import { buildEsbuildWatch } from './lib/build-esbuild/index.js' +import { buildPages } from './lib/build-pages/index.js' +import { + identifyPages, + layoutSuffixs, + layoutStyleSuffix, + templateSuffixs, + globalVarsNames, + globalDataNames, + esbuildSettingsNames, + markdownItSettingsNames, + pageClientNames, + layoutClientSuffixs, + globalClientNames, + globalStyleNames, + pageStyleName, + pageWorkerSuffixs, +} from './lib/identify-pages.js' +import { resolveVars } from './lib/build-pages/resolve-vars.js' +import { ensureDest } from './lib/helpers/ensure-dest.js' +import { DomStackAggregateError } from './lib/helpers/dom-stack-aggregate-error.js' /** - * @import { TopBunOpts, Results } from './lib/builder.js' - * @import { FSWatcher, Stats } from 'node:fs' -*/ + * @typedef {BuildOptions} BuildOptions + */ + +/** + * @template {Record} Vars - The type of variables passed to the layout function + * @template [PageReturn=any] PageReturn - The return type of the page function (defaults to any) + * @template [LayoutReturn=string] LayoutReturn - The return type of the layout function (defaults to string) + * @typedef {LayoutFunction} LayoutFunction + */ + +/** + * @template {Record} Vars - The type of variables passed to the async layout function + * @template [PageReturn=any] PageReturn - The return type of the page function (defaults to any) + * @template [LayoutReturn=string] LayoutReturn - The return type of the layout function (defaults to string) + * @typedef {AsyncLayoutFunction} AsyncLayoutFunction + */ /** - * @template {Record} T - * @typedef {import('./lib/build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + * @template {Record} [T=Record] - The shape of the derived vars object returned. + * @typedef {GlobalDataFunction} GlobalDataFunction */ /** - * @template {Record} T - * @typedef {import('./lib/build-pages/resolve-vars.js').PostVarsFunction} PostVarsFunction + * @template {Record} [T=Record] - The shape of the derived vars object returned. + * @typedef {AsyncGlobalDataFunction} AsyncGlobalDataFunction */ /** - * @template {Record} T - * @typedef {import('./lib/build-pages/page-builders/page-writer.js').PageFunction} PageFunction + * @template {Record} Vars - The type of variables passed to the page function + * @template [PageReturn=any] PageReturn - The return type of the page function (defaults to any) + * @typedef {PageFunction} PageFunction */ /** - * @template {Record} T - * @typedef {import('./lib/build-pages/page-builders/template-builder.js').TemplateFunction} TemplateFunction + * @template {Record} Vars - The type of variables passed to the async page function + * @template [PageReturn=any] PageReturn - The return type of the page function (defaults to any) + * @typedef {AsyncPageFunction} AsyncPageFunction */ /** - * @template {Record} T - * @typedef {import('./lib/build-pages/page-builders/template-builder.js').TemplateAsyncIterator} TemplateAsyncIterator + * @template {Record} Vars - The type of variables for the template function + * @typedef {TemplateFunction} TemplateFunction */ /** - * @typedef {import('./lib/build-pages/page-builders/template-builder.js').TemplateOutputOverride} TemplateOutputOverride + * @template {Record} Vars - The type of variables for the template async iterator + * @typedef {TemplateAsyncIterator} TemplateAsyncIterator + */ + +/** + * @typedef {TemplateOutputOverride} TemplateOutputOverride */ const DEFAULT_IGNORES = /** @type {const} */ ([ @@ -60,15 +114,37 @@ const DEFAULT_IGNORES = /** @type {const} */ ([ ]) /** - * @template {TopBunOpts} [CurrentOpts=TopBunOpts] + * @template {DomStackOpts} [CurrentOpts=DomStackOpts] - The type of options for the DomStack instance */ -export class TopBun { +export class DomStack { /** @type {string} */ #src = '' /** @type {string} */ #dest = '' /** @type {Readonly} */ opts /** @type {FSWatcher?} */ #watcher = null /** @type {any[]?} */ #cpxWatchers = null /** @type {browserSync.BrowserSyncInstance?} */ #browserSyncServer = null + /** @type {BuildContext?} */ #esbuildContext = null + /** @type {SiteData?} */ #siteData = null + + // Watch maps (rebuilt after every full rebuild) + /** @type {Map>} depFilepath β†’ Set */ + #layoutDepMap = new Map() + /** @type {Map>} layoutName β†’ Set */ + #layoutPageMap = new Map() + /** @type {Map} filepath β†’ PageInfo */ + #pageFileMap = new Map() + /** @type {Map} filepath β†’ layoutName */ + #layoutFileMap = new Map() + /** @type {Map>} depFilepath β†’ Set */ + #pageDepMap = new Map() + /** @type {Map>} depFilepath β†’ Set */ + #templateDepMap = new Map() + /** @type {Set} absolute filepaths of esbuild entry points */ + #esbuildEntryPoints = new Set() + + // Serialized lock so concurrent chokidar events don't pile up + /** @type {Promise} */ + #buildLock = Promise.resolve() /** * @@ -118,7 +194,7 @@ export class TopBun { } /** - * Build and watch a top-bun build + * Build and watch a domstack build * @param {object} [params] * @param {boolean} params.serve * @return {Promise} @@ -130,18 +206,47 @@ export class TopBun { }) { if (this.watching) throw new Error('Already watching.') - /** @type Results */ - let report + // ── Initial build (inline, not via builder()) ──────────────────────── + const siteData = await identifyPages(this.#src, this.opts) + if (siteData.errors.length > 0) { + throw new DomStackAggregateError(siteData.errors, 'Page walk finished but there were errors.', siteData) + } + + await ensureDest(this.#dest, siteData) + + // Start esbuild in watch mode (stable filenames, no hash) + let esbuildContext try { - report = await builder(this.#src, this.#dest, { ...this.opts, static: false }) + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + esbuildContext = context + } catch (err) { + throw new Error('Error starting esbuild watch context', { cause: err }) + } + this.#esbuildContext = esbuildContext + this.#siteData = siteData + + // Build pages (initial full build) + let report + try { + const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, this.opts) + report = { + warnings: [...siteData.warnings, ...pageBuildResults.warnings], + siteData, + pageBuildResults, + } + buildLogger(report) console.log('Initial JS, CSS and Page Build Complete') } catch (err) { errorLogger(err) - if (!(err instanceof TopBunAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) + if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) report = err.results } + // Build watch maps after initial build + await this.#rebuildMaps(siteData) + + // ── Copy watchers & browser-sync ───────────────────────────────────── const copyDirs = getCopyDirs(this.opts.copy) this.#cpxWatchers = [ @@ -175,57 +280,438 @@ export class TopBun { }) }) + // ── Chokidar watcher ───────────────────────────────────────────────── const ig = ignore().add(this.opts.ignore ?? []) const anymatch = (/** @type {string} */name) => ig.ignores(relname(this.#src, name)) const watcher = chokidar.watch(this.#src, { /** - * Determines whether a given path should be ignored by the watcher. - * - * @param {string} filePath - The path to the file or directory. - * @param {Stats} [stats] - The stats object for the path (may be undefined). - * @returns {boolean} - Returns true if the path should be ignored. - */ + * Determines whether a given path should be ignored by the watcher. + * + * @param {string} filePath - The path to the file or directory. + * @param {Stats} [stats] - The stats object for the path (may be undefined). + * @returns {boolean} - Returns true if the path should be ignored. + */ ignored: (filePath, stats) => { - // Combine your existing 'anymatch' function with the new extension check return ( anymatch(filePath) || - Boolean((stats?.isFile() && !/\.(js|css|html|md)$/.test(filePath))) + Boolean((stats?.isFile() && !/\.(js|mjs|cjs|ts|mts|cts|css|html|md)$/.test(filePath))) ) }, persistent: true, + // Increase the atomic write window so editors that do slow atomic saves + // (write to a temp file then rename) emit a `change` event rather than + // `unlink` + `add`, which would otherwise trigger unnecessary full rebuilds. + atomic: 300, }) - this._watcher = watcher + this.#watcher = watcher await once(watcher, 'ready') + const enqueue = (/** @type {() => Promise} */ fn) => { + this.#buildLock = this.#buildLock.then(() => fn().catch(errorLogger)) + } + watcher.on('add', path => { - console.log(`File ${path} has been added`) - builder(this.#src, this.#dest, { ...this.opts, static: false }) - .then(buildLogger) - .catch(errorLogger) + enqueue(() => this.#handleAddUnlink(path, 'added')) }) watcher.on('change', path => { assert(this.#src) assert(this.#dest) - console.log(`File ${path} has been changed`) - builder(this.#src, this.#dest, { ...this.opts, static: false }) - .then(buildLogger) - .catch(errorLogger) + enqueue(() => this.#handleChange(path)) }) watcher.on('unlink', path => { - console.log(`File ${path} has been removed`) - builder(this.#src, this.#dest, { ...this.opts, static: false }) - .then(buildLogger) - .catch(errorLogger) + enqueue(() => this.#handleAddUnlink(path, 'removed')) }) watcher.on('error', errorLogger) return report } + /** + * Full rebuild: re-identify pages, restart esbuild, rebuild all pages, rebuild maps. + * Used for structural changes (add/unlink), global.vars.*, esbuild.settings.*. + */ + async #fullRebuild () { + console.log('Triggering full rebuild...') + // Dispose the old esbuild context + if (this.#esbuildContext) { + await this.#esbuildContext.dispose() + this.#esbuildContext = null + } + + const siteData = await identifyPages(this.#src, this.opts) + + if (siteData.errors.length > 0) { + console.error('identifyPages errors:') + for (const err of siteData.errors) console.error(' ', err.message) + return + } + + await ensureDest(this.#dest, siteData) + + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + this.#esbuildContext = context + this.#siteData = siteData + + await this.#runPageBuild(siteData) + await this.#rebuildMaps(siteData) + } + + /** + * Handle file add/unlink events. Categorizes the file to determine the minimal rebuild: + * - esbuild entry point added/removed: restart esbuild + targeted page rebuild + * - Otherwise: full rebuild (structural change to the page/layout/template set) + * + * @param {string} changedPath - Absolute path of the added/removed file. + * @param {'added' | 'removed'} event - The type of event. + */ + async #handleAddUnlink (changedPath, event) { + const changedBasename = basename(changedPath) + + // Check if this is an esbuild entry point by basename pattern + const isEsbuildEntry = ( + pageClientNames.includes(changedBasename) || + layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || + changedBasename.endsWith(layoutStyleSuffix) || + pageWorkerSuffixs.some(s => changedBasename.endsWith(s)) || + globalClientNames.includes(changedBasename) || + globalStyleNames.includes(changedBasename) || + changedBasename === pageStyleName + ) + + if (isEsbuildEntry) { + console.log(`"${changedBasename}" ${event}, restarting esbuild...`) + + // Re-identify pages to discover the new/removed entry point + const siteData = await identifyPages(this.#src, this.opts) + if (siteData.errors.length > 0) { + console.error('identifyPages errors:') + for (const err of siteData.errors) console.error(' ', err.message) + return + } + + await ensureDest(this.#dest, siteData) + + // Restart esbuild with updated entry points + if (this.#esbuildContext) { + await this.#esbuildContext.dispose() + this.#esbuildContext = null + } + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + this.#esbuildContext = context + this.#siteData = siteData + + // Determine which pages are affected by this entry point change + const changedDir = relative(this.#src, dirname(changedPath)) + + if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { + // Global asset: rebuild all pages + logRebuildTree(changedBasename, new Set(siteData.pages)) + await this.#runPageBuild(siteData) + } else if (layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || changedBasename.endsWith(layoutStyleSuffix)) { + // Layout asset: rebuild pages using that layout + const layoutName = Object.values(siteData.layouts).find(l => + l.layoutClient?.filepath === changedPath || l.layoutStyle?.filepath === changedPath + )?.layoutName + if (layoutName) { + // Rebuild maps first so layoutPageMap is current + await this.#rebuildMaps(siteData) + const affectedPages = this.#layoutPageMap.get(layoutName) + if (affectedPages && affectedPages.size > 0) { + logRebuildTree(changedBasename, affectedPages) + const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) + await this.#runPageBuild(siteData, pageFilterPaths, []) + return + } + } + // Couldn't determine layout β€” rebuild all pages to be safe + await this.#runPageBuild(siteData) + } else { + // Page-level asset (client.*, style.css, *.worker.*): rebuild only that page + const affectedPage = siteData.pages.find(p => p.path === changedDir) + if (affectedPage) { + logRebuildTree(changedBasename, new Set([affectedPage])) + await this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], []) + } else { + // Page not found (maybe it was removed) β€” rebuild all pages + await this.#runPageBuild(siteData) + } + } + + await this.#rebuildMaps(siteData) + } else { + // Non-esbuild file: structural change (page, layout, template, config, etc.) + console.log(`"${changedBasename}" ${event}, triggering full rebuild...`) + return this.#fullRebuild() + } + } + + /** + * Full page rebuild only: re-run all pages+templates with existing esbuild context. + * Used for global.data.*, markdown-it.settings.* (all md pages). + * + * @param {SiteData} siteData + * @param {string[] | null} [pageFilterPaths] + * @param {string[] | null} [templateFilterPaths] + */ + async #runPageBuild (siteData, pageFilterPaths = null, templateFilterPaths = null) { + try { + const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, { + ...this.opts, + ...(pageFilterPaths ? { pageFilterPaths } : {}), + ...(templateFilterPaths ? { templateFilterPaths } : {}), + }) + const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null + buildLogger( + isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, + isFiltered ? this.#dest : undefined + ) + } catch (err) { + errorLogger(err) + } + } + + /** + * Build and maintain the six watch maps from siteData. + * `find()` returns CWD-relative paths; we resolve them to absolute for map keys. + * + * @param {SiteData} siteData + */ + async #rebuildMaps (siteData) { + const layoutDepMap = /** @type {Map>} */ (new Map()) + const layoutPageMap = /** @type {Map>} */ (new Map()) + const pageFileMap = /** @type {Map} */ (new Map()) + const layoutFileMap = /** @type {Map} */ (new Map()) + const pageDepMap = /** @type {Map>} */ (new Map()) + const templateDepMap = /** @type {Map>} */ (new Map()) + + // layoutFileMap: layout filepath β†’ layoutName + for (const layout of Object.values(siteData.layouts)) { + layoutFileMap.set(layout.filepath, layout.layoutName) + } + + // layoutDepMap: dep filepath β†’ Set + for (const layout of Object.values(siteData.layouts)) { + try { + const deps = await find(layout.filepath) + for (const dep of deps) { + const absPath = resolve(dep) + if (!layoutDepMap.has(absPath)) layoutDepMap.set(absPath, new Set()) + layoutDepMap.get(absPath)?.add(layout.layoutName) + } + } catch { + // dep analysis is best-effort + } + } + + // layoutPageMap: layoutName β†’ Set + // Build by reading each page's vars file (lightweight, no full render) + const defaultVars = /** @type {{ layout?: string }} */ (await resolveVars({ + varsPath: resolve(import.meta.dirname, 'lib/defaults/default.vars.js'), + })) + const bareGlobalVars = /** @type {{ layout?: string }} */ (await resolveVars({ + varsPath: siteData?.globalVars?.filepath, + })) + const globalVars = { ...defaultVars, ...bareGlobalVars } + const defaultLayout = globalVars.layout ?? 'root' + + for (const pageInfo of siteData.pages) { + let layoutName = defaultLayout + if (pageInfo.pageVars) { + try { + const pageVars = /** @type {{ layout?: string }} */ (await resolveVars({ varsPath: pageInfo.pageVars.filepath })) + if (typeof pageVars.layout === 'string') layoutName = pageVars.layout + } catch { + // fall back to default + } + } + if (!layoutPageMap.has(layoutName)) layoutPageMap.set(layoutName, new Set()) + layoutPageMap.get(layoutName)?.add(pageInfo) + } + + // pageFileMap: page filepath & page.vars filepath β†’ PageInfo + for (const pageInfo of siteData.pages) { + pageFileMap.set(pageInfo.pageFile.filepath, pageInfo) + if (pageInfo.pageVars) pageFileMap.set(pageInfo.pageVars.filepath, pageInfo) + } + + // pageDepMap: dep filepath β†’ Set + for (const pageInfo of siteData.pages) { + const filesToTrack = [pageInfo.pageFile.filepath] + if (pageInfo.pageVars) filesToTrack.push(pageInfo.pageVars.filepath) + for (const file of filesToTrack) { + try { + const deps = await find(file) + for (const dep of deps) { + const absPath = resolve(dep) + if (!pageDepMap.has(absPath)) pageDepMap.set(absPath, new Set()) + pageDepMap.get(absPath)?.add(pageInfo) + } + } catch { + // best-effort + } + } + } + + // templateDepMap: dep filepath β†’ Set + for (const templateInfo of siteData.templates) { + try { + const deps = await find(templateInfo.templateFile.filepath) + for (const dep of deps) { + const absPath = resolve(dep) + if (!templateDepMap.has(absPath)) templateDepMap.set(absPath, new Set()) + templateDepMap.get(absPath)?.add(templateInfo) + } + } catch { + // best-effort + } + } + + // esbuildEntryPoints: absolute filepaths of all esbuild entry points + const esbuildEntryPoints = /** @type {Set} */ (new Set()) + if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath)) + if (siteData.globalStyle) esbuildEntryPoints.add(resolve(siteData.globalStyle.filepath)) + for (const page of siteData.pages) { + if (page.clientBundle) esbuildEntryPoints.add(resolve(page.clientBundle.filepath)) + if (page.pageStyle) esbuildEntryPoints.add(resolve(page.pageStyle.filepath)) + if (page.workers) { + for (const w of Object.values(page.workers)) esbuildEntryPoints.add(resolve(w.filepath)) + } + } + for (const layout of Object.values(siteData.layouts)) { + if (layout.layoutClient) esbuildEntryPoints.add(resolve(layout.layoutClient.filepath)) + if (layout.layoutStyle) esbuildEntryPoints.add(resolve(layout.layoutStyle.filepath)) + } + + this.#layoutDepMap = layoutDepMap + this.#layoutPageMap = layoutPageMap + this.#pageFileMap = pageFileMap + this.#layoutFileMap = layoutFileMap + this.#pageDepMap = pageDepMap + this.#templateDepMap = templateDepMap + this.#esbuildEntryPoints = esbuildEntryPoints + } + + /** + * Chokidar change handler β€” implements the decision tree from the plan. + * + * @param {string} changedPath - Absolute path of the changed file. + */ + async #handleChange (changedPath) { + const siteData = this.#siteData + if (!siteData) return + + const changedBasename = basename(changedPath) + + // 2. global.vars.* β†’ full rebuild (esbuild restart + all pages) + if (globalVarsNames.some(n => changedBasename === n)) { + console.log(`"${changedBasename}" changed, triggering full rebuild...`) + return this.#fullRebuild() + } + + // 3. global.data.* β†’ full page rebuild (no esbuild restart) + if (globalDataNames.some(n => changedBasename === n)) { + console.log(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runPageBuild(siteData) + } + + // 4. esbuild.settings.* β†’ full rebuild + if (esbuildSettingsNames.some(n => changedBasename === n)) { + console.log(`"${changedBasename}" changed, triggering full rebuild...`) + return this.#fullRebuild() + } + + // 5. markdown-it.settings.* β†’ rebuild all md pages only + if (markdownItSettingsNames.some(n => changedBasename === n)) { + const mdPages = new Set(siteData.pages.filter(p => p.type === 'md')) + logRebuildTree(changedBasename, mdPages) + return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), []) + } + + // 6. esbuild entry point (client.js, style.css, .layout.css, .layout.client.*, *.worker.*, global.client.*, global.css) + // esbuild's own watcher handles these. Stable filenames mean page HTML doesn't + // change, so no page rebuild is needed. + if (this.#esbuildEntryPoints.has(changedPath)) { + console.log(`"${changedBasename}" changed, esbuild will handle rebundling.`) + return + } + + // 7. Layout file itself β†’ rebuild pages using that layout + if (layoutSuffixs.some(s => changedBasename.endsWith(s))) { + const layoutName = this.#layoutFileMap.get(changedPath) + if (layoutName) { + const affectedPages = this.#layoutPageMap.get(layoutName) + if (affectedPages && affectedPages.size > 0) { + logRebuildTree(changedBasename, affectedPages) + const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) + return this.#runPageBuild(siteData, pageFilterPaths, []) + } + console.log(`"${changedBasename}" changed but no pages use layout "${layoutName}", skipping.`) + return + } + // Not a registered layout β€” fall through to dep checks + } + + // 8. Dep of a layout + if (this.#layoutDepMap.has(changedPath)) { + const affectedLayoutNames = this.#layoutDepMap.get(changedPath) ?? new Set() + const affectedPages = new Set(/** @type {PageInfo[]} */ ([])) + for (const layoutName of affectedLayoutNames) { + const pages = this.#layoutPageMap.get(layoutName) + if (pages) for (const p of pages) affectedPages.add(p) + } + if (affectedPages.size > 0) { + logRebuildTree(changedBasename, affectedPages) + const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) + return this.#runPageBuild(siteData, pageFilterPaths, []) + } + } + + // 9. Page file or page.vars file + if (this.#pageFileMap.has(changedPath)) { + const affectedPage = this.#pageFileMap.get(changedPath) + if (affectedPage) { + logRebuildTree(changedBasename, new Set([affectedPage])) + return this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], []) + } + } + + // 10. Template file itself + if (templateSuffixs.some(s => changedBasename.endsWith(s))) { + const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath) + if (templateInfo) { + logRebuildTree(changedBasename, undefined, new Set([templateInfo])) + return this.#runPageBuild(siteData, [], [templateInfo.templateFile.filepath]) + } + } + + // 11. Dep of a page.js or page.vars + if (this.#pageDepMap.has(changedPath)) { + const affectedPages = this.#pageDepMap.get(changedPath) ?? new Set() + if (affectedPages.size > 0) { + logRebuildTree(changedBasename, affectedPages) + const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) + return this.#runPageBuild(siteData, pageFilterPaths, []) + } + } + + // 12. Dep of a template file + if (this.#templateDepMap.has(changedPath)) { + const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set() + if (affectedTemplates.size > 0) { + logRebuildTree(changedBasename, undefined, affectedTemplates) + const templateFilterPaths = Array.from(affectedTemplates).map(t => t.templateFile.filepath) + return this.#runPageBuild(siteData, [], templateFilterPaths) + } + } + + // 13. No matching rule β€” skip. + console.log(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) + } + async stopWatching () { if ((!this.watching || !this.#cpxWatchers)) throw new Error('Not watching') if (this.#watcher) this.#watcher.close() @@ -234,9 +720,21 @@ export class TopBun { }) this.#watcher = null this.#cpxWatchers = null + if (this.#esbuildContext) { + await this.#esbuildContext.dispose() + this.#esbuildContext = null + } this.#browserSyncServer?.exit() // This will kill the process this.#browserSyncServer = null } + + /** + * Returns a promise that resolves when all queued rebuilds have finished. + * @returns {Promise} + */ + async settled () { + await this.#buildLock + } } /** @@ -249,6 +747,23 @@ function relname (root, name) { return root === name ? basename(name) : relative(root, name) } +/** + * Log a rebuild tree showing what triggered a rebuild and what will be rebuilt. + * @param {string} trigger - The changed file (display name) + * @param {Set} [pages] + * @param {Set} [templates] + */ +function logRebuildTree (trigger, pages, templates) { + const lines = [`"${trigger}" changed:`] + for (const p of pages ?? []) { + lines.push(` β†’ ${p.outputRelname}`) + } + for (const t of templates ?? []) { + lines.push(` β†’ ${t.outputName} (template)`) + } + console.log(lines.join('\n')) +} + /** * An error logger * @param {Error | AggregateError | any } err The error to log @@ -263,16 +778,17 @@ function errorLogger (err) { } /** - * An build logger - * @param {Results} results + * Log build results. + * @param {Partial | WorkerBuildStepResult} results + * @param {string} [dest] - dest path for relativizing output paths in filtered builds */ -function buildLogger (results) { - if (results?.warnings?.length > 0) { +function buildLogger (results, dest) { + if ((results?.warnings?.length ?? 0) > 0) { console.log( '\nThere were build warnings:\n' ) } - for (const warning of results?.warnings) { + for (const warning of results?.warnings ?? []) { if ('message' in warning) { console.log(` ${warning.message}`) } else { @@ -280,6 +796,28 @@ function buildLogger (results) { } } - console.log(`Pages: ${results.siteData.pages.length} Layouts: ${Object.keys(results.siteData.layouts).length} Templates: ${results.siteData.templates.length}`) + if ('siteData' in results && results.siteData) { + // Full build: show site totals + const layoutCount = Object.keys(results.siteData.layouts).length + console.log(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) + const report = results.pageBuildResults?.report + if (report) { + console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + } + } else if ('report' in results && results.report) { + // Filtered build: show what was actually built + const report = results.report + if (dest) { + for (const p of report.pages) { + console.log(` Built ${relative(dest, p.pageFilePath)}`) + } + for (const t of report.templates) { + for (const output of t.outputs ?? []) { + console.log(` Built ${output}`) + } + } + } + console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + } console.log('\nBuild Success!\n\n') } diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js index 983705c..5b81b9e 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -1,20 +1,18 @@ -// @ts-ignore +/** + * @import { BuildStepResult, BuildStep } from '../builder.js' + */ + +// @ts-expect-error import cpx from 'cpx2' import { join } from 'node:path' const copy = cpx.copy /** + * @typedef {BuildStepResult<'static', CopyBuilderReport>} CopyBuildStepResult + * @typedef {BuildStep<'static', CopyBuilderReport>} CopyBuildStep * @typedef {Awaited>} CopyBuilderReport */ -/** - * @typedef {import('../builder.js').BuildStepResult<'static', CopyBuilderReport>} CopyBuildStepResult - */ - -/** - * @typedef {import('../builder.js').BuildStep<'static', CopyBuilderReport>} CopyBuildStep - */ - /** * @param {string[]} copy * @return {string[]} diff --git a/lib/build-copy/index.test.js b/lib/build-copy/index.test.js index ec47797..4651210 100644 --- a/lib/build-copy/index.test.js +++ b/lib/build-copy/index.test.js @@ -1,8 +1,11 @@ -import tap from 'tap' +import { test } from 'node:test' +import assert from 'node:assert' import { getCopyDirs } from './index.js' -tap.test('getCopyDirs returns correct src/dest pairs', async (t) => { - const copyDirs = getCopyDirs(['fixtures']) +test.describe('build-copy', () => { + test('getCopyDirs returns correct src/dest pairs', async () => { + const copyDirs = getCopyDirs(['fixtures']) - t.strictSame(copyDirs, ['fixtures/**']) + assert.deepStrictEqual(copyDirs, ['fixtures/**']) + }) }) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 0619321..794a5f5 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -1,10 +1,14 @@ +/** + * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' + */ + import { writeFile } from 'fs/promises' import { join, relative, basename } from 'path' import esbuild from 'esbuild' import { resolveVars } from '../build-pages/resolve-vars.js' const __dirname = import.meta.dirname -const TOP_BUN_DEFAULTS_PREFIX = 'top-bun-defaults' +const DOM_STACK_DEFAULTS_PREFIX = 'dom-stack-defaults' /** * @typedef {esbuild.Format} EsbuildFormat @@ -13,7 +17,7 @@ const TOP_BUN_DEFAULTS_PREFIX = 'top-bun-defaults' * @typedef {esbuild.BuildOptions} EsbuildBuildOptions * @typedef {Awaited>} EsbuildBuildResults - * @typedef {import('../builder.js').BuildStep< + * @typedef {BuildStep< * 'esbuild', * { * buildResults?: EsbuildBuildResults @@ -23,28 +27,138 @@ const TOP_BUN_DEFAULTS_PREFIX = 'top-bun-defaults' * >} EsBuildStep */ -/** @typedef {Awaited>} EsBuildStepResults +/** + * @typedef {Awaited>} EsBuildStepResults */ /** - * Build all of the bundles using esbuild. + * Extract a relpathβ†’relpath output map from esbuild metafile outputs. * - * @type {EsBuildStep} + * @param {esbuild.Metafile} metafile + * @param {string} src + * @param {string} dest + * @returns {OutputMap} */ -export async function buildEsbuild (src, dest, siteData, opts) { +function extractOutputMap (metafile, src, dest) { + /** @type {OutputMap} */ + const outputMap = {} + Object.keys(metafile.outputs).forEach(file => { + const entryPoint = metafile.outputs[file]?.entryPoint + if (entryPoint) { + outputMap[relative(src, entryPoint)] = relative(dest, file) + } + }) + return outputMap +} + +/** + * Stamp output relpaths from the outputMap back onto siteData in place. + * + * @param {OutputMap} outputMap + * @param {SiteData} siteData + */ +function updateSiteDataOutputPaths (outputMap, siteData) { + for (const page of siteData.pages) { + if (page.pageStyle) { + const outputRelname = outputMap[page.pageStyle.relname] + if (outputRelname) { + page.pageStyle.outputRelname = outputRelname + page.pageStyle.outputName = basename(outputRelname) + } + } + + if (page.clientBundle) { + const outputRelname = outputMap[page.clientBundle.relname] + if (outputRelname) { + page.clientBundle.outputRelname = outputRelname + page.clientBundle.outputName = basename(outputRelname) + } + } + + if (page.workers) { + for (const workerFile of Object.values(page.workers)) { + const outputRelname = outputMap[workerFile.relname] + if (outputRelname) { + workerFile.outputRelname = outputRelname + workerFile.outputName = basename(outputRelname) + } + } + } + } + + if (siteData.globalClient) { + const outputRelname = outputMap[siteData.globalClient.relname] + if (outputRelname) { + siteData.globalClient.outputRelname = outputRelname + siteData.globalClient.outputName = basename(outputRelname) + } + } + + if (siteData.globalStyle) { + const outputRelname = outputMap[siteData.globalStyle.relname] + if (outputRelname) { + siteData.globalStyle.outputRelname = outputRelname + siteData.globalStyle.outputName = basename(outputRelname) + } + } + + for (const layout of Object.values(siteData.layouts)) { + if (layout.layoutStyle) { + const outputRelname = outputMap[layout.layoutStyle.relname] + if (outputRelname) { + layout.layoutStyle.outputRelname = outputRelname + layout.layoutStyle.outputName = basename(outputRelname) + } + } + + if (layout.layoutClient) { + const outputRelname = outputMap[layout.layoutClient.relname] + if (outputRelname) { + layout.layoutClient.outputRelname = outputRelname + layout.layoutClient.outputName = basename(outputRelname) + } + } + } + + if (siteData.defaultLayout) { + const defaultClient = Object.values(outputMap).find(p => /^dom-stack-defaults.*\.js$/.test(p)) + const defaultStyle = Object.values(outputMap).find(p => /^dom-stack-defaults.*\.css$/.test(p)) + siteData.defaultClient = defaultClient ?? null + siteData.defaultStyle = defaultStyle ?? null + } +} + +/** + * Assemble the esbuild entry points and define map from siteData + opts. + * Shared between one-shot build and watch context creation. + * + * @param {string} src + * @param {string} dest + * @param {SiteData} siteData + * @param {DomStackOpts | null} opts + * @param {{ watch?: boolean }} [modeOpts] + * @returns {Promise} + */ +async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) { const entryPoints = [] if (siteData.globalClient) entryPoints.push(join(src, siteData.globalClient.relname)) if (siteData.globalStyle) entryPoints.push(join(src, siteData.globalStyle.relname)) if (siteData.defaultLayout) { entryPoints.push( - { in: join(__dirname, '../defaults/default.style.css'), out: join(TOP_BUN_DEFAULTS_PREFIX, 'default.style.css') }, - { in: join(__dirname, '../defaults/default.client.js'), out: join(TOP_BUN_DEFAULTS_PREFIX, 'default.client.js') } + { in: join(__dirname, '../defaults/default.style.css'), out: join(DOM_STACK_DEFAULTS_PREFIX, 'default.style.css') }, + { in: join(__dirname, '../defaults/default.client.js'), out: join(DOM_STACK_DEFAULTS_PREFIX, 'default.client.js') } ) } for (const page of siteData.pages) { if (page.clientBundle) entryPoints.push(join(src, page.clientBundle.relname)) if (page.pageStyle) entryPoints.push(join(src, page.pageStyle.relname)) + + if (page.workers) { + for (const workerFile of Object.values(page.workers)) { + entryPoints.push(join(src, workerFile.relname)) + } + } } for (const layout of Object.values(siteData.layouts)) { @@ -57,24 +171,19 @@ export async function buildEsbuild (src, dest, siteData, opts) { key: 'browser', }) - /** @type {{ - * [varName: string]: any - * }} [description] */ + /** @type {{ [varName: string]: any }} */ const define = {} - if (browserVars) { for (const [k, v] of Object.entries(browserVars)) { define[k] = JSON.stringify(v) } } - /** - * Represents a mapping from relpaths to strings. - * @typedef {{[relpath: string]: string}} OutputMap - */ - const target = Array.isArray(opts?.target) ? opts.target : [] + const watch = modeOpts.watch ?? false + + /** @type {esbuild.BuildOptions} */ const buildOpts = { entryPoints, /** @type {EsbuildLogLevel} */ @@ -90,8 +199,9 @@ export async function buildEsbuild (src, dest, siteData, opts) { target, define, metafile: true, - entryNames: '[dir]/[name]-[hash]', - chunkNames: 'chunks/[ext]/[name]-[hash]', + // In watch mode use stable (unhashed) filenames so the output map never changes. + entryNames: watch ? '[dir]/[name]' : '[dir]/[name]-[hash]', + chunkNames: watch ? 'chunks/[ext]/[name]' : 'chunks/[ext]/[name]-[hash]', jsx: 'automatic', jsxImportSource: 'preact' } @@ -102,81 +212,34 @@ export async function buildEsbuild (src, dest, siteData, opts) { const extendedBuildOpts = await esbuildSettingsExtends(buildOpts) - try { - // @ts-ignore This actually works fine - const buildResults = await esbuild.build(extendedBuildOpts) - if (buildResults.metafile) { - await writeFile(join(dest, 'top-bun-esbuild-meta.json'), JSON.stringify(buildResults.metafile, null, ' ')) - } - - /** @type {OutputMap} */ - const outputMap = {} - Object.keys(buildResults?.metafile?.outputs || {}).forEach(file => { - const entryPoint = buildResults?.metafile?.outputs[file]?.entryPoint - if (entryPoint) { - outputMap[relative(src, entryPoint)] = relative(dest, file) - } - }) - - // Add output names to siteData - for (const page of siteData.pages) { - if (page.pageStyle) { - const outputRelname = outputMap[page.pageStyle.relname] - if (outputRelname) { - page.pageStyle.outputRelname = outputRelname - page.pageStyle.outputName = basename(outputRelname) - } - } - - if (page.clientBundle) { - const outputRelname = outputMap[page.clientBundle.relname] - if (outputRelname) { - page.clientBundle.outputRelname = outputRelname - page.clientBundle.outputName = basename(outputRelname) - } - } - } + if (browserVars && Object.keys(browserVars).length > 0 && extendedBuildOpts.define !== buildOpts.define) { + throw new Error( + 'Conflict: both the "browser" export in global.vars and "define" in esbuild.settings are set. ' + + 'Use one or the other to define browser constants.' + ) + } - if (siteData.globalClient) { - const outputRelname = outputMap[siteData.globalClient.relname] - if (outputRelname) { - siteData.globalClient.outputRelname = outputRelname - siteData.globalClient.outputName = basename(outputRelname) - } - } + return extendedBuildOpts +} - if (siteData.globalStyle) { - const outputRelname = outputMap[siteData.globalStyle.relname] - if (outputRelname) { - siteData.globalStyle.outputRelname = outputRelname - siteData.globalStyle.outputName = basename(outputRelname) - } - } +/** + * Build all of the bundles using esbuild. + * + * @type {EsBuildStep} + */ +export async function buildEsbuild (src, dest, siteData, opts) { + try { + const extendedBuildOpts = await assembleBuildOpts(src, dest, siteData, opts, { watch: false }) - for (const layout of Object.values(siteData.layouts)) { - if (layout.layoutStyle) { - const outputRelname = outputMap[layout.layoutStyle.relname] - if (outputRelname) { - layout.layoutStyle.outputRelname = outputRelname - layout.layoutStyle.outputName = basename(outputRelname) - } - } + // @ts-ignore This actually works fine + const buildResults = await esbuild.build(extendedBuildOpts) - if (layout.layoutClient) { - const outputRelname = outputMap[layout.layoutClient.relname] - if (outputRelname) { - layout.layoutClient.outputRelname = outputRelname - layout.layoutClient.outputName = basename(outputRelname) - } - } + if (buildResults.metafile) { + await writeFile(join(dest, 'dom-stack-esbuild-meta.json'), JSON.stringify(buildResults.metafile, null, ' ')) } - if (siteData.defaultLayout) { - const defaultClient = Object.values(outputMap).find(p => /^top-bun-defaults.*\.js$/.test(p)) - const defaultStyle = Object.values(outputMap).find(p => /^top-bun-defaults.*\.css$/.test(p)) - siteData.defaultClient = defaultClient ?? null - siteData.defaultStyle = defaultStyle ?? null - } + const outputMap = buildResults.metafile ? extractOutputMap(buildResults.metafile, src, dest) : {} + updateSiteDataOutputPaths(outputMap, siteData) return { type: 'esbuild', @@ -186,7 +249,7 @@ export async function buildEsbuild (src, dest, siteData, opts) { buildResults, outputMap, // @ts-ignore This is fine - buildOpts, + buildOpts: extendedBuildOpts, }, } } catch (err) { @@ -200,3 +263,58 @@ export async function buildEsbuild (src, dest, siteData, opts) { } } } + +/** + * Create an esbuild watch context with stable (unhashed) output filenames. + * Calls onEnd after each rebuild. Returns the context for disposal. + * + * @param {string} src + * @param {string} dest + * @param {SiteData} siteData + * @param {DomStackOpts} opts + * @param {{ onEnd?: (result: esbuild.BuildResult) => void }} [watchOpts] + * @returns {Promise<{ context: esbuild.BuildContext, outputMap: OutputMap }>} + */ +export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = {}) { + const extendedBuildOpts = await assembleBuildOpts(src, dest, siteData, opts, { watch: true }) + + const plugins = extendedBuildOpts.plugins ?? [] + + /** @type {esbuild.Plugin} */ + const onEndPlugin = { + name: 'domstack-on-end', + setup (build) { + build.onEnd(result => { + if (result.errors.length > 0) { + console.error('JS/CSS rebuild failed:') + for (const err of result.errors) { + console.error(' ', err.text) + } + } else { + console.log('JS/CSS rebuild complete.') + } + if (watchOpts.onEnd) watchOpts.onEnd(result) + }) + } + } + + const contextOpts = { ...extendedBuildOpts, plugins: [...plugins, onEndPlugin] } + + // @ts-ignore esbuild context() accepts same opts as build() + const context = await esbuild.context(contextOpts) + + // Trigger initial build to get the metafile / outputMap + const initialResult = await context.rebuild() + + if (initialResult.metafile) { + await writeFile(join(dest, 'dom-stack-esbuild-meta.json'), JSON.stringify(initialResult.metafile, null, ' ')) + } + + const outputMap = initialResult.metafile ? extractOutputMap(initialResult.metafile, src, dest) : {} + updateSiteDataOutputPaths(outputMap, siteData) + + // Start watching β€” esbuild handles its own rebuild loop from here + await context.watch() + + return { context, outputMap } +} diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index c6f6077..4f13858 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -1,13 +1,17 @@ +/** + * @import { BuilderOptions } from './page-builders/page-writer.js' + * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' + * @import { InternalLayoutFunction } from './page-data.js' + */ + import { Worker } from 'worker_threads' import { join } from 'path' import pMap from 'p-map' import { cpus } from 'os' - import { keyBy } from '../helpers/key-by.js' -import { resolveVars } from './resolve-vars.js' -import { resolveLayout } from './resolve-layout.js' +import { resolveVars, resolveGlobalData } from './resolve-vars.js' import { pageBuilders, templateBuilder } from './page-builders/index.js' -import { PageData } from './page-data.js' +import { PageData, resolveLayout } from './page-data.js' import { pageWriter } from './page-builders/page-writer.js' const MAX_CONCURRENCY = Math.min(cpus().length, 24) @@ -22,25 +26,48 @@ const __dirname = import.meta.dirname */ /** - * @typedef {import('../builder.js').BuildStep< - * 'page', - * PageBuilderReport - * >} PageBuildStep + * Parameters passed to a global.data.js default export function. + * @typedef {object} GlobalDataFunctionParams + * @property {PageData[]} pages - Fully initialized PageData instances for all pages. */ /** - * @typedef {Awaited>} PageBuildStepResult + * Synchronous global.data function. Receives initialized PageData[] (with .vars, .pageInfo, etc.) + * and returns an object stamped onto every page's vars before rendering begins. + * + * @template {Record} [T=Record] - The shape of the derived vars object returned. + * @callback GlobalDataFunction + * @param {GlobalDataFunctionParams} params + * @returns {T | Promise} */ /** - * @template T - * @typedef {import('./resolve-layout.js').LayoutFunction} LayoutFunction + * Asynchronous global.data function. Receives initialized PageData[] (with .vars, .pageInfo, etc.) + * and returns an object stamped onto every page's vars before rendering begins. + * + * @template {Record} [T=Record] - The shape of the derived vars object returned. + * @callback AsyncGlobalDataFunction + * @param {GlobalDataFunctionParams} params + * @returns {Promise} */ /** - * @template T + * @typedef {BuildStep< + * 'page', + * PageBuilderReport + * >} PageBuildStep + */ + +/** + * @typedef {Awaited>} PageBuildStepResult + */ + +/** + * @template {Record} T - The type of variables for the layout + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) * @typedef ResolvedLayout - * @property {LayoutFunction} render - The layout function + * @property {InternalLayoutFunction} render - The layout function * @property {string} name - The name of the layout * @property {string | null} layoutStylePath - The string path to the layout style * @property {string | null} layoutClientPath - The string path to the layout client @@ -50,17 +77,30 @@ const __dirname = import.meta.dirname * @typedef {Omit & { errors: {error: Error, errorData?: object}[] }} WorkerBuildStepResult */ +/** + * Options for filtering which pages/templates to rebuild. + * Uses arrays (not Sets) so they can be structured-cloned across the worker boundary. + * + * @typedef {object} BuildPagesOpts + * @property {string[] | null} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list. + * @property {string[] | null} [templateFilterPaths] - If set, only rebuild templates whose templateFile.filepath is in this list. + */ + export { pageBuilders } /** * Page builder glue. Most of the magic happens in the builders. * - * @type {PageBuildStep} + * @param {string} src + * @param {string} dest + * @param {SiteData} siteData + * @param {DomStackOpts & BuildPagesOpts} [opts] + * @returns {Promise} */ -export function buildPages (src, dest, siteData, _opts) { +export function buildPages (src, dest, siteData, opts) { return new Promise((resolve, reject) => { const worker = new Worker(join(__dirname, 'worker.js'), { - workerData: { src, dest, siteData }, + workerData: { src, dest, siteData, opts }, }) worker.once('message', message => { @@ -99,7 +139,11 @@ export function buildPages (src, dest, siteData, _opts) { * All layouts, variables and page builders need to resolve in here * so that it can be run more than once, after the source files change. * - * @type {(...args: Parameters) => Promise} WorkerBuildStep + * @param {string} src + * @param {string} dest + * @param {SiteData} siteData + * @param {DomStackOpts & BuildPagesOpts} [_opts] + * @returns {Promise} */ export async function buildPagesDirect (src, dest, siteData, _opts) { /** @type {WorkerBuildStepResult} */ @@ -113,6 +157,11 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { warnings: [], } + const pageFilterSet = _opts?.pageFilterPaths ? new Set(_opts.pageFilterPaths) : null + const templateFilterSet = _opts?.templateFilterPaths ? new Set(_opts.templateFilterPaths) : null + + // Note: markdown-it settings are now passed directly to builders through builderOptions + const [ defaultVars, bareGlobalVars, @@ -125,7 +174,7 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { }), ]) - /** @type {ResolvedLayout[]} */ + /** @type {ResolvedLayout[]} */ const resolvedLayoutResults = await pMap(Object.values(siteData.layouts), async (layout) => { const render = await resolveLayout(layout.filepath) return { @@ -146,6 +195,12 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { ...bareGlobalVars, } + // Create builder options from siteData + /** @type {BuilderOptions} */ + const builderOptions = { + markdownItSettingsPath: siteData.markdownItSettings?.filepath || null + } + // Mix in resolveVars, renderInnerPage and renderFullPage methods const pages = await pMap(siteData.pages, async (pageInfo) => { const pageData = new PageData({ @@ -155,6 +210,7 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { globalClient: siteData?.globalClient?.outputRelname, defaultStyle: siteData?.defaultStyle, defaultClient: siteData?.defaultClient, + builderOptions, }) try { // Resolves async vars and binds the page to a reference to its layout fn @@ -168,6 +224,20 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { return pageData }, { concurrency: MAX_CONCURRENCY }) + // Run global.data.js after all pages are initialized β€” receives fully resolved PageData[] + // so it can filter/sort by page.vars.layout, page.vars.publishDate, etc. + const globalDataVars = await resolveGlobalData({ + globalDataPath: siteData.globalData?.filepath, + pages, + }) + + // Stamp globalDataVars onto each page so they appear in page.vars at render time. + if (Object.keys(globalDataVars).length > 0) { + for (const page of pages) { + page.globalDataVars = globalDataVars + } + } + if (result.errors.length > 0) return result /** @type {[number, number]} Divided concurrency valus */ @@ -175,8 +245,17 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { ? [((MAX_CONCURRENCY - 1) / 2) + 1, (MAX_CONCURRENCY - 1) / 2] // odd : [MAX_CONCURRENCY / 2, MAX_CONCURRENCY / 2] // even + // Filter to only requested pages/templates when a filter is active. + const pagesToRender = pageFilterSet + ? pages.filter(p => pageFilterSet.has(p.pageInfo.pageFile.filepath)) + : pages + + const templatesToRender = templateFilterSet + ? siteData.templates.filter(t => templateFilterSet.has(t.templateFile.filepath)) + : siteData.templates + await Promise.all([ - pMap(pages, async (page) => { + pMap(pagesToRender, async (page) => { try { const buildResult = await pageWriter({ src, @@ -192,7 +271,7 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { result.errors.push({ error: buildError, errorData: { page: page.pageInfo } }) } }, { concurrency: dividedConcurrency[0] }), - pMap(siteData.templates, async (template) => { + pMap(templatesToRender, async (template) => { try { const buildResult = await templateBuilder({ src, diff --git a/lib/build-pages/page-builders/fs-path-to-url.test.js b/lib/build-pages/page-builders/fs-path-to-url.test.js index 9062f13..6ebcc5f 100644 --- a/lib/build-pages/page-builders/fs-path-to-url.test.js +++ b/lib/build-pages/page-builders/fs-path-to-url.test.js @@ -1,31 +1,34 @@ -import tap from 'tap' +import { test } from 'node:test' +import assert from 'node:assert' import process from 'process' import { fsPathToUrlPath } from './fs-path-to-url.js' const isWin = process.platform === 'win32' -tap.test('fsPathToUrlPath works for all OS file path types', async (t) => { - const tests = [ - { - input: 'foo/bar/baz', - expect: '/foo/bar/baz', - note: 'unix style paths', - winOnly: false, - }, - { - input: 'foo\\bar\\baz', - expect: '/foo/bar/baz', - note: 'windows style paths', - winOnly: true, - }, - ] +test.describe('fs-path-to-url', () => { + test('fsPathToUrlPath works for all OS file path types', async () => { + const tests = [ + { + input: 'foo/bar/baz', + expect: '/foo/bar/baz', + note: 'unix style paths', + winOnly: false, + }, + { + input: 'foo\\bar\\baz', + expect: '/foo/bar/baz', + note: 'windows style paths', + winOnly: true, + }, + ] - for (const test of tests) { - if (isWin && test.winOnly) { - t.equal(fsPathToUrlPath(test.input), test.expect, test.note) - } else if (!isWin && !test.winOnly) { - t.equal(fsPathToUrlPath(test.input), test.expect, test.note) + for (const test of tests) { + if (isWin && test.winOnly) { + assert.equal(fsPathToUrlPath(test.input), test.expect, test.note) + } else if (!isWin && !test.winOnly) { + assert.equal(fsPathToUrlPath(test.input), test.expect, test.note) + } } - } + }) }) diff --git a/lib/build-pages/page-builders/html/index.js b/lib/build-pages/page-builders/html/index.js index dc20a72..2235715 100644 --- a/lib/build-pages/page-builders/html/index.js +++ b/lib/build-pages/page-builders/html/index.js @@ -1,11 +1,15 @@ +/** + * @import { PageBuilderType } from '../page-writer.js' + */ + import assert from 'node:assert' import { readFile } from 'fs/promises' import Handlebars from 'handlebars' /** * Build all of the bundles using esbuild. - * @template {Record} T - * @type {import('../page-writer.js').PageBuilderType} + * @template {Record} T - The type of variables for the page + * @type {PageBuilderType} */ export async function htmlBuilder ({ pageInfo }) { assert(pageInfo.type === 'html', 'html builder requires a "html" page type') diff --git a/lib/build-pages/page-builders/js/index.js b/lib/build-pages/page-builders/js/index.js index fc192da..8696aec 100644 --- a/lib/build-pages/page-builders/js/index.js +++ b/lib/build-pages/page-builders/js/index.js @@ -1,12 +1,17 @@ +/** + * @import { PageBuilderType } from '../page-writer.js' + */ + import assert from 'node:assert' /** * Build all of the bundles using esbuild. - * @template {Record} T - * @type {import('../page-writer.js').PageBuilderType} + * @template {Record} T - The type of variables for the page + * @template [U=any] U - The return type of the pageLayout function + * @type {PageBuilderType} */ export async function jsBuilder ({ pageInfo }) { - assert(pageInfo.type === 'js', 'js page builder requries "js" page type') + assert(pageInfo.type === 'js', 'js page builder requires "js" page type') const { default: pageLayout, vars } = await import(pageInfo.pageFile.filepath) diff --git a/lib/build-pages/page-builders/md/extract-title-from-md.js b/lib/build-pages/page-builders/md/extract-title-from-md.js new file mode 100644 index 0000000..4392280 --- /dev/null +++ b/lib/build-pages/page-builders/md/extract-title-from-md.js @@ -0,0 +1,28 @@ +import markdownit from 'markdown-it' + +const md = markdownit() + +/** + * Extract the first H1 heading from markdown using markdown-it's token API + * @param {string} markdown + * @returns {string | null} + */ +export function extractFirstH1 (markdown) { + const tokens = md.parse(markdown, {}) + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + // Look for heading_open token with tag 'h1' + if (token && token.type === 'heading_open' && token.tag === 'h1') { + // The next token should be inline with the heading content + const nextToken = tokens[i + 1] + if (nextToken && nextToken.type === 'inline') { + // The inline token's content is the raw text of the heading + return nextToken.content.trim() + } + } + } + + return null +} diff --git a/lib/build-pages/page-builders/md/extract-title-from-md.test.js b/lib/build-pages/page-builders/md/extract-title-from-md.test.js new file mode 100644 index 0000000..eef6773 --- /dev/null +++ b/lib/build-pages/page-builders/md/extract-title-from-md.test.js @@ -0,0 +1,183 @@ +import { test } from 'node:test' +import assert from 'node:assert' + +import { extractFirstH1 } from './extract-title-from-md.js' + +test.describe('extractFirstH1', () => { + test('extracts ATX style H1 headings', async () => { + const tests = [ + { + input: '# Simple Heading', + expect: 'Simple Heading', + note: 'basic ATX H1' + }, + { + input: '# Extra Spaces ', + expect: 'Extra Spaces', + note: 'ATX H1 with extra spaces' + }, + { + input: '# Heading with **bold** and *italic*', + expect: 'Heading with **bold** and *italic*', + note: 'ATX H1 with inline formatting' + }, + { + input: 'Some text\n# First Heading\n## Second Heading', + expect: 'First Heading', + note: 'ATX H1 after other content' + }, + { + input: '## Not H1\n# Real H1\n### Not H1', + expect: 'Real H1', + note: 'ATX H1 between other headings' + } + ] + + for (const testCase of tests) { + const result = extractFirstH1(testCase.input) + assert.equal(result, testCase.expect, testCase.note) + } + }) + + test('extracts Setext style H1 headings', async () => { + const tests = [ + { + input: 'Simple Heading\n==============', + expect: 'Simple Heading', + note: 'basic Setext H1' + }, + { + input: 'Simple Heading\n===', + expect: 'Simple Heading', + note: 'Setext H1 with minimum underline' + }, + { + input: ' Trimmed Heading \n==============', + expect: 'Trimmed Heading', + note: 'Setext H1 with spaces to trim' + }, + { + input: 'Heading with **bold** and *italic*\n==================', + expect: 'Heading with **bold** and *italic*', + note: 'Setext H1 with inline formatting' + }, + { + input: 'Some text\n\nFirst Heading\n=============\n\nMore text', + expect: 'First Heading', + note: 'Setext H1 with surrounding content' + } + ] + + for (const testCase of tests) { + const result = extractFirstH1(testCase.input) + assert.equal(result, testCase.expect, testCase.note) + } + }) + + test('handles edge cases correctly', async () => { + const tests = [ + { + input: '', + expect: null, + note: 'empty string' + }, + { + input: '## Only H2\n### Only H3', + expect: null, + note: 'no H1 present' + }, + { + input: 'Not a heading\n---', + expect: null, + note: 'Setext H2 (dashes) should not match' + }, + { + input: 'Not a heading\n--', + expect: null, + note: 'Setext H2 (dashes) should not match' + }, + { + input: 'Not a heading\n==', + expect: 'Not a heading', + note: 'markdown-it accepts any number of equals for Setext H1' + }, + { + input: '\n========', + expect: null, + note: 'empty line before Setext underline' + }, + { + input: ' # Code block heading', + expect: null, + note: 'indented code block should not match' + }, + { + input: '```\n# Code fence heading\n```', + expect: null, + note: 'fenced code block should not match (simple case)' + } + ] + + for (const testCase of tests) { + const result = extractFirstH1(testCase.input) + assert.equal(result, testCase.expect, testCase.note) + } + }) + + test('prefers first H1 when multiple exist', async () => { + const tests = [ + { + input: '# First ATX\n# Second ATX', + expect: 'First ATX', + note: 'multiple ATX H1s' + }, + { + input: 'First Setext\n============\n\nSecond Setext\n============', + expect: 'First Setext', + note: 'multiple Setext H1s' + }, + { + input: '# ATX First\n\nSetext Second\n=============', + expect: 'ATX First', + note: 'ATX before Setext' + }, + { + input: 'Setext First\n============\n\n# ATX Second', + expect: 'Setext First', + note: 'Setext before ATX' + } + ] + + for (const testCase of tests) { + const result = extractFirstH1(testCase.input) + assert.equal(result, testCase.expect, testCase.note) + } + }) + + test('handles multiline documents correctly', async () => { + const markdown = ` +Some introductory text here +that spans multiple lines + +# The Real Title + +## A subsection + +More content here +` + const result = extractFirstH1(markdown) + assert.equal(result, 'The Real Title', 'finds H1 in realistic document') + }) + + test('handles frontmatter-like content', async () => { + const markdown = `--- +title: Frontmatter Title +--- + +# Actual H1 Title + +Content here` + const result = extractFirstH1(markdown) + assert.equal(result, 'Actual H1 Title', 'ignores frontmatter') + }) +}) diff --git a/lib/build-pages/page-builders/md/get-md.js b/lib/build-pages/page-builders/md/get-md.js index f137d74..a2f41a3 100644 --- a/lib/build-pages/page-builders/md/get-md.js +++ b/lib/build-pages/page-builders/md/get-md.js @@ -31,7 +31,11 @@ const mdOpts = { typographer: true, } -export function getMd () { +/** + * @param {string | null | undefined} [settingsPath] - Path to the markdown-it settings file + * @returns {Promise>} + */ +export async function getMd (settingsPath = null) { const md = markdownIt(mdOpts) .use(markdownItSub) .use(markdownItSup) @@ -52,6 +56,20 @@ export function getMd () { // disable autolinking for filenames md.linkify.tlds('.md', false) // markdown + + // Apply user settings if available + if (settingsPath) { + try { + const settingsModule = await import(settingsPath) + const settingsFunction = settingsModule.default + if (typeof settingsFunction === 'function') { + return await settingsFunction(md) + } + } catch (err) { + console.error('Error loading markdown-it settings:', err) + } + } + return md } @@ -59,11 +77,12 @@ export function getMd () { * Renders markdown, and accepts an optional markdown-it instance * @param {string} mdUnparsed unparsed markdown * @param {object} vars to expose to handlebars - * @param {markdownIt} [md] an instance of markdown - * @return {string} Rendered markdown to html + * @param {markdownIt?} [md] an instance of markdown + * @param {string | null | undefined} [settingsPath] Path to the markdown-it settings file + * @return {Promise} Rendered markdown to html */ -export function renderMd (mdUnparsed, vars, md) { - if (!md) md = getMd() +export async function renderMd (mdUnparsed, vars, md, settingsPath) { + if (!md) md = await getMd(settingsPath) // @ts-ignore if (vars?.vars?.handlebars) { const template = Handlebars.compile(mdUnparsed) @@ -93,11 +112,11 @@ function rewriteLinks (body /*, pretty */) { return body.replace(regex, function (_match, p1, p2, _p3) { const f = p2.toLowerCase() - // root readme - if (f === 'readme') return p1 + '/"' + // root readme or page.md + if (f === 'readme' || f === 'page') return p1 + '/"' - // nested readme - if (f.match(/readme$/)) return p1 + f.replace(/readme$/, '') + '"' + // nested readme or page.md + if (f.match(/readme$/) || f.match(/\/page$/)) return p1 + f.replace(/(readme|page)$/, '') + '"' // pretty url // if (pretty) return p1 + f + '/"' diff --git a/lib/build-pages/page-builders/md/index.js b/lib/build-pages/page-builders/md/index.js index 192ea4d..a69144c 100644 --- a/lib/build-pages/page-builders/md/index.js +++ b/lib/build-pages/page-builders/md/index.js @@ -1,19 +1,27 @@ +/** + * @import markdownIt from 'markdown-it' + * @import { PageBuilderType } from '../page-writer.js' + */ import assert from 'node:assert' import { readFile } from 'fs/promises' import yaml from 'js-yaml' -import * as cheerio from 'cheerio' - import { getMd, renderMd } from './get-md.js' +import { extractFirstH1 } from './extract-title-from-md.js' -const md = getMd() +/** @type {markdownIt | null} */ +let md = null /** * Build all of the bundles using esbuild. - * @template {Record} T - * @type {import('../page-writer.js').PageBuilderType} + * @template {Record} T - The type of variables for the page + * @type {PageBuilderType} */ -export async function mdBuilder ({ pageInfo }) { +export async function mdBuilder ({ pageInfo, options }) { assert(pageInfo.type === 'md', 'md builder requires an "md" page type') + + const markdownItSettingsPath = options?.markdownItSettingsPath || null + + if (!md) md = await getMd(markdownItSettingsPath) const fileContents = await readFile(pageInfo.pageFile.filepath, 'utf8') /** @type {object} */ @@ -30,11 +38,11 @@ export async function mdBuilder ({ pageInfo }) { mdUnparsed = fileContents } - const body = renderMd(mdUnparsed, { handlebars: false, ...frontMatter }, md) - const title = cheerio.load(body)('h1').first().text().trim() + // Extract title from first H1 using markdown-it's token API + const title = extractFirstH1(mdUnparsed) return { vars: Object.assign({ title }, frontMatter), - pageLayout: async (vars) => renderMd(mdUnparsed, vars, md), + pageLayout: async (vars) => await renderMd(mdUnparsed, vars, md, markdownItSettingsPath), } } diff --git a/lib/build-pages/page-builders/page-writer.js b/lib/build-pages/page-builders/page-writer.js index 35c29be..b286a71 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/page-builders/page-writer.js @@ -1,54 +1,94 @@ +/** + * @import { PageInfo } from '../../identify-pages.js' + * @import { PageData } from '../page-data.js' + */ + import { join } from 'path' import { writeFile, mkdir } from 'fs/promises' /** - * @typedef {import('../../identify-pages.js').PageInfo} PageInfo + * @typedef {Object} BuilderOptions + * @property {string | null | undefined} [markdownItSettingsPath] - Path to the markdown-it settings file */ /** * @template {Record} T - * @typedef {import('../page-data.js').PageData} PageData + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @typedef {PageData} PageData */ /** - * pageLayout functions Can be used to type a name.layout.js file + * Common parameters for page functions. * - * @async - * @template {Record} T + * @template {Record} T - The type of variables passed to the page function + * @template [U=any] U - The return type of the page function (defaults to any) + * @typedef {object} PageFunctionParams + * @property {T} vars - All default, global, layout, page, and builder vars shallow merged. + * @property {string[]} [scripts] - Array of script URLs to include. + * @property {string[]} [styles] - Array of stylesheet URLs to include. + * @property {PageInfo} page - Info about the current page + * @property {PageData[]} pages - An array of info about every page + * @property {Object} [workers] - Map of worker names to their output paths + */ + +/** + * Synchronous page function for rendering a page layout. + * + * @template {Record} T - The type of variables passed to the page function + * @template [U=any] U - The return type of the page function (defaults to any) * @callback PageFunction - * @param {object} params - The parameters for the pageLayout. - * @param {T} params.vars - All default, global, layout, page, and builder vars shallow merged. - * @param {string[]} [params.scripts] - Array of script URLs to include. - * @param {string[]} [params.styles] - Array of stylesheet URLs to include. - * @param {PageInfo} params.page - Info about the current page - * @param {PageData[]} params.pages - An array of info about every page - * @returns {Promise} The rendered inner page thats compatible with its matched layout + * @param {PageFunctionParams} params - The parameters for the pageLayout. + * @returns {U | Promise} The rendered inner page thats compatible with its matched layout */ /** - * @template {Record} T + * Asynchronous page function for rendering a page layout. + * + * @template {Record} T - The type of variables passed to the page function + * @template [U=any] U - The return type of the page function (defaults to any) + * @callback AsyncPageFunction + * @param {PageFunctionParams} params - The parameters for the pageLayout. + * @returns {Promise} The rendered inner page thats compatible with its matched layout + */ + +/** + * pageLayout functions can be used to type a name.layout.js file (can be sync or async). + * + * @template {Record} T - The type of variables passed to the page function + * @template [U=any] U - The return type of the page function (defaults to any) + * @typedef {PageFunction | AsyncPageFunction} InternalPageFunction + */ + +/** + * @template {Record} T - The type of variables for the page + * @template [U=any] U - The return type of the pageLayout function * @typedef PageBuilderResult * @property {object} vars - Any variables resolved by the builder - * @property {PageFunction} pageLayout - The function that returns the rendered page + * @property {InternalPageFunction} pageLayout - The function that returns the rendered page */ /** - * @template {Record} T + * @template {Record} T - The type of variables for the page + * @template [U=any] U - The return type of the pageLayout function * @callback PageBuilderType * * @param {object} params * @param {PageInfo} params.pageInfo - * @returns {Promise>} - The results of the build step. + * @param {BuilderOptions} [params.options] + * @returns {Promise>} - The results of the build step. */ /** * Handles rendering and writing a page to disk * @template {Record} T + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) * @param {object} params * @param {string} params.src - The src folder. * @param {string} params.dest - The dest folder. - * @param {PageData} params.page - The PageInfo object of the current page - * @param {PageData[]} params.pages - The PageInfo[] array of all pages + * @param {PageData} params.page - The PageInfo object of the current page + * @param {PageData[]} params.pages - The PageInfo[] array of all pages */ export async function pageWriter ({ dest, @@ -63,5 +103,27 @@ export async function pageWriter ({ await mkdir(pageDir, { recursive: true }) await writeFile(pageFilePath, formattedPageOutput) + // Generate meta.json with worker mappings if page has workers + if (page.pageInfo?.workers) { + /** @type { {[workerName: string]: string } } */ + const workerMappings = {} + + for (const [workerName, workerFile] of Object.entries(page.pageInfo.workers)) { + if (workerFile.outputRelname) { + // Get the basename without the path for client usage + const outputBasename = workerFile.outputName + if (outputBasename) { + workerMappings[workerName] = outputBasename + } + } + } + + if (Object.keys(workerMappings).length > 0) { + const workersFilePath = join(pageDir, 'workers.json') + const workersContent = JSON.stringify(workerMappings, null, 2) + await writeFile(workersFilePath, workersContent) + } + } + return { pageFilePath } } diff --git a/lib/build-pages/page-builders/template-builder.js b/lib/build-pages/page-builders/template-builder.js index 0ad20bc..cdd4bf4 100644 --- a/lib/build-pages/page-builders/template-builder.js +++ b/lib/build-pages/page-builders/template-builder.js @@ -1,14 +1,10 @@ -import { join, resolve, dirname } from 'path' -import { writeFile, mkdir } from 'fs/promises' - /** - * @typedef {import('../../identify-pages.js').TemplateInfo} TemplateInfo + * @import { TemplateInfo } from '../../identify-pages.js' + * @import { PageData } from '../page-data.js' */ -/** - * @template {Record} T - * @typedef {import('../page-data.js').PageData} PageData - */ +import { join, resolve, dirname } from 'path' +import { writeFile, mkdir } from 'fs/promises' /** @typedef {{ * outputName: string, @@ -18,24 +14,24 @@ import { writeFile, mkdir } from 'fs/promises' /** * Callback for rendering a template. * - * @template {Record} T + * @template {Record} T - The type of variables for the template * @callback TemplateFunction * @param {object} params - The parameters for the template. * @param {T} params.vars - All of the site globalVars. * @param {TemplateInfo} params.template - Info about the current template - * @param {PageData[]} params.pages - An array of info about every page + * @param {PageData[]} params.pages - An array of info about every page * @returns {Promise} * } - The results of a template build */ /** - * @template {Record} T + * @template {Record} T - The type of variables for the template function parameters * @typedef {Parameters>} TemplateFunctionParams */ /** * Callback for rendering a template with an async iterator. - * @template T + * @template T - The type of variables for the template async iterator * @callback TemplateAsyncIterator * @param {TemplateFunctionParams[0]} params - Parameters of the template function. * @returns {AsyncIterable} @@ -51,13 +47,13 @@ import { writeFile, mkdir } from 'fs/promises' /** * The template builder renders templates agains the globalVars variables - * @template {Record} T + * @template {Record} T - The type of global variables for the template builder * @param {object} params * @param {string} params.src - The src path of the site build. * @param {string} params.dest - The dest path of the site build. * @param {T} params.globalVars - The resolvedGlobal vars object. * @param {TemplateInfo} params.template - The TemplateInfo of the template. - * @param {PageData[]} params.pages - The array of PageData object. + * @param {PageData[]} params.pages - The array of PageData object. */ export async function templateBuilder ({ dest, diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page-data.js index 2fefbd1..0cafe0f 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -1,46 +1,113 @@ +/** + * @import { PageInfo } from '../identify-pages.js' + * @import { BuilderOptions } from './page-builders/page-writer.js' + * @import { ResolvedLayout } from './index.js' + */ + import { resolveVars, resolvePostVars } from './resolve-vars.js' import { pageBuilders } from './page-builders/index.js' -// @ts-ignore +// @ts-expect-error import pretty from 'pretty' /** - * @typedef {import('../identify-pages.js').PageInfo} PageInfo - * @typedef {import('../builder.js').SiteData} SiteData + * @typedef {Object} WorkerFiles */ /** - * @template T - * @typedef {import('./index.js').ResolvedLayout} ResolvedLayout + * Resolves a layout from an ESM module. + * + * @function + * @template {Record} T - The type of variables for the layout + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @param {string} layoutPath - The string path to the layout ESM module. + * @returns {Promise>} The resolved layout exported as default from the module. */ +export async function resolveLayout (layoutPath) { + const { default: layout } = await import(layoutPath) + + return layout +} + +/** + * Common parameters for layout functions. + * + * @template {Record} T - The type of variables passed to the layout function + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @typedef {object} LayoutFunctionParams + * @property {T} vars - All default, global, layout, page, and builder vars shallow merged. + * @property {string[]} [scripts] - Array of script URLs to include. + * @property {string[]} [styles] - Array of stylesheet URLs to include. + * @property {U} children - The children content, either as a string or a render function. + * @property {PageInfo} page - Info about the current page + * @property {PageData[]} pages - An array of info about every page + * @property {Object} [workers] - Map of worker names to their output paths + */ + +/** + * Synchronous callback for rendering a layout. + * + * @template {Record} T - The type of variables passed to the layout function + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @callback LayoutFunction + * @param {LayoutFunctionParams} params - The parameters for the layout. + * @returns {V | Promise} The rendered content. + */ + +/** + * Asynchronous callback for rendering a layout. + * + * @template {Record} T - The type of variables passed to the layout function + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @callback AsyncLayoutFunction + * @param {LayoutFunctionParams} params - The parameters for the layout. + * @returns {Promise} The rendered content. + */ + +/** + * Callback for rendering a layout (can be sync or async). + * + * @template {Record} T - The type of variables passed to the layout function + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) + * @typedef {LayoutFunction | AsyncLayoutFunction} InternalLayoutFunction + */ /** * Represents the data for a page. - * @template {Record} T + * @template {Record} T - The type of variables for the page data + * @template [U=any] U - The return type of the page function (defaults to any) + * @template [V=string] V - The return type of the layout function (defaults to string) */ export class PageData { /** @type {PageInfo} */ pageInfo - /** @type {ResolvedLayout | null | undefined} */ layout + /** @type {ResolvedLayout | null | undefined} */ layout /** @type {object} */ globalVars + /** @type {object} */ globalDataVars = {} /** @type {object?} */ pageVars = null - /** @type {function?} */ postVars = null /** @type {object?} */ builderVars = null /** @type {string[]} */ styles = [] /** @type {string[]} */ scripts = [] + /** @type {WorkerFiles} */ workerFiles = {} /** @type {boolean} */ #initialized = false - /** @type {T?} */ #renderedPostVars = null /** @type {string?} */ #defaultStyle = null /** @type {string?} */ #defaultClient = null + /** @type {BuilderOptions} */ builderOptions /** * Creates an instance of PageData. * * @param {object} options - The options object. - * @param {PageInfo} options.pageInfo - Page-specific data. - * @param {object} options.globalVars - Global variables available to all pages. - * @param {string | undefined} options.globalStyle - Global style path. - * @param {string | undefined} options.globalClient - Global client-side script path. - * @param {string?} options.defaultStyle - Default style path. - * @param {string?} options.defaultClient - Default client-side script path. + * @param {PageInfo} options.pageInfo - Page-specific data. + * @param {object} options.globalVars - Global variables available to all pages. + * @param {string | undefined} options.globalStyle - Global style path. + * @param {string | undefined} options.globalClient - Global client-side script path. + * @param {string?} options.defaultStyle - Default style path. + * @param {string?} options.defaultClient - Default client-side script path. + * @param {BuilderOptions} options.builderOptions - Options for page builders. */ constructor ({ pageInfo, @@ -49,11 +116,13 @@ export class PageData { globalClient, defaultStyle, defaultClient, + builderOptions, }) { this.pageInfo = pageInfo this.globalVars = globalVars this.#defaultStyle = defaultStyle this.#defaultClient = defaultClient + this.builderOptions = builderOptions if (globalStyle) { this.styles.push(`/${globalStyle}`) @@ -69,41 +138,28 @@ export class PageData { */ get vars () { if (!this.#initialized) throw new Error('Initialize PageData before accessing vars') - const { globalVars, pageVars, builderVars } = this + const { globalVars, globalDataVars, pageVars, builderVars } = this // @ts-ignore return { ...globalVars, + ...globalDataVars, ...pageVars, ...builderVars, } } /** - * @type {import('./resolve-vars.js').PostVarsFunction} + * Access web worker file paths associated with this page + * @return {WorkerFiles} Map of worker names to their output paths */ - async #renderPostVars ({ vars, styles, scripts, pages, page }) { - if (!this.#initialized) throw new Error('Initialize PageData before accessing renderPostVars') - if (!this.postVars) return this.vars - if (this.#renderedPostVars) return this.#renderedPostVars - - const { globalVars, pageVars, builderVars } = this - - const renderedPostVars = { - ...globalVars, - ...pageVars, - ...(await this.postVars({ vars, styles, scripts, pages, page })), - ...builderVars, - } - - this.#renderedPostVars = renderedPostVars - - return renderedPostVars + get workers () { + return this.workerFiles } /** * [init description] * @param {object} params - Parameters required to initialize - * @param {Record>} params.layouts - The array of ResolvedLayouts + * @param {Record>} params.layouts - The array of ResolvedLayouts */ async init ({ layouts }) { if (this.#initialized) return @@ -114,12 +170,10 @@ export class PageData { varsPath: pageVars?.filepath, resolveVars: globalVars, }) - this.postVars = await resolvePostVars({ - varsPath: pageVars?.filepath, - }) + await resolvePostVars({ varsPath: pageVars?.filepath }) // throws if postVars export is detected const builder = pageBuilders[type] - const { vars: builderVars } = await builder({ pageInfo }) + const { vars: builderVars } = await builder({ pageInfo, options: this.builderOptions }) this.builderVars = builderVars /** @type {object} */ @@ -149,6 +203,15 @@ export class PageData { if (pageInfo.clientBundle) { this.scripts.push(`./${pageInfo.clientBundle.outputName}`) } + // Initialize web workers if they exist + if (pageInfo.workers) { + /** @type {WorkerFiles} */ + for (const [workerName, workerFile] of Object.entries(pageInfo.workers)) { + if (workerFile.outputRelname) { + this.workerFiles[workerName] = `./${workerFile.outputName}` + } + } + } // disable-eslint-next-line dot-notation if ('defaultStyle' in finalVars && finalVars.defaultStyle) { @@ -162,40 +225,41 @@ export class PageData { /** * Render the inner contents of a page. * @param {object} params The params required to render the page - * @param {PageData[]} params.pages An array of initialized PageDatas. + * @param {PageData[]} params.pages An array of initialized PageDatas. */ async renderInnerPage ({ pages }) { if (!this.#initialized) throw new Error('Must be initialized before rendering inner pages') - const { pageInfo, styles, scripts, vars } = this + const { pageInfo, styles, scripts, vars, builderOptions, workers } = this if (!pageInfo) throw new Error('A page is required to render') const builder = pageBuilders[pageInfo.type] - const { pageLayout } = await builder({ pageInfo }) - const renderedPostVars = await this.#renderPostVars({ vars, styles, scripts, pages, page: pageInfo }) - // @ts-ignore - return await pageLayout({ vars: renderedPostVars, styles, scripts, pages, page: pageInfo }) + const { pageLayout } = await builder({ pageInfo, options: builderOptions }) + // @ts-expect-error - Builder types vary by page type, but the runtime type is correct + const results = await pageLayout({ vars, styles, scripts, pages, page: pageInfo, workers }) + return results } /** * Render the full contents of a page with its layout * @param {object} params The params required to render the page - * @param {PageData[]} params.pages An array of initialized PageDatas. + * @param {PageData[]} params.pages An array of initialized PageDatas. */ async renderFullPage ({ pages }) { if (!this.#initialized) throw new Error('Must be initialized before rendering full pages') const { pageInfo, layout, vars, styles, scripts } = this if (!pageInfo) throw new Error('A page is required to render') if (!layout) throw new Error('A layout is required to render') - const renderedPostVars = await this.#renderPostVars({ vars, styles, scripts, pages, page: pageInfo }) const innerPage = await this.renderInnerPage({ pages }) return pretty( await layout.render({ - vars: renderedPostVars, + vars, styles, scripts, page: pageInfo, pages, + // @ts-expect-error - innerPage type varies by page builder but layout handles it children: innerPage, + workers: this.workers }) ) } diff --git a/lib/build-pages/resolve-layout.js b/lib/build-pages/resolve-layout.js deleted file mode 100644 index 2d96ffc..0000000 --- a/lib/build-pages/resolve-layout.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @typedef {import('../identify-pages.js').PageInfo} PageInfo - */ - -/** - * @template {Record} T - * @typedef {import('./page-data.js').PageData} PageData - */ - -/** - * Callback for rendering a layout. - * - * @template T - * @callback LayoutFunction - * @param {object} params - The parameters for the layout. - * @param {T} params.vars - All default, global, layout, page, and builder vars shallow merged. - * @param {string[]} [params.scripts] - Array of script URLs to include. - * @param {string[]} [params.styles] - Array of stylesheet URLs to include. - * @param {any} params.children - The children content, either as a string or a render function. - * @param {PageInfo} params.page - Info about the current page - * @param {PageData[]} params.pages - An array of info about every page - * @returns {Promise} The rendered HTML string. - */ - -/** - * Resolves a layout from an ESM module. - * - * @async - * @function - * @template T - * @param {string} layoutPath - The string path to the layout ESM module. - * @returns {Promise>} The resolved layout exported as default from the module. - */ -export async function resolveLayout (layoutPath) { - const { default: layout } = await import(layoutPath) - - return layout -} diff --git a/lib/build-pages/resolve-vars.js b/lib/build-pages/resolve-vars.js index 3e181cc..40338a1 100644 --- a/lib/build-pages/resolve-vars.js +++ b/lib/build-pages/resolve-vars.js @@ -3,9 +3,9 @@ * * @param {object} params * @param {string} [params.varsPath] - Path to the file containing the variables. - * @param {object} [params.resolveVars] - Any variables you want passed to the reolveFunction. + * @param {object} [params.resolveVars] - Any variables you want passed to the resolveFunction. * @param {string} [params.key='default'] - The key to extract from the imported module. Default: 'default' - * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. + * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. */ export async function resolveVars ({ varsPath, @@ -32,26 +32,43 @@ export async function resolveVars ({ } /** - * postVars functions Can be used to generate page vars but access all page data + * @import { PageData } from './page-data.js' + */ + +/** + * Resolve and call a global.data.js file with the initialized PageData array. + * Receives fully resolved PageData instances (with .vars, .pageInfo, etc.) so + * that global.data.js can filter and aggregate by layout, publishDate, title, etc. + * Returns an empty object if no file is provided or the file exports nothing useful. * - * @async - * @template {Record} T - * @callback PostVarsFunction - * @param {object} params - The parameters for the pageLayout. - * @param {T} params.vars - All default, global, layout, page, and builder vars shallow merged. - * @param {string[]} [params.scripts] - Array of script URLs to include. - * @param {string[]} [params.styles] - Array of stylesheet URLs to include. - * @param {import('../identify-pages.js').PageInfo} params.page - Info about the current page - * @param {import('./page-data.js').PageData[]} params.pages - An array of info about every page - * @returns {Promise} The rendered postVars + * @param {object} params + * @param {string} [params.globalDataPath] - Path to the global.data file. + * @param {PageData[]} params.pages - Initialized PageData array. + * @returns {Promise} */ +export async function resolveGlobalData ({ globalDataPath, pages }) { + if (!globalDataPath) return {} + + const imported = await import(globalDataPath) + const maybeGlobalData = imported.default + + if (isFunction(maybeGlobalData)) { + const result = await maybeGlobalData({ pages }) + if (isObject(result)) return result + throw new Error('global.data default export function must return an object') + } else if (isObject(maybeGlobalData)) { + return maybeGlobalData + } else { + return {} + } +} /** * Resolve variables by importing them from a specified path. * * @param {object} params * @param {string} [params.varsPath] - Path to the file containing the variables. - * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. + * @returns {Promise} */ export async function resolvePostVars ({ varsPath, @@ -62,14 +79,14 @@ export async function resolvePostVars ({ const maybePostVars = imported.postVars if (maybePostVars) { - if (isFunction(maybePostVars)) { - return maybePostVars - } else { - throw new Error('postVars must export a function') - } - } else { - return null + throw new Error( + `postVars is no longer supported (found in ${varsPath}). ` + + 'Move data aggregation to a global.data.js file instead. ' + + 'See the domstack docs for details.' + ) } + + return null } /** diff --git a/lib/build-pages/resolve-vars.test.js b/lib/build-pages/resolve-vars.test.js index 2a563e9..cb4b8a2 100644 --- a/lib/build-pages/resolve-vars.test.js +++ b/lib/build-pages/resolve-vars.test.js @@ -1,15 +1,18 @@ -import tap from 'tap' +import { test } from 'node:test' +import assert from 'node:assert' import { resolve } from 'path' import { resolveVars } from './resolve-vars.js' const __dirname = import.meta.dirname -tap.test('resolve vars resolves vars', async (t) => { - const varsPath = resolve(__dirname, '../../test-cases/general-features/src/globals/global.vars.js') +test.describe('resolve-vars', () => { + test('resolve vars resolves vars', async () => { + const varsPath = resolve(__dirname, '../../test-cases/general-features/src/globals/global.vars.js') - const vars = await resolveVars({ varsPath }) + const vars = await resolveVars({ varsPath }) - // @ts-ignore - t.equal(vars.foo, 'global') + // @ts-ignore + assert.equal(vars.foo, 'global') + }) }) diff --git a/lib/build-pages/worker.js b/lib/build-pages/worker.js index 9983c84..fabe8ce 100644 --- a/lib/build-pages/worker.js +++ b/lib/build-pages/worker.js @@ -3,10 +3,10 @@ import { buildPagesDirect } from './index.js' async function run () { if (!parentPort) throw new Error('parentPort returned null') - const { src, dest, siteData } = workerData + const { src, dest, siteData, opts } = workerData let results try { - results = await buildPagesDirect(src, dest, siteData, {}) + results = await buildPagesDirect(src, dest, siteData, opts ?? {}) parentPort.postMessage(results) } catch (err) { console.dir(results, { colors: true, depth: 999 }) diff --git a/lib/build-static/index.js b/lib/build-static/index.js index 8cb3487..934dc33 100644 --- a/lib/build-static/index.js +++ b/lib/build-static/index.js @@ -1,3 +1,7 @@ +/** + * @import { BuildStepResult } from '../builder.js' + * @import { BuildStep } from '../builder.js' + */ // @ts-ignore import cpx from 'cpx2' const copy = cpx.copy @@ -7,11 +11,11 @@ const copy = cpx.copy */ /** - * @typedef {import('../builder.js').BuildStepResult<'static', StaticBuilderReport>} StaticBuildStepResult + * @typedef {BuildStepResult<'static', StaticBuilderReport>} StaticBuildStepResult */ /** - * @typedef {import('../builder.js').BuildStep<'static', StaticBuilderReport>} StaticBuildStep + * @typedef {BuildStep<'static', StaticBuilderReport>} StaticBuildStep */ /** diff --git a/lib/builder.js b/lib/builder.js index 09fcb98..5ac58cc 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -1,19 +1,23 @@ +/** + * @import {Message as EsbuildMessage} from 'esbuild' + * @import { DomStackWarning } from './helpers/dom-stack-warning.js' + * @import { EsBuildStepResults } from './build-esbuild/index.js' + * @import { PageBuildStepResult } from './build-pages/index.js' + * @import { StaticBuildStepResult } from './build-static/index.js' + * @import { CopyBuildStepResult } from './build-copy/index.js' +*/ + import { buildPages } from './build-pages/index.js' import { identifyPages } from './identify-pages.js' import { buildStatic } from './build-static/index.js' import { buildCopy } from './build-copy/index.js' import { buildEsbuild } from './build-esbuild/index.js' -import { TopBunAggregateError } from './helpers/top-bun-aggregate-error.js' +import { DomStackAggregateError } from './helpers/dom-stack-aggregate-error.js' import { ensureDest } from './helpers/ensure-dest.js' -/** - * @typedef {import('esbuild').Message} EsbuildMessage - * @typedef {import('./helpers/top-bun-warning.js').TopBunWarning} TopBunWarning - */ - /** * @typedef {Array} BuildStepErrors - * @typedef {Array} BuildStepWarnings + * @typedef {Array} BuildStepWarnings */ /** @@ -35,12 +39,12 @@ import { ensureDest } from './helpers/ensure-dest.js' * @param {string} src - The source directory from which the site should be built. * @param {string} dest - The destination directory where the built site should be placed. * @param {SiteData} siteData - Data related to the site being built. - * @param {TopBunOpts?} opts - Additional options for the build step. + * @param {DomStackOpts?} opts - Additional options for the build step. * @returns {Promise>} - The results of the build step. */ /** - * @typedef TopBunOpts + * @typedef DomStackOpts * @property {boolean|undefined} [static=true] - Enable/disable static file processing * @property {boolean|undefined} [metafile=true] - Enable/disable the writing of the esbuild metadata file. * @property {string[]|undefined} [ignore=[]] - Array of ignore strings @@ -54,13 +58,6 @@ import { ensureDest } from './helpers/ensure-dest.js' * @typedef {Awaited>} SiteData */ -/** - * @typedef {import('./build-esbuild/index.js').EsBuildStepResults} EsBuildStepResults - * @typedef {import('./build-pages/index.js').PageBuildStepResult} PageBuildStepResult - * @typedef {import('./build-static/index.js').StaticBuildStepResult} StaticBuildStepResult - * @typedef {import('./build-copy/index.js').CopyBuildStepResult} CopyBuildStepResult - */ - /** * @typedef Results * @property {SiteData} siteData @@ -72,16 +69,15 @@ import { ensureDest } from './helpers/ensure-dest.js' */ /** - * Builds a top-bun site from src to dest with a few options. + * Builds a domstack site from src to dest with a few options. * * - * @async * @function * @export * @param {string} src - The source directory from which the site should be built. * @param {string} dest - The destination directory where the built site should be placed. - * @param {TopBunOpts} opts - Options for the build process. - * @returns Results + * @param {DomStackOpts} opts - Options for the build process. + * @returns {Promise} * * @example * @@ -106,7 +102,7 @@ export async function builder (src, dest, opts) { warnings.push(...siteData.warnings) if (siteData.errors.length > 0) { - const pageWalkErrors = new TopBunAggregateError(siteData.errors, 'Page walk finished but there were errors.', siteData) + const pageWalkErrors = new DomStackAggregateError(siteData.errors, 'Page walk finished but there were errors.', siteData) throw pageWalkErrors } @@ -145,7 +141,7 @@ export async function builder (src, dest, opts) { results.copyResults = copyResults if (errors.length > 0) { - const preBuildError = new TopBunAggregateError(errors, 'Prebuild finished but there were errors.', results) + const preBuildError = new DomStackAggregateError(errors, 'Prebuild finished but there were errors.', results) throw preBuildError } @@ -156,7 +152,7 @@ export async function builder (src, dest, opts) { results.pageBuildResults = pageBuildResults if (errors.length > 0) { - const buildError = new TopBunAggregateError(errors, 'Build finished but there were errors.', results) + const buildError = new DomStackAggregateError(errors, 'Build finished but there were errors.', results) throw buildError } else { return results diff --git a/lib/defaults/default.client.js b/lib/defaults/default.client.js index 4000282..e2babe9 100644 --- a/lib/defaults/default.client.js +++ b/lib/defaults/default.client.js @@ -1,4 +1,4 @@ -// @ts-ignore +// @ts-expect-error import { toggleTheme } from 'mine.css' -// @ts-ignore +// @ts-expect-error window.toggleTheme = toggleTheme diff --git a/lib/defaults/default.root.layout.js b/lib/defaults/default.root.layout.js index a14f0ef..9b8164c 100644 --- a/lib/defaults/default.root.layout.js +++ b/lib/defaults/default.root.layout.js @@ -1,25 +1,28 @@ -// @ts-ignore -import { html, render } from 'uhtml-isomorphic' - /** - * @template {Record} T - * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + * @import { LayoutFunction } from '../../index.js' + * @import { VNode } from 'preact' */ +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' /** - * Build all of the bundles using esbuild. - * - * @type {LayoutFunction<{ + * @typedef {{ * title: string, * siteName: string, * defaultStyle: boolean, * basePath: string - * }>} + * }} DefaultRootLayoutVars + */ + +/** + * Build all of the bundles using esbuild. + * + * @type {LayoutFunction} */ export default function defaultRootLayout ({ vars: { title, - siteName = 'TopBun', + siteName = 'domstack', basePath, /* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */ }, @@ -29,25 +32,30 @@ export default function defaultRootLayout ({ /* pages */ /* page */ }) { - return render(String, html` + return /* html */` - - - ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} - - ${scripts - ? scripts.map(script => html``) - : null} - ${styles - ? styles.map(style => html``) - : null} - - -
    - ${typeof children === 'string' ? html([children]) : children /* Support both uhtml and string children. Optional. */} -
    - + ${render(html` + + + ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + + ${scripts + ? scripts.map(script => html``) - : null} - ${styles - ? styles.map(style => html``) - : null} + + + ${scripts?.map(script => + html`