Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 613fa88239 | |||
| 567ee63c7a | |||
| 15cba71bf0 |
@@ -14,7 +14,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -29,10 +29,14 @@ jobs:
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ inputs.tag || github.ref }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: false
|
||||
- name: Build and zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -41,31 +45,38 @@ jobs:
|
||||
New-Item -ItemType Directory -Force dist | Out-Null
|
||||
$exes = 'papercraft','papercraft-cli','papercraft-mcp','papercraft-relay' | ForEach-Object { "target/release/$_.exe" }
|
||||
Compress-Archive -Path $exes -DestinationPath "dist/papercraft-$v-windows-x86_64.zip"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with: { name: windows, path: dist/* }
|
||||
|
||||
macos:
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ inputs.tag || github.ref }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: aarch64-apple-darwin }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable, targets: aarch64-apple-darwin }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: false
|
||||
- name: Package (Apple silicon, ad-hoc signed, not notarized)
|
||||
run: packaging/macos/package.sh --arch aarch64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with: { name: macos, path: dist/release/* }
|
||||
|
||||
linux:
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ inputs.tag || github.ref }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: false
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -78,24 +89,32 @@ jobs:
|
||||
cp target/release/papercraft target/release/papercraft-cli target/release/papercraft-mcp target/release/papercraft-relay stage/
|
||||
tar -C stage -czf "dist/papercraft-$v-linux-x86_64.tar.gz" .
|
||||
tar -C stage -czf "dist/papercraft-cli-$v-linux-x86_64.tar.gz" papercraft-cli papercraft-mcp
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with: { name: linux, path: dist/* }
|
||||
|
||||
publish:
|
||||
if: github.server_url == 'https://github.com'
|
||||
needs: [test, windows, macos, linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with: { path: files, merge-multiple: true }
|
||||
- name: Create the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists; refusing to overwrite its assets." >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" files/* --repo "$GITHUB_REPOSITORY" --title "PaperCraft ${TAG#v}" \
|
||||
--generate-notes --verify-tag || gh release upload "$TAG" files/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
--generate-notes --verify-tag
|
||||
|
||||
site:
|
||||
if: github.server_url == 'https://github.com'
|
||||
needs: publish
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/site.yml
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The page comes from the default branch even when a release tag calls this workflow.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ github.event.repository.default_branch }}" }
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# The full test suite. Local runs cover only the targets a change touches (RULES.md, "The machine
|
||||
# you build on"); this is where everything runs, before a release can publish. Run it alone with
|
||||
# `gh workflow run test.yml -f ref=<branch or tag>`. Gitea reads .github/workflows too, so the job
|
||||
# is gated on the server URL.
|
||||
# Pull requests and main run the full test suite and release-profile builds. The release workflow
|
||||
# calls the same test job; its own platform jobs build the release artifacts. Gitea reads
|
||||
# .github/workflows too, so jobs are gated on the server URL.
|
||||
name: test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -16,19 +19,90 @@ on:
|
||||
description: Branch or tag to test (default the one you run from)
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: CI Windows tests
|
||||
if: github.server_url == 'https://github.com'
|
||||
# Windows: the platform the app is developed on, and its runners render with a software GPU.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ inputs.ref || github.ref }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-unknown-unknown }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable, targets: wasm32-unknown-unknown }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-test
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
- run: cargo test --locked --no-fail-fast
|
||||
- run: cargo check --locked --lib --target wasm32-unknown-unknown
|
||||
|
||||
clippy:
|
||||
name: CI Clippy
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { ref: "${{ inputs.ref || github.ref }}" }
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable, components: clippy }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-clippy
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Warnings are not fatal yet; errors (the lottie module's deny list) fail the job.
|
||||
- run: cargo clippy --all-targets --locked
|
||||
|
||||
windows:
|
||||
name: CI Windows release build
|
||||
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
- run: cargo build --release --locked --bins
|
||||
|
||||
macos:
|
||||
name: CI macOS release build
|
||||
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: '11.0'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable, targets: aarch64-apple-darwin }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
- run: cargo build --release --locked --bins --target aarch64-apple-darwin
|
||||
|
||||
linux:
|
||||
name: CI Linux release build
|
||||
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
|
||||
with: { toolchain: stable }
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
with:
|
||||
shared-key: papercraft-release
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev libxkbcommon-x11-dev libxcb-xkb-dev libx11-xcb-dev libwayland-dev libvulkan-dev libdbus-1-dev
|
||||
- run: cargo build --release --locked --bins
|
||||
|
||||
@@ -6,3 +6,7 @@ app.asar.unpacked/
|
||||
web/pkg/
|
||||
.worktrees/
|
||||
.oc-agents/
|
||||
|
||||
.export-panels.tmp.pc
|
||||
.design-work/
|
||||
tools/showcase/*.log
|
||||
|
||||
+12
-6
@@ -55,7 +55,9 @@ fetches from a public git repository at the revision pinned in `Cargo.toml`.
|
||||
|
||||
## Make the change
|
||||
|
||||
1. Fork the repository and branch from `main` (`fix/short-name`, `feat/short-name`).
|
||||
1. Fork the repository if needed and make a short-lived branch from current `main`
|
||||
(`fix/short-name`, `feat/short-name`). Open a pull request into `main` when ready, or a draft
|
||||
while work is in progress.
|
||||
2. **Write the test first.** Fix a bug by adding a test that fails, then make it pass. See
|
||||
[Tests](#tests).
|
||||
3. Keep the change focused. One concern per pull request; unrelated cleanups go in their own.
|
||||
@@ -74,8 +76,9 @@ cargo check --lib --target wasm32-unknown-unknown # the web build
|
||||
```
|
||||
|
||||
Each file in `tests/` is its own binary linking the whole app, so the full suite is slow and heavy
|
||||
on a desktop. It runs on GitHub instead: before every release (`.github/workflows/test.yml`), or
|
||||
on demand with `gh workflow run test.yml -f ref=<branch>`.
|
||||
on a desktop. GitHub runs the full Windows suite, the web check and Windows, macOS and Linux
|
||||
release builds on pull requests and `main`, and again before each release. The Windows suite can
|
||||
also be run on demand with `gh workflow run test.yml -f ref=<branch>`.
|
||||
|
||||
Keep the tree free of the third-party branding patterns listed in `.debrand`.
|
||||
|
||||
@@ -111,14 +114,17 @@ Reference the issue (`Fixes #123`).
|
||||
* Fill in the template: what changed, why, how you tested it, screenshots for UI changes.
|
||||
* Keep it reviewable. If it grows past a few hundred lines of non-generated code, split it.
|
||||
* The branch must be up to date with `main`; rebase rather than merge.
|
||||
* All required checks must pass before merging. Request review when another maintainer is
|
||||
available; a solo maintainer may review and merge their own change after checks pass.
|
||||
* Expect review comments; they are about the code, not you. Push fixes as new commits and we
|
||||
squash on merge.
|
||||
* A maintainer merges once a review is approved.
|
||||
|
||||
## Releases
|
||||
|
||||
Maintainers cut releases. A `v*` tag pushed to GitHub builds Windows, macOS and Linux and
|
||||
publishes them (`.github/workflows/release.yml`). Contributors do not need to bump versions.
|
||||
Maintainers cut releases from green `main`. Merge a version-bump pull request first, then tag its
|
||||
commit. A `v*` tag pushed to GitHub builds Windows, macOS and Linux and publishes them after the
|
||||
tests pass (`.github/workflows/release.yml`). Do not move a published tag; release a new patch
|
||||
version for a fix. Contributors do not need to bump versions for ordinary changes.
|
||||
|
||||
## Licensing
|
||||
|
||||
|
||||
Generated
+204
-7
@@ -66,6 +66,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -473,6 +488,27 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -633,6 +669,46 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1276,6 +1352,18 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1300,6 +1388,63 @@ dependencies = [
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontcull-font-types"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7187211f698d3144c4278d99cede5f6d67814cd676bbf1d2b8607049b3d76b2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontcull-klippa"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceb68d1b34e3c675e78f1cabbc49e19c5962d6c6173b7c77d77392431b99840"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fnv",
|
||||
"fontcull-skrifa",
|
||||
"fontcull-write-fonts",
|
||||
"hashbrown 0.15.5",
|
||||
"regex",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontcull-read-fonts"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170928e2d36f9605167b8480acd6e9fc5ef5daaf64fac43d178af9c563adba29"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fontcull-font-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontcull-skrifa"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c39120d1806ed11baf0559537bd601c6c60d7bf84ddcc385adea8fb8ff62d35"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fontcull-read-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontcull-write-fonts"
|
||||
version = "0.44.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b506b0883cf5f8998391cbffa92024143cedcbd0f344fc3f1507dec12956cf"
|
||||
dependencies = [
|
||||
"fontcull-font-types",
|
||||
"fontcull-read-fonts",
|
||||
"indexmap",
|
||||
"kurbo 0.12.0",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.24.0"
|
||||
@@ -1548,7 +1693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1625,6 +1770,17 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1633,7 +1789,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1642,9 +1798,15 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2193,6 +2355,17 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -3128,7 +3301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3143,6 +3316,11 @@ dependencies = [
|
||||
"egui-winit",
|
||||
"env_logger",
|
||||
"fastrand",
|
||||
"fontcull-klippa",
|
||||
"fontcull-read-fonts",
|
||||
"fontcull-skrifa",
|
||||
"fontcull-write-fonts",
|
||||
"getrandom 0.4.3",
|
||||
"i_overlay",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -3165,10 +3343,12 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"ttf-parser",
|
||||
"ttf2woff2",
|
||||
"usvg",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -3224,7 +3404,7 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"color",
|
||||
"kurbo",
|
||||
"kurbo 0.13.1",
|
||||
"linebender_resource_handle",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -4335,6 +4515,12 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4347,7 +4533,7 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"kurbo 0.13.1",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
@@ -4750,6 +4936,17 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "ttf2woff2"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e13bfd9cb6a94529c1d33b0f15b69746fe8583a52b9509f59ace80340f4dcf3"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"byteorder",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -4864,7 +5061,7 @@ dependencies = [
|
||||
"fontdb",
|
||||
"harfrust",
|
||||
"imagesize",
|
||||
"kurbo",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree 0.21.1",
|
||||
|
||||
+13
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -51,12 +51,18 @@ usvg = "0.48"
|
||||
resvg = "0.48"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
base64 = "0.23"
|
||||
ttf2woff2 = { version = "0.14.1", default-features = false }
|
||||
fontcull-klippa = "0.1.2"
|
||||
fontcull-skrifa = "0.39.2"
|
||||
fontcull-read-fonts = "0.38.0"
|
||||
fontcull-write-fonts = "0.44.3"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
# Line tables only: crash.log stack traces name files and lines, at no runtime cost.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Browser bundle only (`web/build.sh`): size over compile time. Native release is untouched.
|
||||
[profile.web]
|
||||
@@ -91,6 +97,8 @@ rmcp = { version = "3.5.1", default-features = false, features = ["server", "bas
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws", "json", "query"] }
|
||||
tokio-util = "0.7"
|
||||
# Share tokens come from the OS generator (the version rand and uuid already compile).
|
||||
getrandom = "0.4"
|
||||
# Shared-project sync; the system TLS stack so https servers work without bundling one.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
|
||||
# Anthropic API key in the OS keychain (chat API backend). Linux uses the Secret Service (persistent; keyutils is not).
|
||||
@@ -99,11 +107,13 @@ keyring = { version = "3", default-features = false, features = ["apple-native",
|
||||
libc = "0.2"
|
||||
# Pages and versions on disk are zstd-compressed JSON (`project::packed`).
|
||||
zstd = "0.13"
|
||||
# Checks downloaded updates against the checksum GitHub publishes.
|
||||
sha2 = "0.10"
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", features = ["webgl"] }
|
||||
|
||||
@@ -76,6 +76,15 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-11-06: 0.10.0 (websites, languages, Present mode, safety)
|
||||
|
||||
* `export_site` turns frames into a website in one call: plain HTML, Astro, Vue (Nuxt or Vite), React (Next or Vite) or Angular; css, scss or tailwind, and vanilla-extract, styled-components or emotion for React; static pages or a single-page app; a folder per language. Fonts are subset to WOFF2 with metric-matched fallbacks, pages reflow below the designed width, and the host files cache hashed assets for a year.
|
||||
* Languages as variable modes: `get_strings` reads a page's text for translation, `set_strings` fills a language or a pseudo-locale.
|
||||
* Present mode plays a page's frames as a clickable prototype: links jump to frames, open overlays, go back or open an address.
|
||||
* `get_context` orients an agent in one call and big reads take `maxTokens`; `import_html` opens pages from prompt-to-design tools; `install_font` adds fonts on top of Inter. 104 tools.
|
||||
* Safety: agent paths are checked, `delegate` and `imagegen` never pass values as shell syntax, a panic in a tool fails only that call, sync no longer overwrites a page saved mid-download and repairs merged trees, share tokens come from the OS generator, imports have a memory cap, and the updater checks the published SHA-256.
|
||||
* Panics are kept in `crash.log`; Settings > Canvas persists; a keyboard shortcuts sheet.
|
||||
|
||||
### 2026-10-28: overflow lint rule and `get_bounds`
|
||||
|
||||
* `lint_design` has an `overflow` rule (`src/mcp/lint_overflow.rs`): layers past their artboard or clipping parent, and text taller than its box. Scrolling frames and what is pinned in them are exempt; it is never auto-fixed. It is a pure function over a node set, so a scope filter can pass any subset.
|
||||
|
||||
@@ -119,7 +119,7 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
|
||||
|
||||
## Agents
|
||||
|
||||
PaperCraft exposes **98 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
|
||||
PaperCraft exposes **104 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
|
||||
|
||||
| Way | Notes |
|
||||
|---|---|
|
||||
@@ -187,7 +187,7 @@ Annotations are notes that explain a design in place: business rules, behaviour,
|
||||
|
||||
### Download
|
||||
|
||||
Releases are at [git.marcospaulo.dev.br/masi/papercraft/releases](https://git.marcospaulo.dev.br/masi/papercraft/releases).
|
||||
Releases are at [github.com/nxtime/papercraft/releases](https://github.com/nxtime/papercraft/releases), the same place the in-app updater checks.
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
@@ -266,7 +266,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
|
||||
## Status and known limits
|
||||
|
||||
> [!IMPORTANT]
|
||||
> PaperCraft is an alpha (0.8.x). It is used daily on Windows, macOS and Linux, and its project format migrates forward, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
|
||||
> PaperCraft is an alpha (0.10.x). It is used daily on Windows, macOS and Linux, and its project format migrates forward, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
|
||||
|
||||
**What works today:** local projects with pages and an infinite canvas; vector, text, auto layout, constraints, components with properties and variants, variables with modes, shared libraries and screens; annotations, lint and Present mode with prototype links; PNG, SVG and PDF export, Figma, Excalidraw and SVG import; the MCP server, CLI (online and offline) and in-app Chat mode; versions, agent runs with review and revert; optional relay sync.
|
||||
|
||||
@@ -284,7 +284,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
|
||||
* **Sharing** has no live presence (cursors, selections) yet.
|
||||
* **Scale:** cached frames take about 3 ms at 10k visible nodes; it is not tuned for 100k.
|
||||
* **Pen pressure** is simulated from stroke velocity on macOS tablets, because winit 0.30 does not expose it for mouse-like pen events.
|
||||
* **Releases:** the Gitea releases ship Windows x86_64. macOS (Apple silicon) and Linux x86_64 builds come from the packaging scripts or from source; the Linux build is not part of the current releases.
|
||||
* **Releases:** the GitHub releases ship Windows x86_64. macOS (Apple silicon) and Linux x86_64 builds come from the packaging scripts or from source; the Linux build is not part of the current releases.
|
||||
* **PDF** text is outlined, not selectable.
|
||||
|
||||
## Project layout
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 15 | MCP and CLI tool surface | **done** | 98 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 15 | MCP and CLI tool surface | **done** | 104 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
|
||||
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
|
||||
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
|
||||
@@ -82,7 +82,7 @@ Spec: [specs/agent-runs/spec.md](specs/agent-runs/spec.md), tasks: [specs/agent-
|
||||
| 39 | Agent runs and auto-versions | **done** | Editing calls group into runs; edited pages get auto-versions; runs persist, revert per page, and prune to 50. |
|
||||
| 40 | Review agent changes | **done** | Id-paired diff, grouped review, canvas overlay, per-change revert and accept all; version compare UI remains todo. |
|
||||
| 41 | Agent presence on the canvas | todo | Outline the node a tool call touches, label it, pause or stop the run. |
|
||||
| 42 | `get_context` tool | todo | Selection, variables, nearby components and lint summary in one call, so an agent orients in one round trip. |
|
||||
| 42 | `get_context` tool | **done** | Selection, outline with token estimates, variables, components and lint summary in one call, so an agent orients in one round trip. Big reads take `maxTokens` and report `estimatedTokens` and `truncated`. |
|
||||
| 43 | Comments assigned to agents | todo | `@agent` in a thread triggers an edit and a reply. Needs threaded comments. |
|
||||
| 44 | In-app prompt bar | **done** | Chat mode: Claude Code, Codex or your own Anthropic key; see `docs/chat.md`. |
|
||||
| 45 | Correct tool annotations | **done** | `lint_design` no longer sends `readOnlyHint`; `fix: true` edits are classified. |
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Design-quality rules for work made in PaperCraft (or any flexbox-ba
|
||||
|
||||
# papercraft-design
|
||||
|
||||
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the only font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
|
||||
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the default font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
|
||||
|
||||
## 0. Before you draw anything: commit to a direction
|
||||
|
||||
@@ -18,7 +18,7 @@ Write down, in a few lines, before the first tool call:
|
||||
|
||||
Avoid the default AI look: purple-to-blue gradient on white, a row of three identical rounded cards with an icon and two lines, centered everything, generic stock phrases ("Seamless. Powerful. Effortless."), emoji as icons, drop shadows everywhere. If your draft would pass for any SaaS template, change one structural thing (layout, scale contrast, color block) until it would not.
|
||||
|
||||
**Inter is the only font.** You cannot pair typefaces, so hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
|
||||
**Inter is the default font.** Add another with `install_font` only when the brief calls for it. Hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
|
||||
|
||||
## 1. Spacing: a 4/8pt scale
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Drive the PaperCraft design app (native infinite-canvas design tool
|
||||
|
||||
PaperCraft is a native Rust design app. You edit the project that is open in the running app: every tool call is one undo step and the window repaints live. Same tools over MCP (`http://127.0.0.1:29980/mcp` or the `papercraft-mcp` stdio bridge) and over the shell (`papercraft-cli`). Look and taste live in the `papercraft-design` skill; this one is the mechanics.
|
||||
|
||||
References: [compact-workflow.md](references/compact-workflow.md) (pages and groups, compressing, design-code sync, few tokens, shared work), [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page).
|
||||
References: [compact-workflow.md](references/compact-workflow.md) (pages and groups, compressing, design-code sync, few tokens, shared work), [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page), [export-site.md](references/export-site.md) (designs as a website or a React, Vue, Angular or Astro project).
|
||||
|
||||
## 1. Connect and orient
|
||||
|
||||
@@ -17,6 +17,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
|
||||
|
||||
* **Check the project is the right one.** `get_basic_info` returns `fileName` and `path`. Build only in a project that belongs to the work (a NotesCraft design file is for NotesCraft screens, not for redesigning PaperCraft). For a different product: `create_file {name}`, then `open_file {fileId}`, and say so. `open_file` swaps the project the user is looking at, so tell them. Pass `fileId` on later calls when more than one project is in play.
|
||||
* Not running: ask the user to start PaperCraft and open a project (or `create_file` then `open_file`). `PAPERCRAFT_MCP_PORT` must match if the port was changed. Do not try to launch the GUI yourself unless asked.
|
||||
* Call `get_context` first (page, target, outline with token estimates, variables, components, lint); read deeper nodes only as needed, one at a time. Every big read takes `maxTokens` and says when it was `truncated`.
|
||||
* Read `get_basic_info` (pages, artboards, sizes, fonts, and `lessons`: the person's rules from past critiques; follow every one), then `get_selection` (what the user is looking at) and `get_variables` and `get_components`. Reuse what exists before creating anything.
|
||||
* `pageId` defaults to the page the user is viewing, which can change between calls: pass it explicitly. Node ids look like `3fa9c1:14`; never show them to the user.
|
||||
* App closed (server, CI)? `papercraft-cli call --offline ... --project <name>` runs the same tools on the project store and saves after every editing call; `papercraft-cli shell` keeps one session for many calls and reads scripts from stdin. It is refused while the app has that project open. See `docs/agents.md`.
|
||||
@@ -29,6 +30,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
|
||||
## 2. The loop: build, look, lint, fix
|
||||
|
||||
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
|
||||
**Themes are modes, never copies.** One artboard per screen. Show a dark or light (or any other) theme by giving each variable a value per mode and switching with `set_variable_mode`; screenshot each mode in turn. Do not duplicate artboards side by side as "Screen / Light" and "Screen / Dark", and do not rebuild a screen with different hex values for another theme: the copies drift apart and the mode switch stops meaning anything. If a theme needs a different shape (an ornament only one theme shows), make it a variable too (a colour that is `transparent` in the other mode, or `visible` bound to a boolean variable).
|
||||
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
|
||||
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
|
||||
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
|
||||
@@ -122,7 +124,7 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance` (also a nested badge inside a row instance: a swap that survives main edits), `get_components`, `<x-paper-instance>` |
|
||||
| Component properties | One component with properties instead of one per state: `set_component_props` (`text`, `bool`, `icon`, `enum` options in the overrides format; templates `text`, `active` for tab bars and navs, `status` with [{value, color}]), then `set_instance_props {nodeIds, values}` or `<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>`; `get_components` lists them |
|
||||
| Merge per-state components | `merge_variants {componentIds, name}` turns `Tab bar / Hoje`, `Tab bar / Lojas`... into one `Tab bar` with an enum, moves every instance and deletes the emptied variants |
|
||||
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode |
|
||||
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode; or one of `overlay` (frame opened as a dimmed, scrollable overlay), `url` (https, http, mailto, tel), `action` `back` / `close` |
|
||||
| Screens | Fixed size (390x844, 1440x900...); content longer than the screen scrolls by itself (the main column under a fixed sidebar and header, else the screen), or set `overflow: auto` on the frame that should scroll; it keeps its size and the overflow scrolls on the canvas and in Present (inspector: Scroll overflow; page: Screens scroll) |
|
||||
| Project | `rename_file {name}` (open project stays open), `move_nodes {toPageId}` keeps instances linked and notes attached |
|
||||
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
|
||||
@@ -136,13 +138,14 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
|
||||
| Check quality | `lint_design` (`changedOnly: true` or `since` between edits, a full pass at the end; `overflow` flags layers past their artboard or clipping parent) |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf`, `export_presentation` (a client deck: cover, contents, a slide per screen) |
|
||||
| Publish as a website | `export_site`: ask the person for framework, render and styling first ([export-site.md](references/export-site.md)), then follow its `next_steps` |
|
||||
| Point at things for the user | `select_nodes` (`zoom: true`) |
|
||||
| Take back | `undo`, `redo` |
|
||||
| Save | `finish_working_on_nodes` |
|
||||
|
||||
## 6. Known limits (design around them)
|
||||
|
||||
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Font is Inter** unless the brief needs another typeface: `install_font` adds a .ttf or .otf to the project and `font-family` applies it to a whole text layer. Each family has regular and bold plus italic at most, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Variables** are typed (`color`, `number`, `string`, `boolean`), live in collections with modes, group with `/` (`space/md`, `color/brand/primary`) and can alias each other. Colors work as `var(--name)` on fills and text; every other field binds with `bind_variable` (stroke, text-color, width, height, gap, padding, radius, font-size, line-height, opacity, visible). Numbers bind as px; line-height below 4 is a multiplier. Gradients keep fixed colors.
|
||||
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
|
||||
* **No rotation.** Nodes are axis-aligned.
|
||||
@@ -169,6 +172,7 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| Content is cut off | A frame inside the screen clips it (`overflow: hidden`, or a fixed height under a hugging parent). Give the column that should scroll `overflow: auto`; for a board that should grow, `overflow: "visible"` and `height: "fit-content"`. |
|
||||
| Tempted to show a second theme next to the first | Don't copy the artboards. Give the variables a value in each mode and switch with `set_variable_mode`; screenshot one mode, switch, screenshot again. |
|
||||
| A color did not change with the theme | It was written as hex, or it is a stroke or gradient. Use `var(--name)` on fills and text; strokes need `update_styles` after a mode switch. |
|
||||
| `create_instance` fails | Wrong `componentId` / `componentKey` (read `get_components`). Mains on other pages and enabled libraries work; no import needed. |
|
||||
| A page is full of copied components at y=0 | Something imported mains onto the page (`import_components`, or old `write_html` builds). Swap their instances to the design-system mains (`swap_instance {componentKey}`), check a screenshot, then delete the unused copies. |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Website export (`export_site`)
|
||||
|
||||
`export_site` writes frames as a website: plain HTML, or a project for React, Vue, Angular or Astro. Ask the person which framework, render and styling they want before the first export. Recommend from what they already have.
|
||||
|
||||
## Recommend
|
||||
|
||||
1. **Framework:** the one their repo or team uses (read `package.json`). If none:
|
||||
- `html` for a finished site nobody will develop further;
|
||||
- `astro` for a content or marketing site they will grow.
|
||||
2. **Render:**
|
||||
- `static` (prerendered pages) for anything public: search engines and link previews read the prerendered head;
|
||||
- `spa` only for an app behind a login, because it sets the head in the browser.
|
||||
3. **Styling:**
|
||||
- what their project already uses, else `css`;
|
||||
- `scss` and `tailwind` (v4) work with every framework;
|
||||
- CSS-in-JS works with React only: `vanilla-extract` (zero runtime, recommended), `styled-components` or `emotion` (each adds about 12 KB of runtime).
|
||||
|
||||
Client JavaScript per page (gzip, measured on the sample site in November 2026):
|
||||
|
||||
| Framework | static | spa |
|
||||
|---|---|---|
|
||||
| html, astro | 0 | n/a |
|
||||
| vue | 0 (Nuxt scripts off) | 40 KB |
|
||||
| angular | 77 KB | 63 KB |
|
||||
| react | 136 KB (148 KB with styled-components or emotion) | 97 KB (108 KB with styled-components or emotion) |
|
||||
|
||||
## What each framework writes
|
||||
|
||||
| Framework, render | Project | Build | Serve |
|
||||
|---|---|---|---|
|
||||
| html | pages, one shared stylesheet, `assets/` | none (`scss`/`tailwind`: `npm install && npm run build`) | the folder |
|
||||
| astro | Astro 7: `src/layouts/Layout.astro` owns the head, one component per frame in `src/components` | `npm install && npm run build` | `dist/` |
|
||||
| vue, static | Nuxt 4, one SFC per frame, every route prerendered | `npm install && npm run generate` | `.output/public/` |
|
||||
| vue, spa | Vite 8, vue-router 5, @unhead/vue | `npm install && npm run build` | `dist/` with a fallback to `index.html` |
|
||||
| angular, static | Angular 22 standalone components, every route prerendered | `npm install && npm run build` | `dist/site/browser/` |
|
||||
| angular, spa | the same, rendered in the browser | `npm install && npm run build` | `dist/site/browser/` with a fallback to `index.html` |
|
||||
| react, static | Next.js 16 static export | `npm install && npm run build` | `out/` |
|
||||
| react, spa | Vite 8, React 19, React Router 8 | `npm install && npm run build` | `dist/` with a fallback to `index.html` |
|
||||
|
||||
The reply's `next_steps` repeats the commands for the chosen variant.
|
||||
|
||||
The writers share these behaviours:
|
||||
- Every framework gets the same pages: semantic tags from layer names, one h1, `<img>` with alt text from the layer name, self-hosted fonts, Open Graph tags and `robots.txt`.
|
||||
- Headings follow the outline: text in a header or nav (the logo) is not a heading, a short line right under a larger heading in the same group is a paragraph, and no level is skipped.
|
||||
- Name a layer `Favicon` (any case; a picture or a vector) and it becomes the site icon, as SVG or the picture's own file. Without one the page links `data:,`, so the browser asks for no `/favicon.ico`.
|
||||
- The page's h1 face and the face most body text uses are preloaded (two fonts at most). Custom properties are written only where a declaration reads them, and `warnings` lists every text layer below WCAG AA contrast (4.5:1, 3:1 for large text).
|
||||
- A link or button with no text gets the layer name as its `aria-label`.
|
||||
- A frame at least 1024 px wide takes the window's full width. A narrower frame (a phone or tablet screen) stays a centred column of its designed width. Every page is at least as tall as the window, so its background reaches the edges.
|
||||
- Below the designed width, pages reflow instead of clipping: fixed widths give way, rows of cards wrap, and type of 40 px or more shrinks with the window. Rows wider than their frame (carousels) and layers that overflow on purpose keep their designed size.
|
||||
- Fonts are cut down to the characters the site uses in every language and written as WOFF2, each with a fallback face of matching metrics so the swap does not move the layout. Kerning is left out when the subsetter cannot keep it; the canvas does not kern either.
|
||||
- `base_url` makes canonical and hreflang URLs absolute and adds `sitemap.xml`.
|
||||
|
||||
## Hosting
|
||||
|
||||
Every export also writes the files static hosts read. `vercel.json` sits at the project root; the others go in `public/` for the npm frameworks, so the build copies them into the folder you deploy (for html, the root is that folder):
|
||||
|
||||
- `_headers` (Netlify, Cloudflare Pages) and `vercel.json`: `Cache-Control: public, max-age=31536000, immutable` on the content-hashed paths only (`/assets/*`, plus `/_astro/*`, `/_nuxt/*`, `/_next/static/*`, or Angular's root `/*.js` and `/*.css`). HTML gets no rule on purpose: those hosts already revalidate it, and Netlify and Cloudflare would merge a catch-all with the asset rule.
|
||||
- SPA renders only: `_redirects` (`/* /index.html 200`) and a Vercel rewrite to `/index.html`. Real files win over the fallback.
|
||||
- `.nojekyll` for astro, static vue and static react, whose output folders start with `_`.
|
||||
- GitHub Pages ignores `_headers` and `vercel.json` (it sets `max-age=600`); deploy there with `.nojekyll`.
|
||||
|
||||
## Languages
|
||||
|
||||
- **Input:** pass `modes` with a variable collection whose modes are languages (`en`, `pt-BR`), and bind the copy to string variables.
|
||||
- **Routes:** each mode becomes `/<lang>/` routes with one JSON catalog per language, and `/` lists the languages.
|
||||
- **What changes per language:** text that differs becomes a message, and styles that differ become `:lang()` rules.
|
||||
- **Title and description:** `title` and `description` may be objects keyed by mode name (`{"en": "...", "pt-BR": "..."}`); each language's pages carry their own `<title>`, meta description and og/twitter tags. A language without an entry takes the base language's.
|
||||
- **Language picker:** `/` carries the first language's `lang`.
|
||||
- **Single language:** routes sit at the root with literal text, and `lang` sets the language tag.
|
||||
|
||||
## Limits
|
||||
|
||||
- One layout per frame. Auto-layout frames shrink to the screen; frames without auto layout stay absolutely positioned, and the export warns about them.
|
||||
- Pictures keep their format (PNG, JPEG, GIF, lossless WebP), downscaled to twice their displayed size. There is no AVIF.
|
||||
- Vectors and icons are SVG files shown with `<img>`.
|
||||
- Without `base_url`, canonical and hreflang URLs are relative (React leaves them out and warns), and no sitemap is written. Pass `base_url` for anything public.
|
||||
- After design changes, export again to the same folder: a re-export replaces only the files the previous export wrote.
|
||||
@@ -28,9 +28,17 @@
|
||||
|
||||
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self` (`start`, `center`, `end`, `stretch`), `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-width`, `max-width`, `min-height`, `max-height`, `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
|
||||
|
||||
Rewritten before layers are made, each reported in `warnings`: `margin` becomes the parent's `gap` (when uniform) or spacer layers, `margin-*:auto` a growing spacer and `margin:0 auto` centring; `display:grid` becomes rows of cells (`fr` tracks grow, px tracks keep their width, `repeat(auto-fill, minmax())` counts columns from the width); inline boxes (chips, badges) become boxes in a wrapping row; `<input>` and `<textarea>` become a box showing their placeholder or value. Prefer `gap` and `padding` anyway: the result is the same and shorter.
|
||||
Rewritten before layers are made, each reported in `warnings`:
|
||||
- `margin` between siblings becomes the parent's `gap` when every gap is the same, else spacer layers; the margin before the first and after the last child becomes the parent's `padding` (spacers when its padding is not plain px). `margin-*:auto` is a growing spacer and `margin:0 auto` centres the item across the flow. Negative margins are dropped with a warning.
|
||||
- `display:grid` becomes rows of cells: `repeat(N, 1fr)`, px, `fr`, `minmax(px, 1fr)` and `repeat(auto-fill, minmax())` (columns counted from the width) tracks; `gap`, `row-gap`, `column-gap`; `grid-column: span N` (also `1 / 3`) makes a cell cover N columns. `grid-template-rows`, areas, `grid-row` and explicit placement are not supported: cells flow row by row, with a warning.
|
||||
- `transform: translate()`, `translateX()` and `translateY()` move a free-positioned layer by that offset (px). Inside auto layout the offset cannot stay, so use margin or gap. `rotate`, `scale`, `skew` and `matrix` are reported in `ignoredStyles` (layers have no rotation).
|
||||
- Inline boxes (chips, badges) become boxes in a wrapping row; `<input>` and `<textarea>` become a box showing their placeholder or value.
|
||||
|
||||
Not supported (check `ignoredStyles` to be sure): tables, `float`, `calc()`, viewport units, `transform` and rotation.
|
||||
`ignoredStyles` lists what was dropped, one entry per property (`transform: rotate, scale`).
|
||||
|
||||
Prefer `gap` and `padding` anyway: the result is the same and shorter. To open a whole exported page (a `.html` or a `.zip` with its stylesheets and images), use `import_html`: it inlines `<style>` and linked CSS (tag, `.class` and `#id` selectors, `:root` custom properties; media queries and combinators are reported), registers the zip's images, and writes each HTML file into its own new frame on the page.
|
||||
|
||||
Not supported (check `ignoredStyles` to be sure): tables, `float`, `calc()`, viewport units, rotation.
|
||||
|
||||
Tips:
|
||||
|
||||
@@ -41,7 +49,7 @@ Tips:
|
||||
|
||||
## Text
|
||||
|
||||
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
|
||||
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is **Inter** unless `font-family` names a family added with `install_font` (it applies to the whole text layer, not a span). There are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
|
||||
|
||||
## Boxes
|
||||
|
||||
|
||||
@@ -93,7 +93,19 @@ jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' landing.h
|
||||
|
||||
Check the headline in the screenshot: text boxes with a fixed `width` can wrap earlier than in a browser, so give long headlines generous widths (960px above) and verify.
|
||||
|
||||
## 3. Dashboard (sidebar, stat cards, chart)
|
||||
## 3. Translate a page
|
||||
|
||||
A language is a mode of a string collection, never a copy of the page. Text layers bind to string variables, so switching the mode changes the text and re-fits auto-size text and auto layout.
|
||||
|
||||
1. `set_strings {"extract": true, "frame": "<frame id>", "mode": "en"}`: every text layer gets a variable named from its layer path (`hero/title`) and the keys come back with their texts.
|
||||
2. Optional, before any real translation: `set_strings {"collection": "Strings", "mode": "xx", "pseudo": true}`, then `set_variable_mode {"mode": "xx"}`. The text is accented, about 35% longer and in brackets, so overflow shows up now.
|
||||
3. `set_strings {"collection": "Strings", "mode": "pt", "strings": {"hero/title": "..."}}` per language. Translate in steps with `get_strings {"missing": "pt"}`, which lists only the keys still without a value, with their base text. An unknown key is an error that applies nothing.
|
||||
4. `set_variable_mode {"mode": "pt"}`, then `lint_design` and `get_screenshot` in each mode to check overflow: fix a layer that outgrows its box by letting it wrap or hug, never by shrinking the text only in one language.
|
||||
5. `set_variable_mode` back to the base mode.
|
||||
|
||||
Typing into a bound text detaches it from its variable (as a new colour does for a `text-color` binding); edit the text with `set_strings` instead.
|
||||
|
||||
## 4. Dashboard (sidebar, stat cards, chart)
|
||||
|
||||
A row artboard (`flexDirection:"row"`) with a fixed 240px sidebar and a `flex-grow:1` main area.
|
||||
|
||||
@@ -132,7 +144,7 @@ jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' dashboard
|
||||
|
||||
Charts are just boxes: size the bars from the data (`height = value / max * plot height`). Add axis labels at 12px. Always label what the bars mean; color alone is not information.
|
||||
|
||||
## 4. Design-system page
|
||||
## 5. Design-system page
|
||||
|
||||
Generate swatches from the project's own variables and a type-scale specimen, so the page cannot drift from the tokens:
|
||||
|
||||
|
||||
@@ -70,12 +70,22 @@ Essential context about the open project: project name, the page, node count, ar
|
||||
The selected layers on the page the user is viewing: ids, names, types, sizes, positions and the artboard each belongs to, plus `viewport` ({pageId, rect: [x, y, w, h] world, zoom}).
|
||||
|
||||
|
||||
### `get_node_info` (read-only)
|
||||
### `get_context` (read-only)
|
||||
|
||||
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
|
||||
The one compact read to make before editing (about 2k tokens): the `page`, the `target` (`id`, else the first selected layer, else the whole page) and its `screen`, the `selection`, an `outline` of the target two levels deep with child counts and an `estimatedTokens` per layer (what a `get_tree_summary` of it costs, so you know what is cheap to read next), the `variables` used inside it (value in the active mode, collection), every collection with its modes and the active one, the `components` used with instance counts, and a `lint` summary of the target only (counts per rule and the first 5 findings). Read deeper nodes afterwards, one at a time.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | string | | A layer or screen to describe; omit for the selection, else the page. |
|
||||
|
||||
|
||||
### `get_node_info` (read-only)
|
||||
|
||||
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist. Reports `estimatedTokens`; over `maxTokens` the child ids are cut (`truncated`).
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
| `nodeId` | string | yes | The node to inspect. |
|
||||
|
||||
|
||||
@@ -92,22 +102,25 @@ Rects of a whole subtree in one read, instead of one get_node_info per layer. Re
|
||||
|
||||
### `get_children` (read-only)
|
||||
|
||||
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions.
|
||||
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions. Reports `estimatedTokens`; over `maxTokens` the list is cut (`truncated`).
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
| `nodeId` | string | yes | The parent node, or "root". |
|
||||
|
||||
|
||||
### `get_tree_summary` (read-only)
|
||||
|
||||
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
|
||||
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page. A second result block reports `estimatedTokens`; over `maxTokens` the tree is cut at a whole line and the block carries `truncated`, `nextOffset` (pass it as `offset`) and a hint.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
| `depth` | number | | Levels shown, counting nodeId (or each top-level layer for "root") as level 1 (default 3, max 10). Deeper levels collapse to "… N children". |
|
||||
| `fullText` | boolean | | Show each text layer's complete text instead of its first 40 characters (for copy review). |
|
||||
| `nodeId` | string | yes | Root of the summary, or "root". |
|
||||
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
|
||||
|
||||
Depth counts from the node you pass, not from the page. For an artboard `Home` with a `Screens` frame holding rows, `{"nodeId": "<Home id>", "depth": 2}` stops at `Screens`:
|
||||
|
||||
@@ -122,22 +135,26 @@ The rows inside `Screens` are level 3, so they need `depth: 3` from `Home` (the
|
||||
|
||||
### `get_jsx` (read-only)
|
||||
|
||||
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens).
|
||||
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens). A second result block reports `estimatedTokens`; over `maxTokens` the code is cut at a whole line and the block carries `truncated`, `nextOffset` (pass it as `offset`) and a hint to export a narrower node.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: "inline" (default), "tailwind", "react", "angular", "html", or "egui". |
|
||||
| `nodeId` | string | yes | The node to convert. |
|
||||
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
|
||||
|
||||
|
||||
### `get_design_spec` (read-only)
|
||||
|
||||
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot.
|
||||
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot. Reports `estimatedTokens` (json format: a field; other formats: a second result block); over `maxTokens` the output is cut at a whole line (json: whole array elements) and flagged `truncated` with how to read the rest.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
| `format` | `markdown` \| `json` | | Output format: "markdown" (default) or "json". |
|
||||
| `nodeId` | string | yes | Root frame or node id to export. |
|
||||
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target UI framework (default "react"). |
|
||||
|
||||
|
||||
@@ -191,9 +208,17 @@ The image used by a node's image fill, as image content.
|
||||
| `nodeId` | string | yes | A node with an image fill. |
|
||||
|
||||
|
||||
### `install_font`
|
||||
|
||||
Add a font and keep it in the open project, so it travels with the project. Takes one .ttf or .otf per call (one style of a family; a variable font gives its default style); call again for its bold and italic files. Returns the family; use it as font-family in write_html and update_styles. Check the font's licence allows the use.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `source` | string | yes | A local file path, or an http(s) URL of the font file itself (not a web page, zip or WOFF). |
|
||||
|
||||
### `get_font_family_info` (read-only)
|
||||
|
||||
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
|
||||
Whether font families are available and their weights/styles. Inter (400, 700, normal and italic) is built in and families added with install_font follow; others fall back to Inter.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -294,6 +319,25 @@ Write the handoff bundle a code base builds from into a folder: index.md (one li
|
||||
| `since` | string | | Handoff point to report changes against. |
|
||||
|
||||
|
||||
### `export_site`
|
||||
|
||||
Write frames of a page as a website in one call; returns a short summary, never the markup. Before the first export ASK THE PERSON which `framework`, `render` and `styling` they want, and recommend from what they have: the framework their repo or team uses (read package.json), else html for a finished site nobody will develop or astro for a content site they will grow. Client JavaScript per page (gzip): html and astro 0; static vue ~71 KB, angular ~77 KB, react ~135 KB; spa vue ~40 KB, angular ~63 KB, react ~97 KB. render static (prerendered) for anything public that needs search or link previews, spa only for an app behind a login. styling: what their project uses, else css; scss and tailwind work everywhere; CSS-in-JS is react only: vanilla-extract (recommended, zero runtime), styled-components or emotion. Defaults html, static, css. One page per frame (the first is the home page) with semantic tags and one h1, <img> alt and link labels from layer names, self-hosted fonts, Open Graph and robots.txt; `base_url` makes canonical and hreflang absolute and adds sitemap.xml. Prototype links to exported frames and Url links become links. With `modes` (a collection whose modes are languages such as en and pt-BR) each mode is a locale: text that differs becomes a message per locale, styles that differ become :lang() rules. A project (any framework, scss, tailwind) needs a build: follow `next_steps`. The folder must be new, empty or an earlier export_site folder; a re-export removes only what the previous one wrote. Per framework: [export-site.md](export-site.md). Returns {dir, framework, render, styling, pages, files, bytes, written, removed, links: {resolved, unresolved}, urls, next_steps, warnings}.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `path` | string | yes | Folder to write into (created if missing). |
|
||||
| `frames` | array | | Frame ids, in page order. Default: the page's screens (set_screens), else its top-level frames. |
|
||||
| `modes` | object | | `{collection, modes: [..]}`: collection name or id and the mode names to export, e.g. ["en", "pt-BR"]. |
|
||||
| `base_url` | string | | Where the site will be served (https://example.com): absolute hreflang and canonical URLs, and sitemap.xml. |
|
||||
| `framework` | string | | Output: html (static pages, no build step), or a project for react, vue, angular or astro. Ask the person; default html. |
|
||||
| `render` | string | | static: pages pre-rendered for search (landing, marketing, docs). spa: client-rendered app (behind a login). Only for react, vue and angular. Ask the person; default static. |
|
||||
| `styling` | string | | css (default, no build step), scss, tailwind, or CSS-in-JS for react only: vanilla-extract (recommended, zero runtime), styled-components, emotion. Ask the person; use what their project uses. |
|
||||
| `lang` | string | | Language tag of a single-language site (en, pt-BR): `<html lang>` and og:locale. |
|
||||
| `title` | string or object | | Site title: the home page title and the suffix of the others. Default: the frame names. With `modes`, an object keyed by mode name (`{"en": "...", "pt-BR": "..."}`) gives each language its own; a language without an entry takes the base language's. |
|
||||
| `description` | string or object | | Meta description for every page. Default: each page's first line of copy. With `modes`, an object keyed by mode name gives each language its own, as for `title`. |
|
||||
| `pageId` | string | | Page to export from (default the active page). |
|
||||
|
||||
|
||||
## Write
|
||||
|
||||
### `create_artboard`
|
||||
@@ -311,7 +355,7 @@ Creates an artboard (top-level frame) and returns its id; then add content with
|
||||
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
|
||||
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. Margins become gaps or spacers, display:grid becomes rows of cells, text inputs show their placeholder (each reported in warnings). No tables.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); <b>/<i>/<u>/<s>/<span style> make rich text. font-family applies to a whole text layer: the first installed family in the list, Inter by default.
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
|
||||
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
|
||||
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
|
||||
@@ -324,6 +368,15 @@ Returns the ids of the created top-level layers and the styles that were ignored
|
||||
| `mode` | `insert-children` \| `replace` | yes | |
|
||||
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
|
||||
|
||||
### `import_html`
|
||||
|
||||
Open an HTML page exported by a prompt-to-design tool as layers: a .html file or a .zip with its stylesheets and images. `<style>` blocks and linked stylesheets are inlined (tag, .class and #id selectors; media queries and combinators are reported), images in the zip become assets, and each HTML file is written with write_html into its own new top-level frame on the page (side by side, named after the file). Returns the frames per file, `ignoredStyles` grouped by property and `warnings`.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `path` | string | yes | A local .html or .zip file. |
|
||||
| `pageId` | string | no | The page for the frames. Default: the active page. |
|
||||
|
||||
|
||||
### `update_styles`
|
||||
|
||||
@@ -414,6 +467,17 @@ Variables of the page's project. Default JSON: `variables` (name -> hex), `token
|
||||
| `format` | `css` \| `json` \| `tailwind` | | |
|
||||
|
||||
|
||||
### `get_strings` (read-only)
|
||||
|
||||
The page text for translation, with no styles or geometry. Returns `strings` (text layers bound to a string variable, by variable name, with the value per mode name), `unbound` (layer id -> text of text layers bound to nothing; set_strings {extract} binds them), `missing` (per mode name, the keys with no value of their own, which show the base text) and `collections`. `missing` set to a mode name returns only {mode, base, missing: {key: base text}}, so a translation is done in steps. Scope: `frame` (a layer id; default every layer of the page), `pageId`; `collection` keeps one collection.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `collection` | string | | Collection name or id to keep. |
|
||||
| `frame` | string | | Layer id to read inside. Omitted = the whole page. |
|
||||
| `missing` | string | | A mode name: only the keys that mode lacks, with their base text. |
|
||||
|
||||
|
||||
### `set_variables`
|
||||
|
||||
Define or change variables (names like `surface`, `accent-text`, or grouped `color/brand/primary`), on every page. Plain {"name": "#hex"} defines a color. An entry may be an object {value, type, collection, mode, alias, description, hidden}: `type` color|number|string|boolean (numbers bind as px), `collection` a name or id (created if missing), `alias` the name of another variable of the same type. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
@@ -457,7 +521,7 @@ Define non-color design tokens (spacing, radius, font size, shadows, widths), on
|
||||
|
||||
### `bind_variable`
|
||||
|
||||
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible. `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
|
||||
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible, text (a text layer's content, from a string variable; typing into the layer detaches it). `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -466,6 +530,20 @@ Bind a field of layers to a variable (or detach it). `field`: stroke, text-color
|
||||
| `variable` | string \| null | | |
|
||||
|
||||
|
||||
### `set_strings`
|
||||
|
||||
Translate a page in one undoable call; languages are modes of a string collection, never copies. Does not switch the active mode (set_variable_mode does). One of: (a) `extract` true with `frame` (default the page) and `collection` (default `Strings`, created with a base mode named `mode`, default `Default`): every unbound text layer gets a string variable named from its layer path (`hero/title`, repeats numbered), holding its text in the base mode, and is bound to it; returns the keys with their texts. (b) `collection`, `mode` and `strings` {key: text}: sets those values, creating the mode if missing; an unknown key, a non-string variable or an unknown collection is an error that lists the offenders and changes nothing. (c) `collection`, `mode` and `pseudo` true: fills the mode with the base text accented, about 35% longer and in brackets, to show overflow before a real translation. Bound text re-fits when the mode is switched, so run lint_design and get_screenshot per mode.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `collection` | string | | Collection name or id. |
|
||||
| `extract` | boolean | | |
|
||||
| `frame` | string | | extract: layer id to read inside. Omitted = the whole page. |
|
||||
| `mode` | string | | Mode name (the language); extract: the base mode's name when the collection is created. |
|
||||
| `pseudo` | boolean | | |
|
||||
| `strings` | object | | {key: text}, keys as returned by get_strings. |
|
||||
|
||||
|
||||
### `set_variable_mode`
|
||||
|
||||
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
|
||||
@@ -495,7 +573,11 @@ Write the page's variables, modes and tokens as an OpenDesign design-system pack
|
||||
|
||||
### `get_components` (read-only)
|
||||
|
||||
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages).
|
||||
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages). Reports `estimatedTokens`; over `maxTokens` the list is cut (`truncated`).
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
|
||||
|
||||
|
||||
### `import_components`
|
||||
@@ -553,6 +635,14 @@ Detach one or more component instances into plain frames, breaking their link to
|
||||
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
|
||||
|
||||
|
||||
### `set_prototype_links`
|
||||
|
||||
Sets what clicking a layer does in Present mode. Per link give at most one of `to`, `overlay`, `url` or `action`; none clears the layer's link. Every link is checked before any is applied. Links never cross pages.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `links` | array | yes | Objects `{nodeId, to?, overlay?, url?, action?}`. `to`: a frame on the same page to go to (null removes). `overlay`: a frame opened centred over the current one with a dimmed backdrop (taller than the viewport scrolls; backdrop click or Escape closes). `url`: https, http, mailto or tel only. `action`: `back` (previous frame) or `close` (close the overlay). |
|
||||
|
||||
### `search_icons` (read-only)
|
||||
|
||||
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
|
||||
|
||||
+124
-11
@@ -57,6 +57,24 @@ fn release_freed_memory() {
|
||||
|
||||
/// Shortest gap between frames drawn for an agent's edits alone (20 FPS).
|
||||
const AGENT_FRAME: Duration = Duration::from_millis(50);
|
||||
/// How long agent calls wait for a drag to end before running anyway, so a stuck gesture
|
||||
/// cannot block them for good.
|
||||
const GESTURE_HOLD: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Whether agent calls should keep waiting: a gesture is in progress and has not held them
|
||||
/// longer than `GESTURE_HOLD`. `since` is when the wait began; it is cleared once they may run.
|
||||
fn hold_for_gesture(busy: bool, since: &mut Option<Instant>, now: Instant) -> bool {
|
||||
if !busy {
|
||||
*since = None;
|
||||
return false;
|
||||
}
|
||||
let start = *since.get_or_insert(now);
|
||||
if now.duration_since(start) >= GESTURE_HOLD {
|
||||
*since = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
gfx: Option<Gfx>,
|
||||
@@ -89,6 +107,9 @@ pub struct App {
|
||||
)>,
|
||||
/// Groups the agent's editing calls into reviewable runs.
|
||||
runs: crate::runs::Tracker,
|
||||
/// Tool calls waiting for the person's drag to end, and since when.
|
||||
held: Vec<crate::mcp::Call>,
|
||||
held_since: Option<Instant>,
|
||||
/// Claims and what each agent last saw; shared with delegate threads, which release their
|
||||
/// helper's claims when it ends.
|
||||
swarm: std::sync::Arc<std::sync::Mutex<crate::mcp::swarm::Swarm>>,
|
||||
@@ -143,10 +164,13 @@ impl App {
|
||||
menu: None,
|
||||
mcp: None,
|
||||
runs: Default::default(),
|
||||
held: Vec::new(),
|
||||
held_since: None,
|
||||
swarm: Default::default(),
|
||||
};
|
||||
app.st.library.library = library;
|
||||
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
|
||||
crate::ui::canvas_prefs::CanvasPrefs::load().apply(&mut app.ed, &mut app.st);
|
||||
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
|
||||
app.ed.clipboard_io = crate::io::clipboard::System::new()
|
||||
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
|
||||
@@ -300,6 +324,10 @@ impl App {
|
||||
self.st.save_state.clear();
|
||||
}
|
||||
|
||||
fn release_unused_images(&self, dir: &std::path::Path) {
|
||||
crate::assets::release_unused(&dir.join(crate::project::ASSETS_DIR), self.ed.images_held_outside_pages());
|
||||
}
|
||||
|
||||
fn open_project(&mut self, dir: &std::path::Path) {
|
||||
if self.session.project.is_some() {
|
||||
self.save_project(true);
|
||||
@@ -309,6 +337,7 @@ impl App {
|
||||
match self.session.open(&mut self.ed, dir) {
|
||||
Ok(()) => {
|
||||
self.runs.end_counted(crate::project::format::now_ms(), counts, None);
|
||||
self.release_unused_images(dir);
|
||||
self.st.home = None;
|
||||
self.st.project_name = self.session.project_name();
|
||||
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
@@ -745,6 +774,8 @@ impl App {
|
||||
}
|
||||
if self.confirm_discard() {
|
||||
event_loop.exit();
|
||||
} else {
|
||||
self.st.updater.restart = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1154,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
);
|
||||
window.request_redraw();
|
||||
self.st.perf.gpu = format!("{} ({:?})", adapter_info.name, adapter_info.backend);
|
||||
self.st.perf.display_hz = display_hz(&window);
|
||||
self.gfx = Some(Gfx {
|
||||
window,
|
||||
surface,
|
||||
@@ -1161,6 +1193,11 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
WindowEvent::Moved(_) => {
|
||||
if let Some(g) = &self.gfx {
|
||||
self.st.perf.display_hz = display_hz(&g.window);
|
||||
}
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged { .. } => {
|
||||
// Moved between Retina and non-Retina displays. egui-winit has already picked
|
||||
// up the new pixels-per-point; resize the surface to the new physical size
|
||||
@@ -1277,10 +1314,15 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
} else {
|
||||
d
|
||||
};
|
||||
self.ed.handle(InputEvent::Scroll {
|
||||
delta: -d,
|
||||
pos: self.to_canvas(p),
|
||||
});
|
||||
let pos = self.to_canvas(p);
|
||||
// Only a notched wheel: a trackpad's pixel scrolling stays a pan.
|
||||
let notched = matches!(delta, MouseScrollDelta::LineDelta(..));
|
||||
if self.st.wheel_zooms && notched && !self.ed.mods.shift {
|
||||
let factor = (d.y * crate::editor::ZOOM_PER_SCROLL_PX).exp();
|
||||
self.ed.handle(InputEvent::Zoom { factor, pos });
|
||||
} else {
|
||||
self.ed.handle(InputEvent::Scroll { delta: -d, pos });
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
@@ -1447,7 +1489,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if std::mem::take(&mut self.close_requested) {
|
||||
if std::mem::take(&mut self.close_requested) || self.st.updater.restart.is_some() {
|
||||
self.try_exit(event_loop);
|
||||
}
|
||||
// Autosave runs off a timer so it also fires while the window sits idle.
|
||||
@@ -1462,6 +1504,9 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
}
|
||||
self.tick_sync();
|
||||
if !self.held.is_empty() {
|
||||
self.run_mcp_calls();
|
||||
}
|
||||
let wake = [
|
||||
self.next_repaint,
|
||||
self.session.next_deadline(),
|
||||
@@ -1573,13 +1618,25 @@ impl App {
|
||||
|
||||
/// Run the MCP tool calls that arrived since the last wake-up, then repaint.
|
||||
fn run_mcp_calls(&mut self) {
|
||||
let calls: Vec<crate::mcp::Call> = match &self.mcp {
|
||||
Some((rx, _, _)) => rx.try_iter().collect(),
|
||||
None => return,
|
||||
let Some((rx, _, _)) = &self.mcp else {
|
||||
// No server to answer for: fail held calls rather than leave their callers waiting.
|
||||
for c in std::mem::take(&mut self.held) {
|
||||
let _ = c.reply.send(Err("PaperCraft's MCP server is not running".into()));
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut calls = std::mem::take(&mut self.held);
|
||||
calls.extend(rx.try_iter());
|
||||
if calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A call landing mid-drag would commit the half-made drag as its own undo step and
|
||||
// the rest of the drag would overwrite it; wait for the gesture to end instead.
|
||||
if hold_for_gesture(self.ed.is_busy(), &mut self.held_since, Instant::now()) {
|
||||
self.held = calls;
|
||||
self.next_repaint = Some(Instant::now() + AGENT_FRAME);
|
||||
return;
|
||||
}
|
||||
for c in calls {
|
||||
// Network and Codex work runs off the editor thread; it only reads the project folder.
|
||||
// The helper calls back into this app through papercraft-cli, so it must not hold the UI thread.
|
||||
@@ -1620,7 +1677,13 @@ impl App {
|
||||
}
|
||||
let mut runs = std::mem::take(&mut self.runs);
|
||||
let swarm = self.swarm.clone();
|
||||
let r = swarm.lock().unwrap().call(&mut runs, self, &c.client, &c.name, &c.args, crate::project::format::now_ms());
|
||||
let called = catch_call(&c.name, || swarm.lock().unwrap_or_else(|p| p.into_inner()).call(&mut runs, self, &c.client, &c.name, &c.args, crate::project::format::now_ms()));
|
||||
let r = called.unwrap_or_else(|e| {
|
||||
// The panic may have left an edit half done; drop it before the next call commits it.
|
||||
self.ed.abort();
|
||||
swarm.clear_poison();
|
||||
Err(e)
|
||||
});
|
||||
let touched = runs.take_touched();
|
||||
if r.is_ok() {
|
||||
let who = runs.label(&c.client);
|
||||
@@ -1676,6 +1739,7 @@ impl crate::mcp::exec::Host for App {
|
||||
self.session
|
||||
.open(&mut self.ed, dir)
|
||||
.map_err(|e| format!("could not open {}: {e}", dir.display()))?;
|
||||
self.release_unused_images(dir);
|
||||
self.st.home = None;
|
||||
self.st.project_name = self.session.project_name();
|
||||
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
@@ -1768,14 +1832,63 @@ pub fn run(start: Start) -> Result<(), String> {
|
||||
let mut app = App::new(start, store);
|
||||
app.mcp = start_mcp(event_loop.create_proxy());
|
||||
app.st.agent_endpoint = app.mcp.as_ref().map(|(_, r, _)| r.url()).unwrap_or_default();
|
||||
event_loop.run_app(&mut app).map_err(|e| e.to_string())
|
||||
if let Some(dir) = std::env::current_exe().ok().as_deref().and_then(std::path::Path::parent) {
|
||||
crate::update::remove_replaced(dir);
|
||||
}
|
||||
if !cfg!(debug_assertions) && ui::update::auto_enabled() {
|
||||
app.st.updater.check_quietly();
|
||||
}
|
||||
event_loop.run_app(&mut app).map_err(|e| e.to_string())?;
|
||||
match app.st.updater.restart.take() {
|
||||
Some(ui::update::Restart::Relaunch) => crate::update::relaunch(None),
|
||||
Some(ui::update::Restart::Install(msi)) => crate::update::relaunch(Some(&msi)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's monitor's refresh rate, rounded to whole Hz.
|
||||
fn display_hz(window: &Window) -> Option<u32> {
|
||||
window.current_monitor()?.refresh_rate_millihertz().map(|mhz| (mhz + 500) / 1000)
|
||||
}
|
||||
|
||||
/// Runs one tool call so that a panic in it becomes an error for that call instead of ending
|
||||
/// the app (calls run on the UI thread). Release builds unwind; the web build aborts.
|
||||
/// Err: the panic, described.
|
||||
fn catch_call(tool: &str, call: impl FnOnce() -> crate::mcp::ToolResult) -> Result<crate::mcp::ToolResult, String> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(call)).map_err(|p| {
|
||||
let why = p.downcast_ref::<&str>().copied().or_else(|| p.downcast_ref::<String>().map(String::as_str)).unwrap_or("unknown cause");
|
||||
format!("{tool} crashed ({why}); PaperCraft kept the document consistent, but part of the call may have been applied: check the state before retrying. This is a PaperCraft bug.")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::prefer_x11;
|
||||
use super::{catch_call, hold_for_gesture, prefer_x11, GESTURE_HOLD};
|
||||
use std::time::Instant;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn a_panicking_tool_call_becomes_an_error() {
|
||||
let err = catch_call("write_html", || panic!("boom")).unwrap_err();
|
||||
assert!(err.contains("write_html") && err.contains("boom") && err.contains("check the state"), "{err}");
|
||||
assert!(catch_call("x", || Ok(Vec::new())).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_calls_wait_for_a_drag_but_not_forever() {
|
||||
let t0 = Instant::now();
|
||||
let mut since = None;
|
||||
assert!(!hold_for_gesture(false, &mut since, t0));
|
||||
assert!(hold_for_gesture(true, &mut since, t0));
|
||||
assert!(hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD / 2));
|
||||
// A stuck gesture releases them.
|
||||
assert!(!hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD));
|
||||
// The gesture ending clears the wait.
|
||||
assert!(hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD));
|
||||
assert!(!hold_for_gesture(false, &mut since, t0 + GESTURE_HOLD));
|
||||
assert!(since.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_prefers_x11_when_a_display_is_available() {
|
||||
for kernel in ["6.6.87.2-microsoft-standard-WSL2", "4.4.0-19041-Microsoft"] {
|
||||
|
||||
+25
-1
@@ -6,7 +6,7 @@
|
||||
//! their assets as `assets/<id>.<ext>`; copy/paste between projects carries only the id, and
|
||||
//! the destination project writes the file on its next save.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
@@ -108,6 +108,22 @@ pub fn load_dir(dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a project opens, forget the images of the one that was open before: the registry is
|
||||
/// process-wide and would otherwise only grow. Kept are the images in the new project's `dir`
|
||||
/// (including pages still loading), and `extra` (the clipboard and library documents, which can
|
||||
/// hold ids that are not in the folder).
|
||||
pub fn release_unused(dir: &Path, extra: impl IntoIterator<Item = String>) {
|
||||
let mut keep: HashSet<String> = extra.into_iter().collect();
|
||||
if let Ok(rd) = crate::vfs::read_dir(dir) {
|
||||
keep.extend(rd.flatten().filter_map(|e| Some(e.path().file_stem()?.to_string_lossy().into_owned())));
|
||||
}
|
||||
prune(&mut registry().lock().unwrap(), &keep);
|
||||
}
|
||||
|
||||
fn prune(map: &mut HashMap<String, Arc<ImageAsset>>, keep: &HashSet<String>) {
|
||||
map.retain(|id, _| keep.contains(id));
|
||||
}
|
||||
|
||||
/// Write the given assets into `dir` (skipping ones already there).
|
||||
pub fn save_to_dir(dir: &Path, ids: impl IntoIterator<Item = String>) -> std::io::Result<()> {
|
||||
for id in ids {
|
||||
@@ -205,6 +221,14 @@ mod tests {
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_drops_images_nobody_keeps() {
|
||||
let asset = |id: &str| Arc::new(ImageAsset { id: id.into(), width: 1, height: 1, bytes: Vec::new(), ext: "png" });
|
||||
let mut map: HashMap<String, Arc<ImageAsset>> = ["a", "b"].iter().map(|id| (id.to_string(), asset(id))).collect();
|
||||
prune(&mut map, &HashSet::from(["a".to_string()]));
|
||||
assert_eq!(map.keys().collect::<Vec<_>>(), ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_dedups_by_content() {
|
||||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 255]));
|
||||
|
||||
+110
-4
@@ -3,7 +3,7 @@
|
||||
//! (or `session/load`) with PaperCraft's MCP server, `session/prompt`, then the agent's
|
||||
//! `session/update` notifications are mapped to [`Event`]s until the prompt response arrives.
|
||||
|
||||
use super::events::{Event, Turn, TurnConfig};
|
||||
use super::events::{Event, Retry, Turn, TurnConfig};
|
||||
use super::{tools, Usage};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde_json::{json, Value};
|
||||
@@ -33,6 +33,8 @@ const SERVER_NAME: &str = "papercraft";
|
||||
const BRIDGE: &str = "papercraft-mcp";
|
||||
/// How agents prefix MCP tool names; longest first.
|
||||
const NAME_PREFIXES: [&str; 4] = ["mcp__papercraft__", "mcp_papercraft_", "papercraft__", "papercraft_"];
|
||||
/// Where opencode puts a pending retry in a `session_info_update` (null once a call goes through).
|
||||
const RETRY_META: &str = "opencode/retry";
|
||||
/// Same keys the other backends drop: the agent must run on its own login.
|
||||
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
|
||||
@@ -113,7 +115,7 @@ pub fn start(cfg: &TurnConfig, program: &Path, args: &[String], env: &[(String,
|
||||
|
||||
fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], env: &[(String, String)], prompt: &str, tx: Sender<Event>, bridge: Option<PathBuf>) -> Result<AcpTurn, String> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args).envs(env.iter().cloned()).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
crate::chat::path::no_window(&mut cmd).args(args).envs(env.iter().cloned()).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
@@ -176,6 +178,9 @@ fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], env: &[(String, Stri
|
||||
cost: None,
|
||||
plans: 0,
|
||||
sid: None,
|
||||
retrying: None,
|
||||
switch_on_retry: false,
|
||||
cancelled_for: None,
|
||||
};
|
||||
let result = c.run(&cfg, &prompt, bridge, &session);
|
||||
c.close_open_tools();
|
||||
@@ -255,6 +260,13 @@ struct Client {
|
||||
cost: Option<f64>,
|
||||
plans: u32,
|
||||
sid: Option<String>,
|
||||
/// The provider error the agent is waiting out, while it retries.
|
||||
retrying: Option<String>,
|
||||
/// A fallback model is left ("Free (auto)"): a retry cancels the prompt to move on to it
|
||||
/// instead of waiting for the same provider.
|
||||
switch_on_retry: bool,
|
||||
/// The prompt was cancelled for this retry; its answer becomes that error.
|
||||
cancelled_for: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -356,6 +368,8 @@ impl Client {
|
||||
}
|
||||
let mut fallbacks = cfg.fallbacks.iter();
|
||||
let r = loop {
|
||||
self.switch_on_retry = models.as_ref().is_some_and(|m| fallbacks.clone().any(|f| m.options.iter().any(|(id, _)| id == f)));
|
||||
(self.retrying, self.cancelled_for) = (None, None);
|
||||
let e = match self.call("session/prompt", json!({"sessionId": sid, "prompt": blocks}), None) {
|
||||
Ok(r) => break r,
|
||||
Err(e) => e,
|
||||
@@ -403,8 +417,17 @@ impl Client {
|
||||
}
|
||||
}
|
||||
match self.rx.recv_timeout(TICK) {
|
||||
Ok(msg) if msg.get("method").is_some() => self.incoming(&msg),
|
||||
Ok(msg) if msg.get("method").is_some() => {
|
||||
self.incoming(&msg);
|
||||
if method == "session/prompt" {
|
||||
self.cancel_for_retry();
|
||||
}
|
||||
}
|
||||
Ok(msg) if msg["id"] == json!(id) => {
|
||||
// Cancelled to move off a rate-limited model: the error is the provider's.
|
||||
if let Some(why) = self.cancelled_for.take() {
|
||||
return Err(format!("Rate limited: {why}"));
|
||||
}
|
||||
return match msg.get("error") {
|
||||
Some(e) => Err(rpc_error(e)),
|
||||
None => Ok(msg["result"].clone()),
|
||||
@@ -421,6 +444,17 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under "Free (auto)", a retry before any tool ran cancels the prompt: the next free model
|
||||
/// answers now instead of the agent waiting out this one's rate limit.
|
||||
fn cancel_for_retry(&mut self) {
|
||||
if !self.switch_on_retry || self.cancelled_for.is_some() || !self.tools.is_empty() || self.stopped.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let (Some(why), Some(sid)) = (self.retrying.clone(), self.sid.clone()) else { return };
|
||||
send(&self.stdin, &json!({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": sid}}));
|
||||
self.cancelled_for = Some(why);
|
||||
}
|
||||
|
||||
fn incoming(&mut self, msg: &Value) {
|
||||
let method = msg["method"].as_str().unwrap_or_default();
|
||||
match (method, msg.get("id")) {
|
||||
@@ -451,6 +485,13 @@ impl Client {
|
||||
}
|
||||
}
|
||||
"plan" => self.plan(u),
|
||||
"session_info_update" => {
|
||||
if let Some(r) = u["_meta"].get(RETRY_META) {
|
||||
let retry = retry_of(r);
|
||||
self.retrying = retry.as_ref().map(|r| r.message.clone());
|
||||
self.emit(Event::Retrying(retry));
|
||||
}
|
||||
}
|
||||
"usage_update" => {
|
||||
if let Some(used) = u["used"].as_u64() {
|
||||
self.emit(Event::Context(used));
|
||||
@@ -551,6 +592,45 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// opencode's retry status: `{attempt, nextRetryAt, error: {type, message, status?}}`; None for
|
||||
/// null (the retry is over).
|
||||
fn retry_of(r: &Value) -> Option<Retry> {
|
||||
if !r.is_object() {
|
||||
return None;
|
||||
}
|
||||
let e = &r["error"];
|
||||
let text = e["message"].as_str().filter(|m| !m.trim().is_empty()).or_else(|| e["type"].as_str()).unwrap_or("The provider failed the request");
|
||||
let message = match e["status"].as_u64() {
|
||||
Some(code) if !text.contains(&code.to_string()) => format!("{} (HTTP {code})", text.trim()),
|
||||
_ => text.trim().to_owned(),
|
||||
};
|
||||
Some(Retry {
|
||||
attempt: r["attempt"].as_u64().map_or(1, |a| a as u32),
|
||||
message,
|
||||
next_at_ms: r["nextRetryAt"].as_str().and_then(iso_ms),
|
||||
})
|
||||
}
|
||||
|
||||
/// Unix ms of an RFC 3339 UTC time (`2026-11-02T17:14:41.528Z`, as JavaScript's `toISOString`).
|
||||
fn iso_ms(s: &str) -> Option<u64> {
|
||||
let s = s.strip_suffix('Z')?;
|
||||
let (date, time) = s.split_once('T')?;
|
||||
let mut d = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
|
||||
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
|
||||
let (hms, frac) = time.split_once('.').unwrap_or((time, "0"));
|
||||
let mut t = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
|
||||
let (h, min, sec) = (t.next()??, t.next()??, t.next()??);
|
||||
let ms: i64 = format!("{frac:0<3}").get(..3)?.parse().ok()?;
|
||||
// Days from civil (Howard Hinnant).
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
u64::try_from(((days * 24 + h) * 60 + min) * 60 + sec).ok().map(|secs| secs * 1000 + ms as u64)
|
||||
}
|
||||
|
||||
fn reply(id: &Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
@@ -687,6 +767,8 @@ mod tests {
|
||||
const LOAD: &str = include_str!("../../tests/fixtures/chat/acp_load.jsonl");
|
||||
const LOAD_FAILS: &str = include_str!("../../tests/fixtures/chat/acp_load_fails.jsonl");
|
||||
const ERROR: &str = include_str!("../../tests/fixtures/chat/acp_error.jsonl");
|
||||
const RETRY: &str = include_str!("../../tests/fixtures/chat/acp_retry.jsonl");
|
||||
const RETRY_WAIT: &str = include_str!("../../tests/fixtures/chat/acp_retry_wait.jsonl");
|
||||
|
||||
fn cfg() -> TurnConfig {
|
||||
TurnConfig { project_dir: std::env::temp_dir(), mcp_url: "http://127.0.0.1:29991/mcp".into(), system: "SYS".into(), ..Default::default() }
|
||||
@@ -838,6 +920,30 @@ mod tests {
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retrying_agent_is_shown_and_free_auto_moves_on_at_once() {
|
||||
let mut c = cfg();
|
||||
c.fallbacks = vec!["free/b".into()];
|
||||
let (ev, sent) = run(RETRY, c, None, |_, _| {});
|
||||
let retry = Retry { attempt: 1, message: "Rate limit exceeded (HTTP 429)".into(), next_at_ms: Some(1_793_639_681_528) };
|
||||
assert!(ev.contains(&Event::Retrying(Some(retry))), "{ev:?}");
|
||||
assert_eq!(method(&sent, "session/cancel")["params"]["sessionId"], "sess-1");
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::ModelSwitch { from, to, reason } if from == "free/a" && to == "free/b" && reason.contains("Rate limit exceeded"))), "{ev:?}");
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "end_turn"), "{ev:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_model_waits_out_its_retry_and_the_notice_clears() {
|
||||
let (ev, sent) = run(RETRY_WAIT, cfg(), None, |_, _| {});
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::Retrying(Some(r)) if r.attempt == 2 && r.message == "Overloaded")), "{ev:?}");
|
||||
assert!(ev.contains(&Event::Retrying(None)), "{ev:?}");
|
||||
assert!(!sent.iter().any(|v| v["method"] == "session/cancel"), "no fallback: the agent keeps trying");
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "end_turn"), "{ev:?}");
|
||||
assert_eq!(iso_ms("1970-01-01T00:00:01.5Z"), Some(1500));
|
||||
assert_eq!(iso_ms("2000-03-01T00:00:00Z"), Some(951_868_800_000));
|
||||
assert_eq!(iso_ms("not a date"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_after_a_tool_ran_is_not_retried() {
|
||||
let mut c = cfg();
|
||||
@@ -987,7 +1093,7 @@ mod tests {
|
||||
];
|
||||
let (tx, rx) = channel();
|
||||
let (stdin, _) = (Arc::new(Mutex::new(None)), ());
|
||||
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None };
|
||||
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None, retrying: None, switch_on_retry: false, cancelled_for: None };
|
||||
for u in fx {
|
||||
c.update(&serde_json::from_str(u).unwrap());
|
||||
}
|
||||
|
||||
+20
-3
@@ -175,8 +175,15 @@ fn detect_def(def: &AgentDef, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Agen
|
||||
let version = probe(&bin, "--version", path, PROBE_TIMEOUT);
|
||||
if let Some(want) = def.identity {
|
||||
let hit = |o: &Option<String>| o.as_ref().is_some_and(|o| o.to_lowercase().contains(want));
|
||||
if !hit(&version) && !hit(&probe(&bin, "--help", path, HELP_TIMEOUT)) {
|
||||
return AgentState::WrongProgram { path: shown };
|
||||
if !hit(&version) {
|
||||
// Only a `--help` that answered without the name proves a different program. One that
|
||||
// did not answer in time (a cold start or an antivirus scan while the app launches,
|
||||
// usual on Windows where node CLIs start through a `.cmd` shim) keeps the agent: its
|
||||
// turn fails with its own error if it is not the agent after all.
|
||||
let help = probe(&bin, "--help", path, HELP_TIMEOUT);
|
||||
if help.is_some() && !hit(&help) {
|
||||
return AgentState::WrongProgram { path: shown };
|
||||
}
|
||||
}
|
||||
}
|
||||
AgentState::Ready { path: shown, version: version.as_deref().and_then(|v| v.lines().find(|l| !l.trim().is_empty())).unwrap_or_default().trim().to_owned() }
|
||||
@@ -209,7 +216,7 @@ fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std:
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
crate::chat::path::no_window(&mut cmd).arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for k in super::path::CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
@@ -424,6 +431,16 @@ mod tests {
|
||||
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn a_program_too_slow_to_answer_is_still_detected() {
|
||||
// `opencode --version` prints only the number, so `--help` decides; at app launch it can
|
||||
// miss the timeout, which used to file the agent under "Different program".
|
||||
let (dir, path) = fake_dir(&[("opencode", r#"if [ "$1" = --version ]; then echo 1.18.30; else /bin/sleep 30; fi"#)]);
|
||||
let def = find("opencode").unwrap();
|
||||
assert_eq!(detect_def(def, &ChatPrefs::default(), &path), AgentState::Ready { path: dir.join("opencode").display().to_string(), version: "1.18.30".into() });
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_override_wins_and_hung_program_times_out() {
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "/bin/sleep 30")]);
|
||||
|
||||
@@ -31,6 +31,22 @@ pub enum Event {
|
||||
/// The turn moved from model `from` to `to` because `from` failed with `reason` before doing
|
||||
/// anything ("Free (auto)").
|
||||
ModelSwitch { from: String, to: String, reason: String },
|
||||
/// The agent's provider failed the model call (rate limit, overload) and the agent waits to
|
||||
/// try again on its own; None once a call goes through. Without this a rate-limited turn just
|
||||
/// sat on "Working" with no message.
|
||||
Retrying(Option<Retry>),
|
||||
}
|
||||
|
||||
/// An agent waiting out a failed model call (opencode's `session_info_update` with
|
||||
/// `_meta["opencode/retry"]`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Retry {
|
||||
/// 1 for the first retry.
|
||||
pub attempt: u32,
|
||||
/// The provider's error, e.g. "Rate limit exceeded (HTTP 429)".
|
||||
pub message: String,
|
||||
/// Unix ms of the next attempt, when the agent says.
|
||||
pub next_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Sends a tool call to the UI thread (the app's MCP dispatch), so runs, undo and the agent log
|
||||
|
||||
+2
-2
@@ -157,7 +157,7 @@ pub fn parse_pi(out: &str) -> Vec<AgentModel> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn fetch_pi(program: &std::path::Path, env: &[(String, String)]) -> Option<Vec<AgentModel>> {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
cmd.args(["--list-models", "--offline"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
|
||||
crate::chat::path::no_window(&mut cmd).args(["--list-models", "--offline"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
|
||||
super::path::apply(&mut cmd);
|
||||
let out = cmd.output().map_err(|e| log::warn!("models: could not run {}: {e}", program.display())).ok()?;
|
||||
let models = parse_pi(&String::from_utf8_lossy(&out.stdout));
|
||||
@@ -169,7 +169,7 @@ pub fn fetch_pi(program: &std::path::Path, env: &[(String, String)]) -> Option<V
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn fetch_opencode(program: &std::path::Path, env: &[(String, String)]) -> Option<Vec<AgentModel>> {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
cmd.args(["models", "--verbose"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
|
||||
crate::chat::path::no_window(&mut cmd).args(["models", "--verbose"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
|
||||
super::path::apply(&mut cmd);
|
||||
let out = cmd.output().map_err(|e| log::warn!("models: could not run {}: {e}", program.display())).ok()?;
|
||||
let models = parse_opencode(&String::from_utf8_lossy(&out.stdout));
|
||||
|
||||
+9
-2
@@ -56,7 +56,7 @@ fn merge(shell: &str, process: &OsStr, extras: Vec<PathBuf>) -> String {
|
||||
/// Runs `shell -l -c script` and returns the text between the PATH markers; None on failure or timeout.
|
||||
fn shell_path(shell: &str, script: &str, timeout: Duration) -> Option<String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
no_window(&mut cmd).args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
@@ -107,7 +107,14 @@ fn is_executable(p: &Path) -> bool {
|
||||
|
||||
/// Sets the merged PATH on a child, so node-based CLIs find `node`.
|
||||
pub fn apply(cmd: &mut Command) -> &mut Command {
|
||||
cmd.env("PATH", OsString::from(resolved_path()))
|
||||
no_window(cmd).env("PATH", OsString::from(resolved_path()))
|
||||
}
|
||||
|
||||
/// The app has no console on Windows, so every console tool it starts would open its own.
|
||||
pub fn no_window(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
std::os::windows::process::CommandExt::creation_flags(cmd, windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Keeps panics on disk: a release build on Windows has no console, so stderr goes nowhere and
|
||||
//! the app would simply vanish.
|
||||
|
||||
use std::backtrace::Backtrace;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const FILE_NAME: &str = "crash.log";
|
||||
|
||||
/// Where crash reports go.
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
Some(crate::project::config_dir()?.join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Appends each panic, on any thread, to `path`; the default report still prints afterwards.
|
||||
pub fn install(path: PathBuf) {
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
let thread = std::thread::current();
|
||||
let report = format!(
|
||||
"--- unix time {secs}, version {}, thread {}\n{info}\n{}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
thread.name().unwrap_or("unnamed"),
|
||||
Backtrace::force_capture(),
|
||||
);
|
||||
if let Err(error) = append(&path, &report) {
|
||||
eprintln!("could not write {}: {error}", path.display());
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
|
||||
fn append(path: &Path, report: &str) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(report.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panic_is_appended_to_the_log_with_where_it_happened() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-crash-{}", std::process::id()));
|
||||
let log = dir.join(FILE_NAME);
|
||||
install(log.clone());
|
||||
for _ in 0..2 {
|
||||
assert!(std::panic::catch_unwind(|| panic!("boom for the log")).is_err());
|
||||
}
|
||||
drop(std::panic::take_hook());
|
||||
|
||||
let text = std::fs::read_to_string(&log).unwrap();
|
||||
assert_eq!(text.matches("boom for the log").count(), 2);
|
||||
assert!(text.contains("crash_log"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
+100
-31
@@ -90,35 +90,48 @@ impl Editor {
|
||||
}
|
||||
// Variables whose CSS name changed (rename, group rename): bound fields follow.
|
||||
let renames = css_renames(&before, &next);
|
||||
let mut fills = Fills::new();
|
||||
for i in 0..self.pages.len() {
|
||||
let old = self.pages[i].modes.clone();
|
||||
let modes = match (&active_modes, i == active) {
|
||||
(Some(m), true) => m.clone(),
|
||||
_ => old.clone(),
|
||||
};
|
||||
let cache = next.cache_for(&modes);
|
||||
let carry = Some((before.clone(), next.clone()));
|
||||
let page_modes = (modes != old).then(|| (old, modes.clone()));
|
||||
let extra = if i == active { on_active.take() } else { None };
|
||||
let n = self.with_page(i, |ed| {
|
||||
ed.rebuild_cache(
|
||||
label,
|
||||
&cache,
|
||||
&renames,
|
||||
pre,
|
||||
extra.map(|o| o.apply),
|
||||
|rec| {
|
||||
rec.design = carry;
|
||||
rec.page_modes = page_modes;
|
||||
},
|
||||
true,
|
||||
)
|
||||
});
|
||||
self.pages[i].modes = modes;
|
||||
add_fills(&mut fills, n);
|
||||
}
|
||||
// A panic part way (a tool call the app survives) would leave the pages already rebuilt
|
||||
// on `next` and `self.design` on `before`. Roll forward to `next` so they agree again.
|
||||
let rebuilt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut fills = Fills::new();
|
||||
for i in 0..self.pages.len() {
|
||||
let old = self.pages[i].modes.clone();
|
||||
let modes = match (&active_modes, i == active) {
|
||||
(Some(m), true) => m.clone(),
|
||||
_ => old.clone(),
|
||||
};
|
||||
let cache = next.cache_for(&modes);
|
||||
let carry = Some((before.clone(), next.clone()));
|
||||
let page_modes = (modes != old).then(|| (old, modes.clone()));
|
||||
let extra = if i == active { on_active.take() } else { None };
|
||||
let n = self.with_page(i, |ed| {
|
||||
ed.rebuild_cache(
|
||||
label,
|
||||
&cache,
|
||||
&renames,
|
||||
pre,
|
||||
extra.map(|o| o.apply),
|
||||
|rec| {
|
||||
rec.design = carry;
|
||||
rec.page_modes = page_modes;
|
||||
},
|
||||
true,
|
||||
)
|
||||
});
|
||||
self.pages[i].modes = modes;
|
||||
add_fills(&mut fills, n);
|
||||
}
|
||||
fills
|
||||
}));
|
||||
self.design = next;
|
||||
let fills = match rebuilt {
|
||||
Ok(f) => f,
|
||||
Err(panic) => {
|
||||
self.abort();
|
||||
self.rebuild_all_silently(&renames);
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
};
|
||||
Ok((r, fills))
|
||||
}
|
||||
|
||||
@@ -128,7 +141,11 @@ impl Editor {
|
||||
self.finish_loading();
|
||||
for i in 0..self.pages.len() {
|
||||
let cache = self.design.cache_for(&self.pages[i].modes);
|
||||
self.with_page(i, |ed| ed.rebuild_cache("", &cache, renames, None, None, |_| {}, false));
|
||||
// Recovery from a panic runs through here: a page that panics again is left as it is
|
||||
// so the pages after it still get rebuilt (with_page restores its own state).
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.with_page(i, |ed| ed.rebuild_cache("", &cache, renames, None, None, |_| {}, false))
|
||||
}));
|
||||
self.pages[i].saved_gen = u64::MAX;
|
||||
}
|
||||
}
|
||||
@@ -184,16 +201,21 @@ impl Editor {
|
||||
// re-running layout (fit_text/fixup need the editor).
|
||||
pub fn page_doc_in_mode(&mut self, page: usize, mode: &str) -> Result<crate::model::Document, String> {
|
||||
let (cid, mid) = find_mode(&self.design, mode).ok_or(format!("no mode `{mode}`"))?;
|
||||
Ok(self.page_doc_in(page, &cid, &mid))
|
||||
}
|
||||
|
||||
/// [`Self::page_doc_in_mode`] for a mode picked by collection and mode id.
|
||||
pub fn page_doc_in(&mut self, page: usize, cid: &str, mid: &str) -> crate::model::Document {
|
||||
self.ensure_loaded(page);
|
||||
let mut modes = self.pages[page].modes.clone();
|
||||
modes.insert(cid, mid);
|
||||
modes.insert(cid.into(), mid.into());
|
||||
let cache = self.design.cache_for(&modes);
|
||||
let mut doc = self.page_doc(page).clone();
|
||||
let mut rec = crate::history::Recording::new("Preview mode", &doc);
|
||||
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
|
||||
tx.set_cache(&cache, &BTreeMap::new());
|
||||
apply_bindings(&mut tx, &cache, &BTreeMap::new());
|
||||
Ok(doc)
|
||||
doc
|
||||
}
|
||||
|
||||
/// Switch the active page to `mode_id` of `collection_id`: that page's modes, cache and bound
|
||||
@@ -281,6 +303,7 @@ impl Editor {
|
||||
"gap" | "padding" | "padding-top" | "padding-right" | "padding-bottom"
|
||||
| "padding-left" => n.layout.is_some(),
|
||||
"font-size" | "line-height" | "text-color" => n.kind.font_size().is_some(),
|
||||
"text" => n.kind.text().is_some(),
|
||||
_ => true,
|
||||
};
|
||||
if want.is_some() && !ok {
|
||||
@@ -364,6 +387,7 @@ fn field_kind(field: &str) -> Option<Kind> {
|
||||
| "padding-bottom" | "padding-left" | "radius" | "font-size" | "line-height"
|
||||
| "opacity" => Kind::Number,
|
||||
"visible" => Kind::Boolean,
|
||||
"text" => Kind::String,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -377,6 +401,25 @@ fn token_number(cache: &variables::Cache, name: &str) -> Option<f64> {
|
||||
t.strip_suffix("px").unwrap_or(t).trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Replace a text layer's content with a bound string, keeping whole-text spans whole and
|
||||
/// clamping the others, as `set_text_content` does.
|
||||
fn set_bound_text(n: &mut Node, new: &str) {
|
||||
let Some(old) = n.kind.text().filter(|t| *t != new) else { return };
|
||||
let old_len = old.chars().count();
|
||||
let len = new.chars().count();
|
||||
if let Some(slot) = n.kind.text_mut() {
|
||||
*slot = new.into();
|
||||
}
|
||||
for s in &mut n.text_style.spans {
|
||||
if s.start == 0 && s.end >= old_len {
|
||||
s.end = len;
|
||||
}
|
||||
s.start = s.start.min(len);
|
||||
s.end = s.end.min(len);
|
||||
}
|
||||
n.text_style.spans.retain(|s| s.end > s.start);
|
||||
}
|
||||
|
||||
/// Set `field` of `n` from variable `name` in `cache`. An unresolved variable leaves the value.
|
||||
fn apply_field(n: &mut Node, field: &str, name: &str, cache: &variables::Cache) {
|
||||
match field {
|
||||
@@ -398,6 +441,11 @@ fn apply_field(n: &mut Node, field: &str, name: &str, cache: &variables::Cache)
|
||||
n.visible = *b;
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
if let Some(t) = cache.tokens.get(name) {
|
||||
set_bound_text(n, t);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let Some(v) = token_number(cache, name) else {
|
||||
return;
|
||||
@@ -920,6 +968,27 @@ mod tests {
|
||||
e.page_doc(page).variables["accent"]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_part_way_through_a_design_edit_leaves_every_page_on_the_new_design() {
|
||||
let (mut e, ids) = two_pages();
|
||||
let green = Color::rgb(0, 255, 0);
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let boom = |_: &mut crate::history::Tx| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() == 2 {
|
||||
panic!("second page");
|
||||
}
|
||||
};
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
e.edit_design_each("Change", |ds| set_color(ds, &PageModes::new(), "accent", None, green), &boom)
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
for (page, &id) in ids.iter().enumerate() {
|
||||
assert_eq!(accent(&e, page), green);
|
||||
assert_eq!(fill(&e, page, id).0, green);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_on_another_page_restores_design_and_every_cache() {
|
||||
let (mut e, ids) = two_pages();
|
||||
|
||||
+57
-13
@@ -115,7 +115,7 @@ impl Tool {
|
||||
}
|
||||
|
||||
/// Ctrl+wheel zoom rate: one 40px wheel notch is about 13%.
|
||||
const ZOOM_PER_SCROLL_PX: f64 = 0.003;
|
||||
pub const ZOOM_PER_SCROLL_PX: f64 = 0.003;
|
||||
|
||||
/// Requests the editor can't fulfil itself (dialogs, file system, clipboard).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -217,6 +217,15 @@ enum Gesture {
|
||||
const DRAG_THRESHOLD: f64 = 3.0;
|
||||
const PASTE_OFFSET: f64 = 16.0;
|
||||
|
||||
/// A frame Present mode shows as an overlay: drawn `shift` away from where it sits on the canvas,
|
||||
/// clipped to `window`, over a dimmed backdrop. A view state; the document is not touched.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PresentOverlay {
|
||||
pub id: NodeId,
|
||||
pub shift: V2,
|
||||
pub window: Rect,
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
pub doc: Document,
|
||||
pub history: History,
|
||||
@@ -231,6 +240,8 @@ pub struct Editor {
|
||||
pub guides: Vec<Guide>,
|
||||
/// World-space marquee rectangle while dragging.
|
||||
pub marquee: Option<Rect>,
|
||||
/// The overlay Present mode has open, if any.
|
||||
pub present_overlay: Option<PresentOverlay>,
|
||||
pub hover: Option<NodeId>,
|
||||
pub pointer: V2,
|
||||
pub ink_width: f64,
|
||||
@@ -348,6 +359,7 @@ impl Editor {
|
||||
text_edit: None,
|
||||
guides: Vec::new(),
|
||||
marquee: None,
|
||||
present_overlay: None,
|
||||
hover: None,
|
||||
pointer: V2::ZERO,
|
||||
ink_width: 3.0,
|
||||
@@ -477,6 +489,22 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolls back the open transaction, if any, without touching the undo history. For work that
|
||||
/// stopped half way (a gesture cancelled, a tool call that panicked).
|
||||
pub fn abort(&mut self) {
|
||||
self.touched_mains.clear();
|
||||
if let Some((rec, key)) = self.take_rec() {
|
||||
let mut h = History::default();
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if h.commit(&self.doc, rec, key)
|
||||
&& let Some(u) = h.undo(&mut self.doc)
|
||||
{
|
||||
changed = Changed::Nodes(u.nodes.into());
|
||||
}
|
||||
self.after_history(changed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one edit as a complete transaction.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
|
||||
self.begin(label);
|
||||
@@ -2259,17 +2287,7 @@ impl Editor {
|
||||
| Gesture::ConnectorEnd { .. }
|
||||
| Gesture::ConnectorMid { .. }
|
||||
| Gesture::Erase => {
|
||||
// Roll back the open transaction.
|
||||
if let Some((rec, key)) = self.take_rec() {
|
||||
let mut h = History::default();
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if h.commit(&self.doc, rec, key)
|
||||
&& let Some(u) = h.undo(&mut self.doc)
|
||||
{
|
||||
changed = Changed::Nodes(u.nodes.into());
|
||||
}
|
||||
self.after_history(changed);
|
||||
}
|
||||
self.abort();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2334,6 +2352,17 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Image ids held by the clipboard and the library documents: they can outlive the project
|
||||
/// that registered them, so [`crate::assets::release_unused`] must keep them.
|
||||
pub fn images_held_outside_pages(&self) -> Vec<String> {
|
||||
let mut ids: Vec<String> = self.library_docs.values().flat_map(crate::assets::used_by).collect();
|
||||
ids.extend(self.clipboard.iter().flat_map(|n| n.style.fills.iter()).filter_map(|p| match p {
|
||||
crate::model::Paint::Image { asset, .. } => Some(asset.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
ids
|
||||
}
|
||||
|
||||
/// Paste from the system clipboard when it holds something newer than our own copy
|
||||
/// (layers from another window, Excalidraw JSON, SVG, an image or text); otherwise from
|
||||
/// the internal clipboard.
|
||||
@@ -2448,8 +2477,13 @@ impl Editor {
|
||||
let same_page = self.clipboard_page.as_deref() == Some(self.active_page_id());
|
||||
let world = self.clipboard_world.clone();
|
||||
let new_roots = self.insert_subtrees("Paste", &nodes, &roots, &world, offset, same_page);
|
||||
let skipped = roots.len() - new_roots.len();
|
||||
self.selection = new_roots;
|
||||
self.status = format!("Pasted {} layer(s)", self.selection.len());
|
||||
self.status = if skipped > 0 {
|
||||
format!("Pasted {} layer(s); {skipped} skipped: a component can't contain an instance of itself", self.selection.len())
|
||||
} else {
|
||||
format!("Pasted {} layer(s)", self.selection.len())
|
||||
};
|
||||
}
|
||||
|
||||
/// Insert copies of subtrees. `world[i]` is root `i`'s world rect; `same_page` keeps the
|
||||
@@ -2633,6 +2667,16 @@ pub fn insert_subtrees_tx(
|
||||
let parent = n
|
||||
.parent
|
||||
.filter(|p| same_page && tx.doc().nodes.contains_key(p));
|
||||
// An instance of X pasted inside X's main would expand into itself and lose its layers.
|
||||
let nests = parent.is_some()
|
||||
&& nodes
|
||||
.iter()
|
||||
.filter(|d| d.id == n.id || is_desc(nodes, d.id, n.id))
|
||||
.filter_map(|d| d.instance.as_ref())
|
||||
.any(|l| l.source.is_none() && !map.contains_key(&l.main) && structure::would_nest(tx.doc(), l.main, parent));
|
||||
if nests {
|
||||
continue;
|
||||
}
|
||||
let w = world.get(i).copied().unwrap_or(n.rect);
|
||||
let mut copy = n.clone();
|
||||
copy.id = map[&n.id];
|
||||
|
||||
+26
-7
@@ -472,8 +472,14 @@ impl Editor {
|
||||
let saved_marker = self.saved_marker;
|
||||
let outer = self.edit_page.replace(i);
|
||||
swap(self, &mut s);
|
||||
let r = f(self);
|
||||
self.commit();
|
||||
// A panic in `f` (a tool call the app survives) must not leave page `i`'s document in the
|
||||
// active slot: the next save would write it under the wrong page.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
|
||||
if r.is_ok() {
|
||||
self.commit();
|
||||
} else {
|
||||
self.abort();
|
||||
}
|
||||
swap(self, &mut s);
|
||||
self.edit_page = outer;
|
||||
self.saved_marker = saved_marker;
|
||||
@@ -481,8 +487,13 @@ impl Editor {
|
||||
self.revision += 1;
|
||||
// The active page's document is the one it was before `f`.
|
||||
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
|
||||
self.flush_cross_pages();
|
||||
r
|
||||
match r {
|
||||
Ok(r) => {
|
||||
self.flush_cross_pages();
|
||||
r
|
||||
}
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-sync instances on other pages after a main component changed: each touched page
|
||||
@@ -495,7 +506,17 @@ impl Editor {
|
||||
// Instances on a page that is still being read must follow the change too.
|
||||
self.finish_loading();
|
||||
self.cross_busy = true;
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
// `cross_busy` stays set for good if a sync panics, which would stop all later syncs.
|
||||
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.flush_rounds(MAX_ROUNDS)));
|
||||
self.cross_pending.clear();
|
||||
self.cross_busy = false;
|
||||
if let Err(panic) = done {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_rounds(&mut self, max_rounds: usize) {
|
||||
for _ in 0..max_rounds {
|
||||
let Some((src, mains)) = (!self.cross_pending.is_empty())
|
||||
.then(|| self.cross_pending.remove(0))
|
||||
else {
|
||||
@@ -529,8 +550,6 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
}
|
||||
self.cross_pending.clear();
|
||||
self.cross_busy = false;
|
||||
}
|
||||
|
||||
/// Hand over the pages `open_pages` got as stand-ins (`pending`: indexes into the page list)
|
||||
|
||||
@@ -407,12 +407,15 @@ pub fn category(a: Action) -> &'static str {
|
||||
| A::SaveAs
|
||||
| A::ExportPng
|
||||
| A::ExportSvg
|
||||
| A::ExportPdf
|
||||
| A::ExportPresentation
|
||||
| A::PlaceImage
|
||||
| A::Home => "File",
|
||||
A::Undo
|
||||
| A::Redo
|
||||
| A::Copy
|
||||
| A::CopyAsPng
|
||||
| A::CopyAsSvg
|
||||
| A::Cut
|
||||
| A::Paste
|
||||
| A::Duplicate
|
||||
|
||||
+164
-6
@@ -132,6 +132,117 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// How many instance links `origin` follows: a file can hold a loop of them.
|
||||
const MAX_LINK_HOPS: usize = 64;
|
||||
|
||||
/// The layer an instance finally derives from, following links within this document (a link
|
||||
/// to another document, or a missing main, ends the chain).
|
||||
fn origin(doc: &Document, mut id: NodeId) -> NodeId {
|
||||
for _ in 0..MAX_LINK_HOPS {
|
||||
let Some(link) = doc.get(id).and_then(|n| n.instance.as_ref()) else { break };
|
||||
let (main, source) = link.swap.as_ref().map_or((link.main, &link.source), |s| (s.main, &s.source));
|
||||
if source.is_some() || doc.get(main).is_none() {
|
||||
break;
|
||||
}
|
||||
id = main;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// True when an instance of `main` placed under `under` (`None` = top level) would sit inside
|
||||
/// the layers it is made from, directly or through the instances `main` contains. Such an
|
||||
/// instance would expand into itself for ever.
|
||||
pub fn would_nest(doc: &Document, main: NodeId, under: Option<NodeId>) -> bool {
|
||||
// The layers themselves, not their mains: a layer added inside an instance stays in that
|
||||
// instance, so an instance of X inside another instance of X is fine.
|
||||
let hosts: HashSet<NodeId> = under.into_iter().flat_map(|u| std::iter::once(u).chain(doc.ancestors(u))).collect();
|
||||
if hosts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut todo = vec![main];
|
||||
while let Some(root) = todo.pop() {
|
||||
for n in doc.subtree(root) {
|
||||
let o = origin(doc, n);
|
||||
if hosts.contains(&n) || hosts.contains(&o) {
|
||||
return true;
|
||||
}
|
||||
if o != n && seen.insert(o) {
|
||||
todo.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Like [`would_nest`] for a main on page `main_page` placed under `under` on page `page`,
|
||||
/// following instance links across pages. A loop through two pages is not infinite (each sync
|
||||
/// copies from a snapshot) but nests the copies one level deeper on every edit.
|
||||
pub fn would_nest_across(ed: &Editor, main_page: usize, main: NodeId, page: usize, under: Option<NodeId>) -> bool {
|
||||
// A page still being read has no document yet; its links cannot be followed.
|
||||
let doc_of = |p: usize| (p == ed.active_page).then_some(&ed.doc).or_else(|| ed.pages.get(p)?.stash.as_ref().map(|s| &s.doc));
|
||||
let Some(host_doc) = doc_of(page) else { return false };
|
||||
let hosts: HashSet<(usize, NodeId)> = under
|
||||
.into_iter()
|
||||
.flat_map(|u| std::iter::once(u).chain(host_doc.ancestors(u)))
|
||||
.map(|n| (page, n))
|
||||
.collect();
|
||||
if hosts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let origin = |mut at: (usize, NodeId)| {
|
||||
for _ in 0..MAX_LINK_HOPS {
|
||||
let Some(link) = doc_of(at.0).and_then(|d| d.get(at.1)).and_then(|n| n.instance.as_ref()) else { break };
|
||||
let (m, source) = link.target();
|
||||
let next = match source {
|
||||
None => at.0,
|
||||
Some(Source::Page(id)) => match ed.pages.iter().position(|p| p.id == *id) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
},
|
||||
Some(_) => break,
|
||||
};
|
||||
if doc_of(next).and_then(|d| d.get(m)).is_none() {
|
||||
break;
|
||||
}
|
||||
at = (next, m);
|
||||
}
|
||||
at
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let mut todo = vec![(main_page, main)];
|
||||
while let Some((p, root)) = todo.pop() {
|
||||
let Some(d) = doc_of(p) else { continue };
|
||||
for id in d.subtree(root) {
|
||||
let o = origin((p, id));
|
||||
if hosts.contains(&(p, id)) || hosts.contains(&o) {
|
||||
return true;
|
||||
}
|
||||
if o != (p, id) && seen.insert(o) {
|
||||
todo.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether moving or pasting `id` under `under` would put an instance inside its own main.
|
||||
pub fn moves_into_cycle(doc: &Document, id: NodeId, under: Option<NodeId>) -> bool {
|
||||
doc.subtree(id).into_iter().any(|n| doc.get(n).is_some_and(|x| x.instance.is_some()) && would_nest(doc, n, under))
|
||||
}
|
||||
|
||||
/// [`moves_into_cycle`] for a subtree copied or moved from `src_page` under `under` on `page`.
|
||||
pub fn copy_nests_across(ed: &Editor, src_page: usize, id: NodeId, page: usize, under: Option<NodeId>) -> bool {
|
||||
let doc = ed.page_doc(src_page);
|
||||
doc.subtree(id).into_iter().any(|n| doc.get(n).is_some_and(|x| x.instance.is_some()) && would_nest_across(ed, src_page, n, page, under))
|
||||
}
|
||||
|
||||
/// Whether swapping instance `inst` to `new_main` is impossible: the new main is the instance
|
||||
/// or inside it (the swap would delete it), or would nest the instance in itself.
|
||||
pub fn swap_nests(doc: &Document, inst: NodeId, new_main: NodeId) -> bool {
|
||||
inst == new_main || doc.is_ancestor(inst, new_main) || would_nest(doc, new_main, doc.get(inst).and_then(|n| n.parent))
|
||||
}
|
||||
|
||||
/// Copy a subtree as a new instance subtree under `parent` (ids are fresh). The main is on
|
||||
/// the same page.
|
||||
pub fn instantiate(
|
||||
@@ -179,6 +290,10 @@ pub fn instantiate_from(
|
||||
}
|
||||
tx.insert(n, parent, index);
|
||||
for c in src_node.children {
|
||||
// A child that holds this very instance (or one of its mains) would copy itself for ever.
|
||||
if src.is_none() && would_nest(tx.doc(), c, Some(id)) {
|
||||
continue;
|
||||
}
|
||||
instantiate_from(tx, src, source.clone(), c, Some(id), None, None);
|
||||
}
|
||||
id
|
||||
@@ -293,6 +408,9 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
sync_node(tx, k, false, kid_ext);
|
||||
}
|
||||
None => {
|
||||
if ext.is_none() && would_nest(tx.doc(), *mc, Some(inst)) {
|
||||
continue;
|
||||
}
|
||||
instantiate_from(tx, ext, source.clone(), *mc, Some(inst), Some(i), None);
|
||||
}
|
||||
}
|
||||
@@ -763,11 +881,7 @@ impl Editor {
|
||||
/// Swap an instance root to point at `new_main` component, preserving overrides on children
|
||||
/// matched by name and kind.
|
||||
pub fn swap_instance(&mut self, inst: NodeId, new_main: NodeId) -> bool {
|
||||
if !self.is_instance_root(inst)
|
||||
|| !self.doc.get(new_main).is_some_and(|m| m.component)
|
||||
|| inst == new_main
|
||||
|| self.doc.is_ancestor(inst, new_main)
|
||||
{
|
||||
if !self.is_instance_root(inst) || !self.doc.get(new_main).is_some_and(|m| m.component) || swap_nests(&self.doc, inst, new_main) {
|
||||
return false;
|
||||
}
|
||||
self.edit("Swap instance", |tx| swap_instance_tx(tx, None, None, inst, new_main));
|
||||
@@ -1003,6 +1117,9 @@ pub fn swap_instance_tx(
|
||||
new_main: NodeId,
|
||||
) -> bool {
|
||||
let src = ext.unwrap_or(tx.doc());
|
||||
if ext.is_none() && swap_nests(tx.doc(), inst, new_main) {
|
||||
return false;
|
||||
}
|
||||
let Some(new_comp) = src.get(new_main).cloned() else {
|
||||
return false;
|
||||
};
|
||||
@@ -1059,7 +1176,7 @@ pub fn swap_instance_tx(
|
||||
/// the outer instance, so later syncs of the outer main keep the swap.
|
||||
pub fn swap_nested_tx(tx: &mut Tx, ext: Option<&Document>, source: Option<Source>, inst: NodeId, new_main: NodeId) -> bool {
|
||||
let src = ext.unwrap_or(tx.doc());
|
||||
if !src.get(new_main).is_some_and(|c| c.component) {
|
||||
if !src.get(new_main).is_some_and(|c| c.component) || (ext.is_none() && swap_nests(tx.doc(), inst, new_main)) {
|
||||
return false;
|
||||
}
|
||||
let name = src.get(new_main).unwrap().name.clone();
|
||||
@@ -1182,4 +1299,45 @@ mod tests {
|
||||
let l = e.doc.get(kids[0]).unwrap().instance.clone().unwrap();
|
||||
assert_eq!((l.main, l.source), (kid, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_inside_its_own_component_syncs_without_recursing() {
|
||||
let mut e = Editor::default();
|
||||
let comp = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
|
||||
let kid = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 5., 5.)), Some(comp));
|
||||
e.selection = vec![comp];
|
||||
e.create_component();
|
||||
assert!(would_nest(&e.doc, comp, Some(kid)));
|
||||
let inst = e.edit("i", |tx| instantiate(tx, comp, Some(kid), None, None));
|
||||
e.edit("sync", |tx| sync_node(tx, inst, true, None));
|
||||
e.edit("sync main", |tx| sync_node(tx, inst, true, None));
|
||||
assert!(e.doc.get(inst).unwrap().children.is_empty(), "the copy of the kid would hold the instance again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_two_components_is_a_nest() {
|
||||
let mut e = Editor::default();
|
||||
let a = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
|
||||
let b = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(100., 0., 50., 50.)), None);
|
||||
e.selection = vec![a, b];
|
||||
e.create_component();
|
||||
let in_b = e.edit("i", |tx| instantiate(tx, a, Some(b), None, None));
|
||||
assert!(e.doc.get(in_b).is_some_and(|n| n.instance.is_some()));
|
||||
assert!(would_nest(&e.doc, b, Some(a)), "b holds an instance of a: a cannot hold b");
|
||||
assert!(!would_nest(&e.doc, a, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_can_sit_inside_another_instance_of_the_same_component() {
|
||||
let mut e = Editor::default();
|
||||
let comp = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
|
||||
e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 5., 5.)), Some(comp));
|
||||
e.selection = vec![comp];
|
||||
e.create_component();
|
||||
let outer = e.edit("i", |tx| instantiate(tx, comp, None, None, None));
|
||||
let slot = e.doc.get(outer).unwrap().children[0];
|
||||
assert!(!would_nest(&e.doc, comp, Some(slot)), "a layer of an instance is not a layer of its main");
|
||||
let inner = e.edit("i", |tx| instantiate(tx, comp, Some(slot), None, None));
|
||||
assert_eq!(e.doc.get(inner).unwrap().children.len(), 1, "the nested copy keeps its layers");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +492,24 @@ fn copy_paste_frame_with_children_and_undo() {
|
||||
e.doc.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_an_instance_into_its_own_component_is_skipped() {
|
||||
let mut e = ed();
|
||||
let main = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 100.0, 100.0)), None);
|
||||
let kid = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0)), Some(main));
|
||||
e.select(vec![main]);
|
||||
e.create_component();
|
||||
let inst = e.edit("i", |tx| crate::editor::structure::instantiate(tx, main, None, None, None));
|
||||
// A stale clipboard: the copy's recorded parent is now a layer of the component.
|
||||
let mut node = e.doc.get(inst).unwrap().clone();
|
||||
node.parent = Some(kid);
|
||||
let before = e.doc.nodes.len();
|
||||
let made = e.insert_subtrees("Paste", &[node], &[inst], &[Rect::new(0.0, 0.0, 100.0, 100.0)], V2::ZERO, true);
|
||||
assert!(made.is_empty());
|
||||
assert_eq!(e.doc.nodes.len(), before);
|
||||
e.doc.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_into_and_out_of_frames_reparents() {
|
||||
let mut e = ed();
|
||||
@@ -654,3 +672,51 @@ fn restore_snapshot_restores_design_system_in_one_undo() {
|
||||
assert_eq!(e.doc.modes["Light"]["accent"], Color::rgb(0, 200, 0));
|
||||
assert_eq!(rect_of(&e, a).min(), v2(300.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_rolls_back_a_half_finished_edit_and_keeps_the_history() {
|
||||
let mut e = ed();
|
||||
let (a, _) = two_rects(&mut e);
|
||||
e.undo();
|
||||
e.redo();
|
||||
let (undo, redo, doc) = (e.history.undo_len(), e.history.redo_len(), e.doc.nodes.clone());
|
||||
e.begin("Move");
|
||||
e.tx().node_mut(a).unwrap().rect = Rect::new(0.0, 0.0, 9.0, 9.0);
|
||||
e.abort();
|
||||
assert_eq!(e.doc.nodes, doc, "the partial change is gone");
|
||||
assert_eq!((e.history.undo_len(), e.history.redo_len()), (undo, redo));
|
||||
assert!(e.rec.is_none(), "no transaction stays open");
|
||||
e.undo();
|
||||
assert_eq!(e.history.undo_len(), undo - 1, "the next undo steps back the last real edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_inside_with_page_leaves_the_active_page_intact() {
|
||||
let mut e = ed();
|
||||
let (a, _) = two_rects(&mut e);
|
||||
e.select(vec![a]);
|
||||
e.add_page_in_background(None);
|
||||
let (doc, sel, undo) = (e.doc.nodes.clone(), e.selection.clone(), e.history.undo_len());
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
e.with_page(1, |e| {
|
||||
e.begin("half");
|
||||
panic!("tool bug");
|
||||
})
|
||||
}));
|
||||
assert!(r.is_err(), "the panic still propagates");
|
||||
assert_eq!(e.doc.nodes, doc, "the active page keeps its own document");
|
||||
assert_eq!((e.selection.clone(), e.history.undo_len()), (sel, undo));
|
||||
assert!(e.edit_page.is_none() && e.rec.is_none() && !e.cross_busy);
|
||||
assert!(e.pages[1].stash.is_some(), "the other page is parked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_and_validate_stop_on_a_parent_loop() {
|
||||
let mut e = ed();
|
||||
let (a, b) = two_rects(&mut e);
|
||||
e.doc.nodes.get_mut(&a).unwrap().parent = Some(b);
|
||||
e.doc.nodes.get_mut(&b).unwrap().parent = Some(a);
|
||||
assert_eq!(e.doc.ancestors(a), vec![b]);
|
||||
assert!(e.doc.is_ancestor(b, a));
|
||||
assert!(e.doc.validate().is_err(), "a loop is reported, not followed");
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ impl Editor {
|
||||
let byte = byte_at(text, caret);
|
||||
text.insert_str(byte, s);
|
||||
n.text_style.on_insert(caret, n_new);
|
||||
// Typed text replaces a bound string, as a new colour replaces a `text-color` binding.
|
||||
n.bindings.remove("text");
|
||||
if let Some(sp) = inherit {
|
||||
n.text_style.apply_range(caret, caret + n_new, |t| {
|
||||
*t = TextSpan {
|
||||
@@ -81,6 +83,7 @@ impl Editor {
|
||||
let (ba, bb) = (byte_at(text, a), byte_at(text, b));
|
||||
text.replace_range(ba..bb, "");
|
||||
n.text_style.on_delete(a, b);
|
||||
n.bindings.remove("text");
|
||||
}
|
||||
self.text_edit = Some(TextEdit {
|
||||
id: te.id,
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
//! Fonts people add on top of the bundled Inter.
|
||||
//!
|
||||
//! Faces are registered process-wide and never dropped, like image assets, because glyph caches
|
||||
//! and queued draws keep their ids. A project stores its faces as `assets/fonts/<hash>.<ext>`, so
|
||||
//! it opens the same anywhere; text naming a family that is not installed renders in Inter.
|
||||
|
||||
use crate::text::{BUILT_IN_FACES, FaceId, face_id};
|
||||
use std::path::Path;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
const FONTS_DIR: &str = "fonts";
|
||||
/// Larger than any Latin family with hinting, and small enough to refuse a wrong download.
|
||||
pub const MAX_FONT_BYTES: usize = 32 << 20;
|
||||
const REGULAR_WEIGHT: i32 = 400;
|
||||
const BOLD_WEIGHT: i32 = 700;
|
||||
/// Chars cached per face, see `text::CACHED_CHARS`.
|
||||
pub type AdvanceTable = Vec<(FaceId, u16, f64)>;
|
||||
|
||||
pub struct LoadedFace {
|
||||
hash: String,
|
||||
pub bytes: &'static [u8],
|
||||
pub face: ttf_parser::Face<'static>,
|
||||
weight: i32,
|
||||
/// The Inter face of the same style, for characters this one lacks.
|
||||
pub fallback: FaceId,
|
||||
pub advances: OnceLock<AdvanceTable>,
|
||||
}
|
||||
|
||||
impl LoadedFace {
|
||||
fn ext(&self) -> &'static str {
|
||||
if self.face.tables().cff.is_some() { "otf" } else { "ttf" }
|
||||
}
|
||||
}
|
||||
|
||||
struct Family {
|
||||
name: String,
|
||||
/// Indexed like a built-in `FaceId`: bit 0 = bold, bit 1 = italic.
|
||||
slots: [Option<FaceId>; 4],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
faces: Vec<&'static LoadedFace>,
|
||||
families: Vec<Family>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
fn family(&self, name: &str) -> Option<&Family> {
|
||||
self.families.iter().find(|f| f.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn loaded(&self, id: FaceId) -> &'static LoadedFace {
|
||||
self.faces[usize::from(id - BUILT_IN_FACES)]
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> &'static RwLock<Registry> {
|
||||
static R: OnceLock<RwLock<Registry>> = OnceLock::new();
|
||||
R.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// A face registered by [`register`]; ids below [`BUILT_IN_FACES`] are Inter's.
|
||||
pub fn loaded(id: FaceId) -> &'static LoadedFace {
|
||||
registry().read().unwrap().loaded(id)
|
||||
}
|
||||
|
||||
fn family_name(face: &ttf_parser::Face) -> Option<String> {
|
||||
use ttf_parser::name_id::{FAMILY, TYPOGRAPHIC_FAMILY};
|
||||
let named = |id| face.names().into_iter().find(|n| n.name_id == id && n.is_unicode()).and_then(|n| n.to_string());
|
||||
named(TYPOGRAPHIC_FAMILY).or_else(|| named(FAMILY)).filter(|n| !n.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Registers a TrueType or OpenType file and returns its family's name.
|
||||
pub fn register(bytes: &[u8]) -> Result<String, String> {
|
||||
let hash = crate::assets::hash(bytes);
|
||||
let mut reg = registry().write().unwrap();
|
||||
let known = (0..reg.faces.len()).find(|&i| reg.faces[i].hash == hash).map(|i| i as FaceId + BUILT_IN_FACES);
|
||||
if let Some(id) = known {
|
||||
let name = reg.families.iter().find(|f| f.slots.contains(&Some(id))).map(|f| f.name.clone());
|
||||
return name.ok_or_else(|| "the font was replaced by another of its style".to_owned());
|
||||
}
|
||||
if bytes.len() > MAX_FONT_BYTES {
|
||||
return Err(format!("the font is over {} MB", MAX_FONT_BYTES >> 20));
|
||||
}
|
||||
let probe = ttf_parser::Face::parse(bytes, 0).map_err(|_| "not a TrueType or OpenType font (.ttf or .otf); WOFF and archives must be unpacked first".to_owned())?;
|
||||
let tables = probe.tables();
|
||||
if tables.cmap.is_none() || (tables.glyf.is_none() && tables.cff.is_none()) {
|
||||
return Err("the font has no outlines or character map".into());
|
||||
}
|
||||
let family = family_name(&probe).ok_or("the font does not name its family")?;
|
||||
if family.eq_ignore_ascii_case("inter") {
|
||||
return Err("Inter is built in".into());
|
||||
}
|
||||
let (bold, italic) = (probe.is_bold() || probe.weight().to_number() >= 600, probe.is_italic() || probe.is_oblique());
|
||||
let weight = i32::from(probe.weight().to_number());
|
||||
|
||||
// Leaked on purpose: a face lives as long as the process, see the module comment.
|
||||
let bytes: &'static [u8] = Box::leak(bytes.to_vec().into_boxed_slice());
|
||||
let face = ttf_parser::Face::parse(bytes, 0).map_err(|e| e.to_string())?;
|
||||
let fallback = face_id(bold, italic);
|
||||
let id = reg.faces.len() as FaceId + BUILT_IN_FACES;
|
||||
reg.faces.push(Box::leak(Box::new(LoadedFace { hash, bytes, face, weight, fallback, advances: OnceLock::new() })));
|
||||
|
||||
let index = match reg.families.iter().position(|f| f.name.eq_ignore_ascii_case(&family)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
reg.families.push(Family { name: family.clone(), slots: [None; 4] });
|
||||
reg.families.len() - 1
|
||||
}
|
||||
};
|
||||
let slot = usize::from(fallback);
|
||||
let target = if bold { BOLD_WEIGHT } else { REGULAR_WEIGHT };
|
||||
let held = reg.families[index].slots[slot].map(|old| (reg.loaded(old).weight - target).abs());
|
||||
// Of a family's many weights, the slot keeps the one nearest what it stands for.
|
||||
if held.is_none_or(|held| (weight - target).abs() < held) {
|
||||
reg.families[index].slots[slot] = Some(id);
|
||||
}
|
||||
Ok(reg.families[index].name.clone())
|
||||
}
|
||||
|
||||
/// The face to draw with: the family's nearest style, or Inter's when it is not installed.
|
||||
pub fn style_face(family: Option<&str>, bold: bool, italic: bool) -> FaceId {
|
||||
let inter = face_id(bold, italic);
|
||||
let Some(name) = family else { return inter };
|
||||
let reg = registry().read().unwrap();
|
||||
let Some(f) = reg.family(name) else { return inter };
|
||||
let slot = usize::from(inter);
|
||||
[slot, slot & 2, slot & 1, 0].iter().find_map(|&s| f.slots[s]).or_else(|| f.slots.iter().flatten().next().copied()).unwrap_or(inter)
|
||||
}
|
||||
|
||||
/// Installed family names, sorted.
|
||||
pub fn families() -> Vec<String> {
|
||||
let mut names: Vec<String> = registry().read().unwrap().families.iter().map(|f| f.name.clone()).collect();
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
/// A family's installed name, however `name` is spelled, with `(bold, italic)` for each style.
|
||||
pub fn styles(name: &str) -> Option<(String, Vec<(bool, bool)>)> {
|
||||
let reg = registry().read().unwrap();
|
||||
let f = reg.family(name)?;
|
||||
let styles = (0..4).filter(|&s| f.slots[s].is_some()).map(|s| (s & 1 != 0, s & 2 != 0)).collect();
|
||||
Some((f.name.clone(), styles))
|
||||
}
|
||||
|
||||
/// What a CSS `font-family` list asks for, its first family that can be drawn: `Some(None)` is
|
||||
/// Inter, and `None` means none can, so the layer keeps its font.
|
||||
pub fn pick(css: &str) -> Option<Option<String>> {
|
||||
css.split(',').map(|f| f.trim().trim_matches(['"', '\''])).find_map(|f| {
|
||||
if f.eq_ignore_ascii_case("inter") {
|
||||
return Some(None);
|
||||
}
|
||||
styles(f).map(|(name, _)| Some(name))
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers a font and keeps it in the project, when there is one.
|
||||
pub fn install(bytes: &[u8], assets_dir: Option<&Path>) -> Result<String, String> {
|
||||
let family = register(bytes)?;
|
||||
if let Some(dir) = assets_dir {
|
||||
save_to_dir(dir, std::slice::from_ref(&family)).map_err(|e| format!("could not save the font: {e}"))?;
|
||||
}
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
/// Registers the fonts in a project's `assets/` folder.
|
||||
pub fn load_dir(assets_dir: &Path) {
|
||||
let Ok(rd) = crate::vfs::read_dir(assets_dir.join(FONTS_DIR)) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let loaded = crate::vfs::read(e.path()).map_err(|e| e.to_string()).and_then(|b| register(&b));
|
||||
if let Err(err) = loaded {
|
||||
log::warn!("{}: {err}", e.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes these families into a project's `assets/` folder (skipping files already there).
|
||||
pub fn save_to_dir(assets_dir: &Path, families: &[String]) -> std::io::Result<()> {
|
||||
let dir = assets_dir.join(FONTS_DIR);
|
||||
for name in families {
|
||||
let faces: Vec<&'static LoadedFace> = {
|
||||
let reg = registry().read().unwrap();
|
||||
let Some(f) = reg.family(name) else { continue };
|
||||
f.slots.iter().flatten().map(|id| reg.loaded(*id)).collect()
|
||||
};
|
||||
for face in faces {
|
||||
let path = dir.join(format!("{}.{}", face.hash, face.ext()));
|
||||
if !crate::vfs::exists(&path) {
|
||||
crate::vfs::create_dir_all(&dir)?;
|
||||
crate::io::atomic::write(&path, face.bytes)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Font families a document's text names.
|
||||
pub fn used_by(doc: &crate::model::Document) -> Vec<String> {
|
||||
let mut out: Vec<String> = doc.nodes.values().filter_map(|n| n.text_style.font.clone()).collect();
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Color, TextAlign, TextStyle};
|
||||
|
||||
static VARELA: &[u8] = include_bytes!("../assets/fonts/VarelaRound-Regular.ttf");
|
||||
|
||||
fn styled(font: &str) -> TextStyle {
|
||||
TextStyle { font: Some(font.into()), ..TextStyle::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_rejects_what_is_not_a_font_and_keeps_one_copy() {
|
||||
assert!(register(b"<html>not a font</html>").is_err());
|
||||
assert_eq!(register(crate::theme::INTER_REGULAR), Err("Inter is built in".into()));
|
||||
let family = register(VARELA).unwrap();
|
||||
assert_eq!(family, "Varela Round");
|
||||
assert_eq!(register(VARELA).unwrap(), family);
|
||||
assert_eq!(families().iter().filter(|f| **f == family).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_lays_out_in_its_family_and_falls_back_per_character() {
|
||||
let family = register(VARELA).unwrap();
|
||||
let wide = |font: &TextStyle| crate::text::layout_rich("Hamburg", 20.0, Color::INK, font, None, TextAlign::Left);
|
||||
let custom = wide(&styled(&family));
|
||||
assert!(custom.glyphs.iter().all(|g| g.face >= BUILT_IN_FACES));
|
||||
assert_ne!(custom.width, wide(&TextStyle::default()).width);
|
||||
|
||||
let missing = crate::text::layout_rich("Ω", 20.0, Color::INK, &styled(&family), None, TextAlign::Left);
|
||||
let glyph_lacking = crate::text::face_of(custom.glyphs[0].face).glyph_index('Ω').is_none();
|
||||
assert_eq!(missing.glyphs[0].face < BUILT_IN_FACES, glyph_lacking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_without_a_style_uses_its_nearest_and_an_unknown_one_inter() {
|
||||
let family = register(VARELA).unwrap();
|
||||
assert_eq!(style_face(Some(&family), true, true), style_face(Some(&family), false, false));
|
||||
assert_eq!(style_face(Some("No Such Font"), true, false), face_id(true, false));
|
||||
assert!(style_face(Some("varela round"), false, false) >= BUILT_IN_FACES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_lists_take_the_first_family_that_can_be_drawn() {
|
||||
let family = register(VARELA).unwrap();
|
||||
assert_eq!(pick("'Nope', \"varela round\", serif"), Some(Some(family)));
|
||||
assert_eq!(pick("Nope, Inter"), Some(None));
|
||||
assert_eq!(pick("system-ui, sans-serif"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_keeps_its_fonts_in_its_assets_folder() {
|
||||
let family = register(VARELA).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-fonts-{}", std::process::id()));
|
||||
install(VARELA, Some(&dir)).unwrap();
|
||||
save_to_dir(&dir, std::slice::from_ref(&family)).unwrap();
|
||||
let files: Vec<_> = std::fs::read_dir(dir.join(FONTS_DIR)).unwrap().flatten().collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(std::fs::read(files[0].path()).unwrap(), VARELA);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ pub fn is_zip(bytes: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// A relative path made only of normal components, so an archive cannot write outside `root`.
|
||||
fn safe_relative(name: &str) -> Option<PathBuf> {
|
||||
pub(crate) fn safe_relative(name: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(name);
|
||||
p.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
|
||||
+21
-5
@@ -474,8 +474,8 @@ fn build(mut imp: Imported) -> (Document, Vec<NodeId>) {
|
||||
let id = tx.alloc_id();
|
||||
n.id = id;
|
||||
tx.insert(n, None, None);
|
||||
order.push((eid.clone(), id));
|
||||
ids.insert(eid, id);
|
||||
order.push(id);
|
||||
}
|
||||
// Frames: frame children (listed below the frame in Excalidraw) move inside it.
|
||||
for (eid, (_, frame)) in &imp.nesting {
|
||||
@@ -487,10 +487,7 @@ fn build(mut imp: Imported) -> (Document, Vec<NodeId>) {
|
||||
}
|
||||
// Groups, outermost first so inner groups nest inside outer ones.
|
||||
let mut group_nodes: HashMap<String, NodeId> = HashMap::new();
|
||||
for id in &order {
|
||||
let Some((eid, _)) = ids.iter().find(|(_, v)| *v == id) else {
|
||||
continue;
|
||||
};
|
||||
for (eid, id) in &order {
|
||||
let (groups, _) = imp.nesting[eid].clone();
|
||||
let mut parent = tx.doc().get(*id).and_then(|n| n.parent);
|
||||
for g in groups.iter().rev() {
|
||||
@@ -654,6 +651,25 @@ mod tests {
|
||||
assert!(!frame.children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_thousands_of_grouped_elements() {
|
||||
let els: Vec<String> = (0..3000)
|
||||
.map(|i| {
|
||||
format!(
|
||||
r#"{{"id":"r{i}","type":"rectangle","x":{x},"y":0,"width":10,"height":10,"groupIds":["g{g}"]}}"#,
|
||||
x = i * 12,
|
||||
g = i / 3
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let json = format!(r#"{{"type":"excalidraw","elements":[{}]}}"#, els.join(","));
|
||||
let doc = import_scene(&json).unwrap();
|
||||
let rects = doc.nodes.values().filter(|n| n.kind.type_name() == "Rectangle").count();
|
||||
let groups = doc.nodes.values().filter(|n| n.kind.type_name() == "Group").count();
|
||||
assert_eq!((rects, groups), (3000, 1000));
|
||||
doc.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_the_library_fixture() {
|
||||
let json = include_str!("../../tests/fixtures/excalidraw/library.excalidrawlib");
|
||||
|
||||
+39
-3
@@ -20,6 +20,9 @@ const END_LEN: usize = 22;
|
||||
const METHOD_STORED: u16 = 0;
|
||||
const METHOD_DEFLATE: u16 = 8;
|
||||
const MAX_ENTRY: usize = 256 << 20;
|
||||
/// Largest total inflated across the wanted entries of one archive; entries may overlap in the
|
||||
/// file, so the per-entry limit alone does not bound memory.
|
||||
const MAX_ZIP_TOTAL: usize = 1 << 30;
|
||||
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
|
||||
|
||||
// ---------------------------------------------------------------------------------- detection
|
||||
@@ -99,10 +102,21 @@ fn zip_has_entry(bytes: &[u8], target: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Canvas bytes, the file name from `meta.json`, and the `images/*` entries.
|
||||
type FigZip = (Vec<u8>, Option<String>, HashMap<String, Vec<u8>>);
|
||||
|
||||
/// Extract `canvas.fig`, optional `meta.json`, and any `images/*` entries from a `.fig` ZIP.
|
||||
fn read_fig_zip(
|
||||
bytes: &[u8],
|
||||
) -> Result<(Vec<u8>, Option<String>, HashMap<String, Vec<u8>>), String> {
|
||||
) -> Result<FigZip, String> {
|
||||
read_fig_zip_capped(bytes, MAX_ZIP_TOTAL)
|
||||
}
|
||||
|
||||
fn read_fig_zip_capped(
|
||||
bytes: &[u8],
|
||||
max_total: usize,
|
||||
) -> Result<FigZip, String> {
|
||||
let mut total = 0usize;
|
||||
let end = (0..=bytes.len().saturating_sub(END_LEN))
|
||||
.rev()
|
||||
.find(|&i| u32_at(bytes, i) == Some(END_SIG))
|
||||
@@ -138,12 +152,23 @@ fn read_fig_zip(
|
||||
let l_extra = u16_at(bytes, local + 28).ok_or("truncated zip")? as usize;
|
||||
let start = local + LOCAL_LEN + l_name + l_extra;
|
||||
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
|
||||
let budget = max_total - total;
|
||||
let too_big = || format!("{name}: archive expands past {} MiB", max_total >> 20);
|
||||
let data = match method {
|
||||
METHOD_STORED if raw.len() > budget => return Err(too_big()),
|
||||
METHOD_STORED => raw.to_vec(),
|
||||
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
|
||||
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
|
||||
METHOD_DEFLATE => {
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY.min(budget))
|
||||
.map_err(|e| match e.status {
|
||||
miniz_oxide::inflate::TINFLStatus::HasMoreOutput if budget < MAX_ENTRY => {
|
||||
too_big()
|
||||
}
|
||||
_ => format!("cannot inflate {name}: {:?}", e.status),
|
||||
})?
|
||||
}
|
||||
m => return Err(format!("{name}: unsupported zip compression method {m}")),
|
||||
};
|
||||
total += data.len();
|
||||
if name == "canvas.fig" {
|
||||
canvas = Some(data);
|
||||
} else if name == "meta.json" {
|
||||
@@ -2162,6 +2187,17 @@ fn parse_svg_path_data(d: &str) -> Option<VPath> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fig_zip_refuses_entries_past_the_total_cap() {
|
||||
let z = crate::io::zip::write(&[
|
||||
("canvas.fig".into(), vec![1; 600]),
|
||||
("images/a".into(), vec![2; 600]),
|
||||
]);
|
||||
let err = read_fig_zip_capped(&z, 1000).unwrap_err();
|
||||
assert!(err.contains("expands past"), "{err}");
|
||||
assert_eq!(read_fig_zip_capped(&z, 1200).unwrap().2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_imports_frames_autolayout_shapes_and_text() {
|
||||
let json = r#"{
|
||||
|
||||
+12
-5
@@ -1,4 +1,4 @@
|
||||
//! Text as real PDF text: each Inter face a text uses is embedded once per file (a Type0 font
|
||||
//! Text as real PDF text: each face a text uses is embedded once per file (a Type0 font
|
||||
//! over a CIDFontType2, glyph ids as character codes) with a ToUnicode map, so a reader can
|
||||
//! select, search, copy and edit it in any language the font covers.
|
||||
|
||||
@@ -35,12 +35,19 @@ impl Fonts {
|
||||
.into_iter()
|
||||
.find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME)
|
||||
.and_then(|n| n.to_string())
|
||||
.unwrap_or_else(|| "Inter".into());
|
||||
let file = objs.stream(&format!("/Length1 {}", bytes.len()), bytes);
|
||||
.filter(|n| n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
.unwrap_or_else(|| format!("Font{face}"));
|
||||
// OpenType outlines are embedded whole; their glyph ids are not TrueType's map.
|
||||
let cff = f.tables().cff.is_some();
|
||||
let (file, file_key, cid_font) = if cff {
|
||||
(objs.stream("/Subtype /OpenType", bytes), "FontFile3", "/CIDFontType0")
|
||||
} else {
|
||||
(objs.stream(&format!("/Length1 {}", bytes.len()), bytes), "FontFile2", "/CIDFontType2 /CIDToGIDMap /Identity")
|
||||
};
|
||||
let b = f.global_bounding_box();
|
||||
let descriptor = objs.add(
|
||||
format!(
|
||||
"<< /Type /FontDescriptor /FontName /{name} /Flags {} /FontBBox [{} {} {} {}] /ItalicAngle {} /Ascent {} /Descent {} /CapHeight {} /StemV 80 /FontFile2 {file} 0 R >>",
|
||||
"<< /Type /FontDescriptor /FontName /{name} /Flags {} /FontBBox [{} {} {} {}] /ItalicAngle {} /Ascent {} /Descent {} /CapHeight {} /StemV 80 /{file_key} {file} 0 R >>",
|
||||
if f.is_italic() { 32 | 64 } else { 32 },
|
||||
num(f64::from(b.x_min) * k),
|
||||
num(f64::from(b.y_min) * k),
|
||||
@@ -60,7 +67,7 @@ impl Fonts {
|
||||
.join(" ");
|
||||
let cid = objs.add(
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /W [{widths}] >>"
|
||||
"<< /Type /Font /Subtype {cid_font} /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /W [{widths}] >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
+42
-4
@@ -217,12 +217,13 @@ fn walk(g: &usvg::Group, opacity: f32, out: &mut Vec<Node>) {
|
||||
fn raster(node: &usvg::Node, g: &usvg::Group, opacity: f32) -> Option<Node> {
|
||||
use resvg::tiny_skia::{Pixmap, Transform};
|
||||
let b = node.abs_layer_bounding_box()?;
|
||||
let (w, h) = ((b.width() * RASTER_SCALE).ceil() as u32, (b.height() * RASTER_SCALE).ceil() as u32);
|
||||
let mut pixmap = Pixmap::new(w.max(1), h.max(1))?;
|
||||
// A huge group would ask for gigabytes of pixels: lower the scale until it fits the budget.
|
||||
let (scale, w, h) = raster_size(b.width(), b.height());
|
||||
let mut pixmap = Pixmap::new(w, h)?;
|
||||
// render_node applies the group's own transform after the root one and shifts by the bounds,
|
||||
// so the root carries the parent's absolute transform, between that shift and its undo.
|
||||
let parent = g.abs_transform().pre_concat(g.transform().invert()?);
|
||||
let root = Transform::from_scale(RASTER_SCALE, RASTER_SCALE).pre_translate(-b.x(), -b.y()).pre_concat(parent).pre_translate(b.x(), b.y());
|
||||
let root = Transform::from_scale(scale, scale).pre_translate(-b.x(), -b.y()).pre_concat(parent).pre_translate(b.x(), b.y());
|
||||
resvg::render_node(node, root, &mut pixmap.as_mut())?;
|
||||
let asset = crate::assets::register(&pixmap.encode_png().ok()?).ok()?;
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(b.x() as f64, b.y() as f64, b.width() as f64, b.height() as f64));
|
||||
@@ -232,13 +233,28 @@ fn raster(node: &usvg::Node, g: &usvg::Group, opacity: f32) -> Option<Node> {
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// The scale and pixel size a group of this size is drawn at: capped by total pixels and by each
|
||||
/// side, since a very long thin group still rounds every side up to a whole pixel.
|
||||
fn raster_size(width: f32, height: f32) -> (f32, u32, u32) {
|
||||
let scale = RASTER_SCALE
|
||||
.min((MAX_RASTER_PIXELS / (width * height)).sqrt())
|
||||
.min(MAX_RASTER_SIDE / width)
|
||||
.min(MAX_RASTER_SIDE / height);
|
||||
let side = |v: f32| ((v * scale).ceil().clamp(1.0, MAX_RASTER_SIDE)) as u32;
|
||||
(scale, side(width), side(height))
|
||||
}
|
||||
|
||||
/// Most pixels along one side of a masked group's image.
|
||||
const MAX_RASTER_SIDE: f32 = 16_384.0;
|
||||
/// Pixels per SVG unit for a masked group drawn as an image: sharp at 3x zoom.
|
||||
const RASTER_SCALE: f32 = 3.0;
|
||||
/// Most pixels a masked group's image may have (64 MB of RGBA).
|
||||
const MAX_RASTER_PIXELS: f32 = 16_000_000.0;
|
||||
|
||||
fn options() -> usvg::Options<'static> {
|
||||
let mut o = usvg::Options::default();
|
||||
let db = o.fontdb_mut();
|
||||
for i in 0..4u8 {
|
||||
for i in 0..crate::text::BUILT_IN_FACES {
|
||||
db.load_font_data(crate::text::face_bytes(i).to_vec());
|
||||
}
|
||||
db.load_font_data(crate::theme::INTER_MEDIUM.to_vec());
|
||||
@@ -309,6 +325,14 @@ mod tests {
|
||||
<text x="20" y="100" font-size="20" fill="#333">Hello</text>
|
||||
</svg>"##;
|
||||
|
||||
#[test]
|
||||
fn raster_size_caps_each_side_not_only_the_total() {
|
||||
let (scale, w, h) = raster_size(4e8, 0.01);
|
||||
assert!(w <= 16_384 && h >= 1, "{w}x{h} at {scale}");
|
||||
assert!((w as u64) * (h as u64) < 20_000_000);
|
||||
assert_eq!(raster_size(100.0, 50.0), (RASTER_SCALE, 300, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_shapes_gradients_strokes_and_text() {
|
||||
let nodes = parse(SVG).unwrap();
|
||||
@@ -333,6 +357,20 @@ mod tests {
|
||||
assert_eq!(doc.root.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_masked_group_is_drawn_at_a_capped_size() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<mask id="m"><rect width="60000" height="60000" fill="#fff"/></mask>
|
||||
<g mask="url(#m)"><rect width="60000" height="60000" fill="#f00"/></g>
|
||||
</svg>"##;
|
||||
let nodes = parse(svg).unwrap();
|
||||
let Some(Paint::Image { asset, .. }) = nodes.iter().find_map(|n| n.style.fills.first()) else {
|
||||
panic!("masked group was not rastered");
|
||||
};
|
||||
let img = crate::assets::get(asset).unwrap();
|
||||
assert!(u64::from(img.width) * u64::from(img.height) <= 16_100_000, "{}x{}", img.width, img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_our_own_svg_export() {
|
||||
let ed = crate::demo::showcase();
|
||||
|
||||
+79
-2
@@ -20,6 +20,9 @@ const METHOD_DEFLATE: u16 = 8;
|
||||
const DOS_DATE: u16 = 0x0021;
|
||||
/// Largest entry the reader inflates, against zip bombs.
|
||||
const MAX_ENTRY: usize = 512 << 20;
|
||||
/// Largest total the reader inflates across all entries: entries may overlap in the file, so
|
||||
/// per-entry limits alone let a small archive expand to many times its size.
|
||||
const MAX_TOTAL: usize = 1 << 30;
|
||||
|
||||
pub type Entry = (String, Vec<u8>);
|
||||
|
||||
@@ -99,6 +102,12 @@ fn u32_at(b: &[u8], i: usize) -> Result<u32, String> {
|
||||
|
||||
/// Every file in the archive (folders are skipped), verifying CRCs.
|
||||
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
|
||||
read_capped(bytes, MAX_TOTAL)
|
||||
}
|
||||
|
||||
/// [`read`] with a smaller cap on the total inflated size.
|
||||
pub fn read_capped(bytes: &[u8], max_total: usize) -> Result<Vec<Entry>, String> {
|
||||
let mut total = 0usize;
|
||||
let end = (0..=bytes.len().saturating_sub(END_LEN))
|
||||
.rev()
|
||||
.find(|&i| u32_at(bytes, i).is_ok_and(|s| s == END_SIG))
|
||||
@@ -134,12 +143,23 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
|
||||
+ u16_at(bytes, local + 26)? as usize
|
||||
+ u16_at(bytes, local + 28)? as usize;
|
||||
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
|
||||
let budget = max_total - total;
|
||||
let too_big = || format!("{name}: archive expands past {} MiB", max_total >> 20);
|
||||
let data = match method {
|
||||
METHOD_STORED if raw.len() > budget => return Err(too_big()),
|
||||
METHOD_STORED => raw.to_vec(),
|
||||
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
|
||||
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
|
||||
METHOD_DEFLATE => {
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY.min(budget))
|
||||
.map_err(|e| match e.status {
|
||||
miniz_oxide::inflate::TINFLStatus::HasMoreOutput if budget < MAX_ENTRY => {
|
||||
too_big()
|
||||
}
|
||||
_ => format!("cannot inflate {name}: {:?}", e.status),
|
||||
})?
|
||||
}
|
||||
m => return Err(format!("{name}: unsupported zip method {m}")),
|
||||
};
|
||||
total += data.len();
|
||||
if data.len() != usize_ || crc32(&data) != crc {
|
||||
return Err(format!("{name}: checksum mismatch"));
|
||||
}
|
||||
@@ -213,6 +233,63 @@ mod tests {
|
||||
assert_eq!(read(&patched).unwrap(), vec![("x".to_string(), data)]);
|
||||
}
|
||||
|
||||
/// A zip whose `count` central entries all point at one deflated local entry of `data`.
|
||||
fn overlapping_zip(data: &[u8], count: u16) -> Vec<u8> {
|
||||
let packed = miniz_oxide::deflate::compress_to_vec(data, 6);
|
||||
let mut z = Vec::new();
|
||||
z.extend(LOCAL_SIG.to_le_bytes());
|
||||
z.extend([20, 0, 0, 0]);
|
||||
z.extend(METHOD_DEFLATE.to_le_bytes());
|
||||
z.extend([0; 4]);
|
||||
z.extend(crc32(data).to_le_bytes());
|
||||
z.extend((packed.len() as u32).to_le_bytes());
|
||||
z.extend((data.len() as u32).to_le_bytes());
|
||||
z.extend([1, 0, 0, 0]);
|
||||
z.push(b'x');
|
||||
z.extend(&packed);
|
||||
let dir = z.len() as u32;
|
||||
for i in 0..count {
|
||||
let mut c = vec![0u8; CENTRAL_LEN];
|
||||
c[..4].copy_from_slice(&CENTRAL_SIG.to_le_bytes());
|
||||
c[10..12].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
|
||||
c[16..20].copy_from_slice(&crc32(data).to_le_bytes());
|
||||
c[20..24].copy_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
c[24..28].copy_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c[28] = 1;
|
||||
z.extend(c);
|
||||
z.push(b'a' + i as u8);
|
||||
}
|
||||
let dir_len = z.len() as u32 - dir;
|
||||
z.extend(END_SIG.to_le_bytes());
|
||||
z.extend([0; 4]);
|
||||
z.extend(count.to_le_bytes());
|
||||
z.extend(count.to_le_bytes());
|
||||
z.extend(dir_len.to_le_bytes());
|
||||
z.extend(dir.to_le_bytes());
|
||||
z.extend([0; 2]);
|
||||
z
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_archives_that_inflate_past_the_total_cap() {
|
||||
let data = vec![7u8; 600_000];
|
||||
let one = overlapping_zip(&data, 1);
|
||||
assert_eq!(read_capped(&one, 1 << 20).unwrap().len(), 1);
|
||||
// Two entries over the same bytes: each is under the cap, together they are not.
|
||||
let two = overlapping_zip(&data, 2);
|
||||
let err = read_capped(&two, 1 << 20).unwrap_err();
|
||||
assert!(err.contains("expands past 1 MiB"), "{err}");
|
||||
// A single entry bigger than the whole cap is refused too.
|
||||
assert!(read_capped(&one, 100_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_stored_entries_against_the_total_cap() {
|
||||
let z = write(&[("a".into(), vec![1; 600]), ("b".into(), vec![2; 600])]);
|
||||
assert!(read_capped(&z, 1000).is_err());
|
||||
assert_eq!(read_capped(&z, 1200).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_corruption() {
|
||||
assert!(read(b"nope").is_err());
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ fn tea_login(host: &str) -> Option<String> {
|
||||
|
||||
fn tea(args: &[&str]) -> Result<String, String> {
|
||||
let bin = crate::chat::path::which("tea").ok_or("no issue token in Settings and no `tea` CLI: add a token in Settings, Updates, Issues")?;
|
||||
let out = Command::new(bin).args(args).env("PATH", crate::chat::path::resolved_path()).output().map_err(|e| e.to_string())?;
|
||||
let out = crate::chat::path::apply(Command::new(bin).args(args)).output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(format!("tea: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod fonts;
|
||||
pub mod chat;
|
||||
pub mod crash_log;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cli;
|
||||
pub mod connector;
|
||||
|
||||
@@ -241,6 +241,7 @@ fn component_nodes(doc: &Document, root: NodeId, key: &str) -> Vec<Node> {
|
||||
n.parent = n.parent.filter(|p| inside.contains(p)).map(|p| snapshot_id(key, p));
|
||||
n.children = n.children.iter().map(|c| snapshot_id(key, *c)).collect();
|
||||
n.link = None;
|
||||
n.link_action = None;
|
||||
n.comments.clear();
|
||||
match &mut n.instance {
|
||||
Some(l) if l.source.is_none() && inside.contains(&l.main) => l.main = snapshot_id(key, l.main),
|
||||
@@ -746,4 +747,26 @@ mod tests {
|
||||
assert_eq!(start.unwrap().node, snapshot_id("k", NodeId(2)));
|
||||
assert!(end.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_published_component_never_holds_an_instance_of_another_component() {
|
||||
// A's main holds an instance of B and B's holds one of A: both are published as plain
|
||||
// copies, so a library can't form a loop however the projects are linked.
|
||||
let frame = |id: u64| Node::new(NodeId(id), NodeKind::Frame { clip: false }, Rect::new(0., 0., 10., 10.));
|
||||
let link = |main: u64, source: Option<Source>| Some(InstanceLink { main: NodeId(main), overrides: vec![], source, swap: None });
|
||||
let mut doc = Document::default();
|
||||
let (mut a, mut a_in, mut b, mut b_in) = (frame(1), frame(2), frame(3), frame(4));
|
||||
a_in.instance = link(3, None);
|
||||
b_in.instance = link(1, Some(Source::Library("other".into())));
|
||||
a.children = vec![NodeId(2)];
|
||||
a_in.parent = Some(NodeId(1));
|
||||
b.children = vec![NodeId(4)];
|
||||
b_in.parent = Some(NodeId(3));
|
||||
for n in [a, a_in, b, b_in] {
|
||||
doc.nodes.insert(n.id, n);
|
||||
}
|
||||
for (root, key) in [(1, "a"), (3, "b")] {
|
||||
assert!(component_nodes(&doc, NodeId(root), key).iter().all(|n| n.instance.is_none()), "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(crate) fn ellipse([x, y]: [f32; 2], [width, height]: [f32; 2]) -> Bezier {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
mod tests {
|
||||
use super::{ellipse, estimate, path, rectangle};
|
||||
use crate::lottie::model::{Bezier, Matrix};
|
||||
|
||||
@@ -157,6 +157,7 @@ fn ease(progress: f32, outgoing: [f32; 2], incoming: [f32; 2]) -> f32 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
mod tests {
|
||||
use crate::lottie::model::{Key, Track};
|
||||
|
||||
|
||||
@@ -244,4 +244,5 @@ fn reverse(path: &mut crate::lottie::model::Bezier) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
mod tests;
|
||||
|
||||
@@ -105,6 +105,7 @@ fn interpolate([ax, ay]: [f32; 2], [bx, by]: [f32; 2], t: f32) -> [f32; 2] {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
mod tests {
|
||||
use super::apply;
|
||||
use crate::lottie::Polyline;
|
||||
|
||||
+11
-7
@@ -12,13 +12,17 @@
|
||||
//! over all keyframes and may reject animations whose sampled frames are smaller.
|
||||
//! The 0.05 pixel cubic tessellation tolerance is subject to `f32` rounding.
|
||||
|
||||
#![deny(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::unimplemented,
|
||||
clippy::todo,
|
||||
clippy::unreachable
|
||||
// Tests assert with `panic!`/`unwrap`; only the shipped code must not.
|
||||
#![cfg_attr(
|
||||
not(test),
|
||||
deny(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::unimplemented,
|
||||
clippy::todo,
|
||||
clippy::unreachable
|
||||
)
|
||||
)]
|
||||
|
||||
mod decode;
|
||||
|
||||
+12
@@ -1,3 +1,6 @@
|
||||
// A launched app must not open a terminal window; `main` re-attaches to one it was run from.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
use papercraft::app::Start;
|
||||
use papercraft::editor::Editor;
|
||||
use papercraft::io::{export, file, import, png, svg};
|
||||
@@ -62,7 +65,16 @@ fn load_editor(path: Option<&str>, demo: bool) -> Result<Editor, String> {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Fails when no terminal started us, which is the normal launch and needs no handling.
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
use windows_sys::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
}
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
if let Some(path) = papercraft::crash_log::path() {
|
||||
papercraft::crash_log::install(path);
|
||||
}
|
||||
match run(std::env::args().skip(1).collect()) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Token budget for read tools: every big read reports `estimatedTokens` and is cut at a clean
|
||||
//! boundary (whole lines, or whole array elements of the JSON) when it passes `maxTokens`, so
|
||||
//! one call cannot swamp the agent's context.
|
||||
|
||||
use super::Out;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Budget when a call gives no `maxTokens`.
|
||||
pub const DEFAULT_MAX_TOKENS: usize = 20_000;
|
||||
const BYTES_PER_TOKEN: usize = 4;
|
||||
|
||||
pub fn tokens(bytes: usize) -> usize {
|
||||
bytes.div_ceil(BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
fn budget_bytes(max_tokens: Option<f64>) -> usize {
|
||||
max_tokens
|
||||
.filter(|t| *t >= 1.0)
|
||||
.map_or(DEFAULT_MAX_TOKENS, |t| t as usize)
|
||||
.saturating_mul(BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
/// A text result (tree, code, markdown). The first block is the text itself, unchanged when it
|
||||
/// fits; the second is a JSON block with `estimatedTokens` and, once cut, `truncated`,
|
||||
/// `nextOffset` (pass it as `offset` to read on) and `hint`. Cuts fall on line ends and at least
|
||||
/// one line is always returned.
|
||||
pub fn text_out(body: String, max_tokens: Option<f64>, offset: Option<f64>, hint: &str) -> Vec<Out> {
|
||||
let total = tokens(body.len());
|
||||
let skip = offset.filter(|o| *o >= 1.0).map_or(0, |o| o as usize);
|
||||
let budget = budget_bytes(max_tokens);
|
||||
if skip == 0 && body.len() <= budget {
|
||||
return vec![Out::Text(body), Out::Text(json!({"estimatedTokens": total}).to_string())];
|
||||
}
|
||||
let mut shown = String::new();
|
||||
let mut next = skip;
|
||||
let mut cut = false;
|
||||
for line in body.split_inclusive('\n').skip(skip) {
|
||||
if next > skip && shown.len() + line.len() > budget {
|
||||
cut = true;
|
||||
break;
|
||||
}
|
||||
shown.push_str(line);
|
||||
next += 1;
|
||||
}
|
||||
let mut meta = json!({"estimatedTokens": total});
|
||||
if cut {
|
||||
meta["truncated"] = json!(true);
|
||||
meta["nextOffset"] = json!(next);
|
||||
meta["hint"] = json!(format!("Pass offset={next} to read on, or {hint}."));
|
||||
}
|
||||
vec![Out::Text(shown), Out::Text(meta.to_string())]
|
||||
}
|
||||
|
||||
/// A JSON result (pretty-printed, as before). `estimatedTokens` is added to the object; over
|
||||
/// budget, the longest array loses its tail (whole elements, so the JSON stays valid) until it
|
||||
/// fits, and `truncated` and `hint` are added. Arrays are not paged: the hint names the
|
||||
/// narrower read to use.
|
||||
pub fn json_out(mut v: Value, max_tokens: Option<f64>, hint: &str) -> Vec<Out> {
|
||||
let size = |v: &Value| serde_json::to_string_pretty(v).map_or(0, |s| s.len());
|
||||
let budget = budget_bytes(max_tokens);
|
||||
let total = size(&v);
|
||||
v["estimatedTokens"] = json!(tokens(total));
|
||||
if total > budget {
|
||||
v["truncated"] = json!(true);
|
||||
v["hint"] = json!(format!("Output was cut at {} tokens: {hint}.", budget / BYTES_PER_TOKEN));
|
||||
while size(&v) > budget {
|
||||
let Some(items) = longest_array(&mut v).filter(|a| !a.is_empty()) else { break };
|
||||
items.truncate(items.len() / 2);
|
||||
}
|
||||
}
|
||||
vec![Out::Text(serde_json::to_string_pretty(&v).unwrap_or_default())]
|
||||
}
|
||||
|
||||
/// The first array on the heaviest path down the object, wherever it sits.
|
||||
fn longest_array(v: &mut Value) -> Option<&mut Vec<Value>> {
|
||||
fn weight(v: &Value) -> usize {
|
||||
match v {
|
||||
Value::Array(a) if !a.is_empty() => v.to_string().len(),
|
||||
Value::Array(_) => 0,
|
||||
Value::Object(m) => m.values().map(weight).max().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => Some(a),
|
||||
Value::Object(m) => {
|
||||
let key = m.iter().max_by_key(|(_, e)| weight(e)).filter(|(_, e)| weight(e) > 0)?.0.clone();
|
||||
longest_array(m.get_mut(&key)?)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
+232
-54
@@ -6,14 +6,15 @@ use crate::model::{
|
||||
};
|
||||
use crate::variables::{DesignSystem, Kind, PageModes, Value as DsValue};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
|
||||
pub(crate) fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
|
||||
n.parent.and_then(|p| doc.get(p)).and_then(|p| p.layout)
|
||||
}
|
||||
|
||||
/// `(set, name)` of an icon frame: a frame called `Icon / <set>:<name>` whose children are all vector
|
||||
/// geometry (what `<x-paper-icon>` and the icon tools create).
|
||||
fn icon_of(doc: &Document, n: &Node) -> Option<(String, String)> {
|
||||
pub(crate) fn icon_of(doc: &Document, n: &Node) -> Option<(String, String)> {
|
||||
if !matches!(n.kind, NodeKind::Frame { .. }) || n.children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -65,7 +66,7 @@ fn icon_tint(doc: &Document, n: &Node) -> (Option<String>, Option<String>) {
|
||||
|
||||
/// Inline SVG of exactly this node (and its descendants), without the XML prolog. The export is in
|
||||
/// world coordinates; the result is shifted so its `viewBox` starts at 0 0 and can be placed anywhere.
|
||||
fn inline_svg(doc: &Document, id: NodeId) -> String {
|
||||
pub(crate) fn inline_svg(doc: &Document, id: NodeId) -> String {
|
||||
let svg = crate::io::svg::export(doc, Some(&[id]), None)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
@@ -227,7 +228,7 @@ fn tw_font_size(s: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
pub(crate) fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
let mut c: Vec<String> = Vec::new();
|
||||
let pl = parent_layout(doc, n);
|
||||
let in_flow = pl.is_some() && !n.layout_child.absolute;
|
||||
@@ -319,6 +320,9 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
font_size, align, ..
|
||||
} => {
|
||||
c.push(tw_font_size(*font_size));
|
||||
if let Some(font) = &n.text_style.font {
|
||||
c.push(format!("font-['{}']", font.replace(' ', "_")));
|
||||
}
|
||||
if n.text_style.bold {
|
||||
c.push("font-bold".into());
|
||||
}
|
||||
@@ -378,22 +382,88 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
c.join(" ")
|
||||
}
|
||||
|
||||
fn jsx_text(t: &str) -> String {
|
||||
if t.chars()
|
||||
.all(|c| !matches!(c, '{' | '}' | '<' | '>' | '\n' | '"' | '\\'))
|
||||
{
|
||||
t.to_string()
|
||||
} else {
|
||||
format!("{{{}}}", serde_json::to_string(t).unwrap_or_default())
|
||||
/// Where escaped text is going; each target has its own characters that mean something.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Escape {
|
||||
/// HTML text or a double-quoted attribute value.
|
||||
Html,
|
||||
/// JSX text: anything JSX would parse becomes a string expression.
|
||||
Jsx,
|
||||
/// Inside an Angular component's template literal, text or attribute value.
|
||||
Angular,
|
||||
}
|
||||
|
||||
/// The one escaper every exporter uses, so a layer's text or name reads back as written.
|
||||
pub(crate) fn escape(mode: Escape, t: &str) -> String {
|
||||
match mode {
|
||||
Escape::Html => escape_with(t, false),
|
||||
// `&` too: JSX decodes `&` in text, so "Fish & Chips" must not stay bare.
|
||||
Escape::Jsx if !t.contains(['{', '}', '<', '>', '&', '\n', '"', '\\']) => t.to_string(),
|
||||
Escape::Jsx => format!("{{{}}}", serde_json::to_string(t).unwrap_or_default()),
|
||||
Escape::Angular => escape_with(t, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_comp_attr(doc: &Document, n: &Node) -> String {
|
||||
/// HTML escaping; `template` also makes the result safe inside a TypeScript template literal
|
||||
/// (`\`, backtick) and inert for Angular (`{{ x }}` interpolation and `{`/`}` blocks).
|
||||
fn escape_with(t: &str, template: bool) -> String {
|
||||
let mut out = String::with_capacity(t.len());
|
||||
for c in t.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\\' if template => out.push_str(r"\\"),
|
||||
'`' if template => out.push_str(r"\`"),
|
||||
'{' if template => out.push_str("{"),
|
||||
'}' if template => out.push_str("}"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A CSS identifier piece (variable key, mode name) with everything but letters, digits, `-` and
|
||||
/// `_` written as a hex escape, so a name can never end the rule or the `<style>` block.
|
||||
pub(crate) fn css_ident(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c.to_string() } else { format!("\\{:x} ", c as u32) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A quoted CSS string; `<` is escaped too so `</style>` cannot appear.
|
||||
pub(crate) fn css_string(s: &str) -> String {
|
||||
let body: String = s
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' | '\\' | '<' | '\n' | '\r' => format!("\\{:x} ", c as u32),
|
||||
c => c.to_string(),
|
||||
})
|
||||
.collect();
|
||||
format!("\"{body}\"")
|
||||
}
|
||||
|
||||
/// The CSS text of a computed style value; font names are user text, so each family is quoted
|
||||
/// (generic keywords stay bare).
|
||||
pub(crate) fn css_value(prop: &str, val: &str) -> String {
|
||||
if prop != "fontFamily" {
|
||||
return val.to_string();
|
||||
}
|
||||
const GENERIC: [&str; 8] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-monospace", "ui-sans-serif"];
|
||||
let family = |f: &str| {
|
||||
let f = f.trim().trim_matches(['"', '\'']);
|
||||
if GENERIC.contains(&f) { f.to_string() } else { css_string(f) }
|
||||
};
|
||||
val.split(',').map(family).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
fn instance_comp_attr(doc: &Document, n: &Node, mode: Escape) -> String {
|
||||
n.instance
|
||||
.as_ref()
|
||||
.and_then(|l| doc.get(l.main))
|
||||
.filter(|m| m.component)
|
||||
.map(|m| format!(" data-component=\"{}\"", m.name.replace('"', "'")))
|
||||
.map(|m| format!(" data-component=\"{}\"", escape(mode, &m.name)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -430,7 +500,7 @@ fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) ->
|
||||
/// A linked component's JSX, filled in from the instance (None when it has no React link).
|
||||
pub(crate) fn linked_react(doc: &Document, id: NodeId) -> Option<String> {
|
||||
let n = doc.get(id)?;
|
||||
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, jsx_text))
|
||||
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, |t| escape(Escape::Jsx, t)))
|
||||
}
|
||||
|
||||
/// The distinct import lines of the linked components under `root`, in order of first use.
|
||||
@@ -459,13 +529,14 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
|
||||
let ind = " ".repeat(depth);
|
||||
// A linked component is written as its library usage, not rebuilt from its layers.
|
||||
if let Some(link) = code_link(doc, n).filter(|l| !l.react.is_empty()) {
|
||||
push_indented(out, &ind, &fill_template(doc, id, &link.react, jsx_text));
|
||||
push_indented(out, &ind, &fill_template(doc, id, &link.react, |t| escape(Escape::Jsx, t)));
|
||||
return;
|
||||
}
|
||||
let cls = tw_classes(doc, n, is_root);
|
||||
let name = n.name.replace('"', "'");
|
||||
let comp_attr = instance_comp_attr(doc, n);
|
||||
let name = escape(Escape::Html, &n.name);
|
||||
let comp_attr = instance_comp_attr(doc, n, Escape::Html);
|
||||
if let Some((set, icon)) = icon_of(doc, n) {
|
||||
let (set, icon) = (escape(Escape::Html, &set), escape(Escape::Html, &icon));
|
||||
out.push_str(&format!(
|
||||
"{ind}<div data-name=\"{name}\" data-icon=\"{set}:{icon}\"{comp_attr} className=\"{cls}\">{}</div>\n",
|
||||
inline_svg(doc, id)
|
||||
@@ -491,7 +562,7 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
|
||||
NodeKind::Text { text, .. } => {
|
||||
out.push_str(&format!(
|
||||
"{ind}<span{comp_attr} className=\"{cls}\">{}</span>\n",
|
||||
jsx_text(text)
|
||||
escape(Escape::Jsx, text)
|
||||
));
|
||||
}
|
||||
NodeKind::Rect | NodeKind::Ellipse => {
|
||||
@@ -514,7 +585,7 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
|
||||
NodeKind::Sticky { text, .. } => {
|
||||
out.push_str(&format!(
|
||||
"{ind}<div data-name=\"{name}\"{comp_attr} className=\"{cls} p-4\">{}</div>\n",
|
||||
jsx_text(text)
|
||||
escape(Escape::Jsx, text)
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
@@ -539,7 +610,7 @@ pub fn to_tailwind_jsx(doc: &Document, root: NodeId) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn camel_to_kebab(s: &str) -> String {
|
||||
pub(crate) fn camel_to_kebab(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
@@ -552,19 +623,41 @@ fn camel_to_kebab(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_html(t: &str) -> String {
|
||||
t.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
/// Prototype links the page can honour: layer link target -> the href to use.
|
||||
pub(crate) type Hrefs = BTreeMap<NodeId, String>;
|
||||
|
||||
/// A layer with a prototype link whose target has a page becomes a real anchor around it;
|
||||
/// `display: contents` keeps the anchor out of layout, so the design does not move.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn html_node(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
depth: usize,
|
||||
is_root: bool,
|
||||
hrefs: &Hrefs,
|
||||
styles: &mut String,
|
||||
markup: &mut String,
|
||||
in_link: bool,
|
||||
) {
|
||||
let href = doc.get(id).and_then(|n| n.link).and_then(|t| hrefs.get(&t));
|
||||
// An anchor inside an anchor is invalid HTML and browsers close the outer one early.
|
||||
let Some(href) = href.filter(|_| !in_link) else { return html_node_inner(doc, id, depth, is_root, hrefs, styles, markup, in_link) };
|
||||
let ind = " ".repeat(depth);
|
||||
markup.push_str(&format!("{ind}<a class=\"pc-link\" href=\"{}\">\n", escape(Escape::Html, href)));
|
||||
html_node_inner(doc, id, depth + 1, is_root, hrefs, styles, markup, true);
|
||||
markup.push_str(&format!("{ind}</a>\n"));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn html_node_inner(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
depth: usize,
|
||||
is_root: bool,
|
||||
hrefs: &Hrefs,
|
||||
styles: &mut String,
|
||||
markup: &mut String,
|
||||
in_link: bool,
|
||||
) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
let cls = format!("node-{}", id.0);
|
||||
@@ -580,15 +673,16 @@ fn html_node(
|
||||
styles.push_str(&format!(".{cls} {{\n"));
|
||||
for (k, v) in &st {
|
||||
if let Some(val) = v.as_str() {
|
||||
styles.push_str(&format!(" {}: {val};\n", camel_to_kebab(k)));
|
||||
styles.push_str(&format!(" {}: {};\n", camel_to_kebab(k), css_value(k, val)));
|
||||
}
|
||||
}
|
||||
styles.push_str("}\n");
|
||||
|
||||
let ind = " ".repeat(depth);
|
||||
let name = n.name.replace('"', "'");
|
||||
let comp_attr = instance_comp_attr(doc, n);
|
||||
let name = escape(Escape::Html, &n.name);
|
||||
let comp_attr = instance_comp_attr(doc, n, Escape::Html);
|
||||
if let Some((set, icon)) = icon_of(doc, n) {
|
||||
let (set, icon) = (escape(Escape::Html, &set), escape(Escape::Html, &icon));
|
||||
markup.push_str(&format!(
|
||||
"{ind}<div class=\"{cls}\" data-name=\"{name}\" data-icon=\"{set}:{icon}\"{comp_attr}>{}</div>\n",
|
||||
inline_svg(doc, id)
|
||||
@@ -606,7 +700,7 @@ fn html_node(
|
||||
"{ind}<div class=\"{cls}\" data-name=\"{name}\"{comp_attr}>\n"
|
||||
));
|
||||
for c in &n.children {
|
||||
html_node(doc, *c, depth + 1, false, styles, markup);
|
||||
html_node(doc, *c, depth + 1, false, hrefs, styles, markup, in_link);
|
||||
}
|
||||
markup.push_str(&format!("{ind}</div>\n"));
|
||||
}
|
||||
@@ -614,13 +708,13 @@ fn html_node(
|
||||
NodeKind::Text { text, .. } => {
|
||||
markup.push_str(&format!(
|
||||
"{ind}<span class=\"{cls}\"{comp_attr}>{}</span>\n",
|
||||
escape_html(text)
|
||||
escape(Escape::Html, text)
|
||||
));
|
||||
}
|
||||
NodeKind::Sticky { text, .. } => {
|
||||
markup.push_str(&format!(
|
||||
"{ind}<div class=\"{cls}\" data-name=\"{name}\"{comp_attr}>{}</div>\n",
|
||||
escape_html(text)
|
||||
escape(Escape::Html, text)
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
@@ -634,25 +728,32 @@ fn html_node(
|
||||
|
||||
/// Generate a self-contained `<style>...</style>` + HTML block for `root`.
|
||||
pub fn to_html_css(doc: &Document, root: NodeId) -> String {
|
||||
let (css, markup) = html_css_parts(doc, root, &Hrefs::new());
|
||||
format!("<style>\n{css}</style>\n{markup}")
|
||||
}
|
||||
|
||||
/// The stylesheet and the markup of `root` apart, for a caller that builds the page around them
|
||||
/// (`export_site`), with links to the frames in `hrefs` written as anchors.
|
||||
pub(crate) fn html_css_parts(doc: &Document, root: NodeId, hrefs: &Hrefs) -> (String, String) {
|
||||
let mut css_out = String::new();
|
||||
css_out.push_str(":root {\n");
|
||||
for (k, c) in &doc.variables {
|
||||
css_out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
|
||||
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), c.to_hex()));
|
||||
}
|
||||
for (k, v) in &doc.tokens {
|
||||
css_out.push_str(&format!(" --{k}: {v};\n"));
|
||||
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), v.replace(['{', '}', ';', '<'], " ")));
|
||||
}
|
||||
css_out.push_str("}\n");
|
||||
for (mode, vars) in &doc.modes {
|
||||
css_out.push_str(&format!("[data-mode=\"{mode}\"] {{\n"));
|
||||
css_out.push_str(&format!("[data-mode={}] {{\n", css_string(mode)));
|
||||
for (k, c) in vars {
|
||||
css_out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
|
||||
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), c.to_hex()));
|
||||
}
|
||||
css_out.push_str("}\n");
|
||||
}
|
||||
let mut markup = String::new();
|
||||
html_node(doc, root, 0, true, &mut css_out, &mut markup);
|
||||
format!("<style>\n{css_out}</style>\n{markup}")
|
||||
html_node(doc, root, 0, true, hrefs, &mut css_out, &mut markup, false);
|
||||
(css_out, markup)
|
||||
}
|
||||
|
||||
/// Variables and tokens as `:root` custom properties plus one `[data-theme]` block per mode.
|
||||
@@ -915,14 +1016,6 @@ pub fn to_react_tsx(doc: &Document, root: NodeId) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_ng_template(t: &str) -> String {
|
||||
escape_html(t)
|
||||
.replace('`', "\\`")
|
||||
.replace("${", "\\${")
|
||||
.replace('{', "{")
|
||||
.replace('}', "}")
|
||||
}
|
||||
|
||||
fn angular_node(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
@@ -945,22 +1038,23 @@ fn angular_node(
|
||||
styles.push_str(&format!(" .{cls} {{\n"));
|
||||
for (k, v) in &st {
|
||||
if let Some(val) = v.as_str() {
|
||||
styles.push_str(&format!(" {}: {val};\n", camel_to_kebab(k)));
|
||||
styles.push_str(&format!(" {}: {};\n", camel_to_kebab(k), css_value(k, val)));
|
||||
}
|
||||
}
|
||||
styles.push_str(" }\n");
|
||||
|
||||
let ind = " ".repeat(depth + 2);
|
||||
let name = n.name.replace('"', "'");
|
||||
let comp_attr = instance_comp_attr(doc, n);
|
||||
let name = escape(Escape::Angular, &n.name);
|
||||
let comp_attr = instance_comp_attr(doc, n, Escape::Angular);
|
||||
let attrs = format!(
|
||||
"class=\"{cls}\" data-node=\"{}\" data-name=\"{name}\"{comp_attr}",
|
||||
id.0
|
||||
);
|
||||
if let Some((set, icon)) = icon_of(doc, n) {
|
||||
let (set, icon) = (escape(Escape::Angular, &set), escape(Escape::Angular, &icon));
|
||||
markup.push_str(&format!(
|
||||
"{ind}<div {attrs} data-icon=\"{set}:{icon}\">{}</div>\n",
|
||||
escape_ng_template(&inline_svg(doc, id))
|
||||
escape(Escape::Angular, &inline_svg(doc, id))
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -979,19 +1073,19 @@ fn angular_node(
|
||||
NodeKind::Text { text, .. } => {
|
||||
markup.push_str(&format!(
|
||||
"{ind}<span {attrs}>{}</span>\n",
|
||||
escape_ng_template(text)
|
||||
escape(Escape::Angular, text)
|
||||
));
|
||||
}
|
||||
NodeKind::Sticky { text, .. } => {
|
||||
markup.push_str(&format!(
|
||||
"{ind}<div {attrs}>{}</div>\n",
|
||||
escape_ng_template(text)
|
||||
escape(Escape::Angular, text)
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
markup.push_str(&format!(
|
||||
"{ind}<div {attrs}>{}</div>\n",
|
||||
escape_ng_template(&inline_svg(doc, id))
|
||||
escape(Escape::Angular, &inline_svg(doc, id))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1391,7 +1485,7 @@ fn build_spec_tree(
|
||||
return Some(json!({
|
||||
"id": n.id.0, "name": n.name, "kind": "library",
|
||||
"library": {"kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui,
|
||||
"usage": fill_template(doc, id, &link.react, jsx_text), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
|
||||
"usage": fill_template(doc, id, &link.react, |t| escape(Escape::Jsx, t)), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
|
||||
"bounds": {"width": n.rect.w, "height": n.rect.h},
|
||||
"sizing": {"horizontal": "fixed", "vertical": "fixed", "inFlow": in_flow},
|
||||
}));
|
||||
@@ -1495,7 +1589,7 @@ fn build_spec_tree(
|
||||
Some(json!({
|
||||
"content": text,
|
||||
"fontSize": font_size,
|
||||
"fontFamily": "Inter",
|
||||
"fontFamily": n.text_style.font.as_deref().unwrap_or("Inter"),
|
||||
"fontWeight": if n.text_style.bold { 700 } else { 400 },
|
||||
"lineHeight": n.text_style.line_height.map(|m| m * font_size),
|
||||
"letterSpacing": n.text_style.letter_spacing,
|
||||
@@ -2211,6 +2305,90 @@ mod tests {
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Color, InstanceLink};
|
||||
|
||||
fn hostile_doc() -> (Document, NodeId) {
|
||||
let mut doc = Document::default();
|
||||
let root = doc.alloc_id();
|
||||
let mut frame = Node::new(root, NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 200.0, 100.0));
|
||||
frame.name = "Home".into();
|
||||
let icon = doc.alloc_id();
|
||||
let mut icon_node = Node::new(icon, NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 24.0, 24.0));
|
||||
icon_node.name = "Icon / a\" onmouseover=\"alert(1):b".into();
|
||||
icon_node.parent = Some(root);
|
||||
let shape = doc.alloc_id();
|
||||
let mut shape_node = Node::new(shape, NodeKind::Rect, Rect::new(0.0, 0.0, 4.0, 4.0));
|
||||
shape_node.parent = Some(icon);
|
||||
icon_node.children.push(shape);
|
||||
let txt = doc.alloc_id();
|
||||
let mut txt_node = Node::new(
|
||||
txt,
|
||||
NodeKind::Text { text: "hi".into(), font_size: 12.0, auto_width: true, align: TextAlign::Left },
|
||||
Rect::new(0.0, 30.0, 40.0, 20.0),
|
||||
);
|
||||
txt_node.text_style.font = Some("x}</style><script>alert(1)</script>".into());
|
||||
txt_node.parent = Some(root);
|
||||
frame.children = vec![icon, txt];
|
||||
for (id, n) in [(root, frame), (icon, icon_node), (shape, shape_node), (txt, txt_node)] {
|
||||
doc.nodes.insert(id, n);
|
||||
}
|
||||
doc.root.push(root);
|
||||
doc.variables.insert("a}</style><script>1</script>".into(), Color::WHITE);
|
||||
doc.modes.insert("m\"]{}</style><script>2</script>".into(), BTreeMap::from([("k;}".to_owned(), Color::WHITE)]));
|
||||
(doc, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_keeps_hostile_names_inside_their_attributes_and_the_style_block() {
|
||||
let (doc, root) = hostile_doc();
|
||||
let (css, markup) = html_css_parts(&doc, root, &Hrefs::new());
|
||||
let tree = crate::mcp::html::parse(&markup);
|
||||
fn walk<'a>(nodes: &'a [crate::mcp::html::Html], out: &mut Vec<&'a crate::mcp::html::Element>) {
|
||||
for n in nodes {
|
||||
if let crate::mcp::html::Html::Element(e) = n {
|
||||
out.push(e);
|
||||
walk(&e.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut els = Vec::new();
|
||||
walk(&tree, &mut els);
|
||||
assert!(els.iter().all(|e| e.tag != "script"), "{markup}");
|
||||
let icon = els.iter().find(|e| e.attr("data-icon").is_some()).expect("icon element");
|
||||
assert_eq!(icon.attr("data-icon"), Some("a\" onmouseover=\"alert(1):b"), "{markup}");
|
||||
assert!(icon.attrs.iter().all(|(k, _)| k == "class" || k == "data-name" || k == "data-icon"), "{markup}");
|
||||
assert!(!css.contains("</style>") && !css.contains("<script"), "{css}");
|
||||
assert!(css.contains("font-family: \"x}"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_does_not_nest_anchors() {
|
||||
let (mut doc, root) = hostile_doc();
|
||||
let inner = doc.get(root).unwrap().children[0];
|
||||
let target = NodeId(999);
|
||||
doc.nodes.get_mut(&root).unwrap().link = Some(target);
|
||||
doc.nodes.get_mut(&inner).unwrap().link = Some(target);
|
||||
let (_, markup) = html_css_parts(&doc, root, &Hrefs::from([(target, "t.html".to_owned())]));
|
||||
assert_eq!(markup.matches("<a ").count(), 1, "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_covers_markup_and_quotes() {
|
||||
assert_eq!(escape(Escape::Html, r#"a & <b> "c" {{x}}"#), "a & <b> "c" {{x}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsx_escape_keeps_entities_literal() {
|
||||
assert_eq!(escape(Escape::Jsx, "Plain text"), "Plain text");
|
||||
assert_eq!(escape(Escape::Jsx, "Fish & Chips"), r#"{"Fish & Chips"}"#);
|
||||
assert_eq!(escape(Escape::Jsx, "a {b}\n"), r#"{"a {b}\n"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_escape_defuses_bindings_and_template_literals() {
|
||||
assert_eq!(escape(Escape::Angular, "{{ total }}"), "{{ total }}");
|
||||
assert_eq!(escape(Escape::Angular, r"a\b`c"), r"a\\b\`c");
|
||||
assert_eq!(escape(Escape::Angular, r#"say "hi" & <go>"#), "say "hi" & <go>");
|
||||
}
|
||||
|
||||
fn sample_card_doc() -> (Document, NodeId) {
|
||||
let mut doc = Document::default();
|
||||
doc.variables
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! `get_context`: the one compact read an agent makes before editing. It answers where the agent
|
||||
//! is, what is cheap to read next, which tokens and components are in play and what is already
|
||||
//! wrong, in about two thousand tokens, so the heavy reads (tree, code, spec) can be aimed at
|
||||
//! one node.
|
||||
|
||||
use super::exec::{artboard_of, design_of, node_key, resolve};
|
||||
use super::{Out, ToolResult};
|
||||
use crate::editor::Editor;
|
||||
use crate::model::{Document, NodeId, Source};
|
||||
use crate::variables::{DesignSystem, Value as DsVal};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Rough cost of reading one layer through `get_tree_summary` (a line of about 64 bytes).
|
||||
const TOKENS_PER_NODE: usize = 16;
|
||||
/// Entries listed per outline level and in the selection; the rest is counted in `more`.
|
||||
const MAX_LISTED: usize = 24;
|
||||
const MAX_ISSUES: usize = 5;
|
||||
|
||||
pub fn get_context(ed: &mut Editor, id: Option<&str>) -> ToolResult {
|
||||
let (page, target) = match id.filter(|s| !s.is_empty() && *s != "root") {
|
||||
Some(key) => {
|
||||
let (p, n) = resolve(ed, key, ed.active_page)?;
|
||||
(p, Some(n))
|
||||
}
|
||||
None => (ed.active_page, ed.selection.iter().copied().find(|i| ed.doc.get(*i).is_some())),
|
||||
};
|
||||
let doc = ed.page_doc(page);
|
||||
let roots = target.map_or_else(|| doc.root.clone(), |t| vec![t]);
|
||||
let parents = target.map_or_else(|| roots.clone(), |t| doc.children(Some(t)).to_vec());
|
||||
let scope: Vec<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
|
||||
|
||||
let mut out = json!({"page": {"id": ed.pages[page].id, "name": ed.pages[page].name}});
|
||||
if let Some(t) = target {
|
||||
out["target"] = brief(ed, page, t);
|
||||
if let Some(ab) = artboard_of(doc, t).filter(|ab| *ab != t) {
|
||||
out["screen"] = brief(ed, page, ab);
|
||||
}
|
||||
}
|
||||
let selected: Vec<NodeId> = ed.selection.iter().copied().filter(|i| ed.doc.get(*i).is_some()).collect();
|
||||
if page == ed.active_page && !selected.is_empty() {
|
||||
out["selection"] = json!(selected.iter().take(MAX_LISTED).map(|i| brief(ed, page, *i)).collect::<Vec<_>>());
|
||||
}
|
||||
out["outline"] = outline(ed, page, &parents, 2);
|
||||
|
||||
let ds = design_of(ed);
|
||||
out["variables"] = used_variables(ed, page, &scope, &ds);
|
||||
out["collections"] = collections(ed, page, &ds);
|
||||
out["components"] = used_components(ed, page, &scope);
|
||||
out["lint"] = lint_summary(ed, page, &roots);
|
||||
Ok(vec![Out::Text(out.to_string())])
|
||||
}
|
||||
|
||||
fn brief(ed: &Editor, page: usize, id: NodeId) -> Value {
|
||||
let n = ed.page_doc(page).get(id);
|
||||
json!({"id": node_key(ed, page, id), "name": n.map(|n| n.name.clone()),
|
||||
"kind": n.map(|n| n.kind.type_name()),
|
||||
"size": n.map(|n| format!("{}x{}", n.rect.w.round(), n.rect.h.round()))})
|
||||
}
|
||||
|
||||
/// `parents` and, `levels` deep, their children, each with a child count and the tokens a full
|
||||
/// `get_tree_summary` of that layer would cost.
|
||||
fn outline(ed: &Editor, page: usize, parents: &[NodeId], levels: usize) -> Value {
|
||||
let doc = ed.page_doc(page);
|
||||
let mut items: Vec<Value> = parents
|
||||
.iter()
|
||||
.take(MAX_LISTED)
|
||||
.filter_map(|id| {
|
||||
let n = doc.get(*id)?;
|
||||
let mut v = brief(ed, page, *id);
|
||||
v["children"] = json!(n.children.len());
|
||||
v["estimatedTokens"] = json!(doc.subtree(*id).len() * TOKENS_PER_NODE);
|
||||
if levels > 1 && !n.children.is_empty() {
|
||||
v["items"] = outline(ed, page, &n.children, levels - 1);
|
||||
}
|
||||
Some(v)
|
||||
})
|
||||
.collect();
|
||||
if parents.len() > MAX_LISTED {
|
||||
items.push(json!({"more": parents.len() - MAX_LISTED}));
|
||||
}
|
||||
json!(items)
|
||||
}
|
||||
|
||||
fn used_variables(ed: &Editor, page: usize, scope: &[NodeId], ds: &DesignSystem) -> Value {
|
||||
let doc = ed.page_doc(page);
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
for n in scope.iter().filter_map(|i| doc.get(*i)) {
|
||||
names.extend(n.style.fills.iter().filter_map(|p| p.var()).map(str::to_string));
|
||||
names.extend(n.bindings.values().cloned());
|
||||
}
|
||||
let modes = &ed.pages[page].modes;
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let found = ds.find_by_css(&name);
|
||||
let value = match (doc.variables.get(&name), found) {
|
||||
(Some(c), _) => json!(c.to_hex()),
|
||||
(None, Some((c, v))) => {
|
||||
// Same fallbacks as `DesignSystem::resolve`, which the canvas uses.
|
||||
let mode = modes.get(&c.id).filter(|m| c.modes.iter().any(|x| &x.id == *m)).unwrap_or(&c.default_mode);
|
||||
v.values.get(mode).or_else(|| v.values.get(&c.default_mode)).map_or(Value::Null, |x| ds_value(ds, x))
|
||||
}
|
||||
(None, None) => Value::Null,
|
||||
};
|
||||
json!({"name": name, "value": value, "collection": found.map(|(c, _)| c.name.clone())})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ds_value(ds: &DesignSystem, v: &DsVal) -> Value {
|
||||
match v {
|
||||
DsVal::Color(c) => json!(c.to_hex()),
|
||||
DsVal::Number(n) => json!(n),
|
||||
DsVal::String(s) => json!(s),
|
||||
DsVal::Boolean(b) => json!(b),
|
||||
DsVal::Alias(id) => json!(ds.find(id).map(|(_, t)| t.name.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
fn collections(ed: &Editor, page: usize, ds: &DesignSystem) -> Value {
|
||||
let modes = &ed.pages[page].modes;
|
||||
ds.collections
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let active = modes.get(&c.id).unwrap_or(&c.default_mode);
|
||||
let name = c.modes.iter().find(|m| &m.id == active).map(|m| m.name.clone());
|
||||
json!({"name": c.name, "modes": c.modes.iter().map(|m| &m.name).collect::<Vec<_>>(), "active": name})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Components with at least one instance inside `scope`, counting instance roots only.
|
||||
fn used_components(ed: &Editor, page: usize, scope: &[NodeId]) -> Value {
|
||||
let doc = ed.page_doc(page);
|
||||
let mut counts: BTreeMap<(usize, NodeId), usize> = BTreeMap::new();
|
||||
for n in scope.iter().filter_map(|i| doc.get(*i)) {
|
||||
let Some(link) = &n.instance else { continue };
|
||||
if n.parent.and_then(|p| doc.get(p)).is_some_and(|p| p.instance.is_some()) {
|
||||
continue;
|
||||
}
|
||||
let (main, source) = link.target();
|
||||
let main_page = match source {
|
||||
None => page,
|
||||
Some(Source::Page(p)) => match ed.pages.iter().position(|s| s.id == *p) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
},
|
||||
Some(Source::Library(_)) => continue,
|
||||
};
|
||||
if main != n.id || main_page != page {
|
||||
*counts.entry((main_page, main)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.map(|((p, main), instances)| {
|
||||
json!({"id": node_key(ed, p, main), "name": ed.page_doc(p).get(main).map(|m| m.name.clone()),
|
||||
"instances": instances})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The lint rules on this scope only: counts per rule and the first few findings.
|
||||
fn lint_summary(ed: &Editor, page: usize, roots: &[NodeId]) -> Value {
|
||||
let doc: &Document = ed.page_doc(page);
|
||||
let mut issues = super::lint::lint(doc, roots, &super::lint::DEFAULT_SCALE);
|
||||
issues.extend(super::lint::not_an_instance(doc, roots, &super::lint::component_mains(ed)));
|
||||
// Layers hidden behind a drawer or modal are not findings: nobody sees them.
|
||||
issues.retain(|i| !crate::editor::screens::coverage(doc, i.node).is_some_and(|c| c.is_hidden()));
|
||||
let mut by_rule: Map<String, Value> = Map::new();
|
||||
for i in &issues {
|
||||
let n = by_rule.get(i.rule).and_then(Value::as_u64).unwrap_or(0);
|
||||
by_rule.insert(i.rule.to_string(), json!(n + 1));
|
||||
}
|
||||
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
|
||||
json!({"errors": count("error"), "warnings": count("warning"), "info": count("info"),
|
||||
"byRule": by_rule,
|
||||
"first": issues.iter().take(MAX_ISSUES).map(|i| json!({
|
||||
"rule": i.rule, "severity": i.severity, "node": node_key(ed, page, i.node), "message": i.message,
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Node, NodeKind};
|
||||
use crate::variables::Kind;
|
||||
|
||||
#[test]
|
||||
fn a_variable_missing_in_the_active_mode_shows_its_default_mode_value() {
|
||||
let mut ed = Editor::default();
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(0., 0., 10., 10.));
|
||||
n.bindings.insert("gap".into(), "space".into());
|
||||
let id = ed.add_node(n, None);
|
||||
let mut ds = DesignSystem::default();
|
||||
let c = ds.add_collection("Sizes");
|
||||
let dark = ds.add_mode(&c, "Dark").unwrap();
|
||||
let v = ds.add_variable(&c, "space", Kind::Number).unwrap();
|
||||
let default_mode = ds.collections[0].default_mode.clone();
|
||||
ds.set_value(&v, &default_mode, DsVal::Number(8.0)).unwrap();
|
||||
ds.collections[0].variables[0].values.remove(&dark);
|
||||
ed.pages[0].modes.insert(c, dark);
|
||||
let vars = used_variables(&ed, 0, &[id], &ds);
|
||||
assert_eq!(vars[0]["value"], json!(8.0));
|
||||
}
|
||||
}
|
||||
+104
-23
@@ -142,7 +142,7 @@ pub fn split_ws(s: &str) -> Vec<String> {
|
||||
/// (use [`percent`]).
|
||||
pub fn length(s: &str, font_size: f64) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let num = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let num = |t: &str| finite(t);
|
||||
if s == "0" {
|
||||
return Some(0.0);
|
||||
}
|
||||
@@ -150,21 +150,26 @@ pub fn length(s: &str, font_size: f64) -> Option<f64> {
|
||||
return num(t);
|
||||
}
|
||||
if let Some(t) = s.strip_suffix("rem") {
|
||||
return num(t).map(|v| v * 16.0);
|
||||
return num(t).map(|v| v * 16.0).filter(|v| v.is_finite());
|
||||
}
|
||||
if let Some(t) = s.strip_suffix("em") {
|
||||
return num(t).map(|v| v * font_size);
|
||||
return num(t).map(|v| v * font_size).filter(|v| v.is_finite());
|
||||
}
|
||||
if let Some(t) = s.strip_suffix("pt") {
|
||||
return num(t).map(|v| v * 4.0 / 3.0);
|
||||
return num(t).map(|v| v * 4.0 / 3.0).filter(|v| v.is_finite());
|
||||
}
|
||||
num(s)
|
||||
}
|
||||
|
||||
/// A number that JSON can store: "NaN" and "1e999" parse as f64 but would make a saved page unopenable.
|
||||
pub fn finite(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
pub fn percent(s: &str) -> Option<f64> {
|
||||
s.trim()
|
||||
.strip_suffix('%')
|
||||
.and_then(|t| t.trim().parse::<f64>().ok())
|
||||
.and_then(finite)
|
||||
.map(|v| v / 100.0)
|
||||
}
|
||||
|
||||
@@ -239,7 +244,7 @@ fn channel(s: &str, scale: f64) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
match percent(s) {
|
||||
Some(p) => Some(p * scale),
|
||||
None => s.parse::<f64>().ok(),
|
||||
None => finite(s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,18 +347,15 @@ pub fn color(s: &str) -> Option<Color> {
|
||||
channel(&a[2], 255.0)? / 255.0,
|
||||
],
|
||||
"hsl" | "hsla" if a.len() >= 3 => {
|
||||
let h = a[0].trim_end_matches("deg").parse::<f64>().ok()?;
|
||||
let sat = percent(&a[1]).or_else(|| a[1].parse::<f64>().ok().map(|v| v / 100.0))?;
|
||||
let l = percent(&a[2]).or_else(|| a[2].parse::<f64>().ok().map(|v| v / 100.0))?;
|
||||
let h = finite(a[0].trim_end_matches("deg"))?;
|
||||
let sat = percent(&a[1]).or_else(|| finite(&a[1]).map(|v| v / 100.0))?;
|
||||
let l = percent(&a[2]).or_else(|| finite(&a[2]).map(|v| v / 100.0))?;
|
||||
hsl(h, sat, l)
|
||||
}
|
||||
"oklch" if a.len() >= 3 => {
|
||||
let l = channel(&a[0], 1.0)?;
|
||||
let c = channel(&a[1], 0.4)?;
|
||||
let h = a[2]
|
||||
.trim_end_matches("deg")
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
let h = finite(a[2].trim_end_matches("deg"))?
|
||||
.to_radians();
|
||||
oklab_to_srgb(l, c * h.cos(), c * h.sin())
|
||||
}
|
||||
@@ -399,7 +401,7 @@ pub fn gradient(s: &str) -> Option<Paint> {
|
||||
if name == "linear-gradient" {
|
||||
let first = args.first()?.to_ascii_lowercase();
|
||||
if let Some(d) = first.strip_suffix("deg") {
|
||||
angle = d.trim().parse().ok()?;
|
||||
angle = finite(d)?;
|
||||
stops_src = &args[1..];
|
||||
} else if let Some(dir) = first.strip_prefix("to ") {
|
||||
let (mut x, mut y) = (0.0f64, 0.0f64);
|
||||
@@ -924,11 +926,18 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
let is_text = n.kind.text().is_some();
|
||||
let fs = n.kind.font_size().unwrap_or(16.0);
|
||||
let mut shift = (0.0, 0.0);
|
||||
for (k, v) in styles {
|
||||
let v = v.trim();
|
||||
let lower = v.to_ascii_lowercase();
|
||||
let ok = match k.as_str() {
|
||||
"fontSize" => true, // applied above; inherited by text children
|
||||
"transform" => {
|
||||
let (by, lost) = transform(v, fs);
|
||||
shift = (shift.0 + by.0, shift.1 + by.1);
|
||||
a.ignored.extend(lost.into_iter().map(|f| format!("transform: {f}")));
|
||||
true
|
||||
}
|
||||
"width" => {
|
||||
set_size(n, 0, v, ctx, &mut a, k);
|
||||
true
|
||||
@@ -1098,7 +1107,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
let parts = split_ws(v);
|
||||
let grow = parts
|
||||
.first()
|
||||
.and_then(|g| g.parse::<f64>().ok())
|
||||
.and_then(|g| finite(g))
|
||||
.unwrap_or(0.0);
|
||||
match ctx.parent_layout {
|
||||
Some(pl) => {
|
||||
@@ -1170,6 +1179,10 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
}
|
||||
"textTransform" if is_text => {
|
||||
if matches!(lower.as_str(), "uppercase" | "lowercase" | "capitalize") {
|
||||
// Changed text replaces a bound string, as in `set_text_content`.
|
||||
n.bindings.remove("text");
|
||||
}
|
||||
if let Some(t) = n.kind.text_mut() {
|
||||
match lower.as_str() {
|
||||
"uppercase" => *t = t.to_uppercase(),
|
||||
@@ -1243,8 +1256,14 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
true
|
||||
}
|
||||
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
|
||||
| "fontFamily" | "wordBreak" | "overflowWrap" => {
|
||||
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
|
||||
| "wordBreak" | "overflowWrap" => {
|
||||
// Accepted without an equivalent; harmless.
|
||||
true
|
||||
}
|
||||
"fontFamily" => {
|
||||
if let Some(font) = crate::fonts::pick(v) {
|
||||
n.text_style.font = font;
|
||||
}
|
||||
true
|
||||
}
|
||||
"overflow" | "overflowY" if n.is_frame() => {
|
||||
@@ -1304,7 +1323,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
let w = match lower.as_str() {
|
||||
"bold" | "bolder" => 700.0,
|
||||
"normal" | "lighter" => 400.0,
|
||||
_ => v.parse().unwrap_or(400.0),
|
||||
_ => finite(v).unwrap_or(400.0),
|
||||
};
|
||||
n.text_style.bold = w >= 600.0;
|
||||
true
|
||||
@@ -1321,7 +1340,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
} else if v.ends_with("px") || v.ends_with("em") || v.ends_with("rem") {
|
||||
length(v, fs).map(|px| px / fs)
|
||||
} else {
|
||||
v.parse::<f64>().ok()
|
||||
finite(v)
|
||||
};
|
||||
n.text_style.line_height = lh.filter(|x| *x > 0.0);
|
||||
lh.is_some() || lower == "normal"
|
||||
@@ -1514,7 +1533,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"opacity" => match v.parse::<f64>().ok().or(percent(v)) {
|
||||
"opacity" => match finite(v).or(percent(v)) {
|
||||
Some(o) => {
|
||||
n.style.opacity = o.clamp(0.0, 1.0);
|
||||
true
|
||||
@@ -1542,7 +1561,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
"architect" => Some(0.0),
|
||||
"artist" => Some(1.0),
|
||||
"cartoonist" => Some(2.0),
|
||||
_ => v.parse::<f64>().ok(),
|
||||
_ => finite(v),
|
||||
};
|
||||
match r {
|
||||
Some(r) => {
|
||||
@@ -1569,7 +1588,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
"bowing" => match v.parse::<f64>() {
|
||||
"bowing" => match finite(v).ok_or(()) {
|
||||
Ok(b) => {
|
||||
rough_mut(n).bowing = b;
|
||||
true
|
||||
@@ -1589,9 +1608,49 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
a.ignored.push(k.clone());
|
||||
}
|
||||
}
|
||||
if shift != (0.0, 0.0) {
|
||||
if ctx.parent_layout.is_some() && !n.layout_child.absolute {
|
||||
// Auto layout places the box again on every pass, so an offset would not stay.
|
||||
a.ignored.push("transform: translate (inside auto layout; use margin or gap)".into());
|
||||
} else {
|
||||
n.rect.x += shift.0;
|
||||
n.rect.y += shift.1;
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// The offset of a `transform`'s translate functions, and the functions the model cannot hold
|
||||
/// (there is no rotation, scale or skew on a layer).
|
||||
fn transform(v: &str, font_size: f64) -> ((f64, f64), Vec<String>) {
|
||||
let (mut by, mut lost) = ((0.0, 0.0), Vec::new());
|
||||
let mut rest = v.trim();
|
||||
while let Some(open) = rest.find('(') {
|
||||
let name = rest[..open].trim().to_ascii_lowercase();
|
||||
let Some(close) = rest[open..].find(')') else { break };
|
||||
let args: Vec<&str> = rest[open + 1..open + close].split(',').map(str::trim).collect();
|
||||
rest = &rest[open + close + 1..];
|
||||
let len = |i: usize| args.get(i).map(|t| length(t, font_size));
|
||||
let (x, y) = match name.as_str() {
|
||||
"translate" | "translate3d" => (len(0), len(1).or(Some(Some(0.0)))),
|
||||
"translatex" => (len(0), Some(Some(0.0))),
|
||||
"translatey" => (Some(Some(0.0)), len(0)),
|
||||
_ => {
|
||||
lost.push(name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match (x.flatten(), y.flatten()) {
|
||||
(Some(x), Some(y)) => {
|
||||
by.0 += x;
|
||||
by.1 += y;
|
||||
}
|
||||
_ => lost.push(format!("{name} (needs px)")),
|
||||
}
|
||||
}
|
||||
(by, lost)
|
||||
}
|
||||
|
||||
fn set_dash(n: &mut Node, style: &str) {
|
||||
let w = n.style.stroke_width.max(1.0);
|
||||
n.style.dash = match style {
|
||||
@@ -1837,7 +1896,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
NodeKind::Text {
|
||||
font_size, align, ..
|
||||
} => {
|
||||
put("fontFamily", "Inter".into());
|
||||
put("fontFamily", n.text_style.font.clone().unwrap_or_else(|| "Inter".into()));
|
||||
put("fontSize", px(*font_size));
|
||||
put(
|
||||
"fontWeight",
|
||||
@@ -2044,6 +2103,28 @@ mod tests {
|
||||
assert_eq!(back["borderRadius"], "12px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_transform_unbinds_the_text_it_changes() {
|
||||
let kind = NodeKind::Text { text: "hi".into(), font_size: 14.0, auto_width: true, align: Default::default() };
|
||||
let mut n = Node::new(NodeId(4), kind, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
n.bindings.insert("text".into(), "greeting".into());
|
||||
apply(&mut n, &parse_inline("text-transform: uppercase"), &Context::default());
|
||||
assert_eq!(n.kind.text().map(String::as_str), Some("HI"));
|
||||
assert!(!n.bindings.contains_key("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_moves_a_free_layer_and_the_rest_of_transform_is_reported() {
|
||||
let mut n = Node::new(NodeId(3), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let a = apply(&mut n, &parse_inline("transform: translate(10px, -4px) rotate(5deg) scale(2); left: 20px"), &Context::default());
|
||||
assert_eq!((n.rect.x, n.rect.y), (30.0, -4.0));
|
||||
assert_eq!(a.ignored, ["transform: rotate", "transform: scale"]);
|
||||
let ctx = Context { parent_layout: Some(AutoLayout::default()), parent_size: None };
|
||||
let a = apply(&mut n, &parse_inline("transform: translateX(8px)"), &ctx);
|
||||
assert_eq!(n.rect.x, 30.0);
|
||||
assert_eq!(a.ignored.len(), 1, "{:?}", a.ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_sketch() {
|
||||
let Some(Paint::Linear { stops, from, to }) = gradient("linear-gradient(90deg, red, blue)")
|
||||
|
||||
+4
-12
@@ -4,7 +4,7 @@
|
||||
//! inherits `PAPERCRAFT_MCP_PORT`), and only its final answer comes back into the main context.
|
||||
|
||||
use super::{Out, ToolResult};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -44,20 +44,12 @@ pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_
|
||||
let task = args["task"].as_str().map(str::trim).filter(|t| !t.is_empty()).ok_or("give the task with everything the helper needs")?;
|
||||
let cli = crate::chat::path::which("papercraft-cli").ok_or("papercraft-cli is not installed, so a helper cannot reach PaperCraft")?;
|
||||
let prompt = format!("{}\n\n{}\n\nTask:\n{task}", preamble(&cli.display().to_string()), role_rules(role));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &command.replace("{prompt}", "%PAPERCRAFT_PROMPT%")]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &command.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")]);
|
||||
c
|
||||
};
|
||||
let mut cmd = super::shell::command(command, &[("{prompt}", "PAPERCRAFT_PROMPT", &prompt)]);
|
||||
crate::chat::path::no_window(&mut cmd);
|
||||
// Its own process group, so stopping it also ends the agent the shell started.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", &prompt)
|
||||
.env("PAPERCRAFT_AGENT", agent.0)
|
||||
.env("PAPERCRAFT_PARENT", agent.1)
|
||||
// An app opened from Finder has a bare PATH: give the helper the user's shell PATH.
|
||||
@@ -99,7 +91,7 @@ pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_
|
||||
/// End the helper and everything it started.
|
||||
fn kill_group(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
let _ = Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
|
||||
let _ = std::process::Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
+246
-101
@@ -123,16 +123,16 @@ impl Host for SessionHost {
|
||||
|
||||
// ------------------------------------------------------------------------------ arguments
|
||||
|
||||
struct Args<'a>(&'a Map<String, Value>);
|
||||
pub(super) struct Args<'a>(pub(super) &'a Map<String, Value>);
|
||||
|
||||
impl<'a> Args<'a> {
|
||||
fn get(&self, k: &str) -> Option<&'a Value> {
|
||||
pub(super) fn get(&self, k: &str) -> Option<&'a Value> {
|
||||
self.0.get(k).filter(|v| !v.is_null())
|
||||
}
|
||||
fn str(&self, k: &str) -> Option<&'a str> {
|
||||
pub(super) fn str(&self, k: &str) -> Option<&'a str> {
|
||||
self.get(k).and_then(Value::as_str).map(str::trim)
|
||||
}
|
||||
fn req_str(&self, k: &str) -> Result<&'a str, String> {
|
||||
pub(super) fn req_str(&self, k: &str) -> Result<&'a str, String> {
|
||||
self.str(k)
|
||||
.ok_or_else(|| format!("missing required string argument `{k}`"))
|
||||
}
|
||||
@@ -142,11 +142,13 @@ impl<'a> Args<'a> {
|
||||
Value::String(s) => s.trim().trim_end_matches("px").parse().ok(),
|
||||
_ => None,
|
||||
})
|
||||
// "NaN" and "1e999" parse, but JSON cannot store them: a saved page would not reopen.
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
fn bool(&self, k: &str) -> Option<bool> {
|
||||
pub(super) fn bool(&self, k: &str) -> Option<bool> {
|
||||
self.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
|
||||
pub(super) fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
|
||||
self.get(k)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| format!("missing required array argument `{k}`"))
|
||||
@@ -164,7 +166,7 @@ impl<'a> Args<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn text(v: Value) -> ToolResult {
|
||||
pub(super) fn text(v: Value) -> ToolResult {
|
||||
Ok(vec![Out::Text(
|
||||
serde_json::to_string_pretty(&v).unwrap_or_default(),
|
||||
)])
|
||||
@@ -214,7 +216,7 @@ pub(crate) fn find_page(ed: &Editor, key: &str) -> Result<usize, String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
|
||||
pub(super) fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
|
||||
match a.str("pageId") {
|
||||
Some(p) if !p.is_empty() => find_page(ed, p),
|
||||
_ => Ok(ed.active_page),
|
||||
@@ -222,7 +224,7 @@ fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
|
||||
}
|
||||
|
||||
/// Resolve a node id string to (page index, node id).
|
||||
fn resolve(ed: &Editor, key: &str, default_page: usize) -> Result<(usize, NodeId), String> {
|
||||
pub(crate) fn resolve(ed: &Editor, key: &str, default_page: usize) -> Result<(usize, NodeId), String> {
|
||||
let key = key.trim().trim_start_matches('#');
|
||||
let (page, num) = match key.split_once(':') {
|
||||
Some((p, n)) => (find_page(ed, p)?, n),
|
||||
@@ -296,6 +298,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"rename_file" => rename_file(host, &a),
|
||||
"get_font_family_info" => font_info(&a),
|
||||
"get_guide" => guide(&a),
|
||||
"import_html" => super::import_html::run(host, args),
|
||||
"publish_library" | "list_libraries" | "enable_library" | "update_library"
|
||||
| "disable_library" => {
|
||||
check_file(host, &a)?;
|
||||
@@ -322,6 +325,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"get_basic_info" => basic_info(host, &a),
|
||||
"get_inventory" => get_inventory(ed),
|
||||
"get_selection" => selection(ed),
|
||||
"get_context" => super::context::get_context(ed, a.str("id")),
|
||||
"get_node_info" => node_info(ed, &a),
|
||||
"get_children" => children(ed, &a),
|
||||
"get_bounds" => get_bounds(ed, &a),
|
||||
@@ -346,6 +350,8 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"design_rules" => design_rules(&a),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"install_font" => install_font(host, &a),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"fetch_reference_images" => super::refs::fetch(host.project().map(|p| p.dir.clone()).as_deref(), None, &Value::Object(a.0.clone())),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"generate_image" => {
|
||||
@@ -358,7 +364,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"set_component_props" => set_component_props(ed, &a),
|
||||
"set_instance_props" => set_instance_props(ed, &a),
|
||||
"merge_variants" => merge_variants(ed, &a),
|
||||
"set_prototype_links" => set_prototype_links(ed, &a),
|
||||
"set_prototype_links" => super::prototype::set_prototype_links(ed, &a),
|
||||
"set_screens" => set_screens(ed, &a),
|
||||
"export" => export(host, &a),
|
||||
"export_combined_pdf" => export_pdf(host, &a),
|
||||
@@ -367,6 +373,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"list_handoffs" => super::handoff::list(host),
|
||||
"get_handoff_changes" => super::handoff::get_changes(host, args),
|
||||
"export_handoff" => export_handoff(host, args),
|
||||
"export_site" => export_site(host, args),
|
||||
"create_artboard" => create_artboard(ed, &a),
|
||||
"write_html" => write_html(ed, &a),
|
||||
"create_shape" => create_shape(ed, &a),
|
||||
@@ -391,6 +398,8 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"create_instance" => create_instance(ed, &a),
|
||||
"swap_instance" => swap_instance(ed, &a),
|
||||
"bind_variable" => bind_variable(ed, &a),
|
||||
"get_strings" => super::strings::get_strings(ed, &a),
|
||||
"set_strings" => super::strings::set_strings(ed, &a),
|
||||
"detach_instance" => detach_instance(ed, &a),
|
||||
"diff_frames" => diff_frames(ed, &a),
|
||||
"search_icons" => search_icons(&a),
|
||||
@@ -456,6 +465,9 @@ fn make_room(ed: &mut Editor, page: usize, before: &std::collections::HashMap<No
|
||||
fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
|
||||
let key = key.trim().trim_start_matches("/file/");
|
||||
let p = Path::new(key);
|
||||
if p.is_absolute() {
|
||||
check_path(key)?;
|
||||
}
|
||||
if p.is_absolute() && crate::vfs::is_file(&p.join(crate::project::MANIFEST)) {
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
@@ -757,7 +769,7 @@ fn node_brief(ed: &Editor, page: usize, id: NodeId) -> Value {
|
||||
}
|
||||
|
||||
/// The top-level frame a node belongs to.
|
||||
fn artboard_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
pub(super) fn artboard_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let top = doc.ancestors(id).last().copied().unwrap_or(id);
|
||||
doc.get(top).filter(|n| n.is_frame()).map(|n| n.id)
|
||||
}
|
||||
@@ -826,7 +838,7 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
"width": n.rect.w, "height": n.rect.h, "section": section})
|
||||
}).collect::<Vec<_>>(),
|
||||
"otherTopLevelLayers": others,
|
||||
"fontFamilies": ["Inter"],
|
||||
"fontFamilies": std::iter::once("Inter".to_owned()).chain(crate::fonts::families()).collect::<Vec<_>>(),
|
||||
"selectionCount": if page == ed.active_page { ed.selection.len() } else { 0 },
|
||||
"openFeedback": (0..ed.pages.len())
|
||||
.map(|p| ed.page_doc(p).nodes.values().map(|n| n.open_comments()).sum::<usize>())
|
||||
@@ -1138,7 +1150,7 @@ fn node_info(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
if let Some(s) = screen_json(ed, page, id) {
|
||||
v["screen"] = s;
|
||||
}
|
||||
text(v)
|
||||
Ok(super::budget::json_out(v, a.num("maxTokens"), "use get_children for the child ids"))
|
||||
}
|
||||
|
||||
fn children(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
@@ -1156,7 +1168,7 @@ fn children(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
.iter()
|
||||
.map(|c| node_brief(ed, page, *c))
|
||||
.collect();
|
||||
text(json!({"children": kids}))
|
||||
Ok(super::budget::json_out(json!({"children": kids}), a.num("maxTokens"), "ask for a deeper node or use find_nodes"))
|
||||
}
|
||||
|
||||
fn get_bounds(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
@@ -1254,7 +1266,7 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
if out.is_empty() {
|
||||
out = "(empty page)".into();
|
||||
}
|
||||
Ok(vec![Out::Text(out)])
|
||||
Ok(super::budget::text_out(out, a.num("maxTokens"), a.num("offset"), "pass a narrower nodeId or a smaller depth"))
|
||||
}
|
||||
|
||||
/// Largest screenshot edge, in pixels.
|
||||
@@ -1310,6 +1322,20 @@ fn shot_out(img: &image::RgbaImage, scale: &mut f64) -> Result<Option<Vec<Out>>,
|
||||
}
|
||||
|
||||
fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
// GPU failures (device lost, out of memory) panic inside wgpu and the readback. In the app that
|
||||
// is the UI thread, so unsaved work dies with it; a shot only reads, so nothing is left half done.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shoot(host, a))).unwrap_or_else(|p| {
|
||||
let why = p
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| p.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("unknown panic");
|
||||
log::error!("get_screenshot panicked: {why}");
|
||||
Err(format!("the screenshot failed: {why}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn shoot(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let gpu = host.gpu();
|
||||
let defer = host.defer_pixels();
|
||||
if gpu.is_none() && !defer {
|
||||
@@ -1319,7 +1345,8 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
.str("out")
|
||||
.or_else(|| a.str("path"))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
.map(|p| out_file(p, "png"))
|
||||
.transpose()?;
|
||||
let ed = host.editor();
|
||||
let def = page_arg(ed, a)?;
|
||||
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
|
||||
@@ -1619,7 +1646,7 @@ fn design_rules(a: &Args) -> ToolResult {
|
||||
(_, Some(q)) if !q.trim().is_empty() => vec!["search", "-n", "5", q.trim()],
|
||||
_ => return Err("give query or rule".into()),
|
||||
};
|
||||
let out = std::process::Command::new(bin).args(&args).output().map_err(|e| e.to_string())?;
|
||||
let out = crate::chat::path::no_window(std::process::Command::new(bin).args(&args)).output().map_err(|e| e.to_string())?;
|
||||
let found = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
if found.is_empty() {
|
||||
return Err(format!("no playbook match: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
@@ -1631,7 +1658,9 @@ fn design_rules(a: &Args) -> ToolResult {
|
||||
/// image when it was scaled down, else the image itself. Only files in the project's chat folder.
|
||||
fn zoom_image(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let dir = host.project().map(|p| p.dir.join(crate::chat::TMP_DIR)).ok_or("open a project first")?;
|
||||
let sent = std::path::Path::new(a.req_str("path")?);
|
||||
let sent = a.req_str("path")?;
|
||||
check_path(sent)?;
|
||||
let sent = std::path::Path::new(sent);
|
||||
let (Ok(root), Ok(sent)) = (std::fs::canonicalize(&dir), std::fs::canonicalize(sent)) else {
|
||||
return Err("no such attached image".into());
|
||||
};
|
||||
@@ -1796,13 +1825,16 @@ fn font_info(a: &Args) -> ToolResult {
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let inter = f.trim().eq_ignore_ascii_case("inter");
|
||||
let styles = match inter {
|
||||
true => Some(vec![(false, false), (true, false), (false, true), (true, true)]),
|
||||
false => crate::fonts::styles(f.trim()).map(|(_, styles)| styles),
|
||||
};
|
||||
let note = if styles.is_some() { "" } else { "not installed; install_font adds it, until then text renders in Inter" };
|
||||
json!({
|
||||
"family": f,
|
||||
"available": inter,
|
||||
"styles": if inter { json!([
|
||||
{"weight": 400, "style": "normal"}, {"weight": 700, "style": "normal"},
|
||||
{"weight": 400, "style": "italic"}, {"weight": 700, "style": "italic"}]) } else { json!([]) },
|
||||
"note": if inter { "" } else { "not available; text renders in Inter" },
|
||||
"available": styles.is_some(),
|
||||
"styles": font_styles(styles.as_deref().unwrap_or_default()),
|
||||
"note": note,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1811,6 +1843,32 @@ fn font_info(a: &Args) -> ToolResult {
|
||||
)
|
||||
}
|
||||
|
||||
fn font_styles(styles: &[(bool, bool)]) -> Vec<Value> {
|
||||
styles
|
||||
.iter()
|
||||
.map(|(bold, italic)| json!({"weight": if *bold { 700 } else { 400 }, "style": if *italic { "italic" } else { "normal" }}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn install_font(host: &dyn Host, a: &Args) -> ToolResult {
|
||||
let source = a.req_str("source")?;
|
||||
let bytes = if source.starts_with("https://") || source.starts_with("http://") {
|
||||
super::refs::download(source, crate::fonts::MAX_FONT_BYTES)?.1
|
||||
} else {
|
||||
check_path(source)?;
|
||||
let size = std::fs::metadata(source).map_err(|e| format!("{source}: {e}"))?.len();
|
||||
if size > crate::fonts::MAX_FONT_BYTES as u64 {
|
||||
return Err(format!("{source}: the font is over {} MB", crate::fonts::MAX_FONT_BYTES >> 20).into());
|
||||
}
|
||||
std::fs::read(source).map_err(|e| format!("{source}: {e}"))?
|
||||
};
|
||||
let assets = host.project().map(|p| p.dir.join(crate::project::ASSETS_DIR));
|
||||
let family = crate::fonts::install(&bytes, assets.as_deref())?;
|
||||
let styles = crate::fonts::styles(&family).map(|(_, s)| s).unwrap_or_default();
|
||||
text(json!({"family": family, "styles": font_styles(&styles), "savedInProject": assets.is_some()}))
|
||||
}
|
||||
|
||||
fn guide(a: &Args) -> ToolResult {
|
||||
let t = a.req_str("topic")?;
|
||||
let s = match t {
|
||||
@@ -2288,6 +2346,9 @@ fn delete_annotations(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
|
||||
// ------------------------------------------------------------------------------ jsx
|
||||
|
||||
/// How to read the rest of a cut `get_jsx` or `get_design_spec`.
|
||||
const JSX_HINT: &str = "export a narrower nodeId (a child from get_children)";
|
||||
|
||||
fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let assets_dir = host
|
||||
.project()
|
||||
@@ -2302,14 +2363,15 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
out.push_str(&format!("{import}\n"));
|
||||
}
|
||||
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
|
||||
Ok(vec![Out::Text(out)])
|
||||
Ok(super::budget::text_out(out, a.num("maxTokens"), a.num("offset"), JSX_HINT))
|
||||
}
|
||||
Some(
|
||||
f @ ("tailwind" | "jsx" | "react" | "tsx" | "angular" | "ng" | "html" | "css" | "egui"
|
||||
| "rust"),
|
||||
) => Ok(vec![Out::Text(super::codegen::code_for_target_with_categories(
|
||||
doc, id, f, &ed.design.categories(),
|
||||
)?)]),
|
||||
) => {
|
||||
let code = super::codegen::code_for_target_with_categories(doc, id, f, &ed.design.categories())?;
|
||||
Ok(super::budget::text_out(code, a.num("maxTokens"), a.num("offset"), JSX_HINT))
|
||||
}
|
||||
Some(f) => Err(format!(
|
||||
"unknown format `{f}` (inline, tailwind, react, angular, html, or egui)"
|
||||
)),
|
||||
@@ -2323,15 +2385,15 @@ fn get_design_spec(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
match a.str("format").unwrap_or("markdown") {
|
||||
"" | "markdown" | "md" => {
|
||||
let md = super::codegen::to_ai_spec_markdown_with_categories(doc, id, target, &ed.design.categories())?;
|
||||
Ok(vec![Out::Text(md)])
|
||||
Ok(super::budget::text_out(md, a.num("maxTokens"), a.num("offset"), JSX_HINT))
|
||||
}
|
||||
"json" => {
|
||||
let spec = super::codegen::to_ai_spec_with_categories(doc, id, target, &ed.design.categories())?;
|
||||
text(spec)
|
||||
Ok(super::budget::json_out(spec, a.num("maxTokens"), JSX_HINT))
|
||||
}
|
||||
"compact" => {
|
||||
let s = super::codegen::to_ai_spec_compact_with_categories(doc, id, target, &ed.design.categories())?;
|
||||
Ok(vec![Out::Text(s)])
|
||||
Ok(super::budget::text_out(s, a.num("maxTokens"), a.num("offset"), JSX_HINT))
|
||||
}
|
||||
other => Err(format!(
|
||||
"unknown format `{other}` (expected markdown, json or compact)"
|
||||
@@ -2356,16 +2418,6 @@ fn style_obj(m: &Map<String, Value>) -> String {
|
||||
format!("{{{{ {} }}}}", parts.join(", "))
|
||||
}
|
||||
|
||||
fn jsx_text(t: &str) -> String {
|
||||
if t.chars()
|
||||
.all(|c| !matches!(c, '{' | '}' | '<' | '>' | '\n' | '"' | '\\'))
|
||||
{
|
||||
t.to_string()
|
||||
} else {
|
||||
format!("{{{}}}", serde_json::to_string(t).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
fn jsx_node(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
@@ -2410,7 +2462,7 @@ fn jsx_node(
|
||||
out.push_str(&format!(
|
||||
"{ind}<span style={}>{}</span>\n",
|
||||
style_obj(&st),
|
||||
jsx_text(text)
|
||||
super::codegen::escape(super::codegen::Escape::Jsx, text)
|
||||
));
|
||||
}
|
||||
NodeKind::Rect | NodeKind::Ellipse => {
|
||||
@@ -2443,7 +2495,7 @@ fn jsx_node(
|
||||
out.push_str(&format!(
|
||||
"{ind}<div style={}>{}</div>\n",
|
||||
style_obj(&st),
|
||||
jsx_text(text)
|
||||
super::codegen::escape(super::codegen::Escape::Jsx, text)
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
@@ -2476,26 +2528,85 @@ fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
|
||||
let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return Ok(2.0);
|
||||
};
|
||||
let (num, unit) = s.split_at(s.len() - 1);
|
||||
let v: f64 = num
|
||||
.parse()
|
||||
.map_err(|_| format!("bad scale `{s}` (use e.g. 2x, 512w, 512h, 720p)"))?;
|
||||
let unit_at = s.char_indices().next_back().map_or(0, |(i, _)| i);
|
||||
let (num, unit) = s.split_at(unit_at);
|
||||
let v = css::finite(num)
|
||||
.ok_or_else(|| format!("bad scale `{s}` (use e.g. 2x, 512w, 512h, 720p)"))?;
|
||||
Ok(match unit {
|
||||
"x" => v,
|
||||
"w" => v / b.w.max(1.0),
|
||||
"h" => v / b.h.max(1.0),
|
||||
"p" => v / b.w.min(b.h).max(1.0),
|
||||
_ => s.parse().map_err(|_| format!("bad scale `{s}`"))?,
|
||||
_ => css::finite(s).ok_or_else(|| format!("bad scale `{s}`"))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuses a path an agent could use to make Windows talk to another machine or hide data: a
|
||||
/// network share (`\\host\share`, `//host`, `\\?\UNC\`) sends the user's credentials to the host,
|
||||
/// a device path (`\\.\`) reaches hardware, and a `:` past the drive letter names an NTFS
|
||||
/// alternate data stream. Every path an agent supplies, to read or to write, goes through here.
|
||||
pub fn check_path(path: &str) -> Result<(), String> {
|
||||
let s = path.trim();
|
||||
let b = s.as_bytes();
|
||||
let sep = |c: u8| c == b'\\' || c == b'/';
|
||||
let mut rest = s;
|
||||
if b.len() >= 2 && sep(b[0]) && sep(b[1]) {
|
||||
// `\\?\C:\x` is the long form of a local path; any other double-separator start is a
|
||||
// share or a device, or a way to one (`\\?\UNC\`, `\\?\GLOBALROOT\Device\Mup\`).
|
||||
let t = &b[2..];
|
||||
let local = t.len() >= 4 && t[0] == b'?' && sep(t[1]) && t[2].is_ascii_alphabetic() && t[3] == b':';
|
||||
if !local {
|
||||
return Err(format!("`{path}` is a network or device path; give a local path"));
|
||||
}
|
||||
rest = &s[4..];
|
||||
}
|
||||
if rest.starts_with(r"\??\") {
|
||||
return Err(format!("`{path}` is a device path; give a local path"));
|
||||
}
|
||||
let drive = rest.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) && rest.as_bytes().get(1) == Some(&b':');
|
||||
if rest[if drive { 2 } else { 0 }..].contains(':') {
|
||||
return Err(format!("`{path}`: a `:` is only allowed after the drive letter"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file a tool writes at the agent's `path`. Only the extension the tool produces is
|
||||
/// allowed, so a path cannot name a document to overwrite or a script to drop in a startup folder.
|
||||
fn out_file(path: &str, ext: &str) -> Result<PathBuf, String> {
|
||||
check_path(path)?;
|
||||
let p = PathBuf::from(path);
|
||||
if p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) {
|
||||
return Ok(p);
|
||||
}
|
||||
Err(format!("`{path}` must end in .{ext}: this tool only writes .{ext} files"))
|
||||
}
|
||||
|
||||
/// Checks the folder a tool is about to fill: it is new, empty, or holds the tool's own
|
||||
/// `marker` file from an earlier export, which `is_ours` must recognise by what the tool itself writes. Any other folder may be the
|
||||
/// person's own files.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn out_dir(a: &Args) -> PathBuf {
|
||||
a.str("directory")
|
||||
pub(crate) fn out_folder(dir: &Path, marker: &str, is_ours: impl Fn(&[u8]) -> bool) -> Result<(), String> {
|
||||
check_path(&dir.to_string_lossy())?;
|
||||
let Ok(mut entries) = std::fs::read_dir(dir) else {
|
||||
return if dir.exists() { Err(format!("{}: not a folder", dir.display())) } else { Ok(()) };
|
||||
};
|
||||
let ours = || std::fs::read(dir.join(marker)).is_ok_and(|bytes| is_ours(&bytes));
|
||||
if entries.next().is_none() || ours() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("{}: the folder is not empty and holds no earlier export of this tool; give a new or empty folder", dir.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn out_dir(a: &Args) -> Result<PathBuf, String> {
|
||||
if let Some(d) = a.str("directory") {
|
||||
check_path(d)?;
|
||||
}
|
||||
Ok(a.str("directory")
|
||||
.map(PathBuf::from)
|
||||
.or_else(dirs::download_dir)
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.unwrap_or_else(|| PathBuf::from(".")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -2562,9 +2673,9 @@ fn export_presentation(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
language,
|
||||
};
|
||||
let path = match a.str("path") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
Some(p) => out_file(p, "pdf")?,
|
||||
None => {
|
||||
let dir = out_dir(a);
|
||||
let dir = out_dir(a)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
unique_path(&dir, &title, "pdf")
|
||||
}
|
||||
@@ -2583,6 +2694,16 @@ fn export_handoff(_: &mut dyn Host, _: &Value) -> ToolResult {
|
||||
Err(NO_DISK.into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn export_site(host: &mut dyn Host, a: &Value) -> ToolResult {
|
||||
super::site::export(host, a)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn export_site(_: &mut dyn Host, _: &Value) -> ToolResult {
|
||||
Err(NO_DISK.into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
use crate::io::export::{Format, Item, Scope, Spec};
|
||||
@@ -2593,7 +2714,7 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
.get("nodes")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or("`nodes` must be an object of nodeId → export settings")?;
|
||||
let dir = out_dir(a);
|
||||
let dir = out_dir(a)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let mut written = Vec::new();
|
||||
for (key, settings) in nodes {
|
||||
@@ -2617,17 +2738,27 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
f => return Err(format!("format `{f}` is not supported (png, svg, pdf)")),
|
||||
};
|
||||
let scale = parse_scale(s.str("scale"), b)?;
|
||||
// `name` overrides the file stem (node names repeat across screens); `transparent`
|
||||
// (per node, else top-level) drops the white background for PNG and SVG.
|
||||
let stem_base = s
|
||||
.str("name")
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.map_or_else(|| name.clone(), str::to_owned);
|
||||
let transparent = s
|
||||
.bool("transparent")
|
||||
.or_else(|| a.bool("transparent"))
|
||||
.unwrap_or(false);
|
||||
let stem = if format == Format::Png && scale != 1.0 {
|
||||
format!("{name}@{}x", (scale * 100.0).round() / 100.0)
|
||||
format!("{stem_base}@{}x", (scale * 100.0).round() / 100.0)
|
||||
} else {
|
||||
name.clone()
|
||||
stem_base
|
||||
};
|
||||
let path = unique_path(&dir, &stem, format.ext());
|
||||
let spec = Spec {
|
||||
format,
|
||||
scope: Scope::Selection,
|
||||
scale,
|
||||
transparent: false,
|
||||
transparent,
|
||||
};
|
||||
let item = Item {
|
||||
name: name.clone(),
|
||||
@@ -2666,9 +2797,9 @@ fn export_pdf(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
})
|
||||
.collect();
|
||||
let path = match a.str("path") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
Some(p) => out_file(p, "pdf")?,
|
||||
None => {
|
||||
let dir = out_dir(a);
|
||||
let dir = out_dir(a)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
unique_path(&dir, project_name.as_deref().unwrap_or("papercraft"), "pdf")
|
||||
}
|
||||
@@ -2836,6 +2967,7 @@ fn image_asset(src: &str) -> Result<String, String> {
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
let path = percent_decode(path);
|
||||
check_path(&path)?;
|
||||
crate::vfs::read(&path).map_err(|e| format!("{path}: {e}"))?
|
||||
};
|
||||
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
|
||||
@@ -3507,7 +3639,7 @@ impl Builder<'_, '_> {
|
||||
let self_closing = raw[..raw.find('>').unwrap_or(0)].ends_with('/');
|
||||
// The importer keeps viewBox units: fit the viewBox into width × height ourselves, as a
|
||||
// browser does (centred, aspect kept), and make the viewBox the final size.
|
||||
let vb: Vec<f64> = el.attr("viewBox").map(|v| v.split([' ', ',']).filter_map(|n| n.trim().parse().ok()).collect()).unwrap_or_default();
|
||||
let vb: Vec<f64> = el.attr("viewBox").map(|v| v.split([' ', ',']).filter_map(css::finite).collect()).unwrap_or_default();
|
||||
let mut fitted = None;
|
||||
if let [vx, vy, vw, vh] = vb[..]
|
||||
&& vw > 0.0
|
||||
@@ -3898,9 +4030,9 @@ impl Builder<'_, '_> {
|
||||
return fail(format!("{} did not return an SVG", p.name));
|
||||
};
|
||||
// No viewBox (flagcdn's jp.svg has only width and height): the size is the box.
|
||||
let px = |k: &str| svg_el.attr(k).and_then(|v| v.trim_end_matches("px").parse::<f64>().ok());
|
||||
let px = |k: &str| svg_el.attr(k).and_then(|v| css::finite(v.trim_end_matches("px")));
|
||||
let vb: Vec<f64> = match svg_el.attr("viewBox") {
|
||||
Some(v) => v.split([' ', ',']).filter_map(|n| n.parse().ok()).collect(),
|
||||
Some(v) => v.split([' ', ',']).filter_map(css::finite).collect(),
|
||||
None => px("width").zip(px("height")).map(|(w, h)| vec![0.0, 0.0, w, h]).unwrap_or_default(),
|
||||
};
|
||||
// The importer keeps viewBox units: scale the artwork to the layer size.
|
||||
@@ -3991,7 +4123,7 @@ impl Builder<'_, '_> {
|
||||
// a scale group and make the viewBox match the final size.
|
||||
let vb: Vec<f64> = svg_el
|
||||
.attr("viewBox")
|
||||
.map(|v| v.split([' ', ',']).filter_map(|n| n.parse().ok()).collect())
|
||||
.map(|v| v.split([' ', ',']).filter_map(css::finite).collect())
|
||||
.unwrap_or_default();
|
||||
if let [vx, vy, vw, vh] = vb[..]
|
||||
&& vw > 0.0
|
||||
@@ -4065,6 +4197,10 @@ impl Builder<'_, '_> {
|
||||
return None;
|
||||
};
|
||||
let same_page = page_part.is_none_or(|p| match_page_key(&self.page_id, "", p));
|
||||
if same_page && crate::editor::structure::moves_into_cycle(self.tx.doc(), src, parent.id) {
|
||||
self.warnings.push(format!("x-paper-clone: node `{key}` skipped, {NEST_ERR}"));
|
||||
return None;
|
||||
}
|
||||
let id = if same_page && self.tx.doc().get(src).is_some() {
|
||||
clone_subtree(self.tx, src, parent.id, *index, V2::ZERO).0
|
||||
} else if let Some((_, _, src_doc)) =
|
||||
@@ -4453,7 +4589,7 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
v["parentId"] = json!(parent.map_or(ROOT.into(), |p| node_key(ed, page, p)));
|
||||
}
|
||||
if !ignored.is_empty() {
|
||||
v["ignoredStyles"] = json!(ignored);
|
||||
v["ignoredStyles"] = json!(super::import_html::group_ignored(ignored.iter().map(String::as_str)));
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
v["warnings"] = json!(warnings);
|
||||
@@ -4780,6 +4916,8 @@ fn set_text_content(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
)
|
||||
})?;
|
||||
*node.kind.text_mut().unwrap() = t.clone();
|
||||
// Hand-set text replaces a bound string, as a new colour replaces a `text-color` binding.
|
||||
node.bindings.remove("text");
|
||||
crate::editor::structure::mark_overrides(tx, id, &["text"]);
|
||||
let node = tx.node_mut(id).unwrap();
|
||||
let len = t.chars().count();
|
||||
@@ -4856,7 +4994,7 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
|
||||
/// The project design system; a project that never went through the session reads as its first
|
||||
/// page does, like `edit_design`.
|
||||
fn design_of(ed: &Editor) -> DesignSystem {
|
||||
pub(super) fn design_of(ed: &Editor) -> DesignSystem {
|
||||
if ed.design.collections.is_empty() {
|
||||
return variables::migrate_from_document(ed.page_doc(0));
|
||||
}
|
||||
@@ -4929,9 +5067,11 @@ fn parse_value(name: &str, kind: Option<Kind>, v: &Value) -> Result<(Kind, DsVal
|
||||
.trim()
|
||||
.trim_end_matches("px")
|
||||
.trim()
|
||||
.parse()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|n| n.is_finite())
|
||||
.map(|n| (Kind::Number, DsVal::Number(n)))
|
||||
.map_err(|_| bad("number")),
|
||||
.ok_or_else(|| bad("number")),
|
||||
(Some(Kind::Number), _) => Err(bad("number")),
|
||||
(Some(Kind::String), Value::String(s)) => Ok((Kind::String, DsVal::String(s.clone()))),
|
||||
(Some(Kind::String), _) => Err(bad("string")),
|
||||
@@ -5333,15 +5473,19 @@ fn set_tokens(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
|
||||
fn set_variable_mode(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let mode = a.req_str("mode")?.to_string();
|
||||
if !ed.page_doc(ed.active_page).modes.contains_key(&mode) {
|
||||
let known: Vec<String> = ed.page_doc(ed.active_page).modes.keys().cloned().collect();
|
||||
let Some((cid, mid)) = design::find_mode(&ed.design, &mode) else {
|
||||
let known: Vec<&str> = ed
|
||||
.design
|
||||
.collections
|
||||
.iter()
|
||||
.flat_map(|c| c.modes.iter().map(|m| m.name.as_str()))
|
||||
.collect();
|
||||
return Err(format!(
|
||||
"no mode `{mode}`; define it with set_variables {{mode}} first (modes: {})",
|
||||
known.join(", ")
|
||||
));
|
||||
}
|
||||
};
|
||||
// Project-wide like before: every page switches (the Design tab switches one page).
|
||||
let (cid, mid) = design::find_mode(&ed.design, &mode).ok_or(format!("no mode `{mode}`"))?;
|
||||
let mut fills = 0;
|
||||
for page in 0..ed.pages.len() {
|
||||
fills += ed
|
||||
@@ -5401,7 +5545,7 @@ fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
text(json!({"components": items}))
|
||||
Ok(super::budget::json_out(json!({"components": items}), a.num("maxTokens"), "pass pageId to list one page"))
|
||||
}
|
||||
|
||||
/// `color` on an icon (vector shapes with no text, e.g. an <x-paper-icon>) recolors the shapes'
|
||||
@@ -5644,6 +5788,8 @@ fn find_component(ed: &Editor, a: &Args, default_page: usize) -> Result<(usize,
|
||||
Ok((page, id))
|
||||
}
|
||||
|
||||
const NEST_ERR: &str = "a component can't contain an instance of itself, directly or through other components";
|
||||
|
||||
fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let def = page_arg(ed, a)?;
|
||||
// A componentKey that no page owns may name a component of an enabled library.
|
||||
@@ -5665,6 +5811,12 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
(pp, Some(id))
|
||||
}
|
||||
};
|
||||
if lib.is_none() && cpage == page && crate::editor::structure::would_nest(ed.page_doc(page), comp, parent) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
if lib.is_none() && cpage != page && crate::editor::structure::would_nest_across(ed, cpage, comp, page, parent) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
let index = a.num("index").map(|i| i.max(0.0) as usize);
|
||||
let at = a.num("x").zip(a.num("y"));
|
||||
let name = a
|
||||
@@ -5738,33 +5890,6 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
text(json!({"id": node_key(ed, page, id), "textLayers": texts, "layers": layers}))
|
||||
}
|
||||
|
||||
/// Prototype links: clicking `nodeId` in Present mode goes to frame `to` (same page); `to: null`
|
||||
/// removes the link.
|
||||
fn set_prototype_links(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let page = page_arg(ed, a)?;
|
||||
let mut set = Vec::new();
|
||||
for l in a.arr("links")? {
|
||||
let l = Args(l.as_object().ok_or("each link must be an object")?);
|
||||
let (p, from) = resolve(ed, l.req_str("nodeId")?, page)?;
|
||||
let to = match l.str("to").filter(|t| !t.is_empty()) {
|
||||
Some(t) => {
|
||||
let (tp, to) = resolve(ed, t, p)?;
|
||||
if tp != p || !ed.page_doc(p).get(to).is_some_and(|n| n.is_frame()) {
|
||||
return Err(format!("`{t}` must be a frame on the same page"));
|
||||
}
|
||||
Some(to)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
set.push((p, from, to));
|
||||
}
|
||||
let n = set.len();
|
||||
for (p, from, to) in set {
|
||||
ed.with_page(p, |ed| ed.edit("Prototype link", |tx| tx.node_mut(from).unwrap().link = to));
|
||||
}
|
||||
text(json!({"linked": n}))
|
||||
}
|
||||
|
||||
/// The inspector's way to set instance properties on the page being viewed.
|
||||
pub fn set_props_ui(ed: &mut Editor, ids: &[NodeId], name: &str, value: &str) -> Result<(), String> {
|
||||
let page = ed.active_page;
|
||||
@@ -6398,6 +6523,11 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
(d, s)
|
||||
}
|
||||
};
|
||||
// Checked before `with_page`, which parks the other pages' documents it needs to read.
|
||||
let under = ed.page_doc(page).get(inst).and_then(|n| n.parent);
|
||||
if lib.is_none() && cpage != page && crate::editor::structure::would_nest_across(ed, cpage, new_main, page, under) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
ed.with_page(page, |ed| {
|
||||
// An instance root: linked, and not itself inside an instance. A missing main is fine,
|
||||
// so a swap restores such an instance.
|
||||
@@ -6412,8 +6542,8 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
a.req_str("nodeId").unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if cpage == page && lib.is_none() && (inst == new_main || ed.doc.is_ancestor(inst, new_main)) {
|
||||
return Err("could not swap instance to that component".into());
|
||||
if cpage == page && lib.is_none() && crate::editor::structure::swap_nests(&ed.doc, inst, new_main) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
// A nested instance (inside another instance) keeps its place in it: a swap override.
|
||||
let ok = ed.edit("Swap instance", |tx| {
|
||||
@@ -6500,6 +6630,7 @@ fn diff_frames(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
/// design system is project-wide, so every page gets it in one undo step.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn import_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
check_path(a.req_str("path")?)?;
|
||||
let dir = PathBuf::from(a.req_str("path")?);
|
||||
let pkg = crate::design_system::import_package(&dir)?;
|
||||
let ed = host.editor();
|
||||
@@ -6541,6 +6672,9 @@ fn import_design_system(_: &mut dyn Host, _: &Args) -> ToolResult {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn export_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let dir = PathBuf::from(a.req_str("path")?);
|
||||
out_folder(&dir, "manifest.json", |b| {
|
||||
std::str::from_utf8(b).is_ok_and(|raw| crate::design_system::parse_manifest(raw).is_ok())
|
||||
})?;
|
||||
let project = host.project().map(|p| p.manifest.name.clone());
|
||||
let ed = host.editor();
|
||||
let page = page_arg(ed, a)?;
|
||||
@@ -6678,7 +6812,7 @@ fn set_code_link(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
}
|
||||
|
||||
/// The JSON a tool call answers with (its first text block).
|
||||
fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
|
||||
pub(super) fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
|
||||
let outs = call(host, name, &args)?;
|
||||
let t = outs.iter().find_map(|o| if let Out::Text(t) = o { Some(t.as_str()) } else { None }).ok_or_else(|| format!("{name} answered no text"))?;
|
||||
serde_json::from_str(t).map_err(|e| format!("{name}: {e}"))
|
||||
@@ -6903,6 +7037,11 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
(pp, Some(Some(pid)))
|
||||
}
|
||||
};
|
||||
if let Some(under) = parent
|
||||
&& crate::editor::structure::copy_nests_across(ed, src_page, id, dest_page, under)
|
||||
{
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
jobs.push((src_page, dest_page, id, parent));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
@@ -7039,6 +7178,9 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
return Err("the destination can't have children".into());
|
||||
}
|
||||
}
|
||||
if crate::editor::structure::moves_into_cycle(doc, id, target) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
tx.reparent(id, target, None);
|
||||
let list: Vec<NodeId> = tx
|
||||
.doc()
|
||||
@@ -7072,6 +7214,9 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
{
|
||||
return Err("the destination can't have children".into());
|
||||
}
|
||||
if crate::editor::structure::copy_nests_across(ed, src_page, id, dest_page, target) {
|
||||
return Err(NEST_ERR.into());
|
||||
}
|
||||
let src_doc = ed.page_doc(src_page).clone();
|
||||
let ids = (ed.pages[src_page].id.clone(), ed.pages[dest_page].id.clone());
|
||||
// Notes on the moved nodes go with them instead of staying behind detached.
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{markdown, report_since, str_arg, text};
|
||||
use crate::model::{Color, Document, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use crate::io::backup::safe_relative;
|
||||
use std::path::Path;
|
||||
|
||||
const MANIFEST: &str = "manifest.json";
|
||||
@@ -21,6 +22,7 @@ const HANDOFF_PAGE: &str = "handoff";
|
||||
|
||||
pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
|
||||
let dir = Path::new(str_arg(a, "dir").ok_or("`dir` is required: the folder to write the bundle into")?).to_path_buf();
|
||||
exec::out_folder(&dir, MANIFEST, is_bundle_manifest)?;
|
||||
let images = a.get("images").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
|
||||
@@ -96,6 +98,13 @@ pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
|
||||
text(out)
|
||||
}
|
||||
|
||||
/// Whether `bytes` is a manifest this tool wrote: its exact `format` and a `files` map of
|
||||
/// entries that each carry a content `hash`. A foreign manifest.json with a `format` key is not.
|
||||
fn is_bundle_manifest(bytes: &[u8]) -> bool {
|
||||
let Ok(m) = serde_json::from_slice::<Value>(bytes) else { return false };
|
||||
m["format"] == FORMAT && m["files"].as_object().is_some_and(|f| f.values().all(|e| e["hash"].is_string()))
|
||||
}
|
||||
|
||||
fn call(host: &mut dyn Host, tool: &str, args: Value) -> Result<String, String> {
|
||||
exec::call(host, tool, &args)?
|
||||
.into_iter()
|
||||
@@ -159,10 +168,8 @@ fn write(dir: &Path, mut files: BTreeMap<String, Vec<u8>>) -> Result<(usize, usi
|
||||
}
|
||||
let mut removed = 0;
|
||||
for rel in old["files"].as_object().into_iter().flat_map(|m| m.keys()).filter(|k| !files.contains_key(*k)) {
|
||||
// Only paths this tool wrote: never climb out of the folder.
|
||||
if rel.split('/').any(|p| [".", ".."].contains(&p) || p.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
// The manifest is read back from disk: only plain relative paths inside the folder.
|
||||
let Some(rel) = safe_relative(rel) else { continue };
|
||||
if std::fs::remove_file(dir.join(rel)).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
+5
-25
@@ -115,7 +115,7 @@ fn run_codex(bin: &Path, prompt: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let scratch = std::env::temp_dir().join(format!("papercraft-imagegen-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&scratch).map_err(|e| e.to_string())?;
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.env("PATH", crate::chat::path::resolved_path());
|
||||
crate::chat::path::no_window(&mut cmd).env("PATH", crate::chat::path::resolved_path());
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
@@ -154,21 +154,10 @@ fn run_codex(bin: &Path, prompt: &str) -> Result<Vec<PathBuf>, String> {
|
||||
fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let out = std::env::temp_dir().join(format!("papercraft-imagegen-{}-{}-{:08x}", std::process::id(), crate::project::format::now_ms(), fastrand::u32(..)));
|
||||
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
|
||||
let script = shell_script(template);
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &script]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &script]);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", prompt)
|
||||
.env("PAPERCRAFT_ASPECT", aspect)
|
||||
.env("PAPERCRAFT_RATIO", ratio(aspect))
|
||||
.env("PAPERCRAFT_OUT", &out)
|
||||
let out_dir = out.display().to_string();
|
||||
let vars = [("{prompt}", "PAPERCRAFT_PROMPT", prompt), ("{aspect}", "PAPERCRAFT_ASPECT", aspect), ("{ratio}", "PAPERCRAFT_RATIO", ratio(aspect)), ("{out}", "PAPERCRAFT_OUT", out_dir.as_str())];
|
||||
let mut cmd = super::shell::command(template, &vars);
|
||||
let mut child = crate::chat::path::no_window(&mut cmd)
|
||||
// An app opened from Finder has a bare PATH: give the command the user's shell PATH.
|
||||
.env("PATH", crate::chat::path::resolved_path())
|
||||
.current_dir(&out)
|
||||
@@ -197,15 +186,6 @@ fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
/// The template with its placeholders turned into quoted variable references.
|
||||
fn shell_script(template: &str) -> String {
|
||||
template
|
||||
.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")
|
||||
.replace("{aspect}", "\"$PAPERCRAFT_ASPECT\"")
|
||||
.replace("{ratio}", "\"$PAPERCRAFT_RATIO\"")
|
||||
.replace("{out}", "\"$PAPERCRAFT_OUT\"")
|
||||
}
|
||||
|
||||
/// The thread id of a `codex exec --json` run (`thread.started`).
|
||||
fn thread_id(events: &str) -> Option<String> {
|
||||
events
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! `import_html`: opens a page exported by a prompt-to-design tool (one `.html`, or a `.zip` with
|
||||
//! its stylesheets and images) as layers. The CSS is inlined into `style` attributes and the
|
||||
//! images become project assets; `write_html` then maps the result like any other markup.
|
||||
//!
|
||||
//! Each HTML file becomes its own frame on the page, side by side, rather than its own page:
|
||||
//! the files of one export are screens of one design, which are easier to compare and link when
|
||||
//! they share a canvas, and the caller can still name a page to put them on.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::ToolResult;
|
||||
use super::exec::{Host, call_json, check_path, text};
|
||||
use super::html::{self, Element, Html};
|
||||
use crate::io::{backup::safe_relative, zip};
|
||||
|
||||
/// A page without a width of its own gets a desktop viewport, as a browser window would.
|
||||
const DEFAULT_WIDTH: &str = "1280px";
|
||||
/// Elements of a document head that make no layers.
|
||||
const HEAD_TAGS: [&str; 3] = ["link", "meta", "base"];
|
||||
/// Largest HTML file read, and largest zip (read whole, then inflated up to `MAX_INFLATED`).
|
||||
const MAX_HTML_BYTES: u64 = 64 << 20;
|
||||
const MAX_ZIP_BYTES: u64 = 256 << 20;
|
||||
const MAX_INFLATED: usize = 256 << 20;
|
||||
const VOID: [&str; 5] = ["img", "input", "br", "hr", "wbr"];
|
||||
|
||||
pub fn run(host: &mut dyn Host, args: &Value) -> ToolResult {
|
||||
let path = args["path"].as_str().ok_or("missing required string argument `path`")?.trim();
|
||||
check_path(path)?;
|
||||
let is_zip = check_file(path)?;
|
||||
let bytes = crate::vfs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut warnings = Vec::new();
|
||||
let (files, resources) = if is_zip {
|
||||
archive(&bytes, &mut warnings)?
|
||||
} else {
|
||||
let src = String::from_utf8(bytes).map_err(|_| format!("{path}: not UTF-8 text"))?;
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_owned();
|
||||
(vec![(name, src)], HashMap::new())
|
||||
};
|
||||
if files.is_empty() {
|
||||
return Err(format!("{path}: no .html file found"));
|
||||
}
|
||||
let (mut frames, mut ignored) = (Vec::new(), Vec::new());
|
||||
for (name, src) in files {
|
||||
let html = page(&name, &src, &resources, &mut warnings);
|
||||
let mut w = json!({"targetNodeId": "root", "html": html});
|
||||
if let Some(p) = args.get("pageId").filter(|p| !p.is_null()) {
|
||||
w["pageId"] = p.clone();
|
||||
}
|
||||
if let Some(f) = args.get("fileId").filter(|f| !f.is_null()) {
|
||||
w["fileId"] = f.clone();
|
||||
}
|
||||
let r = call_json(host, "write_html", w).map_err(|e| format!("{name}: {e}"))?;
|
||||
let ids: Vec<Value> = r["createdNodeIds"].as_array().cloned().unwrap_or_default();
|
||||
frames.push(json!({"file": name, "nodeIds": ids}));
|
||||
ignored.extend(r["ignoredStyles"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_owned)));
|
||||
warnings.extend(r["warnings"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(|s| format!("{name}: {s}"))));
|
||||
}
|
||||
let mut v = json!({"imported": frames});
|
||||
if !ignored.is_empty() {
|
||||
v["ignoredStyles"] = json!(group_ignored(ignored.iter().map(String::as_str)));
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
v["warnings"] = json!(warnings);
|
||||
}
|
||||
text(v)
|
||||
}
|
||||
|
||||
/// Whether `path` is a zip, after checking it is a page file of a sane size. The text of the
|
||||
/// file comes back to the caller, so nothing else (`~/.ssh/config`) may be opened.
|
||||
fn check_file(path: &str) -> Result<bool, String> {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let is_zip = lower.ends_with(".zip");
|
||||
if !is_zip && !lower.ends_with(".html") && !lower.ends_with(".htm") {
|
||||
return Err(format!("{path}: import_html opens only .html, .htm and .zip files"));
|
||||
}
|
||||
let cap = if is_zip { MAX_ZIP_BYTES } else { MAX_HTML_BYTES };
|
||||
let size = crate::vfs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
if size > cap {
|
||||
return Err(format!("{path}: {} MiB is over the {} MiB limit", size >> 20, cap >> 20));
|
||||
}
|
||||
Ok(is_zip)
|
||||
}
|
||||
|
||||
/// The ignored-style entries of a write, one per property: `transform: rotate, scale`.
|
||||
pub fn group_ignored<'a>(items: impl IntoIterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut by: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for item in items {
|
||||
let (prop, detail) = item.split_once(": ").unwrap_or((item, ""));
|
||||
let details = by.entry(prop).or_default();
|
||||
details.extend(detail.split(", ").filter(|d| !d.is_empty()));
|
||||
}
|
||||
by.into_iter()
|
||||
.map(|(p, d)| if d.is_empty() { p.to_owned() } else { format!("{p}: {}", d.into_iter().collect::<Vec<_>>().join(", ")) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `.html` files of an archive in order, and every other safely named file by path.
|
||||
/// Entries that would leave the archive's folder when extracted are skipped.
|
||||
type Archive = (Vec<(String, String)>, HashMap<String, Vec<u8>>);
|
||||
|
||||
fn archive(bytes: &[u8], warnings: &mut Vec<String>) -> Result<Archive, String> {
|
||||
let (mut pages, mut files) = (Vec::new(), HashMap::new());
|
||||
for (name, data) in zip::read_capped(bytes, MAX_INFLATED)? {
|
||||
if name.starts_with("__MACOSX/") {
|
||||
continue;
|
||||
}
|
||||
if safe_relative(&name).is_none() {
|
||||
warnings.push(format!("skipped `{name}`: its path leaves the archive"));
|
||||
continue;
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".html") || lower.ends_with(".htm") {
|
||||
match String::from_utf8(data) {
|
||||
Ok(src) => pages.push((name, src)),
|
||||
Err(_) => warnings.push(format!("skipped `{name}`: not UTF-8 text")),
|
||||
}
|
||||
} else {
|
||||
files.insert(name, data);
|
||||
}
|
||||
}
|
||||
Ok((pages, files))
|
||||
}
|
||||
|
||||
/// The path `href` names, relative to the file in `dir`, if it stays inside the archive.
|
||||
fn resolve(dir: &str, href: &str) -> Option<String> {
|
||||
let href = href.split(['?', '#']).next().unwrap_or_default();
|
||||
if href.is_empty() || href.contains("://") || href.starts_with("//") || href.starts_with("data:") {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<&str> = if href.starts_with('/') { vec![] } else { dir.split('/').filter(|p| !p.is_empty()).collect() };
|
||||
for p in href.split('/') {
|
||||
match p {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
p => parts.push(p),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
/// One file as markup `write_html` takes: its CSS inlined, its images registered, and its body
|
||||
/// as the one top-level frame.
|
||||
fn page(name: &str, src: &str, resources: &HashMap<String, Vec<u8>>, warnings: &mut Vec<String>) -> String {
|
||||
let dir = name.rsplit_once('/').map_or("", |(d, _)| d);
|
||||
let mut tree = html::parse(src);
|
||||
let css = css_in_order(src, dir, resources, name, warnings);
|
||||
let rules = Rules::parse(&css, warnings);
|
||||
inline(&mut tree, &rules);
|
||||
images(&mut tree, dir, resources, name, warnings);
|
||||
let stem = name.rsplit('/').next().unwrap_or(name).rsplit_once('.').map_or(name, |(s, _)| s);
|
||||
let (mut style, children) = match find(&tree, "body") {
|
||||
Some(b) => (b.attr("style").unwrap_or("").to_owned(), b.children.clone()),
|
||||
None => (String::new(), tree),
|
||||
};
|
||||
if !declarations(&style).any(|(k, _)| k == "width") {
|
||||
style = format!("width:{DEFAULT_WIDTH};{style}");
|
||||
}
|
||||
let frame = Element {
|
||||
tag: "div".into(),
|
||||
attrs: vec![("layer-name".into(), stem.to_owned()), ("style".into(), style)],
|
||||
children: children.into_iter().filter(|c| !matches!(c, Html::Element(e) if HEAD_TAGS.contains(&e.tag.as_str()))).collect(),
|
||||
raw: None,
|
||||
};
|
||||
serialize(&Html::Element(frame))
|
||||
}
|
||||
|
||||
fn find<'a>(tree: &'a [Html], tag: &str) -> Option<&'a Element> {
|
||||
tree.iter().find_map(|h| match h {
|
||||
Html::Element(e) if e.tag == tag => Some(e),
|
||||
Html::Element(e) => find(&e.children, tag),
|
||||
Html::Text(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `href` of every `<link rel="stylesheet">`.
|
||||
fn links(tree: &[Html]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for h in tree {
|
||||
if let Html::Element(e) = h {
|
||||
if e.tag == "link" && e.attr("rel").is_some_and(|r| r.eq_ignore_ascii_case("stylesheet")) {
|
||||
out.extend(e.attr("href").map(str::to_owned));
|
||||
}
|
||||
out.extend(links(&e.children));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CSS of every `<style>` block (the parser drops them from the tree) and linked stylesheet,
|
||||
/// in document order, so a later rule overrides an earlier one as in a browser.
|
||||
fn css_in_order(src: &str, dir: &str, resources: &HashMap<String, Vec<u8>>, name: &str, warnings: &mut Vec<String>) -> String {
|
||||
let lower = src.to_ascii_lowercase();
|
||||
let (mut out, mut at) = (String::new(), 0);
|
||||
while let Some(open) = [lower[at..].find("<style"), lower[at..].find("<link")].into_iter().flatten().min().map(|o| at + o) {
|
||||
let Some(gt) = lower[open..].find('>').map(|g| open + g) else { break };
|
||||
at = gt + 1;
|
||||
if lower[open..].starts_with("<style") {
|
||||
let end = lower[at..].find("</style").map_or(src.len(), |e| at + e);
|
||||
out.push_str(&src[at..end]);
|
||||
out.push('\n');
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
for href in links(&html::parse(&src[open..at])) {
|
||||
match resolve(dir, &href).and_then(|p| resources.get(&p)) {
|
||||
Some(b) => out.push_str(&String::from_utf8_lossy(b)),
|
||||
None => warnings.push(format!("{name}: stylesheet `{href}` not found in the archive")),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Images in the archive become project assets, named by the id `write_html` accepts as a src.
|
||||
fn images(tree: &mut [Html], dir: &str, resources: &HashMap<String, Vec<u8>>, name: &str, warnings: &mut Vec<String>) {
|
||||
for h in tree {
|
||||
let Html::Element(e) = h else { continue };
|
||||
if e.tag == "img"
|
||||
&& let Some(src) = e.attr("src").map(str::to_owned)
|
||||
&& let Some(path) = resolve(dir, &src)
|
||||
{
|
||||
let id = resources.get(&path).ok_or_else(|| "not in the archive".to_owned()).and_then(|b| crate::assets::register(b));
|
||||
match id {
|
||||
Ok(id) => {
|
||||
if let Some(a) = e.attrs.iter_mut().find(|(k, _)| k == "src") {
|
||||
a.1 = id;
|
||||
}
|
||||
}
|
||||
Err(why) => warnings.push(format!("{name}: image `{src}`: {why}")),
|
||||
}
|
||||
}
|
||||
images(&mut e.children, dir, resources, name, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- CSS
|
||||
|
||||
/// A selector of one element: `tag`, `.class`, `#id`, `*` and their combinations.
|
||||
struct Simple {
|
||||
tag: Option<String>,
|
||||
id: Option<String>,
|
||||
classes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Simple {
|
||||
fn parse(sel: &str) -> Option<Simple> {
|
||||
let mut s = Simple { tag: None, id: None, classes: vec![] };
|
||||
let mut rest = sel.trim();
|
||||
let name = |r: &str| r.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')).unwrap_or(r.len());
|
||||
if let Some(r) = rest.strip_prefix('*') {
|
||||
rest = r;
|
||||
} else if !rest.starts_with(['.', '#']) {
|
||||
let n = name(rest);
|
||||
s.tag = Some(rest[..n].to_ascii_lowercase());
|
||||
rest = &rest[n..];
|
||||
}
|
||||
while !rest.is_empty() {
|
||||
let kind = rest.chars().next()?;
|
||||
let k = kind.len_utf8();
|
||||
let n = name(&rest[k..]);
|
||||
let word = rest[k..k + n].to_owned();
|
||||
match kind {
|
||||
'.' if n > 0 => s.classes.push(word),
|
||||
'#' if n > 0 => s.id = Some(word),
|
||||
_ => return None,
|
||||
}
|
||||
rest = &rest[k + n..];
|
||||
}
|
||||
(s.tag.as_deref() != Some("")).then_some(s)
|
||||
}
|
||||
|
||||
fn matches(&self, e: &Element) -> bool {
|
||||
let class = e.attr("class").unwrap_or("");
|
||||
self.tag.as_ref().is_none_or(|t| *t == e.tag)
|
||||
&& self.id.as_deref().is_none_or(|i| e.attr("id") == Some(i))
|
||||
&& self.classes.iter().all(|c| class.split_whitespace().any(|k| k == c))
|
||||
}
|
||||
|
||||
/// Ids, then classes, then the tag decide which rule wins.
|
||||
fn specificity(&self) -> (usize, usize, usize) {
|
||||
(usize::from(self.id.is_some()), self.classes.len(), usize::from(self.tag.is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
struct Rules {
|
||||
/// In cascade order: later wins.
|
||||
list: Vec<(Simple, String)>,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
fn parse(css: &str, warnings: &mut Vec<String>) -> Rules {
|
||||
let (mut vars, mut raw, mut skipped) = (HashMap::new(), Vec::new(), BTreeSet::new());
|
||||
for (sel, body) in blocks(&strip_comments(css)) {
|
||||
if sel.starts_with('@') {
|
||||
skipped.insert(sel.split_whitespace().next().unwrap_or("@").to_owned());
|
||||
continue;
|
||||
}
|
||||
let is_root = matches!(sel.as_str(), ":root" | "html");
|
||||
for part in sel.split(',') {
|
||||
raw.push((part.trim().to_owned(), body.clone(), is_root));
|
||||
}
|
||||
if is_root {
|
||||
for (k, v) in declarations(&body).filter(|(k, _)| k.starts_with("--")) {
|
||||
vars.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut list: Vec<(Simple, String)> = Vec::new();
|
||||
for (sel, body, is_root) in raw {
|
||||
match Simple::parse(&sel) {
|
||||
Some(s) => {
|
||||
let body = declarations(&body)
|
||||
.filter(|(k, _)| !k.starts_with("--"))
|
||||
.map(|(k, v)| format!("{k}:{}", substitute(&v, &vars)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
list.push((s, body));
|
||||
}
|
||||
None if is_root => {}
|
||||
None => {
|
||||
skipped.insert(sel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stable: equal specificity keeps source order.
|
||||
list.sort_by_key(|(s, _)| s.specificity());
|
||||
if !skipped.is_empty() {
|
||||
let shown: Vec<_> = skipped.iter().take(4).cloned().collect();
|
||||
warnings.push(format!("{} CSS rule(s) not inlined (only tag, .class and #id selectors are): {}", skipped.len(), shown.join(" | ")));
|
||||
}
|
||||
Rules { list }
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_comments(css: &str) -> String {
|
||||
let mut out = String::with_capacity(css.len());
|
||||
let mut rest = css;
|
||||
while let Some(i) = rest.find("/*") {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = rest[i + 2..].split_once("*/").map_or("", |(_, r)| r);
|
||||
}
|
||||
out + rest
|
||||
}
|
||||
|
||||
/// `selector { body }` pairs at the top level; at-rules keep their `@name` as selector.
|
||||
fn blocks(css: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = css;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let sel = rest[..open].trim().to_owned();
|
||||
let (mut depth, mut end) = (0usize, rest.len());
|
||||
for (i, c) in rest[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = open + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push((sel, rest[open + 1..end].to_owned()));
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Splits on `;` outside parentheses and quotes, so `url(data:...;base64,...)` and `"a;b"` stay whole.
|
||||
fn split_declarations(body: &str) -> Vec<&str> {
|
||||
let (mut out, mut start, mut depth, mut quote) = (Vec::new(), 0, 0usize, None);
|
||||
for (i, c) in body.char_indices() {
|
||||
match (c, quote) {
|
||||
(q @ ('"' | '\''), None) => quote = Some(q),
|
||||
(q, Some(open)) if q == open => quote = None,
|
||||
('(', None) => depth += 1,
|
||||
(')', None) => depth = depth.saturating_sub(1),
|
||||
(';', None) if depth == 0 => {
|
||||
out.push(&body[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(&body[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn declarations(body: &str) -> impl Iterator<Item = (String, String)> + '_ {
|
||||
split_declarations(body).into_iter().filter_map(|d| {
|
||||
let (k, v) = d.split_once(':')?;
|
||||
let v = v.replace("!important", "");
|
||||
Some((k.trim().to_ascii_lowercase(), v.trim().to_owned()))
|
||||
})
|
||||
}
|
||||
|
||||
/// `var(--x)` and `var(--x, fallback)` with the page's own custom properties.
|
||||
fn substitute(value: &str, vars: &HashMap<String, String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = value;
|
||||
while let Some(i) = rest.find("var(--") {
|
||||
out.push_str(&rest[..i]);
|
||||
let inner = &rest[i + 4..];
|
||||
let Some(end) = inner.find(')') else { break };
|
||||
let name = inner[..end].split(',').next().unwrap_or("").trim();
|
||||
match vars.get(name) {
|
||||
Some(v) => out.push_str(v),
|
||||
None => out.push_str(&rest[i..i + 4 + end + 1]),
|
||||
}
|
||||
rest = &inner[end + 1..];
|
||||
}
|
||||
out + rest
|
||||
}
|
||||
|
||||
/// Puts the matching rules before each element's own `style`, so the element's own wins.
|
||||
fn inline(tree: &mut [Html], rules: &Rules) {
|
||||
for h in tree {
|
||||
let Html::Element(e) = h else { continue };
|
||||
let css: Vec<&str> = rules.list.iter().filter(|(s, _)| s.matches(e)).map(|(_, b)| b.as_str()).filter(|b| !b.is_empty()).collect();
|
||||
if !css.is_empty() {
|
||||
let style = format!("{};{}", css.join(";"), e.attr("style").unwrap_or(""));
|
||||
match e.attrs.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case("style")) {
|
||||
Some(a) => a.1 = style,
|
||||
None => e.attrs.push(("style".into(), style)),
|
||||
}
|
||||
}
|
||||
inline(&mut e.children, rules);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- markup
|
||||
|
||||
fn escape(s: &str, quote: bool) -> String {
|
||||
let s = s.replace('&', "&").replace('<', "<").replace('>', ">");
|
||||
if quote { s.replace('"', """) } else { s }
|
||||
}
|
||||
|
||||
fn serialize(h: &Html) -> String {
|
||||
match h {
|
||||
Html::Text(t) => escape(t, false),
|
||||
Html::Element(e) => {
|
||||
if let Some(raw) = &e.raw {
|
||||
return raw.clone();
|
||||
}
|
||||
let attrs: String = e.attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", escape(v, true))).collect();
|
||||
if VOID.contains(&e.tag.as_str()) {
|
||||
return format!("<{}{attrs}/>", e.tag);
|
||||
}
|
||||
format!("<{0}{attrs}>{1}</{0}>", e.tag, e.children.iter().map(serialize).collect::<String>())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn css_is_inlined_by_specificity_and_the_elements_own_style_wins() {
|
||||
let mut w = Vec::new();
|
||||
let rules = Rules::parse(":root{--c:#123} p{color:red} .a{color:var(--c);margin:0} #x{color:blue} .a .b{color:green} @media (x){p{color:pink}}", &mut w);
|
||||
let mut t = html::parse("<p id=x class=a style=\"margin:4px\">t</p>");
|
||||
inline(&mut t, &rules);
|
||||
let out = serialize(&t[0]);
|
||||
assert!(out.contains("color:red;color:#123;margin:0;color:blue;margin:4px"), "{out}");
|
||||
assert!(w[0].contains("2 CSS rule(s)"), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_selectors_do_not_panic() {
|
||||
let mut w = Vec::new();
|
||||
let rules = Rules::parse(".título{color:red} p€{color:red} p{color:blue}", &mut w);
|
||||
assert_eq!(rules.list.len(), 1, "only `p` is a supported selector");
|
||||
assert!(w[0].contains("2 CSS rule(s)"), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_split_outside_urls_and_strings() {
|
||||
let d: Vec<_> = declarations("background:url(data:image/png;base64,AAA);content:\"a;b\";color:red").collect();
|
||||
assert_eq!(d.len(), 3, "{d:?}");
|
||||
assert_eq!(d[0].1, "url(data:image/png;base64,AAA)");
|
||||
assert_eq!(d[1].1, "\"a;b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_follows_document_order_and_width_means_width() {
|
||||
let res = HashMap::from([("a.css".to_owned(), b"p{color:red}".to_vec())]);
|
||||
let mut w = Vec::new();
|
||||
let src = "<style>p{color:green}</style><link rel=stylesheet href=a.css><style>p{color:blue}</style><body><p>x</p></body>";
|
||||
let css = css_in_order(src, "", &res, "x.html", &mut w);
|
||||
assert!(css.find("green") < css.find("red") && css.find("red") < css.find("blue"), "{css}");
|
||||
let out = page("x.html", "<body style=\"max-width:600px;border-width:1px\"><p>x</p></body>", &res, &mut w);
|
||||
assert!(out.contains("width:1280px"), "{out}");
|
||||
let out = page("x.html", "<body style=\"width:600px\"><p>x</p></body>", &res, &mut w);
|
||||
assert!(!out.contains("1280px"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_skips_macos_and_non_utf8_pages() {
|
||||
let z = zip::write(&[
|
||||
("__MACOSX/._a.html".into(), vec![0xff, 0xfe]),
|
||||
("b.html".into(), vec![0xff, 0xfe]),
|
||||
("a.html".into(), b"<p>x</p>".to_vec()),
|
||||
]);
|
||||
let mut w = Vec::new();
|
||||
let (pages, _) = archive(&z, &mut w).unwrap();
|
||||
assert_eq!(pages.len(), 1);
|
||||
assert_eq!(w.len(), 1, "{w:?}");
|
||||
assert!(w[0].contains("b.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_page_files_of_a_sane_size_are_opened() {
|
||||
let dir = std::env::temp_dir();
|
||||
let txt = dir.join(format!("pc-imp-{:016x}.txt", fastrand::u64(..)));
|
||||
std::fs::write(&txt, "secret").unwrap();
|
||||
let err = check_file(&txt.to_string_lossy()).unwrap_err();
|
||||
assert!(err.contains("only .html"), "{err}");
|
||||
let big = dir.join(format!("pc-imp-{:016x}.HTML", fastrand::u64(..)));
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_HTML_BYTES + 1).unwrap();
|
||||
let err = check_file(&big.to_string_lossy()).unwrap_err();
|
||||
assert!(err.contains("limit"), "{err}");
|
||||
std::fs::remove_file(&txt).ok();
|
||||
std::fs::remove_file(&big).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_styles_group_by_property() {
|
||||
let g = group_ignored(["transform: rotate", "transform: scale", "float", "transform: rotate"]);
|
||||
assert_eq!(g, ["float", "transform: rotate, scale"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_stay_inside_the_archive() {
|
||||
assert_eq!(resolve("a/b", "../c.css").as_deref(), Some("a/c.css"));
|
||||
assert_eq!(resolve("", "../c.css"), None);
|
||||
assert_eq!(resolve("a", "https://x/y.css"), None);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ fn variable_names(name: &str, args: &Value) -> Option<Option<BTreeSet<String>>>
|
||||
"set_variables" => Some(keys("variables")),
|
||||
"set_tokens" => Some(keys("tokens")),
|
||||
"delete_variables" => Some(keys("names")),
|
||||
"set_variable_mode" | "import_design_system" => Some(None),
|
||||
"set_variable_mode" | "set_strings" | "import_design_system" => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
+26
-19
@@ -17,7 +17,7 @@ pub struct Issue {
|
||||
}
|
||||
|
||||
/// Screens at least this wide are desktop (pointer) UIs.
|
||||
const DESKTOP_MIN_W: f64 = 1024.0;
|
||||
pub(crate) const DESKTOP_MIN_W: f64 = 1024.0;
|
||||
/// Touch targets: Apple HIG 44pt, WCAG 2.5.5 (AAA).
|
||||
const TOUCH_TARGET: f64 = 44.0;
|
||||
/// Pointer targets: WCAG 2.2 2.5.8 (AA). Compact 32px desktop controls pass.
|
||||
@@ -140,6 +140,19 @@ fn off_grid(v: f64) -> bool {
|
||||
v > 0.0 && (v / 4.0 - (v / 4.0).round()).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// A text layer's contrast against what is behind it: `(text, backdrop, ratio, needed ratio)`.
|
||||
/// WCAG AA needs 4.5:1, or 3:1 for large text (24px, or 18.66px bold).
|
||||
pub fn text_contrast(doc: &Document, id: NodeId) -> Option<(Color, Color, f64, f64)> {
|
||||
let n = doc.get(id)?;
|
||||
let size = n.kind.font_size()?;
|
||||
n.kind.text()?;
|
||||
let (fg, _) = solid(n)?;
|
||||
let bg = backdrop(doc, id)?;
|
||||
let fg = blend(bg, fg);
|
||||
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
|
||||
Some((fg, bg, contrast(fg, bg), if large { 3.0 } else { 4.5 }))
|
||||
}
|
||||
|
||||
/// Lint the subtrees under `roots` (instances are skipped: their component is linted instead).
|
||||
pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
let mut out: Vec<Issue> = Vec::new();
|
||||
@@ -169,25 +182,19 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
};
|
||||
// ---- text
|
||||
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
|
||||
if let Some((fg, _)) = solid(n)
|
||||
&& let Some(bg) = backdrop(doc, id)
|
||||
if let Some((fg, bg, ratio, need)) = text_contrast(doc, id)
|
||||
&& ratio < need
|
||||
{
|
||||
let fg = blend(bg, fg);
|
||||
let ratio = contrast(fg, bg);
|
||||
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
|
||||
let need = if large { 3.0 } else { 4.5 };
|
||||
if ratio < need {
|
||||
add(
|
||||
"contrast",
|
||||
"error",
|
||||
format!(
|
||||
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
|
||||
fg.to_hex(),
|
||||
bg.to_hex()
|
||||
),
|
||||
"use a lighter or darker text color, or change the background".into(),
|
||||
);
|
||||
}
|
||||
add(
|
||||
"contrast",
|
||||
"error",
|
||||
format!(
|
||||
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
|
||||
fg.to_hex(),
|
||||
bg.to_hex()
|
||||
),
|
||||
"use a lighter or darker text color, or change the background".into(),
|
||||
);
|
||||
}
|
||||
if let Some(l) = crate::text::layout_node(n)
|
||||
&& let Some(t) = n.kind.text()
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
//! relay server itself (native only).
|
||||
|
||||
pub mod bounds;
|
||||
pub mod budget;
|
||||
pub mod codegen;
|
||||
pub mod context;
|
||||
pub mod critique;
|
||||
pub mod css;
|
||||
pub mod diff;
|
||||
@@ -21,18 +23,25 @@ pub mod enforcer;
|
||||
pub mod handoff;
|
||||
pub mod exec;
|
||||
pub mod html;
|
||||
pub mod import_html;
|
||||
pub mod incremental;
|
||||
pub mod lint;
|
||||
pub mod lint_overflow;
|
||||
mod prototype;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod imagegen;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod delegate;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod shell;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refs;
|
||||
pub mod rewrite;
|
||||
pub mod strings;
|
||||
pub mod room;
|
||||
pub mod screen_fit;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod site;
|
||||
pub mod swarm;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod relay;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! `set_prototype_links`: what clicking a layer does in Present mode.
|
||||
|
||||
use super::exec::{page_arg, resolve, text, Args};
|
||||
use super::ToolResult;
|
||||
use crate::editor::Editor;
|
||||
use crate::model::link::valid_url;
|
||||
use crate::model::{LinkAction, NodeId};
|
||||
use serde_json::json;
|
||||
|
||||
/// One layer's new click behaviour: a frame to go to, or another action, or neither (cleared).
|
||||
type Change = (usize, NodeId, Option<NodeId>, Option<LinkAction>);
|
||||
|
||||
/// A frame on the page of the link's layer; links never cross pages.
|
||||
fn target_frame(ed: &Editor, key: &str, page: usize) -> Result<NodeId, String> {
|
||||
let (tp, id) = resolve(ed, key, page)?;
|
||||
if tp != page || !ed.page_doc(page).get(id).is_some_and(|n| n.is_frame()) {
|
||||
return Err(format!("`{key}` must be a frame on the same page"));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn parse_link(ed: &Editor, l: &Args, default_page: usize) -> Result<Change, String> {
|
||||
let (p, from) = resolve(ed, l.req_str("nodeId")?, default_page)?;
|
||||
let to = l.str("to").filter(|t| !t.is_empty());
|
||||
let overlay = l.str("overlay").filter(|t| !t.is_empty());
|
||||
let url = l.str("url");
|
||||
let action = l.str("action");
|
||||
let given = [to.is_some(), overlay.is_some(), url.is_some(), action.is_some()];
|
||||
if given.iter().filter(|g| **g).count() > 1 {
|
||||
return Err("give one of `to`, `overlay`, `url` or `action` per link".into());
|
||||
}
|
||||
let (link, act) = match (to, overlay, url, action) {
|
||||
(Some(t), ..) => (Some(target_frame(ed, t, p)?), None),
|
||||
(_, Some(t), ..) => (None, Some(LinkAction::Overlay { target: target_frame(ed, t, p)? })),
|
||||
(.., Some(u), _) => (None, Some(LinkAction::Url { url: valid_url(u)? })),
|
||||
(.., Some("back")) => (None, Some(LinkAction::Back)),
|
||||
(.., Some("close")) => (None, Some(LinkAction::Close)),
|
||||
(.., Some(other)) => return Err(format!("`action` must be \"back\" or \"close\", not `{other}`")),
|
||||
_ => (None, None),
|
||||
};
|
||||
Ok((p, from, link, act))
|
||||
}
|
||||
|
||||
/// Sets, per layer, one of: `to` (go to a frame), `overlay` (open a frame as an overlay), `url`
|
||||
/// (https, http, mailto or tel), `action` ("back" or "close"). A layer with none of them loses
|
||||
/// its link. Every link is checked before any is applied.
|
||||
pub(super) fn set_prototype_links(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let page = page_arg(ed, a)?;
|
||||
let mut set = Vec::new();
|
||||
for l in a.arr("links")? {
|
||||
let l = Args(l.as_object().ok_or("each link must be an object")?);
|
||||
set.push(parse_link(ed, &l, page)?);
|
||||
}
|
||||
let n = set.len();
|
||||
for (p, from, link, act) in set {
|
||||
ed.with_page(p, |ed| {
|
||||
ed.edit("Prototype link", |tx| {
|
||||
if let Some(node) = tx.node_mut(from) {
|
||||
node.link = link;
|
||||
node.link_action = act;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
text(json!({"linked": n}))
|
||||
}
|
||||
+190
-42
@@ -3,6 +3,8 @@
|
||||
//! become gaps or spacers, inline boxes (chips, badges) become boxes, and text inputs show their
|
||||
//! placeholder. Each rewrite matches what a browser shows; what cannot be matched is reported.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::html::{Element, Html};
|
||||
|
||||
/// Rewrite `tree` in place; returns notes on what was only approximated.
|
||||
@@ -167,47 +169,96 @@ fn grid(e: &mut Element, notes: &mut Vec<String>) {
|
||||
let col_gap = get(&d, "column-gap").and_then(px).or(gaps.get(1).or(gaps.first()).copied()).unwrap_or(0.0);
|
||||
let width = get(&d, "width").and_then(px);
|
||||
let tracks = get(&d, "grid-template-columns").map_or_else(|| vec!["1fr".to_owned()], |t| tracks(t, width, col_gap, notes));
|
||||
unmapped_container(&d, notes);
|
||||
d.retain(|(k, _)| !(k.starts_with("grid") || ["display", "gap", "row-gap", "column-gap", "justify-items"].contains(&k.as_str())));
|
||||
d.extend([("display".into(), "flex".into()), ("flex-direction".into(), "column".into()), ("gap".into(), format!("{row_gap}px"))]);
|
||||
set_decls(e, &d);
|
||||
let cell_style = |t: &str| -> String {
|
||||
if let Some(fr) = t.strip_suffix("fr").and_then(|f| f.trim().parse::<f64>().ok()) {
|
||||
format!("flex-grow:{fr}")
|
||||
} else if let Some(w) = px(t) {
|
||||
format!("width:{w}px")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
if e.children.iter().any(|c| matches!(c, Html::Element(k) if k.attr("style").is_some_and(|s| s.contains("grid-column")))) {
|
||||
notes.push("grid-column spans are laid out as one cell each".into());
|
||||
}
|
||||
let cells: Vec<Html> = std::mem::take(&mut e.children).into_iter().filter(|c| !matches!(c, Html::Text(t) if t.trim().is_empty())).collect();
|
||||
let n = tracks.len().max(1);
|
||||
let mut rows = Vec::new();
|
||||
for (r, chunk) in cells.chunks(n).enumerate() {
|
||||
let mut kids = Vec::new();
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
let style = cell_style(t);
|
||||
match chunk.get(i) {
|
||||
Some(Html::Element(k)) => {
|
||||
let mut k = k.clone();
|
||||
let mut kd = decls(&k);
|
||||
kd.retain(|(n, _)| !n.starts_with("grid"));
|
||||
if get(&kd, "width").is_none() {
|
||||
kd.extend(style.split_once(':').map(|(a, b)| (a.to_owned(), b.to_owned())));
|
||||
}
|
||||
set_decls(&mut k, &kd);
|
||||
kids.push(Html::Element(k));
|
||||
}
|
||||
Some(t @ Html::Text(_)) => kids.push(div(&style, "Cell", vec![t.clone()])),
|
||||
None if style.is_empty() => {}
|
||||
None => kids.push(div(&style, "Empty", vec![])),
|
||||
}
|
||||
}
|
||||
rows.push(div(&format!("display:flex;gap:{col_gap}px"), &format!("Row {}", r + 1), kids));
|
||||
if cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
e.children = rows;
|
||||
let n = tracks.len().max(1);
|
||||
let mut unmapped = BTreeSet::new();
|
||||
// An empty cell keeps the columns of a short row aligned.
|
||||
let empty = |t: &String| {
|
||||
let style = cell_style(std::slice::from_ref(t), col_gap);
|
||||
(!style.is_empty()).then(|| div(&style, "Empty", vec![]))
|
||||
};
|
||||
let mut rows: Vec<Vec<Html>> = vec![vec![]];
|
||||
let mut used = 0;
|
||||
for cell in cells {
|
||||
let span = match &cell {
|
||||
Html::Element(k) => get(&decls(k), "grid-column").and_then(|v| column_span(v, n)).unwrap_or(1),
|
||||
Html::Text(_) => 1,
|
||||
};
|
||||
if used + span > n {
|
||||
if let Some(r) = rows.last_mut() {
|
||||
r.extend(tracks[used..].iter().filter_map(empty));
|
||||
}
|
||||
rows.push(vec![]);
|
||||
used = 0;
|
||||
}
|
||||
let style = cell_style(&tracks[used..used + span], col_gap);
|
||||
let cell = match cell {
|
||||
Html::Element(mut k) => {
|
||||
let mut kd = decls(&k);
|
||||
unmapped.extend(kd.iter().filter(|(p, _)| p.starts_with("grid") && p != "grid-column").map(|(p, _)| p.clone()));
|
||||
kd.retain(|(p, _)| !p.starts_with("grid"));
|
||||
if get(&kd, "width").is_none() {
|
||||
kd.extend(style.split_once(':').map(|(a, b)| (a.to_owned(), b.to_owned())));
|
||||
}
|
||||
set_decls(&mut k, &kd);
|
||||
Html::Element(k)
|
||||
}
|
||||
t => div(&style, "Cell", vec![t]),
|
||||
};
|
||||
if let Some(r) = rows.last_mut() {
|
||||
r.push(cell);
|
||||
}
|
||||
used += span;
|
||||
}
|
||||
if let Some(r) = rows.last_mut() {
|
||||
r.extend(tracks[used.min(n)..].iter().filter_map(empty));
|
||||
}
|
||||
if !unmapped.is_empty() {
|
||||
notes.push(format!("grid {} not supported: cells flow row by row", unmapped.into_iter().collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
e.children = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, kids)| div(&format!("display:flex;gap:{col_gap}px"), &format!("Row {}", r + 1), kids))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// `grid-template-rows`, areas and auto-flow have no layer equivalent.
|
||||
fn unmapped_container(d: &[(String, String)], notes: &mut Vec<String>) {
|
||||
let lost: Vec<&str> = d.iter().map(|(k, _)| k.as_str()).filter(|k| k.starts_with("grid") && !matches!(*k, "grid-template-columns" | "grid-column")).collect();
|
||||
if !lost.is_empty() {
|
||||
notes.push(format!("grid {} not supported: cells flow row by row", lost.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The style that gives a cell spanning `tracks` their width: `fr` tracks add their shares, px
|
||||
/// tracks add their widths and the gaps between them.
|
||||
fn cell_style(tracks: &[String], gap: f64) -> String {
|
||||
let fr: Vec<f64> = tracks.iter().filter_map(|t| t.strip_suffix("fr").and_then(|f| f.trim().parse().ok())).collect();
|
||||
if !fr.is_empty() {
|
||||
return format!("flex-grow:{}", fr.iter().sum::<f64>());
|
||||
}
|
||||
let w: Vec<f64> = tracks.iter().filter_map(|t| px(t)).collect();
|
||||
if w.is_empty() { String::new() } else { format!("width:{}px", w.iter().sum::<f64>() + gap * (w.len() - 1) as f64) }
|
||||
}
|
||||
|
||||
/// Columns a `grid-column` value covers: `span 2`, `1 / 3` or `1 / -1`, at most `max`.
|
||||
fn column_span(v: &str, max: usize) -> Option<usize> {
|
||||
let span = if let Some((_, n)) = v.rsplit_once("span") {
|
||||
n.trim().parse().ok()?
|
||||
} else {
|
||||
let (a, b) = v.split_once('/')?;
|
||||
let (a, b): (i64, i64) = (a.trim().parse().ok()?, b.trim().parse().ok()?);
|
||||
if b < 0 { max as i64 + b + 2 - a } else { b - a }
|
||||
};
|
||||
usize::try_from(span).ok().filter(|s| *s >= 1).map(|s| s.min(max))
|
||||
}
|
||||
|
||||
/// The columns of `grid-template-columns`, with `repeat(n, x)` expanded. `auto-fill` and
|
||||
@@ -291,10 +342,52 @@ fn margin(d: &[(String, String)], row: bool) -> (Option<Option<f64>>, Option<Opt
|
||||
if row { (m[3], m[1]) } else { (m[0], m[2]) }
|
||||
}
|
||||
|
||||
/// An element's padding as top, right, bottom, left when every part is a px length.
|
||||
fn padding(d: &[(String, String)]) -> Option<[f64; 4]> {
|
||||
let mut p = [0.0; 4];
|
||||
for (k, v) in d.iter().filter(|(k, _)| k.starts_with("padding")) {
|
||||
let parts: Vec<f64> = v.split_whitespace().map(px).collect::<Option<_>>()?;
|
||||
match (k.as_str(), parts.as_slice()) {
|
||||
("padding", [a]) => p = [*a; 4],
|
||||
("padding", [a, b]) => p = [*a, *b, *a, *b],
|
||||
("padding", [a, b, c]) => p = [*a, *b, *c, *b],
|
||||
("padding", [a, b, c, l]) => p = [*a, *b, *c, *l],
|
||||
("padding-top", [a]) => p[0] = *a,
|
||||
("padding-right", [a]) => p[1] = *a,
|
||||
("padding-bottom", [a]) => p[2] = *a,
|
||||
("padding-left", [a]) => p[3] = *a,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Layers cannot overlap by pulling a sibling in, so a negative margin is dropped, with a note.
|
||||
fn drop_negative(k: &mut Element, notes: &mut Vec<String>) {
|
||||
let mut d = decls(k);
|
||||
let mut found = false;
|
||||
for (_, v) in d.iter_mut().filter(|(n, _)| n.starts_with("margin")) {
|
||||
let parts: Vec<&str> = v.split_whitespace().collect();
|
||||
if parts.iter().any(|t| px(t).is_some_and(|x| x < 0.0)) {
|
||||
found = true;
|
||||
*v = parts.iter().map(|t| if px(t).is_some_and(|x| x < 0.0) { "0" } else { t }).collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
}
|
||||
if found {
|
||||
notes.push(format!("negative margin dropped on `{}`", k.attr("layer-name").unwrap_or(&k.tag)));
|
||||
set_decls(k, &d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Children's margins along the parent's flow become its gap when they are all one size, else
|
||||
/// spacers of their size; `auto` margins become growing spacers (they take the free space). A
|
||||
/// spacers of their size; the margins at either end become the parent's padding; `auto` margins become growing spacers (they take the free space). A
|
||||
/// block's `margin: 0 auto` centres it.
|
||||
fn margins(e: &mut Element, notes: &mut Vec<String>) {
|
||||
for c in e.children.iter_mut() {
|
||||
if let Html::Element(k) = c {
|
||||
drop_negative(k, notes);
|
||||
}
|
||||
}
|
||||
let d = decls(e);
|
||||
let row = is_row(&d);
|
||||
let flex = matches!(get(&d, "display"), Some("flex" | "inline-flex"));
|
||||
@@ -320,7 +413,11 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
|
||||
space.push(if flex { before + after } else { before.max(after) });
|
||||
}
|
||||
let between = &space[1..m.len()];
|
||||
let uniform = !between.is_empty() && between.iter().all(|s| (s - between[0]).abs() < 1e-9) && space[0] == 0.0 && space[m.len()] == 0.0;
|
||||
// Margins at either end of the flow pad the parent, when its padding is plain px.
|
||||
let (lead, trail) = (space[0], space[m.len()]);
|
||||
let pad = padding(&d).filter(|_| lead > 0.0 || trail > 0.0);
|
||||
// Without that padding the end margins need spacers, which the uniform gap does not make.
|
||||
let uniform = !between.is_empty() && between.iter().all(|s| (s - between[0]).abs() < 1e-9) && (pad.is_some() || (lead == 0.0 && trail == 0.0));
|
||||
let mut pd = d.clone();
|
||||
let mut out: Vec<Html> = Vec::new();
|
||||
let size = if row { "width" } else { "height" };
|
||||
@@ -334,7 +431,7 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
|
||||
let Html::Element(mut k) = c else { unreachable!() };
|
||||
let (start, end) = m[j];
|
||||
let extra = if j > 0 { space[j] - gap } else { space[j] };
|
||||
if !uniform && extra > 0.0 {
|
||||
if !uniform && extra > 0.0 && !(j == 0 && pad.is_some()) {
|
||||
out.push(spacer(extra));
|
||||
}
|
||||
if auto(start) {
|
||||
@@ -355,12 +452,22 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
|
||||
out.push(grow());
|
||||
}
|
||||
}
|
||||
if !uniform && space[m.len()] > 0.0 {
|
||||
out.push(spacer(space[m.len()]));
|
||||
if !uniform && trail > 0.0 && pad.is_none() {
|
||||
out.push(spacer(trail));
|
||||
}
|
||||
if uniform && between[0] > 0.0 {
|
||||
let gapped = uniform && between[0] > 0.0;
|
||||
if gapped {
|
||||
pd.retain(|(k, _)| k != "gap");
|
||||
pd.push(("gap".into(), format!("{}px", gap + between[0])));
|
||||
}
|
||||
if let Some(mut p) = pad {
|
||||
let (s, t) = if row { (3, 1) } else { (0, 2) };
|
||||
p[s] += lead;
|
||||
p[t] += trail;
|
||||
pd.retain(|(k, _)| !k.starts_with("padding"));
|
||||
pd.push(("padding".into(), format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3])));
|
||||
}
|
||||
if gapped || pad.is_some() {
|
||||
set_decls(e, &pd);
|
||||
}
|
||||
e.children = out;
|
||||
@@ -408,6 +515,47 @@ mod tests {
|
||||
assert!(out.contains("height:24px") && !out.contains("height:8px"), "block margins collapse: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_margins_pad_the_parent_and_auto_centres() {
|
||||
let out = run("<div style=\"padding:8px\"><p style=\"margin:16px 0\">a</p><p style=\"margin:16px 0\">b</p></div>");
|
||||
assert!(out.starts_with("<div gap:16px;padding:24px 8px 24px 8px>") && !out.contains("margin") && !out.contains("Spacer"), "{out}");
|
||||
let out = run("<div style=\"display:flex\"><p style=\"margin-left:12px\">a</p></div>");
|
||||
assert!(out.contains("padding:0px 0px 0px 12px"), "{out}");
|
||||
let out = run("<div><div style=\"width:200px;margin:0 auto\">a</div></div>");
|
||||
assert!(out.contains("align-self:center") && !out.contains("margin"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_margins_survive_a_parent_whose_padding_is_not_plain_px() {
|
||||
for pad in ["5%", "0 auto"] {
|
||||
let out = run(&format!("<div style=\"padding:{pad}\"><p style=\"margin:16px 0\">a</p><p style=\"margin:16px 0\">b</p></div>"));
|
||||
assert!(out.matches("height:16px").count() >= 2 && !out.contains("margin"), "{pad}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_grid_makes_no_rows() {
|
||||
let out = run("<div style=\"display:grid;grid-template-columns:1fr 1fr\"></div>");
|
||||
assert!(!out.contains("Row 1"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_margins_are_dropped_with_a_note() {
|
||||
let mut t = super::super::html::parse("<div><p style=\"margin-top:-8px\">a</p></div>");
|
||||
let notes = rewrite(&mut t);
|
||||
assert_eq!(notes, ["negative margin dropped on `p`"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_spans_cover_columns_and_the_rest_is_reported() {
|
||||
let out = run("<div style=\"display:grid;grid-template-columns:1fr 1fr 2fr;gap:8px\"><p style=\"grid-column:span 2\">a</p><p>b</p><p style=\"grid-column:1 / -1\">c</p></div>");
|
||||
assert!(out.contains("<p flex-grow:2>a") && out.contains("<p flex-grow:2>b") && out.contains("<p flex-grow:4>c"), "{out}");
|
||||
assert_eq!(out.matches("display:flex;gap:8px").count(), 2, "{out}");
|
||||
let mut t = super::super::html::parse("<div style=\"display:grid;grid-template-columns:100px 100px;grid-template-rows:auto\"><p style=\"grid-row:2\">a</p></div>");
|
||||
let notes = rewrite(&mut t);
|
||||
assert!(notes.iter().any(|n| n.contains("grid-template-rows")) && notes.iter().any(|n| n.contains("grid-row")), "{notes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_boxes_and_inputs_become_boxes() {
|
||||
let out = run("<div><span style=\"padding:4px\">A</span><span style=\"padding:4px\">B</span></div>");
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Runs a user-configured command template (`{prompt}`, `{out}`, ...) through the platform shell
|
||||
//! without letting any value act as shell syntax: each value travels in its own environment
|
||||
//! variable and the shell only dereferences it after parsing.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// `template` with each `(placeholder, variable, value)` turned into a quoted reference to the
|
||||
/// variable, as a shell command with the variables set.
|
||||
pub fn command(template: &str, vars: &[(&str, &str, &str)]) -> Command {
|
||||
let mut script = template.to_owned();
|
||||
for (placeholder, name, _) in vars {
|
||||
// The reference brings its own quotes: quotes the template already puts around the
|
||||
// placeholder would pair with them and leave the value bare.
|
||||
for q in ['"', '\''] {
|
||||
script = script.replace(&format!("{q}{placeholder}{q}"), placeholder);
|
||||
}
|
||||
script = script.replace(placeholder, &reference(name));
|
||||
}
|
||||
let mut cmd = shell(&script);
|
||||
for (_, name, value) in vars {
|
||||
cmd.env(name, env_value(value));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn shell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let mut cmd = Command::new("cmd.exe");
|
||||
// `/V:ON` expands `!VAR!` after cmd has parsed the line (`%VAR%` expands before, so `&` or `<`
|
||||
// in a value would be syntax). `/S` plus raw quotes: Rust's own quoting is not cmd's.
|
||||
cmd.args(["/V:ON", "/S", "/C"]).raw_arg(format!("\"{script}\""));
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell(script: &str) -> Command {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.args(["-c", script]);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn reference(name: &str) -> String {
|
||||
if cfg!(windows) { format!("\"!{name}!\"") } else { format!("\"${name}\"") }
|
||||
}
|
||||
|
||||
/// The text a variable carries. A value starting with `-` gets a space in front so the program
|
||||
/// cannot read it as an option. On Windows the expanded text is read again by batch-file shims
|
||||
/// (npm's `pi.cmd`, `gf.cmd`, ...) that inherit `/V:ON`: a `"` would end the quoting and let `&`
|
||||
/// run a command, `!` would expand variables and a line break would cut the line, so those become
|
||||
/// `'`, `!` and a space. A shim that re-expands an argument unquoted (`%~1`) is still unsafe.
|
||||
fn env_value(value: &str) -> String {
|
||||
let value = if value.starts_with('-') { format!(" {value}") } else { value.to_owned() };
|
||||
if !cfg!(windows) {
|
||||
return value;
|
||||
}
|
||||
let mut out: String = value
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' => '\'',
|
||||
'!' => '\u{FF01}',
|
||||
'\r' | '\n' => ' ',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
// The closing quote follows: trailing backslashes must not escape it.
|
||||
let trailing = out.len() - out.trim_end_matches('\\').len();
|
||||
out.push_str(&"\\".repeat(trailing));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const NASTY: &str = "a & b | c < d > e ^ f %PATH% !PATH! \"q\" \\\" g\\ \n h";
|
||||
|
||||
fn scratch() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-shell-{}-{:08x}", std::process::id(), fastrand::u32(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Runs `template` with `{prompt}` set to `value`; what the program wrote to `{out}`.
|
||||
fn received(dir: &Path, template: &str, value: &str) -> String {
|
||||
let out = dir.join("arg.txt");
|
||||
let mut cmd = command(template, &[("{prompt}", "PAPERCRAFT_PROMPT", value), ("{out}", "PAPERCRAFT_OUT", &out.display().to_string())]);
|
||||
cmd.current_dir(dir);
|
||||
assert!(cmd.output().unwrap().status.success());
|
||||
std::fs::read_to_string(&out).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_starting_with_a_dash_is_not_read_as_an_option() {
|
||||
let cmd = command("prog {p}", &[("{p}", "PAPERCRAFT_P", "--help")]);
|
||||
let value = cmd.get_envs().find(|(k, _)| *k == "PAPERCRAFT_P").and_then(|(_, v)| v);
|
||||
assert_eq!(value, Some(std::ffi::OsStr::new(" --help")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_value_reaches_the_program_as_one_literal_argument() {
|
||||
let dir = scratch();
|
||||
let got = received(&dir, "printf '%s' {prompt} > {out}", NASTY);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(got, NASTY);
|
||||
}
|
||||
|
||||
/// A PowerShell script that writes its first argument to the file its second names.
|
||||
#[cfg(windows)]
|
||||
fn writer(dir: &Path) -> PathBuf {
|
||||
let script = dir.join("write.ps1");
|
||||
std::fs::write(&script, "[IO.File]::WriteAllText($args[1], $args[0])").unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn a_value_reaches_the_program_as_one_literal_argument() {
|
||||
let dir = scratch();
|
||||
let template = format!("powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" {{prompt}} {{out}}", writer(&dir).display());
|
||||
let got = received(&dir, &template, NASTY);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(got, "a & b | c < d > e ^ f %PATH% \u{FF01}PATH\u{FF01} 'q' \\' g\\ h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn a_batch_file_shim_cannot_be_made_to_run_a_command() {
|
||||
let dir = scratch();
|
||||
// What npm's shims do: hand the arguments on with `%*`, which cmd parses again.
|
||||
let shim = dir.join("tool.cmd");
|
||||
std::fs::write(&shim, format!("@powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" %*\r\n", writer(&dir).display())).unwrap();
|
||||
// Escaping the quote as `\"` (enough for a program's own argv parser) lets this one run.
|
||||
let attack = "x\" & echo pwned> injected.txt & \"y !PATH!";
|
||||
let got = received(&dir, &format!("\"{}\" \"{{prompt}}\" {{out}}", shim.display()), attack);
|
||||
let injected = dir.join("injected.txt").exists();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(!injected, "the prompt ran a command");
|
||||
assert_eq!(got, "x' & echo pwned> injected.txt & 'y \u{FF01}PATH\u{FF01}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The Angular writer: a standalone-component project built with `ng build`. One component per
|
||||
//! page frame; with several locales the routes are `:lang`, each locale's messages are a JSON
|
||||
//! catalog and `/` lists the languages. Each page's head comes from `site.json` (computed here
|
||||
//! by the core's `head_tags`) and is applied by the `Seo` service, so prerendering writes it into
|
||||
//! the HTML. Every TypeScript string literal and the JSON files come from `serde_json`; template
|
||||
//! text goes through the core's Angular escaping.
|
||||
|
||||
mod pages;
|
||||
mod project;
|
||||
mod runtime;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use super::model::{Output, Render, Site, Styling};
|
||||
use crate::mcp::site::style::{class_slug, unique};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// File names that are not pages, so a page cannot take them.
|
||||
const RESERVED_FILES: [&str; 1] = ["lang-index"];
|
||||
|
||||
/// Where a page component lives under `src/app/pages/`.
|
||||
pub(super) struct PageFile {
|
||||
pub stem: String,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
pub fn write(site: &Site) -> Result<Output, String> {
|
||||
let multi = site.locales.iter().any(|l| l.folder.is_some());
|
||||
let files = page_files(site);
|
||||
let mut out = Output::default();
|
||||
for (path, bytes) in &site.assets {
|
||||
out.files.insert(format!("public/{path}"), bytes.clone());
|
||||
}
|
||||
project::write(site, &mut out)?;
|
||||
runtime::write(site, multi, &mut out)?;
|
||||
pages::write(site, multi, &files, &mut out)?;
|
||||
if let Some(base) = &site.base_url {
|
||||
let abs =
|
||||
|loc: usize, page: usize| format!("{base}{}", runtime::route_url(site, page, loc));
|
||||
out.files.insert(
|
||||
"public/sitemap.xml".into(),
|
||||
site.sitemap(&abs, multi.then(|| format!("{base}/")))
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
out.files
|
||||
.insert("public/robots.txt".into(), site.robots().into_bytes());
|
||||
out.next_steps.push(next_step(site));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn next_step(site: &Site) -> String {
|
||||
let deploy = match site.options.render {
|
||||
Render::Static => {
|
||||
"Every page is prerendered to <route>/index.html, so any static host serves it."
|
||||
}
|
||||
Render::Spa => {
|
||||
"It is a single-page app: configure the host to answer unknown paths with index.html."
|
||||
}
|
||||
};
|
||||
let styles = match site.options.styling {
|
||||
Styling::Scss => " The styles are src/styles/*.scss.",
|
||||
Styling::Tailwind => " Tailwind v4 reads the templates; the theme is in src/styles.css.",
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
"Run `npm install` in the output folder, then `npm start` to preview or `npm run build` to write dist/site/browser. {deploy}{styles}"
|
||||
)
|
||||
}
|
||||
|
||||
/// File stems and selectors for the pages: slugs of the stems, unique and never a reserved name.
|
||||
fn page_files(site: &Site) -> Vec<PageFile> {
|
||||
let mut taken: BTreeSet<String> = RESERVED_FILES.iter().map(|s| (*s).to_owned()).collect();
|
||||
site.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let stem = unique(&mut taken, class_slug(&p.stem));
|
||||
PageFile {
|
||||
selector: format!("app-{stem}"),
|
||||
stem,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! The page components, the language index and the routes. A page is a standalone component
|
||||
//! whose template is the core's element tree printed in the Angular dialect.
|
||||
|
||||
use super::PageFile;
|
||||
use crate::mcp::site::markup::{
|
||||
self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text,
|
||||
};
|
||||
use crate::mcp::site::model::{Output, Site, Styling};
|
||||
use crate::mcp::site::tree::{Element, LinkTarget};
|
||||
|
||||
/// The language index is only a list of links; it styles itself.
|
||||
const INDEX_STYLE: &str = "main { margin: 2rem; font-family: system-ui, sans-serif; }";
|
||||
|
||||
struct NgHooks<'a> {
|
||||
site: &'a Site,
|
||||
multi: bool,
|
||||
}
|
||||
|
||||
/// A TypeScript or JSON string literal.
|
||||
fn lit(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
impl Hooks for NgHooks<'_> {
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
let class = if self.site.options.styling == Styling::Tailwind {
|
||||
el.tailwind_classes()
|
||||
} else {
|
||||
el.class.clone()
|
||||
};
|
||||
(!class.is_empty()).then_some(Val::Lit(class))
|
||||
}
|
||||
|
||||
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
|
||||
let attrs = match target {
|
||||
LinkTarget::Page(i) => vec![(
|
||||
"routerLink".to_owned(),
|
||||
Val::Expr(route_expr(self.site, self.multi, *i)),
|
||||
)],
|
||||
LinkTarget::Url(u) => vec![("href".to_owned(), Val::Lit(u.clone()))],
|
||||
};
|
||||
Link {
|
||||
tag: "a".into(),
|
||||
attrs,
|
||||
}
|
||||
}
|
||||
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(format!("/{path}"))
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
if self.multi {
|
||||
// Bracket access: keys hold dots and may start with a digit.
|
||||
TextOut::Raw(format!("{{{{ t()[{}] }}}}", lit(key)))
|
||||
} else {
|
||||
TextOut::Lit(
|
||||
self.site
|
||||
.messages
|
||||
.first()
|
||||
.and_then(|m| m.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `routerLink` array for a page: `['/', lang(), "pricing"]`, or without the language.
|
||||
fn route_expr(site: &Site, multi: bool, page: usize) -> String {
|
||||
let mut parts = vec!["'/'".to_owned()];
|
||||
if multi {
|
||||
parts.push("lang()".into());
|
||||
}
|
||||
if page != 0 {
|
||||
parts.push(lit(&site.pages[page].stem));
|
||||
}
|
||||
format!("[{}]", parts.join(", "))
|
||||
}
|
||||
|
||||
pub fn write(site: &Site, multi: bool, files: &[PageFile], out: &mut Output) -> Result<(), String> {
|
||||
let hooks = NgHooks { site, multi };
|
||||
for (i, (page, file)) in site.pages.iter().zip(files).enumerate() {
|
||||
let mut html = String::new();
|
||||
markup::render(Dialect::Angular, &hooks, &page.root, 0, &mut html);
|
||||
let css = if site.options.styling == Styling::Css {
|
||||
site.style.page_css(i, "/")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let has_css = !css.is_empty();
|
||||
if has_css {
|
||||
out.files
|
||||
.insert(format!("src/app/pages/{}.css", file.stem), css.into_bytes());
|
||||
}
|
||||
let ts = component(
|
||||
&page.component,
|
||||
file,
|
||||
i,
|
||||
multi,
|
||||
html.contains("[routerLink]"),
|
||||
has_css,
|
||||
);
|
||||
out.files.insert(
|
||||
format!("src/app/pages/{}.html", file.stem),
|
||||
html.into_bytes(),
|
||||
);
|
||||
out.files
|
||||
.insert(format!("src/app/pages/{}.ts", file.stem), ts.into_bytes());
|
||||
}
|
||||
routes(site, multi, files, out);
|
||||
if multi {
|
||||
language_index(site, out);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn component(
|
||||
name: &str,
|
||||
file: &PageFile,
|
||||
page: usize,
|
||||
multi: bool,
|
||||
router_link: bool,
|
||||
has_css: bool,
|
||||
) -> String {
|
||||
let stem = &file.stem;
|
||||
let style = if has_css {
|
||||
format!(" styleUrl: {},\n", lit(&format!("./{stem}.css")))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let (core, hook, body) = if multi {
|
||||
(
|
||||
"Component, OnChanges, ViewEncapsulation, computed, inject, input",
|
||||
"OnChanges",
|
||||
format!(
|
||||
" /** Bound from the :lang route parameter. */\n readonly lang = input.required<string>();\n protected readonly t = computed(() => messages(this.lang()));\n private readonly seo = inject(Seo);\n\n ngOnChanges(): void {{\n this.seo.set({page}, this.lang());\n }}\n"
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Component, OnInit, ViewEncapsulation, inject",
|
||||
"OnInit",
|
||||
format!(
|
||||
" private readonly seo = inject(Seo);\n\n ngOnInit(): void {{\n this.seo.set({page});\n }}\n"
|
||||
),
|
||||
)
|
||||
};
|
||||
let router = if router_link {
|
||||
"import { RouterLink } from '@angular/router';\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let i18n = if multi {
|
||||
"import { messages } from '../i18n';\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let imports = if router_link {
|
||||
" imports: [RouterLink],\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"import {{ {core} }} from '@angular/core';\n{router}{i18n}import {{ Seo }} from '../seo';\n\n@Component({{\n selector: {},\n{imports} templateUrl: {},\n{style} encapsulation: ViewEncapsulation.None,\n}})\nexport class {name} implements {hook} {{\n{body}}}\n",
|
||||
lit(&file.selector),
|
||||
lit(&format!("./{stem}.html"))
|
||||
)
|
||||
}
|
||||
|
||||
fn routes(site: &Site, multi: bool, files: &[PageFile], out: &mut Output) {
|
||||
let path = |i: usize| match (multi, i) {
|
||||
(true, 0) => ":lang".to_owned(),
|
||||
(true, _) => format!(":lang/{}", site.pages[i].stem),
|
||||
(false, 0) => String::new(),
|
||||
(false, _) => site.pages[i].stem.clone(),
|
||||
};
|
||||
let mut imports = String::new();
|
||||
let mut list = String::new();
|
||||
if multi {
|
||||
imports.push_str("import { LanguageIndex } from './pages/lang-index';\n");
|
||||
list.push_str(" { path: '', component: LanguageIndex },\n");
|
||||
}
|
||||
for (i, (page, file)) in site.pages.iter().zip(files).enumerate() {
|
||||
imports.push_str(&format!(
|
||||
"import {{ {} }} from {};\n",
|
||||
page.component,
|
||||
lit(&format!("./pages/{}", file.stem))
|
||||
));
|
||||
list.push_str(&format!(
|
||||
" {{ path: {}, component: {} }},\n",
|
||||
lit(&path(i)),
|
||||
page.component
|
||||
));
|
||||
}
|
||||
out.files.insert("src/app/app.routes.ts".into(), format!("import {{ Routes }} from '@angular/router';\n{imports}\nexport const routes: Routes = [\n{list}];\n").into_bytes());
|
||||
if site.options.render != crate::mcp::site::model::Render::Static {
|
||||
return;
|
||||
}
|
||||
let mut server = String::from("import { RenderMode, ServerRoute } from '@angular/ssr';\n");
|
||||
let mut entries = String::new();
|
||||
if multi {
|
||||
server.push_str("import { LANGS } from './i18n';\n\nconst langParams = async () => LANGS.map((lang) => ({ lang }));\n");
|
||||
entries.push_str(" { path: '', renderMode: RenderMode.Prerender },\n");
|
||||
}
|
||||
for i in 0..site.pages.len() {
|
||||
let params = if multi {
|
||||
", getPrerenderParams: langParams"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
entries.push_str(&format!(
|
||||
" {{ path: {}, renderMode: RenderMode.Prerender{params} }},\n",
|
||||
lit(&path(i))
|
||||
));
|
||||
}
|
||||
entries.push_str(" { path: '**', renderMode: RenderMode.Prerender },\n");
|
||||
out.files.insert(
|
||||
"src/app/app.routes.server.ts".into(),
|
||||
format!("{server}\nexport const serverRoutes: ServerRoute[] = [\n{entries}];\n")
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn language_index(site: &Site, out: &mut Output) {
|
||||
let langs = site.language_locales();
|
||||
let items: String = site
|
||||
.locales
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let expr = format!("['/', {}]", lit(l.folder.as_deref().unwrap_or_default()));
|
||||
let lang = l
|
||||
.tag
|
||||
.as_ref()
|
||||
.filter(|_| langs.contains(&i))
|
||||
.map(|t| {
|
||||
format!(
|
||||
" lang=\"{0}\" hreflang=\"{0}\"",
|
||||
escape_attr(Dialect::Angular, t)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" <li><a [routerLink]=\"{}\"{lang}>{}</a></li>\n",
|
||||
escape_attr(Dialect::Html, &expr),
|
||||
escape_text(Dialect::Angular, &l.label)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let html = format!(
|
||||
"<main>\n <h1>{}</h1>\n <ul>\n{items} </ul>\n</main>\n",
|
||||
escape_text(Dialect::Angular, &site.site_title)
|
||||
);
|
||||
let ts = format!(
|
||||
"import {{ Component, OnInit, inject }} from '@angular/core';\nimport {{ RouterLink }} from '@angular/router';\nimport {{ Seo }} from '../seo';\n\n@Component({{\n selector: 'app-lang-index',\n imports: [RouterLink],\n templateUrl: './lang-index.html',\n styles: [{}],\n}})\nexport class LanguageIndex implements OnInit {{\n private readonly seo = inject(Seo);\n\n ngOnInit(): void {{\n this.seo.setIndex();\n }}\n}}\n",
|
||||
lit(INDEX_STYLE)
|
||||
);
|
||||
out.files
|
||||
.insert("src/app/pages/lang-index.html".into(), html.into_bytes());
|
||||
out.files
|
||||
.insert("src/app/pages/lang-index.ts".into(), ts.into_bytes());
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! The project around the pages: package.json, angular.json, tsconfig, the entry points, the
|
||||
//! global stylesheet for the chosen styling and index.html. Everything here is fixed text or
|
||||
//! built with `serde_json`; the only site data is the title, the first locale and the font.
|
||||
|
||||
use crate::mcp::site::markup::{Dialect, escape_attr, escape_text};
|
||||
use crate::mcp::site::model::{Output, Render, Site, Styling};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The name of the Angular project: fixed, so it is always a valid identifier and the build
|
||||
/// folder is `dist/site/browser`.
|
||||
const PROJECT: &str = "site";
|
||||
|
||||
const TSCONFIG: &str = r#"{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
"#;
|
||||
|
||||
const TSCONFIG_APP: &str =
|
||||
"{\n \"extends\": \"./tsconfig.json\",\n \"include\": [\"src/**/*.ts\"]\n}\n";
|
||||
|
||||
const MAIN: &str = "import { bootstrapApplication } from '@angular/platform-browser';\nimport { appConfig } from './app/app.config';\nimport { App } from './app/app';\n\nbootstrapApplication(App, appConfig).catch((err) => console.error(err));\n";
|
||||
|
||||
const MAIN_SERVER: &str = "import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';\nimport { App } from './app/app';\nimport { config } from './app/app.config.server';\n\nconst bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);\n\nexport default bootstrap;\n";
|
||||
|
||||
const APP: &str = "import { Component } from '@angular/core';\nimport { RouterOutlet } from '@angular/router';\n\n@Component({\n selector: 'app-root',\n imports: [RouterOutlet],\n template: '<router-outlet />',\n})\nexport class App {}\n";
|
||||
|
||||
const CONFIG_SERVER: &str = "import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';\nimport { provideServerRendering, withRoutes } from '@angular/ssr';\nimport { appConfig } from './app.config';\nimport { serverRoutes } from './app.routes.server';\n\nconst serverConfig: ApplicationConfig = {\n providers: [provideServerRendering(withRoutes(serverRoutes))],\n};\n\nexport const config = mergeApplicationConfig(appConfig, serverConfig);\n";
|
||||
|
||||
const POSTCSS: &str = "{\n \"plugins\": {\n \"@tailwindcss/postcss\": {}\n }\n}\n";
|
||||
|
||||
pub fn write(site: &Site, out: &mut Output) -> Result<(), String> {
|
||||
let ssr = site.options.render == Render::Static;
|
||||
let json = |v: &Value| {
|
||||
serde_json::to_vec_pretty(v)
|
||||
.map(|mut b| {
|
||||
b.push(b'\n');
|
||||
b
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
out.files
|
||||
.insert("package.json".into(), json(&package_json(site))?);
|
||||
out.files
|
||||
.insert("angular.json".into(), json(&angular_json(site))?);
|
||||
out.files.insert("tsconfig.json".into(), TSCONFIG.into());
|
||||
out.files
|
||||
.insert("tsconfig.app.json".into(), TSCONFIG_APP.into());
|
||||
out.files.insert(
|
||||
".gitignore".into(),
|
||||
".angular/\ndist/\nnode_modules/\n".into(),
|
||||
);
|
||||
out.files.insert("src/main.ts".into(), MAIN.into());
|
||||
out.files.insert("src/app/app.ts".into(), APP.into());
|
||||
out.files
|
||||
.insert("src/app/app.config.ts".into(), app_config(ssr).into());
|
||||
out.files
|
||||
.insert("src/index.html".into(), index_html(site).into());
|
||||
if ssr {
|
||||
out.files
|
||||
.insert("src/main.server.ts".into(), MAIN_SERVER.into());
|
||||
out.files
|
||||
.insert("src/app/app.config.server.ts".into(), CONFIG_SERVER.into());
|
||||
}
|
||||
match site.options.styling {
|
||||
Styling::Scss => {
|
||||
let stems: Vec<(usize, String)> = site
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, p.stem.clone()))
|
||||
.collect();
|
||||
for (path, content) in site.style.scss("/", &stems) {
|
||||
out.files
|
||||
.insert(format!("src/styles/{path}"), content.into());
|
||||
}
|
||||
}
|
||||
Styling::Tailwind => {
|
||||
let css = format!(
|
||||
"@import \"tailwindcss\";\n{}{}{}{}",
|
||||
site.style.tailwind_theme(),
|
||||
site.style.tokens_css(),
|
||||
site.style.fonts_css("/"),
|
||||
site.style.overrides_css("/", |_| true)
|
||||
);
|
||||
out.files.insert("src/styles.css".into(), css.into());
|
||||
out.files.insert(".postcssrc.json".into(), POSTCSS.into());
|
||||
}
|
||||
_ => {
|
||||
out.files
|
||||
.insert("src/styles.css".into(), site.style.global_css("/").into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn package_json(site: &Site) -> Value {
|
||||
let mut deps = json!({
|
||||
"@angular/common": "^22.2.0",
|
||||
"@angular/compiler": "^22.2.0",
|
||||
"@angular/core": "^22.2.0",
|
||||
"@angular/platform-browser": "^22.2.0",
|
||||
"@angular/router": "^22.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
});
|
||||
if site.options.render == Render::Static {
|
||||
deps["@angular/platform-server"] = json!("^22.2.0");
|
||||
deps["@angular/ssr"] = json!("^22.2.6");
|
||||
}
|
||||
let mut dev = json!({
|
||||
"@angular/build": "^22.2.6",
|
||||
"@angular/cli": "^22.2.6",
|
||||
"@angular/compiler-cli": "^22.2.0",
|
||||
"typescript": "~6.0.2"
|
||||
});
|
||||
if site.options.styling == Styling::Tailwind {
|
||||
dev["@tailwindcss/postcss"] = json!("^4.3.4");
|
||||
dev["postcss"] = json!("^8.5.32");
|
||||
dev["tailwindcss"] = json!("^4.3.4");
|
||||
}
|
||||
json!({
|
||||
"name": PROJECT,
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {"ng": "ng", "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development"},
|
||||
"dependencies": deps,
|
||||
"devDependencies": dev
|
||||
})
|
||||
}
|
||||
|
||||
fn angular_json(site: &Site) -> Value {
|
||||
let style = if site.options.styling == Styling::Scss {
|
||||
"src/styles/main.scss"
|
||||
} else {
|
||||
"src/styles.css"
|
||||
};
|
||||
let mut options = json!({
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [{"glob": "**/*", "input": "public"}],
|
||||
"styles": [style]
|
||||
});
|
||||
if site.options.render == Render::Static {
|
||||
options["server"] = json!("src/main.server.ts");
|
||||
options["outputMode"] = json!("static");
|
||||
options["security"] = json!({"allowedHosts": []});
|
||||
}
|
||||
json!({
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {"packageManager": "npm"},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {PROJECT: {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": options,
|
||||
"configurations": {
|
||||
"production": {"outputHashing": "all"},
|
||||
"development": {"optimization": false, "extractLicenses": false, "sourceMap": true}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {"buildTarget": format!("{PROJECT}:build:production")},
|
||||
"development": {"buildTarget": format!("{PROJECT}:build:development")}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
fn app_config(ssr: bool) -> String {
|
||||
let (hydration_import, hydration) = if ssr {
|
||||
(
|
||||
"import { provideClientHydration } from '@angular/platform-browser';\n",
|
||||
" provideClientHydration(),\n",
|
||||
)
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
"import {{ ApplicationConfig, provideBrowserGlobalErrorListeners }} from '@angular/core';\nimport {{ provideRouter, withComponentInputBinding, UrlSerializer }} from '@angular/router';\n{hydration_import}import {{ routes }} from './app.routes';\nimport {{ TrailingSlashSerializer }} from './trailing-slash';\n\nexport const appConfig: ApplicationConfig = {{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes, withComponentInputBinding()),\n {{ provide: UrlSerializer, useClass: TrailingSlashSerializer }},\n{hydration} ],\n}};\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn index_html(site: &Site) -> String {
|
||||
let lang = site
|
||||
.locales
|
||||
.first()
|
||||
.and_then(|l| l.tag.as_deref())
|
||||
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Html, t)))
|
||||
.unwrap_or_default();
|
||||
let tags: String = site
|
||||
.asset_tags(0, "/")
|
||||
.iter()
|
||||
.map(|t| format!(" {}\n", t.to_html()))
|
||||
.collect();
|
||||
format!(
|
||||
"<!doctype html>\n<html{lang}>\n<head>\n <meta charset=\"utf-8\">\n <title>{}</title>\n <base href=\"/\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n{}</head>\n<body>\n <app-root></app-root>\n</body>\n</html>\n",
|
||||
escape_text(Dialect::Html, &site.site_title),
|
||||
tags
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! What the pages share at run time: the `Seo` service and its data (`site.json`, the head of
|
||||
//! every page and locale as the core computed it), the message catalogs and the serializer that
|
||||
//! keeps hrefs ending in `/`.
|
||||
|
||||
use crate::mcp::site::model::{HeadTag, Output, Site};
|
||||
use crate::mcp::site::style::{class_slug, unique};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const SEO_TS: &str = r#"import { DOCUMENT, Injectable, inject } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import data from './site.json';
|
||||
|
||||
interface Tag { tag: string; attrs: [string, string][] }
|
||||
interface Head { title: string; lang?: string; tags: Tag[] }
|
||||
interface SiteData {
|
||||
locales: { folder: string | null; lang: string | null; class: string | null }[];
|
||||
pages: Head[][];
|
||||
index: Head | null;
|
||||
}
|
||||
|
||||
const site = data as unknown as SiteData;
|
||||
|
||||
/** Writes the head of a page: title, description, canonical, hreflang, Open Graph and Twitter tags. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Seo {
|
||||
private readonly title = inject(Title);
|
||||
private readonly doc = inject(DOCUMENT);
|
||||
|
||||
/** The head of one page in the locale with this route folder (the first locale when unknown). */
|
||||
set(page: number, folder: string | null = null): void {
|
||||
const i = Math.max(0, site.locales.findIndex((l) => l.folder === folder));
|
||||
const locale = site.locales[i];
|
||||
this.apply(site.pages[page][i], locale.lang, locale.class);
|
||||
}
|
||||
|
||||
/** The language index at "/". */
|
||||
setIndex(): void {
|
||||
if (site.index) this.apply(site.index, site.index.lang ?? null, null);
|
||||
}
|
||||
|
||||
private apply(head: Head, lang: string | null, cls: string | null): void {
|
||||
const root = this.doc.documentElement;
|
||||
if (lang) root.setAttribute('lang', lang); else root.removeAttribute('lang');
|
||||
if (cls) root.setAttribute('class', cls); else root.removeAttribute('class');
|
||||
this.title.setTitle(head.title);
|
||||
// Tags of the previous page go first, so client navigation never duplicates them.
|
||||
this.doc.head.querySelectorAll('[data-seo]').forEach((n) => n.remove());
|
||||
for (const t of head.tags) {
|
||||
const el = this.doc.createElement(t.tag);
|
||||
for (const [k, v] of t.attrs) el.setAttribute(k, v);
|
||||
el.setAttribute('data-seo', '');
|
||||
this.doc.head.appendChild(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const TRAILING_SLASH_TS: &str = r#"import { DefaultUrlSerializer, UrlTree } from '@angular/router';
|
||||
|
||||
/** Makes routerLink hrefs and router URLs end with "/" (directory style). */
|
||||
export class TrailingSlashSerializer extends DefaultUrlSerializer {
|
||||
override serialize(tree: UrlTree): string {
|
||||
const url = super.serialize(tree);
|
||||
const i = url.search(/[?#]/);
|
||||
const path = i < 0 ? url : url.slice(0, i);
|
||||
return (path.endsWith('/') ? path : path + '/') + (i < 0 ? '' : url.slice(i));
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub fn write(site: &Site, multi: bool, out: &mut Output) -> Result<(), String> {
|
||||
out.files.insert("src/app/seo.ts".into(), SEO_TS.into());
|
||||
out.files
|
||||
.insert("src/app/trailing-slash.ts".into(), TRAILING_SLASH_TS.into());
|
||||
let data = site_json(site, multi);
|
||||
out.files.insert("src/app/site.json".into(), pretty(&data)?);
|
||||
if multi {
|
||||
catalogs(site, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pretty(v: &Value) -> Result<Vec<u8>, String> {
|
||||
serde_json::to_vec_pretty(v)
|
||||
.map(|mut b| {
|
||||
b.push(b'\n');
|
||||
b
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The address of a page under the site: its route with a trailing slash.
|
||||
pub fn route_url(site: &Site, page: usize, loc: usize) -> String {
|
||||
let route = site.url_path(page, loc);
|
||||
if route.ends_with('/') {
|
||||
route
|
||||
} else {
|
||||
format!("{route}/")
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute(site: &Site, route: &str) -> String {
|
||||
format!("{}{route}", site.base_url.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn tag_json(t: &HeadTag) -> Value {
|
||||
json!({"tag": t.tag, "attrs": t.attrs.iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn site_json(site: &Site, multi: bool) -> Value {
|
||||
let url = |_from: usize, to: usize, page: usize| absolute(site, &route_url(site, page, to));
|
||||
let locales: Vec<Value> = site
|
||||
.locales
|
||||
.iter()
|
||||
.map(|l| json!({"folder": l.folder, "lang": l.tag, "class": l.class}))
|
||||
.collect();
|
||||
let pages: Vec<Value> = (0..site.pages.len())
|
||||
.map(|p| {
|
||||
let per_locale: Vec<Value> = (0..site.locales.len())
|
||||
.map(|l| json!({"title": site.pages[p].title[l], "tags": site.head_tags(p, l, &url).iter().map(tag_json).collect::<Vec<_>>()}))
|
||||
.collect();
|
||||
Value::Array(per_locale)
|
||||
})
|
||||
.collect();
|
||||
json!({"locales": locales, "pages": pages, "index": multi.then(|| index_head(site))})
|
||||
}
|
||||
|
||||
/// The head of the language index: the alternates point at the first page of each language.
|
||||
fn index_head(site: &Site) -> Value {
|
||||
let langs = site.language_locales();
|
||||
let at = |loc: usize| absolute(site, &route_url(site, 0, loc));
|
||||
let mut tags = vec![
|
||||
HeadTag {
|
||||
tag: "meta",
|
||||
attrs: vec![
|
||||
("name", "description".into()),
|
||||
("content", "Choose a version of this site.".into()),
|
||||
],
|
||||
},
|
||||
HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![("rel", "canonical".into()), ("href", absolute(site, "/"))],
|
||||
},
|
||||
];
|
||||
for &l in &langs {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", site.locales[l].tag.clone().unwrap_or_default()),
|
||||
("href", at(l)),
|
||||
],
|
||||
});
|
||||
}
|
||||
if let Some(&first) = langs.first() {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", "x-default".into()),
|
||||
("href", at(first)),
|
||||
],
|
||||
});
|
||||
}
|
||||
json!({"title": site.site_title, "lang": site.index_lang(), "tags": tags.iter().map(tag_json).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
/// `src/i18n/<locale>.json` per locale and `src/app/i18n.ts`, which maps route folders to them.
|
||||
fn catalogs(site: &Site, out: &mut Output) -> Result<(), String> {
|
||||
let mut taken = BTreeSet::new();
|
||||
let mut imports = String::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut folders = Vec::new();
|
||||
for (i, l) in site.locales.iter().enumerate() {
|
||||
let folder = l.folder.clone().unwrap_or_default();
|
||||
let file = unique(&mut taken, class_slug(&folder));
|
||||
out.files.insert(
|
||||
format!("src/i18n/{file}.json"),
|
||||
pretty(&json!(site.messages[i]))?,
|
||||
);
|
||||
imports.push_str(&format!("import c{i} from '../i18n/{file}.json';\n"));
|
||||
let key = serde_json::to_string(&folder).map_err(|e| e.to_string())?;
|
||||
entries.push(format!("[{key}, c{i}]"));
|
||||
folders.push(key);
|
||||
}
|
||||
let ts = format!(
|
||||
"{imports}\nexport type Messages = Record<string, string>;\n\n/** The route folder of every language and mode. */\nexport const LANGS: string[] = [{}];\n\nconst catalogs = new Map<string, Messages>([{}]);\n\n/** Unknown codes fall back to the first catalog. */\nexport const messages = (lang: string): Messages => catalogs.get(lang) ?? c0;\n",
|
||||
folders.join(", "),
|
||||
entries.join(", ")
|
||||
);
|
||||
out.files.insert("src/app/i18n.ts".into(), ts.into());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
use super::write;
|
||||
use crate::mcp::site::model::{Framework, Locale, Options, Output, Page, Render, Site, Styling};
|
||||
use crate::mcp::site::style::{Class, FontFace, StyleModel, Token};
|
||||
use crate::mcp::site::tree::{Child, Element, LinkTarget, Text};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const HOSTILE: &str = "</script>{{x}}@if (a) {b}${x}`<img onerror=alert(1)>";
|
||||
|
||||
fn el(tag: &str, class: &str, attrs: &[(&str, &str)], children: Vec<Child>) -> Element {
|
||||
Element {
|
||||
tag: tag.into(),
|
||||
class: class.into(),
|
||||
attrs: attrs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).into(), (*v).into()))
|
||||
.collect(),
|
||||
link: None,
|
||||
tw: if class.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("tw-{class} flex")
|
||||
},
|
||||
has_overrides: false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(s: &str) -> Child {
|
||||
Child::Text(Text::Lit(s.into()))
|
||||
}
|
||||
|
||||
fn msg(k: &str) -> Child {
|
||||
Child::Text(Text::Msg(k.into()))
|
||||
}
|
||||
|
||||
fn link(target: LinkTarget, children: Vec<Child>) -> Child {
|
||||
let mut a = el("a", "button", &[], children);
|
||||
a.link = Some(target);
|
||||
Child::El(a)
|
||||
}
|
||||
|
||||
fn locale(tag: &str, first: bool) -> Locale {
|
||||
Locale {
|
||||
tag: Some(tag.into()),
|
||||
hreflang: true,
|
||||
folder: Some(tag.into()),
|
||||
label: format!("{tag} {HOSTILE}"),
|
||||
scope: if first {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":root:lang({tag})")
|
||||
},
|
||||
class: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn class(
|
||||
name: &str,
|
||||
pages: &[usize],
|
||||
root_of: Option<usize>,
|
||||
overrides: Vec<(usize, Vec<(String, String)>)>,
|
||||
) -> Class {
|
||||
Class {
|
||||
name: name.into(),
|
||||
decls: vec![
|
||||
("display".into(), "flex".into()),
|
||||
("background".into(), "var(--brand)".into()),
|
||||
],
|
||||
overrides,
|
||||
pages: pages.iter().copied().collect::<BTreeSet<_>>(),
|
||||
root_of,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pages (home, pricing); `multi` adds a second locale with its own messages.
|
||||
fn sample(render: Render, styling: Styling, multi: bool) -> Site {
|
||||
let locales = if multi {
|
||||
vec![locale("en", true), locale("pt-BR", false)]
|
||||
} else {
|
||||
vec![Locale {
|
||||
tag: Some("en".into()),
|
||||
hreflang: false,
|
||||
folder: None,
|
||||
label: String::new(),
|
||||
scope: String::new(),
|
||||
class: None,
|
||||
}]
|
||||
};
|
||||
let n = locales.len();
|
||||
let title = |a: &str| (0..n).map(|i| format!("{a} {i}")).collect::<Vec<_>>();
|
||||
let home = el(
|
||||
"div",
|
||||
"home",
|
||||
&[],
|
||||
vec![
|
||||
Child::El(el(
|
||||
"h1",
|
||||
"",
|
||||
&[],
|
||||
vec![if multi {
|
||||
msg("home.hero_title")
|
||||
} else {
|
||||
text("Hello")
|
||||
}],
|
||||
)),
|
||||
Child::El(el("p", "", &[], vec![text(HOSTILE)])),
|
||||
Child::El(el(
|
||||
"img",
|
||||
"",
|
||||
&[("src", "assets/hero.png"), ("alt", HOSTILE)],
|
||||
vec![],
|
||||
)),
|
||||
link(
|
||||
LinkTarget::Page(1),
|
||||
vec![if multi { msg("2cta") } else { text("Pricing") }],
|
||||
),
|
||||
link(
|
||||
LinkTarget::Url("https://x.test/?a=\"><b>".into()),
|
||||
vec![text("Out")],
|
||||
),
|
||||
],
|
||||
);
|
||||
let pricing = el(
|
||||
"main",
|
||||
"pricing",
|
||||
&[],
|
||||
vec![Child::El(el("h1", "", &[], vec![text("Pricing")]))],
|
||||
);
|
||||
let pages = vec![
|
||||
Page {
|
||||
name: "Home".into(),
|
||||
stem: "index".into(),
|
||||
component: "HomePage".into(),
|
||||
root: home,
|
||||
title: title("Home"),
|
||||
description: title("Desc"),
|
||||
},
|
||||
Page {
|
||||
name: "Pricing".into(),
|
||||
stem: "pricing".into(),
|
||||
component: "PricingPage".into(),
|
||||
root: pricing,
|
||||
title: title("Pricing"),
|
||||
description: title("Desc"),
|
||||
},
|
||||
];
|
||||
let messages = if multi {
|
||||
vec![
|
||||
BTreeMap::from([
|
||||
("home.hero_title".to_owned(), "Hello".to_owned()),
|
||||
("2cta".to_owned(), "Pricing".to_owned()),
|
||||
]),
|
||||
BTreeMap::from([
|
||||
("home.hero_title".to_owned(), HOSTILE.to_owned()),
|
||||
("2cta".to_owned(), "Precos".to_owned()),
|
||||
]),
|
||||
]
|
||||
} else {
|
||||
vec![BTreeMap::new()]
|
||||
};
|
||||
let style = StyleModel {
|
||||
tokens: vec![Token {
|
||||
name: "brand".into(),
|
||||
value: "#4f46e5".into(),
|
||||
color: true,
|
||||
overrides: vec![],
|
||||
}],
|
||||
fallbacks: Vec::new(),
|
||||
fonts: vec![FontFace {
|
||||
family: "Varela Round".into(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
path: "assets/fonts/f.ttf".into(),
|
||||
format: "truetype",
|
||||
}],
|
||||
preload: Some(0),
|
||||
favicon: None,
|
||||
h1_faces: Vec::new(),
|
||||
classes: vec![
|
||||
class(
|
||||
"home",
|
||||
&[0],
|
||||
Some(0),
|
||||
if multi {
|
||||
vec![(1, vec![("font-size".into(), "40px".into())])]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
),
|
||||
class("pricing", &[1], Some(1), vec![]),
|
||||
class("button", &[0, 1], None, vec![]),
|
||||
],
|
||||
scopes: locales.iter().map(|l| l.scope.clone()).collect(),
|
||||
};
|
||||
let assets = BTreeMap::from([
|
||||
("assets/hero.png".to_owned(), vec![1, 2, 3]),
|
||||
("assets/fonts/f.ttf".to_owned(), vec![4, 5]),
|
||||
]);
|
||||
Site {
|
||||
options: Options {
|
||||
framework: Framework::Angular,
|
||||
render,
|
||||
styling,
|
||||
},
|
||||
site_title: format!("Acme {HOSTILE}"),
|
||||
base_url: Some("https://x.test".into()),
|
||||
locales,
|
||||
pages,
|
||||
messages,
|
||||
assets,
|
||||
style,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn run(render: Render, styling: Styling, multi: bool) -> Output {
|
||||
write(&sample(render, styling, multi)).unwrap()
|
||||
}
|
||||
|
||||
fn get<'a>(o: &'a Output, path: &str) -> &'a str {
|
||||
std::str::from_utf8(
|
||||
o.files.get(path).unwrap_or_else(|| {
|
||||
panic!("missing {path} in {:?}", o.files.keys().collect::<Vec<_>>())
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const VARIANTS: [(Render, Styling); 6] = [
|
||||
(Render::Static, Styling::Css),
|
||||
(Render::Static, Styling::Scss),
|
||||
(Render::Static, Styling::Tailwind),
|
||||
(Render::Spa, Styling::Css),
|
||||
(Render::Spa, Styling::Scss),
|
||||
(Render::Spa, Styling::Tailwind),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_variant_writes_the_project_files() {
|
||||
for (r, s) in VARIANTS {
|
||||
let o = run(r, s, true);
|
||||
for f in [
|
||||
"package.json",
|
||||
"angular.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"src/index.html",
|
||||
"src/main.ts",
|
||||
"src/app/app.ts",
|
||||
"src/app/app.config.ts",
|
||||
"src/app/app.routes.ts",
|
||||
"src/app/seo.ts",
|
||||
"src/app/site.json",
|
||||
"src/app/i18n.ts",
|
||||
"src/i18n/en.json",
|
||||
"src/i18n/pt-br.json",
|
||||
"src/app/pages/index.ts",
|
||||
"src/app/pages/index.html",
|
||||
"src/app/pages/pricing.ts",
|
||||
"src/app/pages/lang-index.ts",
|
||||
"public/robots.txt",
|
||||
"public/sitemap.xml",
|
||||
"public/assets/hero.png",
|
||||
"public/assets/fonts/f.ttf",
|
||||
] {
|
||||
assert!(o.files.contains_key(f), "{r:?} {s:?}: {f}");
|
||||
}
|
||||
let ssr = r == Render::Static;
|
||||
for f in [
|
||||
"src/main.server.ts",
|
||||
"src/app/app.config.server.ts",
|
||||
"src/app/app.routes.server.ts",
|
||||
] {
|
||||
assert_eq!(o.files.contains_key(f), ssr, "{r:?} {f}");
|
||||
}
|
||||
assert_eq!(
|
||||
o.files.contains_key(".postcssrc.json"),
|
||||
s == Styling::Tailwind
|
||||
);
|
||||
assert_eq!(
|
||||
o.files.contains_key("src/styles/main.scss"),
|
||||
s == Styling::Scss
|
||||
);
|
||||
assert_eq!(o.files.contains_key("src/styles.css"), s != Styling::Scss);
|
||||
assert_eq!(
|
||||
o.files.contains_key("src/app/pages/index.css"),
|
||||
s == Styling::Css,
|
||||
"{r:?} {s:?}"
|
||||
);
|
||||
assert!(get(&o, "angular.json").contains(if s == Styling::Scss {
|
||||
"src/styles/main.scss"
|
||||
} else {
|
||||
"src/styles.css"
|
||||
}));
|
||||
assert!(get(&o, "package.json").contains("tailwindcss") == (s == Styling::Tailwind));
|
||||
assert_eq!(
|
||||
get(&o, "angular.json").contains("\"outputMode\": \"static\""),
|
||||
ssr
|
||||
);
|
||||
assert_eq!(
|
||||
get(&o, "src/app/app.config.ts").contains("provideClientHydration()"),
|
||||
ssr
|
||||
);
|
||||
assert!(
|
||||
o.next_steps[0].contains("npm install")
|
||||
&& o.next_steps[0].contains("dist/site/browser")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_locales_route_by_language_and_read_messages_with_brackets() {
|
||||
let o = run(Render::Static, Styling::Css, true);
|
||||
let routes = get(&o, "src/app/app.routes.ts");
|
||||
assert!(
|
||||
routes.contains("path: \"\", component: LanguageIndex")
|
||||
|| routes.contains("path: '', component: LanguageIndex"),
|
||||
"{routes}"
|
||||
);
|
||||
assert!(
|
||||
routes.contains("path: \":lang\", component: HomePage")
|
||||
&& routes.contains("path: \":lang/pricing\", component: PricingPage"),
|
||||
"{routes}"
|
||||
);
|
||||
let server = get(&o, "src/app/app.routes.server.ts");
|
||||
assert!(
|
||||
server.contains("getPrerenderParams: langParams") && server.contains("path: '**'"),
|
||||
"{server}"
|
||||
);
|
||||
let home = get(&o, "src/app/pages/index.html");
|
||||
assert!(
|
||||
home.contains("{{ t()[\"home.hero_title\"] }}") && home.contains("{{ t()[\"2cta\"] }}"),
|
||||
"{home}"
|
||||
);
|
||||
assert!(
|
||||
home.contains("[routerLink]=\"['/', lang(), "pricing"]\"")
|
||||
&& home.contains("src=\"/assets/hero.png\""),
|
||||
"{home}"
|
||||
);
|
||||
assert!(get(&o, "src/app/pages/index.ts").contains("this.seo.set(0, this.lang())"));
|
||||
assert!(get(&o, "src/app/i18n.ts").contains("LANGS: string[] = [\"en\", \"pt-BR\"]"));
|
||||
let site = get(&o, "src/app/site.json");
|
||||
assert!(
|
||||
site.contains("https://x.test/pt-BR/pricing/")
|
||||
&& site.contains("x-default")
|
||||
&& site.contains("og:locale"),
|
||||
"{site}"
|
||||
);
|
||||
let sitemap = get(&o, "public/sitemap.xml");
|
||||
assert!(
|
||||
sitemap.contains("<loc>https://x.test/</loc>")
|
||||
&& sitemap.contains("<loc>https://x.test/en/pricing/</loc>"),
|
||||
"{sitemap}"
|
||||
);
|
||||
assert!(get(&o, "src/index.html").contains(
|
||||
"<link rel=\"preload\" href=\"/assets/fonts/f.ttf\" as=\"font\" type=\"font/ttf\" crossorigin>"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_locale_has_root_routes_and_literal_text() {
|
||||
let o = run(Render::Spa, Styling::Css, false);
|
||||
assert!(
|
||||
!o.files.contains_key("src/app/i18n.ts")
|
||||
&& !o.files.contains_key("src/app/pages/lang-index.ts")
|
||||
);
|
||||
let routes = get(&o, "src/app/app.routes.ts");
|
||||
assert!(
|
||||
routes.contains("path: \"\", component: HomePage")
|
||||
&& routes.contains("path: \"pricing\", component: PricingPage")
|
||||
&& !routes.contains(":lang"),
|
||||
"{routes}"
|
||||
);
|
||||
let home = get(&o, "src/app/pages/index.html");
|
||||
assert!(
|
||||
home.contains(">Hello</h1>")
|
||||
&& home.contains("[routerLink]=\"['/', "pricing"]\""),
|
||||
"{home}"
|
||||
);
|
||||
assert!(!get(&o, "src/app/pages/index.ts").contains("input."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styling_variants_carry_their_own_sources() {
|
||||
let tw = run(Render::Static, Styling::Tailwind, true);
|
||||
assert!(get(&tw, "src/styles.css").starts_with("@import \"tailwindcss\";\n@theme {"));
|
||||
assert!(get(&tw, "src/app/pages/index.html").contains("class=\"tw-home flex\""));
|
||||
assert_eq!(
|
||||
get(&tw, ".postcssrc.json")
|
||||
.matches("@tailwindcss/postcss")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let scss = run(Render::Spa, Styling::Scss, true);
|
||||
assert!(get(&scss, "src/styles/main.scss").contains("@use 'pages/index';"));
|
||||
let css = run(Render::Static, Styling::Css, true);
|
||||
assert!(
|
||||
get(&css, "src/styles.css").contains("url(/assets/fonts/f.ttf)")
|
||||
&& get(&css, "src/styles.css").contains(".button {")
|
||||
);
|
||||
assert!(
|
||||
get(&css, "src/app/pages/index.css").contains(".home {")
|
||||
&& get(&css, "src/app/pages/index.css").contains(":lang(pt-BR) .home")
|
||||
);
|
||||
assert!(get(&css, "src/app/pages/index.ts").contains("styleUrl: \"./index.css\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_stays_inert_in_templates_and_typescript() {
|
||||
for (r, s) in VARIANTS {
|
||||
for multi in [true, false] {
|
||||
let o = run(r, s, multi);
|
||||
for (path, bytes) in &o.files {
|
||||
if !path.starts_with("src/app/")
|
||||
|| !(path.ends_with(".html") || path.ends_with(".ts"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let t = std::str::from_utf8(bytes).unwrap().replace("{{ '{' }}", "");
|
||||
let bindings = t.matches("{{").count();
|
||||
assert_eq!(
|
||||
bindings,
|
||||
t.matches("{{ t()[").count(),
|
||||
"{path}: an interpolation that is not a message in {t}"
|
||||
);
|
||||
for bad in ["</script>", "@if", "${x}", "<img onerror", "alert(1)>"] {
|
||||
assert!(!t.contains(bad), "{path}: `{bad}` in {t}");
|
||||
}
|
||||
if path.starts_with("src/app/") && path.ends_with(".html") {
|
||||
assert!(!t.contains('`'), "{path}: backtick");
|
||||
}
|
||||
}
|
||||
let index = get(&o, "src/app/pages/index.html");
|
||||
assert!(
|
||||
index.contains("</script>{{ '{' }}{{ '{' }}x}}@if"),
|
||||
"{index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_texts_are_data_in_json() {
|
||||
let o = run(Render::Static, Styling::Css, true);
|
||||
let catalog: serde_json::Value = serde_json::from_str(get(&o, "src/i18n/pt-br.json")).unwrap();
|
||||
assert_eq!(catalog["home.hero_title"], HOSTILE);
|
||||
let data: serde_json::Value = serde_json::from_str(get(&o, "src/app/site.json")).unwrap();
|
||||
assert!(data["index"]["title"].as_str().unwrap().contains(HOSTILE));
|
||||
let lang = get(&o, "src/app/pages/lang-index.html");
|
||||
assert!(
|
||||
!lang.contains("{{x}}") && !lang.contains("</script>") && !lang.contains('`'),
|
||||
"{lang}"
|
||||
);
|
||||
assert!(
|
||||
lang.contains("[routerLink]=\"['/', "pt-BR"]\""),
|
||||
"{lang}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_deterministic() {
|
||||
for (r, s) in VARIANTS {
|
||||
let (a, b) = (run(r, s, true), run(r, s, true));
|
||||
assert_eq!(a.files, b.files, "{r:?} {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_and_token_text_only_reaches_style_files_never_a_typescript_literal() {
|
||||
let evil = "a${x}`</style>";
|
||||
for (r, s) in VARIANTS {
|
||||
let mut site = sample(r, s, true);
|
||||
site.style.fonts[0].family = evil.into();
|
||||
site.style.tokens[0].name = "t".into();
|
||||
site.style.tokens[0].value = format!("\"{evil}\"");
|
||||
let o = write(&site).unwrap();
|
||||
for (path, bytes) in &o.files {
|
||||
let t = std::str::from_utf8(bytes).unwrap_or_default();
|
||||
if path.starts_with("src/app/") && path.ends_with(".ts") {
|
||||
for bad in ["`", "${", "</style>"] {
|
||||
assert!(!t.contains(bad), "{path}: `{bad}` in a TypeScript file");
|
||||
}
|
||||
}
|
||||
if path.starts_with("src/app/") && path.ends_with(".html") {
|
||||
assert!(!t.contains("</style>") && !t.contains("${x}"), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The astro writer: a static Astro 7 project (`npm run build` makes `dist/` with no client
|
||||
//! JavaScript). One component per page frame, one Layout that owns the head, `public/` with the
|
||||
//! assets, `sitemap.xml` and `robots.txt`.
|
||||
//!
|
||||
//! With several locales the pages are `src/pages/[lang]/*.astro` with `getStaticPaths`, the
|
||||
//! messages are one JSON catalog per locale, and `/` lists the versions. A plain lookup is used
|
||||
//! instead of Astro's i18n routing: the routes are fixed, so its middleware adds nothing, and its
|
||||
//! helpers lower-case `pt-BR`. A single locale writes pages at the root with literal text.
|
||||
//! All user text reaches the files through `markup`'s Astro escaping, or as JSON string literals
|
||||
//! in `src/site.ts` (never inside the frontmatter of a page).
|
||||
|
||||
use super::markup::{self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text};
|
||||
use super::model::{HeadTag, Locale, Output, Site, Styling};
|
||||
use super::style::class_slug;
|
||||
use super::tree::{Element, LinkTarget};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const ASTRO_RANGE: &str = "^7.4.7";
|
||||
const SASS_RANGE: &str = "^1.107.1";
|
||||
const TAILWIND_RANGES: [(&str, &str); 2] =
|
||||
[("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")];
|
||||
const NODE_RANGE: &str = ">=22.23.4";
|
||||
/// Key of the only locale of a single-language site in `src/site.ts`.
|
||||
const SINGLE_KEY: &str = "site";
|
||||
const INDEX_CSS: &str = "body { font-family: system-ui, sans-serif; margin: 2rem; }";
|
||||
const TSCONFIG: &str = "{\n \"extends\": \"astro/tsconfigs/strict\",\n \"include\": [\".astro/types.d.ts\", \"**/*\"],\n \"exclude\": [\"dist\"]\n}\n";
|
||||
|
||||
pub fn write(site: &Site) -> Result<Output, String> {
|
||||
check_names(site)?;
|
||||
let multi = site.locales.iter().any(|l| l.folder.is_some());
|
||||
let mut out = Output::default();
|
||||
for (path, bytes) in &site.assets {
|
||||
out.files.insert(format!("public/{path}"), bytes.clone());
|
||||
}
|
||||
out.files
|
||||
.insert("public/robots.txt".into(), site.robots().into_bytes());
|
||||
if let Some(base) = &site.base_url {
|
||||
let abs = |loc: usize, page: usize| format!("{base}{}", route(site, page, loc));
|
||||
out.files.insert(
|
||||
"public/sitemap.xml".into(),
|
||||
site.sitemap(&abs, multi.then(|| format!("{base}/")))
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
out.files.insert("package.json".into(), package_json(site)?);
|
||||
out.files
|
||||
.insert("astro.config.mjs".into(), config(site)?.into_bytes());
|
||||
out.files.insert("tsconfig.json".into(), TSCONFIG.into());
|
||||
out.files
|
||||
.insert("src/site.ts".into(), site_ts(site)?.into_bytes());
|
||||
out.files
|
||||
.insert("src/layouts/Layout.astro".into(), layout(site).into_bytes());
|
||||
styles(site, &mut out);
|
||||
for (i, page) in site.pages.iter().enumerate() {
|
||||
out.files.insert(
|
||||
format!("src/components/{}.astro", page.component),
|
||||
component(site, i, multi).into_bytes(),
|
||||
);
|
||||
let path = match (multi, i) {
|
||||
(true, _) => format!("src/pages/[lang]/{}.astro", page.stem),
|
||||
(false, _) => format!("src/pages/{}.astro", page.stem),
|
||||
};
|
||||
out.files
|
||||
.insert(path, page_file(site, i, multi).into_bytes());
|
||||
}
|
||||
if multi {
|
||||
write_catalogs(site, &mut out)?;
|
||||
out.files.insert(
|
||||
"src/pages/index.astro".into(),
|
||||
language_index(site).into_bytes(),
|
||||
);
|
||||
}
|
||||
out.next_steps.push("Run `npm install && npm run build` in the output folder: the static site is written to dist/ (`npm run dev` serves it while you edit). Run the export again after changing the design.".into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Names that become file names or identifiers must be plain, and unique on a case-insensitive disk.
|
||||
fn check_names(site: &Site) -> Result<(), String> {
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
};
|
||||
let mut taken = BTreeSet::from(["layout".to_owned()]);
|
||||
for p in &site.pages {
|
||||
let ident = p
|
||||
.component
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_uppercase())
|
||||
&& p.component.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
if !ident || !plain(&p.stem) || !taken.insert(p.component.to_lowercase()) {
|
||||
return Err(format!(
|
||||
"page `{}` has no usable component name (`{}`) or file name (`{}`)",
|
||||
p.name, p.component, p.stem
|
||||
));
|
||||
}
|
||||
}
|
||||
for l in &site.locales {
|
||||
if l.folder.as_deref().is_some_and(|f| !plain(f)) {
|
||||
return Err(format!(
|
||||
"locale folder `{}` cannot be used as a route",
|
||||
l.folder.as_deref().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_str(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
fn pretty(v: &Value) -> Result<Vec<u8>, String> {
|
||||
let mut b = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?;
|
||||
b.push(b'\n');
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn package_json(site: &Site) -> Result<Vec<u8>, String> {
|
||||
let mut dev = serde_json::Map::new();
|
||||
match site.options.styling {
|
||||
Styling::Scss => {
|
||||
dev.insert("sass".into(), json!(SASS_RANGE));
|
||||
}
|
||||
Styling::Tailwind => {
|
||||
for (name, range) in TAILWIND_RANGES {
|
||||
dev.insert(name.into(), json!(range));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut pkg = json!({
|
||||
"name": "site", "private": true, "type": "module", "version": "0.0.1",
|
||||
"engines": {"node": NODE_RANGE},
|
||||
"scripts": {"dev": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro"},
|
||||
"dependencies": {"astro": ASTRO_RANGE},
|
||||
});
|
||||
if !dev.is_empty() {
|
||||
pkg["devDependencies"] = Value::Object(dev);
|
||||
}
|
||||
pretty(&pkg)
|
||||
}
|
||||
|
||||
fn config(site: &Site) -> Result<String, String> {
|
||||
let tailwind = site.options.styling == Styling::Tailwind;
|
||||
let mut out = String::from("// @ts-check\nimport { defineConfig } from 'astro/config';\n");
|
||||
if tailwind {
|
||||
out.push_str("import tailwindcss from '@tailwindcss/vite';\n");
|
||||
}
|
||||
out.push_str("\nexport default defineConfig({\n");
|
||||
if let Some(base) = &site.base_url {
|
||||
out.push_str(&format!(" site: {},\n", json_str(base)));
|
||||
}
|
||||
out.push_str(
|
||||
" output: 'static',\n trailingSlash: 'always',\n build: { format: 'directory' },\n",
|
||||
);
|
||||
if tailwind {
|
||||
out.push_str(" vite: { plugins: [tailwindcss()] },\n");
|
||||
}
|
||||
out.push_str("});\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The site-wide stylesheet, which the Layout imports, and for scss the page partials.
|
||||
fn styles(site: &Site, out: &mut Output) {
|
||||
let style = &site.style;
|
||||
let (path, text) = match site.options.styling {
|
||||
Styling::Scss => {
|
||||
let stems: Vec<(usize, String)> = site
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, p.stem.clone()))
|
||||
.collect();
|
||||
let parts = style.scss("/", &stems);
|
||||
let shared = ["_tokens.scss", "_fonts.scss", "_base.scss", "_shared.scss"];
|
||||
let text: String = parts
|
||||
.iter()
|
||||
.filter(|(p, _)| shared.contains(&p.as_str()))
|
||||
.map(|(_, c)| c.as_str())
|
||||
.collect();
|
||||
("src/styles/global.scss", text)
|
||||
}
|
||||
Styling::Tailwind => (
|
||||
"src/styles/global.css",
|
||||
format!(
|
||||
"@import \"tailwindcss\";\n{}{}{}{}",
|
||||
style.tailwind_theme(),
|
||||
style.tokens_css(),
|
||||
style.fonts_css("/"),
|
||||
style.overrides_css("/", |_| true)
|
||||
),
|
||||
),
|
||||
_ => ("src/styles/global.css", style.global_css("/")),
|
||||
};
|
||||
out.files.insert(path.into(), text.into_bytes());
|
||||
}
|
||||
|
||||
/// `locale`'s key in `src/site.ts`: its folder, or one fixed key for a single-language site.
|
||||
fn key(l: &Locale) -> &str {
|
||||
l.folder.as_deref().unwrap_or(SINGLE_KEY)
|
||||
}
|
||||
|
||||
/// The route of a page, with the trailing slash `trailingSlash: 'always'` serves.
|
||||
fn route(site: &Site, page: usize, loc: usize) -> String {
|
||||
let r = site.url_path(page, loc);
|
||||
if r.ends_with('/') { r } else { format!("{r}/") }
|
||||
}
|
||||
|
||||
/// `{"a": "b", ...}` with the keys in the given order.
|
||||
fn object(pairs: &[(&str, &str)]) -> String {
|
||||
let items: Vec<String> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}: {}", json_str(k), json_str(v)))
|
||||
.collect();
|
||||
format!("{{ {} }}", items.join(", "))
|
||||
}
|
||||
|
||||
fn tag_object(t: &HeadTag) -> String {
|
||||
let attrs: Vec<(&str, &str)> = t.attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
format!("{{ tag: {}, attrs: {} }}", json_str(t.tag), object(&attrs))
|
||||
}
|
||||
|
||||
/// The head of one page in one locale, as the data `Layout.astro` prints.
|
||||
fn meta(site: &Site, page: usize, loc: usize) -> String {
|
||||
let base = site.base_url.as_deref().unwrap_or_default();
|
||||
let url = |_from: usize, to: usize, p: usize| format!("{base}{}", route(site, p, to));
|
||||
let mut tags = site.head_tags(page, loc, &url);
|
||||
tags.extend(site.asset_tags(page, "/"));
|
||||
let l = &site.locales[loc];
|
||||
let mut fields = vec![format!("title: {}", json_str(&site.pages[page].title[loc]))];
|
||||
fields.extend(l.tag.as_deref().map(|t| format!("lang: {}", json_str(t))));
|
||||
fields.extend(l.class.as_deref().map(|c| format!("cls: {}", json_str(c))));
|
||||
let tags: Vec<String> = tags
|
||||
.iter()
|
||||
.map(|t| format!(" {}", tag_object(t)))
|
||||
.collect();
|
||||
format!(
|
||||
"{{ {}, tags: [\n{}\n ] }}",
|
||||
fields.join(", "),
|
||||
tags.join(",\n")
|
||||
)
|
||||
}
|
||||
|
||||
/// `src/site.ts`: the locales, the catalogs and every page's head. Strings are JSON literals.
|
||||
fn site_ts(site: &Site) -> Result<String, String> {
|
||||
let multi = site.locales.iter().any(|l| l.folder.is_some());
|
||||
let mut out = String::from("// Generated by papercraft export_site.\n");
|
||||
if multi {
|
||||
for (i, l) in site.locales.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"import m{i} from {};\n",
|
||||
json_str(&format!("./i18n/{}.json", key(l)))
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str("\nexport interface Tag { tag: string; attrs: Record<string, string> }\n");
|
||||
out.push_str(
|
||||
"export interface Meta { title: string; lang?: string; cls?: string; tags: Tag[] }\n\n",
|
||||
);
|
||||
let locales: Vec<String> = site
|
||||
.locales
|
||||
.iter()
|
||||
.map(|l| format!(" {}", object(&[("folder", key(l)), ("label", &l.label)])))
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"export const locales = [\n{},\n];\n",
|
||||
locales.join(",\n")
|
||||
));
|
||||
let catalogs: Vec<String> = site
|
||||
.locales
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| format!(" {}: m{i}", json_str(key(l))))
|
||||
.collect();
|
||||
if multi {
|
||||
out.push_str(&format!(
|
||||
"export const messages: Record<string, Record<string, string>> = {{\n{},\n}};\n",
|
||||
catalogs.join(",\n")
|
||||
));
|
||||
}
|
||||
out.push_str("\nexport const pages: Record<string, Record<string, Meta>> = {\n");
|
||||
for (p, page) in site.pages.iter().enumerate() {
|
||||
out.push_str(&format!(" {}: {{\n", json_str(&page.stem)));
|
||||
for (loc, l) in site.locales.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
" {}: {},\n",
|
||||
json_str(key(l)),
|
||||
meta(site, p, loc)
|
||||
));
|
||||
}
|
||||
out.push_str(" },\n");
|
||||
}
|
||||
out.push_str("};\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_catalogs(site: &Site, out: &mut Output) -> Result<(), String> {
|
||||
for (l, messages) in site.locales.iter().zip(&site.messages) {
|
||||
out.files.insert(
|
||||
format!("src/i18n/{}.json", key(l)),
|
||||
pretty(&json!(messages))?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn layout(site: &Site) -> String {
|
||||
let css = if site.options.styling == Styling::Scss {
|
||||
"scss"
|
||||
} else {
|
||||
"css"
|
||||
};
|
||||
let first = site.locales.first().map_or(SINGLE_KEY, key);
|
||||
format!(
|
||||
"---\nimport \"../styles/global.{css}\";\nimport {{ pages }} from \"../site\";\n\ninterface Props {{ page: string; lang?: string }}\nconst {{ page, lang = {} }} = Astro.props;\nconst meta = pages[page][lang];\n---\n\
|
||||
<!doctype html>\n<html lang={{meta.lang}} class={{meta.cls}}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{{meta.title}}</title>\n {{meta.tags.map(({{ tag, attrs }}) => (tag === \"meta\" ? <meta {{...attrs}} /> : <link {{...attrs}} />))}}\n </head>\n <body>\n <slot />\n </body>\n</html>\n",
|
||||
json_str(first)
|
||||
)
|
||||
}
|
||||
|
||||
fn page_file(site: &Site, page: usize, multi: bool) -> String {
|
||||
let p = &site.pages[page];
|
||||
let depth = if multi { "../../" } else { "../" };
|
||||
let mut out = format!(
|
||||
"---\nimport Layout from \"{depth}layouts/Layout.astro\";\nimport {} from \"{depth}components/{}.astro\";\n",
|
||||
p.component, p.component
|
||||
);
|
||||
if multi {
|
||||
out.push_str(&format!("import {{ locales }} from \"{depth}site\";\n\nexport function getStaticPaths() {{\n return locales.map((l) => ({{ params: {{ lang: l.folder }} }}));\n}}\n\nconst {{ lang }} = Astro.params;\n---\n"));
|
||||
out.push_str(&format!(
|
||||
"<Layout page={{{}}} lang={{lang}}>\n <{} lang={{lang}} />\n</Layout>\n",
|
||||
json_str(&p.stem),
|
||||
p.component
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"---\n<Layout page={{{}}}>\n <{} />\n</Layout>\n",
|
||||
json_str(&p.stem),
|
||||
p.component
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A component per page: the markup, and the styles only this page uses.
|
||||
fn component(site: &Site, page: usize, multi: bool) -> String {
|
||||
let p = &site.pages[page];
|
||||
let mut out = String::from("---\n");
|
||||
if multi {
|
||||
out.push_str("import { messages } from \"../site\";\n\ninterface Props { lang: string }\nconst { lang } = Astro.props;\nconst t = messages[lang];\n");
|
||||
}
|
||||
out.push_str("---\n");
|
||||
markup::render(
|
||||
Dialect::Astro,
|
||||
&AstroHooks { site, multi },
|
||||
&p.root,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
let style = &site.style;
|
||||
let (attr, css) = match site.options.styling {
|
||||
Styling::Css => ("", style.page_css(page, "/")),
|
||||
Styling::Scss => (" lang=\"scss\"", scss_page(site, page)),
|
||||
_ => ("", String::new()),
|
||||
};
|
||||
if !css.trim().is_empty() {
|
||||
out.push_str(&format!(
|
||||
"<style{attr}>\n{}</style>\n",
|
||||
css.replace("</", r"<\/")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scss_page(site: &Site, page: usize) -> String {
|
||||
let stems: Vec<(usize, String)> = site
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, p.stem.clone()))
|
||||
.collect();
|
||||
let file = format!("pages/_{}.scss", class_slug(&site.pages[page].stem));
|
||||
site.style
|
||||
.scss("/", &stems)
|
||||
.into_iter()
|
||||
.find(|(p, _)| *p == file)
|
||||
.map(|(_, c)| c)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
struct AstroHooks<'a> {
|
||||
site: &'a Site,
|
||||
multi: bool,
|
||||
}
|
||||
|
||||
impl Hooks for AstroHooks<'_> {
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
let class = if self.site.options.styling == Styling::Tailwind {
|
||||
el.tailwind_classes()
|
||||
} else {
|
||||
el.class.clone()
|
||||
};
|
||||
(!class.is_empty()).then_some(Val::Lit(class))
|
||||
}
|
||||
|
||||
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
|
||||
let href = match target {
|
||||
LinkTarget::Url(u) => Val::Lit(u.clone()),
|
||||
LinkTarget::Page(i) if self.multi => {
|
||||
let tail = if *i == 0 {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("/{}/", self.site.pages[*i].stem)
|
||||
};
|
||||
Val::Expr(format!("\"/\" + lang + {}", json_str(&tail)))
|
||||
}
|
||||
LinkTarget::Page(i) => Val::Lit(if *i == 0 {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("/{}/", self.site.pages[*i].stem)
|
||||
}),
|
||||
};
|
||||
Link {
|
||||
tag: "a".into(),
|
||||
attrs: vec![("href".into(), href)],
|
||||
}
|
||||
}
|
||||
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(format!("/{path}"))
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
if self.multi {
|
||||
return TextOut::Raw(format!("{{t[{}]}}", json_str(key)));
|
||||
}
|
||||
TextOut::Lit(
|
||||
self.site
|
||||
.messages
|
||||
.first()
|
||||
.and_then(|m| m.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn head_line(t: &HeadTag) -> String {
|
||||
let mut s = format!(" <{}", t.tag);
|
||||
for (k, v) in &t.attrs {
|
||||
if v.is_empty() {
|
||||
s.push_str(&format!(" {k}"));
|
||||
} else {
|
||||
s.push_str(&format!(" {k}=\"{}\"", escape_attr(Dialect::Astro, v)));
|
||||
}
|
||||
}
|
||||
s.push_str(" />\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// `/`: one link per version, with its own head (it is not a locale page).
|
||||
fn language_index(site: &Site) -> String {
|
||||
let base = site.base_url.as_deref().unwrap_or_default();
|
||||
let langs = site.language_locales();
|
||||
let href = |to: usize| {
|
||||
format!(
|
||||
"{base}/{}/",
|
||||
site.locales[to].folder.as_deref().unwrap_or_default()
|
||||
)
|
||||
};
|
||||
let pair = |name: &'static str, v: String| HeadTag {
|
||||
tag: "meta",
|
||||
attrs: vec![("name", name.into()), ("content", v)],
|
||||
};
|
||||
let mut tags = vec![
|
||||
pair("description", "Choose a version of this site.".into()),
|
||||
HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![("rel", "canonical".into()), ("href", format!("{base}/"))],
|
||||
},
|
||||
];
|
||||
for &l in &langs {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", site.locales[l].tag.clone().unwrap_or_default()),
|
||||
("href", href(l)),
|
||||
],
|
||||
});
|
||||
}
|
||||
if let Some(&first) = langs.first() {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", "x-default".into()),
|
||||
("href", href(first)),
|
||||
],
|
||||
});
|
||||
}
|
||||
let mut out = format!(
|
||||
"<!doctype html>\n<html{}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{}</title>\n",
|
||||
site.index_lang()
|
||||
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Astro, t)))
|
||||
.unwrap_or_default(),
|
||||
escape_text(Dialect::Astro, &site.site_title)
|
||||
);
|
||||
tags.iter().for_each(|t| out.push_str(&head_line(t)));
|
||||
out.push_str(&format!(" <style is:global>\n {INDEX_CSS}\n </style>\n </head>\n <body>\n <main>\n <h1>{}</h1>\n <ul>\n", escape_text(Dialect::Astro, &site.site_title)));
|
||||
for (i, l) in site.locales.iter().enumerate() {
|
||||
let lang = l
|
||||
.tag
|
||||
.as_ref()
|
||||
.filter(|_| langs.contains(&i))
|
||||
.map(|t| {
|
||||
format!(
|
||||
" lang=\"{0}\" hreflang=\"{0}\"",
|
||||
escape_attr(Dialect::Astro, t)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
" <li><a href=\"/{}/\"{lang}>{}</a></li>\n",
|
||||
escape_attr(Dialect::Astro, l.folder.as_deref().unwrap_or_default()),
|
||||
escape_text(Dialect::Astro, &l.label)
|
||||
));
|
||||
}
|
||||
out.push_str(" </ul>\n </main>\n </body>\n</html>\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "astro_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,265 @@
|
||||
use super::*;
|
||||
use crate::mcp::site::model::{Framework, Options, Page, Render};
|
||||
use crate::mcp::site::style::{Class, FontFace, StyleModel};
|
||||
use crate::mcp::site::tree::{Child, Text};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const HOSTILE: &str = "{x} </script> `b` ${x} ---";
|
||||
|
||||
fn el(tag: &str, class: &str, children: Vec<Child>) -> Element {
|
||||
Element {
|
||||
tag: tag.into(),
|
||||
class: class.into(),
|
||||
attrs: vec![],
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn locale(tag: &str, folder: Option<&str>) -> Locale {
|
||||
Locale {
|
||||
tag: Some(tag.into()),
|
||||
hreflang: true,
|
||||
folder: folder.map(Into::into),
|
||||
label: HOSTILE.into(),
|
||||
scope: String::new(),
|
||||
class: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn site(styling: Styling, multi: bool) -> Site {
|
||||
let link = Element {
|
||||
link: Some(LinkTarget::Page(1)),
|
||||
..el("a", "", vec![Child::Text(Text::Lit(HOSTILE.into()))])
|
||||
};
|
||||
let ext = Element {
|
||||
link: Some(LinkTarget::Url("https://example.com/?a=1&b=2".into())),
|
||||
..el("a", "", vec![])
|
||||
};
|
||||
let root = el(
|
||||
"main",
|
||||
"home",
|
||||
vec![
|
||||
Child::El(el(
|
||||
"h1",
|
||||
"title",
|
||||
vec![Child::Text(Text::Msg("home.title".into()))],
|
||||
)),
|
||||
Child::El(link),
|
||||
Child::El(ext),
|
||||
],
|
||||
);
|
||||
let n = if multi { 2 } else { 1 };
|
||||
let page = |stem: &str, component: &str, root: Element| Page {
|
||||
name: HOSTILE.into(),
|
||||
stem: stem.into(),
|
||||
component: component.into(),
|
||||
root,
|
||||
title: vec![HOSTILE.into(); n],
|
||||
description: vec!["d".into(); n],
|
||||
};
|
||||
let locales = if multi {
|
||||
vec![locale("en", Some("en")), locale("pt-BR", Some("pt-BR"))]
|
||||
} else {
|
||||
vec![locale("en", None)]
|
||||
};
|
||||
let msg = |v: &str| BTreeMap::from([("home.title".to_owned(), v.to_owned())]);
|
||||
let style = StyleModel {
|
||||
fallbacks: Vec::new(),
|
||||
fonts: vec![FontFace {
|
||||
family: "V".into(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
path: "assets/fonts/ab.ttf".into(),
|
||||
format: "truetype",
|
||||
}],
|
||||
preload: Some(0),
|
||||
favicon: None,
|
||||
h1_faces: Vec::new(),
|
||||
classes: vec![Class {
|
||||
name: "home".into(),
|
||||
decls: vec![("color".into(), "red".into())],
|
||||
overrides: vec![],
|
||||
pages: [0].into(),
|
||||
root_of: Some(0),
|
||||
}],
|
||||
scopes: vec![String::new(); n],
|
||||
..Default::default()
|
||||
};
|
||||
Site {
|
||||
options: Options {
|
||||
framework: Framework::Astro,
|
||||
render: Render::Static,
|
||||
styling,
|
||||
},
|
||||
site_title: HOSTILE.into(),
|
||||
base_url: Some("https://example.com".into()),
|
||||
locales,
|
||||
pages: vec![
|
||||
page("index", "HomePage", root),
|
||||
page("pricing", "PricingPage", el("main", "", vec![])),
|
||||
],
|
||||
messages: if multi {
|
||||
vec![msg(HOSTILE), msg("Ola")]
|
||||
} else {
|
||||
vec![msg(HOSTILE)]
|
||||
},
|
||||
assets: BTreeMap::from([("assets/fonts/ab.ttf".to_owned(), vec![1, 2])]),
|
||||
style,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn text(o: &Output, path: &str) -> String {
|
||||
String::from_utf8(
|
||||
o.files
|
||||
.get(path)
|
||||
.unwrap_or_else(|| panic!("missing {path}: {:?}", o.files.keys()))
|
||||
.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_locale_css_files_and_snippets() {
|
||||
let o = write(&site(Styling::Css, true)).unwrap();
|
||||
for f in [
|
||||
"package.json",
|
||||
"astro.config.mjs",
|
||||
"tsconfig.json",
|
||||
"src/site.ts",
|
||||
"src/layouts/Layout.astro",
|
||||
"src/styles/global.css",
|
||||
"src/components/HomePage.astro",
|
||||
"src/components/PricingPage.astro",
|
||||
"src/pages/index.astro",
|
||||
"src/pages/[lang]/index.astro",
|
||||
"src/pages/[lang]/pricing.astro",
|
||||
"src/i18n/en.json",
|
||||
"src/i18n/pt-BR.json",
|
||||
"public/robots.txt",
|
||||
"public/sitemap.xml",
|
||||
"public/assets/fonts/ab.ttf",
|
||||
] {
|
||||
assert!(o.files.contains_key(f), "{f}");
|
||||
}
|
||||
let c = text(&o, "src/components/HomePage.astro");
|
||||
assert!(
|
||||
c.contains("{t[\"home.title\"]}") && c.contains("const t = messages[lang];"),
|
||||
"{c}"
|
||||
);
|
||||
assert!(
|
||||
c.contains("href={\"/\" + lang + \"/pricing/\"}") && c.contains("<style>"),
|
||||
"{c}"
|
||||
);
|
||||
assert!(text(&o, "src/pages/[lang]/index.astro").contains("getStaticPaths"));
|
||||
let ts = text(&o, "src/site.ts");
|
||||
assert!(
|
||||
ts.contains("\"hreflang\": \"x-default\"")
|
||||
&& ts.contains("https://example.com/pt-BR/pricing/")
|
||||
&& ts.contains("\"rel\": \"preload\""),
|
||||
"{ts}"
|
||||
);
|
||||
assert!(text(&o, "astro.config.mjs").contains("site: \"https://example.com\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_locale_writes_literal_text_at_the_root() {
|
||||
let o = write(&site(Styling::Css, false)).unwrap();
|
||||
assert!(o.files.contains_key("src/pages/pricing.astro"));
|
||||
assert!(
|
||||
!o.files
|
||||
.keys()
|
||||
.any(|k| k.contains("i18n") || k.contains("[lang]"))
|
||||
);
|
||||
let c = text(&o, "src/components/HomePage.astro");
|
||||
assert!(!c.contains("t["), "{c}");
|
||||
assert!(c.contains("href=\"/pricing/\""), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styling_variants() {
|
||||
let scss = write(&site(Styling::Scss, true)).unwrap();
|
||||
assert!(
|
||||
scss.files.contains_key("src/styles/global.scss")
|
||||
&& text(&scss, "package.json").contains("\"sass\"")
|
||||
);
|
||||
assert!(text(&scss, "src/components/HomePage.astro").contains("<style lang=\"scss\">"));
|
||||
assert!(text(&scss, "src/layouts/Layout.astro").contains("global.scss"));
|
||||
let tw = write(&site(Styling::Tailwind, true)).unwrap();
|
||||
assert!(text(&tw, "src/styles/global.css").starts_with("@import \"tailwindcss\";"));
|
||||
assert!(
|
||||
text(&tw, "astro.config.mjs").contains("tailwindcss()")
|
||||
&& text(&tw, "package.json").contains("@tailwindcss/vite")
|
||||
);
|
||||
assert!(!text(&tw, "src/components/HomePage.astro").contains("<style"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_stays_inert() {
|
||||
let o = write(&site(Styling::Css, true)).unwrap();
|
||||
let home = text(&o, "src/pages/index.astro");
|
||||
assert!(
|
||||
!home.contains("{x}") && !home.contains("</script>") && !home.contains("${x}"),
|
||||
"{home}"
|
||||
);
|
||||
let comp = text(&o, "src/components/HomePage.astro");
|
||||
assert!(
|
||||
!comp.contains("{x}") && !comp.contains("${x}") && !comp.contains("</script>"),
|
||||
"{comp}"
|
||||
);
|
||||
let ts = text(&o, "src/site.ts");
|
||||
assert!(ts.contains("title: \"{x} </script> `b` ${x} ---\""), "{ts}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_key_with_odd_characters_never_becomes_code() {
|
||||
let mut s = site(Styling::Css, true);
|
||||
s.pages[0].root = el(
|
||||
"p",
|
||||
"",
|
||||
vec![Child::Text(Text::Msg("a\"]+alert(1)+t[\"".into()))],
|
||||
);
|
||||
s.messages[0].insert("a\"]+alert(1)+t[\"".into(), "x".into());
|
||||
let c = text(&write(&s).unwrap(), "src/components/HomePage.astro");
|
||||
assert!(
|
||||
c.contains(r#"{t["a\"]+alert(1)+t[\""]}"#),
|
||||
"the key stays one JSON string: {c}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_deterministic() {
|
||||
let a = write(&site(Styling::Scss, true)).unwrap();
|
||||
let b = write(&site(Styling::Scss, true)).unwrap();
|
||||
assert_eq!(a.files, b.files);
|
||||
assert!(a.next_steps[0].contains("npm install && npm run build"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_names_are_refused() {
|
||||
let mut s = site(Styling::Css, true);
|
||||
s.pages[1].component = "homepage".into();
|
||||
assert!(write(&s).is_err());
|
||||
let mut s = site(Styling::Css, true);
|
||||
s.pages[1].component = "HOMEPAGE".into();
|
||||
assert!(write(&s).is_err(), "case-insensitive clash");
|
||||
let mut s = site(Styling::Css, true);
|
||||
s.locales[1].folder = Some("../x".into());
|
||||
assert!(write(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_values_cannot_close_the_style_block() {
|
||||
let mut s = site(Styling::Css, true);
|
||||
let evil = "x</style><script>`${x}`</script>";
|
||||
s.style.classes[0]
|
||||
.decls
|
||||
.push(("font-family".into(), evil.into()));
|
||||
s.style.fonts[0].family = evil.into();
|
||||
let o = write(&s).unwrap();
|
||||
let c = text(&o, "src/components/HomePage.astro");
|
||||
assert_eq!(c.matches("</style>").count(), 1, "{c}");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Text that misses WCAG AA contrast. The colours are the designer's, so the export only says so.
|
||||
|
||||
use super::model::Frame;
|
||||
use crate::mcp::lint::text_contrast;
|
||||
use crate::model::{Document, NodeKind};
|
||||
|
||||
/// One warning per visible text layer of the frames below AA, in every locale's document.
|
||||
/// `labels` name the locales; they are left out of the message with a single locale.
|
||||
pub fn warnings(docs: &[Document], frames: &[Frame], labels: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (i, doc) in docs.iter().enumerate() {
|
||||
for f in frames {
|
||||
for id in doc.subtree(f.id) {
|
||||
let Some(n) = doc.get(id).filter(|n| n.visible) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(n.kind, NodeKind::Text { .. }) {
|
||||
continue;
|
||||
}
|
||||
let Some((fg, bg, ratio, need)) = text_contrast(doc, id) else {
|
||||
continue;
|
||||
};
|
||||
if ratio < need {
|
||||
let mode = if docs.len() > 1 {
|
||||
format!(" ({})", labels[i])
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push(format!(
|
||||
"{} / {}{mode}: text {} on {} is {ratio:.2}:1, below WCAG AA ({need}:1); change a colour in the design",
|
||||
f.name,
|
||||
n.name,
|
||||
fg.to_hex(),
|
||||
bg.to_hex()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! What lets a page narrower than its design reflow instead of clipping: fixed widths that give
|
||||
//! way, rows that wrap, big type that scales, pictures that keep their proportions. Only layers
|
||||
//! in an auto-layout flow qualify: absolutely positioned ones cannot reflow (the lint warns).
|
||||
|
||||
use super::style::Decls;
|
||||
use crate::mcp::codegen;
|
||||
use crate::mcp::lint::DESKTOP_MIN_W;
|
||||
use crate::model::{Direction, Document, Node, NodeKind, Sizing};
|
||||
|
||||
/// Type from this size up shrinks with the window; body text keeps its size.
|
||||
const FLUID_TEXT_PX: f64 = 40.0;
|
||||
/// Slack for the rounding of rects.
|
||||
const FIT_SLACK_PX: f64 = 0.5;
|
||||
/// A fixed-width child this wide is a card or column, not an icon or a button.
|
||||
const COLUMN_MIN_PX: f64 = 120.0;
|
||||
|
||||
fn r2(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
/// The extra declarations of `n`: at the designed width and wider each one is a no-op.
|
||||
pub fn decls(doc: &Document, n: &Node, replaced: bool) -> Decls {
|
||||
let mut out = Decls::new();
|
||||
let Some(parent) = codegen::parent_layout(doc, n) else {
|
||||
return out;
|
||||
};
|
||||
if n.layout_child.absolute {
|
||||
return out;
|
||||
}
|
||||
let fixed_w = n.layout_child.sizing[0] == Sizing::Fixed
|
||||
&& !matches!(
|
||||
n.kind,
|
||||
NodeKind::Text {
|
||||
auto_width: true,
|
||||
..
|
||||
}
|
||||
);
|
||||
// A layer wider than its parent's content box overflows on purpose (a bleed, a strip in a
|
||||
// clipped frame): shrinking it would change the design.
|
||||
let room = n
|
||||
.parent
|
||||
.and_then(|p| doc.get(p))
|
||||
.map_or(0.0, |p| p.rect.w - parent.padding[1] - parent.padding[3]);
|
||||
if fixed_w && n.layout_child.max_size[0].is_none() && n.rect.w <= room + FIT_SLACK_PX {
|
||||
out.push(("max-width".into(), "100%".into()));
|
||||
}
|
||||
if replaced
|
||||
&& fixed_w
|
||||
&& n.rect.w <= room + FIT_SLACK_PX
|
||||
&& n.layout_child.sizing[1] == Sizing::Fixed
|
||||
&& n.rect.h >= 1.0
|
||||
{
|
||||
out.push(("height".into(), "auto".into()));
|
||||
out.push((
|
||||
"aspect-ratio".into(),
|
||||
format!("{} / {}", r2(n.rect.w), r2(n.rect.h)),
|
||||
));
|
||||
}
|
||||
if wraps(doc, n) {
|
||||
out.push(("flex-wrap".into(), "wrap".into()));
|
||||
}
|
||||
if let NodeKind::Text { font_size, .. } = &n.kind
|
||||
&& *font_size >= FLUID_TEXT_PX
|
||||
{
|
||||
out.push((
|
||||
"font-size".into(),
|
||||
fluid_size(*font_size, full_size_width(doc, n)),
|
||||
));
|
||||
if let Some(lh) = n.text_style.line_height {
|
||||
// A pixel line height would stay as tall as the big type it was drawn for.
|
||||
out.push(("line-height".into(), format!("{}", r2(lh))));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Tailwind classes of `decls`, for the properties `tw_classes` has no hook for.
|
||||
pub fn tailwind(decls: &Decls) -> Vec<String> {
|
||||
decls
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match k.as_str() {
|
||||
"max-width" => Some("max-w-full".into()),
|
||||
"height" => Some("h-auto".into()),
|
||||
"flex-wrap" => Some("flex-wrap".into()),
|
||||
"aspect-ratio" => Some(format!("aspect-[{}]", v.replace(' ', ""))),
|
||||
"font-size" => Some(format!("text-[length:{v}]").replace(' ', "")),
|
||||
"line-height" => Some(format!("leading-[{v}]")),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The window width from which type keeps its designed size: the page frame's own width, at most
|
||||
/// a desktop's, so a phone design is not shrunk on the phone it was drawn for.
|
||||
fn full_size_width(doc: &Document, n: &Node) -> f64 {
|
||||
let page = doc
|
||||
.ancestors(n.id)
|
||||
.last()
|
||||
.and_then(|p| doc.get(*p))
|
||||
.unwrap_or(n);
|
||||
page.rect.w.min(DESKTOP_MIN_W)
|
||||
}
|
||||
|
||||
/// `clamp(half, designed size at `full` px, designed size)`.
|
||||
pub fn fluid_size(px: f64, full: f64) -> String {
|
||||
format!(
|
||||
"clamp({}px, {}vw, {}px)",
|
||||
r2(px / 2.0),
|
||||
r2(px / full * 100.0),
|
||||
r2(px)
|
||||
)
|
||||
}
|
||||
|
||||
/// A row of two or more fixed-width cards or columns may wrap. Rows with a child that fills the
|
||||
/// rest, icon + label rows and rows that already wrap stay as designed: they fit at any width
|
||||
/// the design does, and wrapping them would only risk a break from font differences.
|
||||
fn wraps(doc: &Document, n: &Node) -> bool {
|
||||
let Some(l) = n
|
||||
.layout
|
||||
.as_ref()
|
||||
.filter(|l| !l.wrap && l.direction == Direction::Horizontal)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let kids: Vec<&Node> = n
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|c| doc.get(*c))
|
||||
.filter(|c| !c.layout_child.absolute)
|
||||
.collect();
|
||||
let columns = kids
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.layout_child.sizing[0] == Sizing::Fixed
|
||||
&& !matches!(c.kind, NodeKind::Text { .. })
|
||||
&& c.rect.w >= COLUMN_MIN_PX
|
||||
})
|
||||
.count();
|
||||
let gap = if l.gap_auto { 0.0 } else { l.gap };
|
||||
let sum: f64 = kids.iter().map(|c| c.rect.w).sum::<f64>() + gap * (kids.len() as f64 - 1.0);
|
||||
// A carousel that overflows its row by design must not stack.
|
||||
let fits = sum <= n.rect.w - l.padding[1] - l.padding[3] + FIT_SLACK_PX;
|
||||
fits && columns >= 2
|
||||
&& kids
|
||||
.iter()
|
||||
.all(|c| c.layout_child.sizing[0] != Sizing::Fill)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fluid_size_is_the_designed_one_from_a_desktop_up() {
|
||||
assert_eq!(fluid_size(56.0, 1024.0), "clamp(28px, 5.47vw, 56px)");
|
||||
assert_eq!(fluid_size(80.0, 1024.0), "clamp(40px, 7.81vw, 80px)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tailwind_names_the_fluid_declarations() {
|
||||
let d: Decls = vec![
|
||||
("max-width".into(), "100%".into()),
|
||||
("aspect-ratio".into(), "300 / 200".into()),
|
||||
("font-size".into(), fluid_size(56.0, 1024.0)),
|
||||
];
|
||||
assert_eq!(
|
||||
tailwind(&d),
|
||||
[
|
||||
"max-w-full",
|
||||
"aspect-[300/200]",
|
||||
"text-[length:clamp(28px,5.47vw,56px)]"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Shrinks the font files a site ships: a subset with the characters the pages use, packed as
|
||||
//! WOFF2, and the metrics of a matching system-font fallback so the swap does not shift layout.
|
||||
|
||||
use fontcull_klippa::{DEFAULT_LAYOUT_FEATURES, Plan, SubsetFlags, subset_font};
|
||||
use fontcull_read_fonts::collections::IntSet;
|
||||
use fontcull_skrifa::{FontRef, GlyphId, Tag};
|
||||
use fontcull_write_fonts::types::NameId;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
/// Always kept: the space, the no-break space, the ellipsis `text-overflow` draws with the
|
||||
/// element's font and the hyphen browsers insert when they hyphenate.
|
||||
const ALWAYS: [char; 4] = [' ', '\u{a0}', '\u{2026}', '-'];
|
||||
|
||||
/// Relative English letter frequencies (a-z) and the space, to weigh the average glyph width the
|
||||
/// way Capsize does for `xWidthAvg`.
|
||||
const WEIGHTS: [(char, f64); 27] = [
|
||||
('a', 8.167),
|
||||
('b', 1.492),
|
||||
('c', 2.782),
|
||||
('d', 4.253),
|
||||
('e', 12.702),
|
||||
('f', 2.228),
|
||||
('g', 2.015),
|
||||
('h', 6.094),
|
||||
('i', 6.966),
|
||||
('j', 0.153),
|
||||
('k', 0.772),
|
||||
('l', 4.025),
|
||||
('m', 2.406),
|
||||
('n', 6.749),
|
||||
('o', 7.507),
|
||||
('p', 1.929),
|
||||
('q', 0.095),
|
||||
('r', 5.987),
|
||||
('s', 6.327),
|
||||
('t', 9.056),
|
||||
('u', 2.758),
|
||||
('v', 0.978),
|
||||
('w', 2.360),
|
||||
('x', 0.150),
|
||||
('y', 1.974),
|
||||
('z', 0.074),
|
||||
(' ', 18.0),
|
||||
];
|
||||
|
||||
/// Average width (em) of [`WEIGHTS`] in Arial and Courier New, the faces `local()` finds on
|
||||
/// Windows, macOS and most Linux setups, measured with the same weights on their own files.
|
||||
const ARIAL: SystemFont = SystemFont {
|
||||
name: "Arial",
|
||||
width: 0.446_97,
|
||||
};
|
||||
const COURIER: SystemFont = SystemFont {
|
||||
name: "Courier New",
|
||||
width: 0.600_10,
|
||||
};
|
||||
|
||||
struct SystemFont {
|
||||
name: &'static str,
|
||||
width: f64,
|
||||
}
|
||||
|
||||
/// A `@font-face` named `<family> Fallback` that draws a system font at the web font's metrics.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Fallback {
|
||||
pub family: String,
|
||||
pub local: &'static str,
|
||||
/// Percentages, as CSS wants them.
|
||||
pub size_adjust: f64,
|
||||
pub ascent: f64,
|
||||
pub descent: f64,
|
||||
pub gap: f64,
|
||||
}
|
||||
|
||||
pub fn fallback_name(family: &str) -> String {
|
||||
format!("{family} Fallback")
|
||||
}
|
||||
|
||||
/// The fallback face for `bytes`, from its hhea metrics and weighted average width. This is the
|
||||
/// approach of Next.js `next/font`, Fontaine and Capsize: scale a system font to the web font's
|
||||
/// average width (`size-adjust`), then divide its vertical metrics by that scale.
|
||||
/// None when the font has no usable metrics.
|
||||
pub fn fallback(family: &str, mono: bool, bytes: &[u8]) -> Option<Fallback> {
|
||||
let face = ttf_parser::Face::parse(bytes, 0).ok()?;
|
||||
let em = f64::from(face.units_per_em());
|
||||
let (mut sum, mut weight) = (0.0, 0.0);
|
||||
for (c, w) in WEIGHTS {
|
||||
let advance = face
|
||||
.glyph_index(c)
|
||||
.and_then(|g| face.glyph_hor_advance(g))?;
|
||||
sum += w * f64::from(advance) / em;
|
||||
weight += w;
|
||||
}
|
||||
let width = sum / weight;
|
||||
let sys = if mono { COURIER } else { ARIAL };
|
||||
if width <= 0.0 || em <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scale = width / sys.width;
|
||||
let pct = |v: f64| (v * 1000.0).round() / 10.0;
|
||||
Some(Fallback {
|
||||
family: fallback_name(family),
|
||||
local: sys.name,
|
||||
size_adjust: pct(scale),
|
||||
ascent: pct(f64::from(face.ascender()) / em / scale),
|
||||
descent: pct(f64::from(-face.descender()) / em / scale),
|
||||
gap: pct(f64::from(face.line_gap()) / em / scale),
|
||||
})
|
||||
}
|
||||
|
||||
/// The characters to keep for text that uses `chars`: those, their other case (CSS
|
||||
/// `text-transform`) and [`ALWAYS`].
|
||||
pub fn keep(chars: &BTreeSet<char>) -> HashSet<char> {
|
||||
let mut out: HashSet<char> = ALWAYS.into_iter().collect();
|
||||
for c in chars {
|
||||
out.insert(*c);
|
||||
out.extend(c.to_uppercase());
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A font file ready to ship: `ext`/`format` describe `bytes`.
|
||||
pub struct Packed {
|
||||
pub bytes: Vec<u8>,
|
||||
pub ext: &'static str,
|
||||
pub format: &'static str,
|
||||
/// What had to be given up to keep the file valid.
|
||||
pub note: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// One klippa (the Rust port of HarfBuzz's subsetter, as vendored by `fontcull`) run, dropping
|
||||
/// `drop` tables.
|
||||
fn run_klippa(bytes: &[u8], chars: &HashSet<char>, drop: &[Tag]) -> Result<Vec<u8>, String> {
|
||||
let font = FontRef::new(bytes).map_err(|e| format!("{e:?}"))?;
|
||||
let unicodes: IntSet<u32> = chars.iter().map(|c| *c as u32).collect();
|
||||
let features: IntSet<Tag> = DEFAULT_LAYOUT_FEATURES.iter().copied().collect();
|
||||
let plan = Plan::new(
|
||||
&IntSet::<GlyphId>::empty(),
|
||||
&unicodes,
|
||||
&font,
|
||||
SubsetFlags::default(),
|
||||
&drop.iter().copied().collect(),
|
||||
&IntSet::<Tag>::all(),
|
||||
&features,
|
||||
&IntSet::<NameId>::empty(),
|
||||
&IntSet::<u16>::empty(),
|
||||
);
|
||||
subset_font(&font, &plan).map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// Whether every lookup of GPOS and GSUB parses to its last subtable. klippa 0.1.2 writes an
|
||||
/// unreadable GPOS for fonts with many kerning pairs (Inter), which browsers' sanitizers then
|
||||
/// discard, so the check must be ours.
|
||||
fn layout_is_sound(font: &[u8]) -> bool {
|
||||
let Ok(face) = ttf_parser::Face::parse(font, 0) else {
|
||||
return false;
|
||||
};
|
||||
let gpos = face.tables().gpos.is_none_or(|t| {
|
||||
(0..t.lookups.len()).all(|i| {
|
||||
t.lookups.get(i).is_some_and(|l| {
|
||||
(0..l.subtables.len()).all(|j| {
|
||||
l.subtables
|
||||
.get::<ttf_parser::gpos::PositioningSubtable>(j)
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
let gsub = face.tables().gsub.is_none_or(|t| {
|
||||
(0..t.lookups.len()).all(|i| {
|
||||
t.lookups.get(i).is_some_and(|l| {
|
||||
(0..l.subtables.len()).all(|j| {
|
||||
l.subtables
|
||||
.get::<ttf_parser::gsub::SubstitutionSubtable>(j)
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
gpos && gsub
|
||||
}
|
||||
|
||||
/// `bytes` cut down to `chars`. Layout tables the subsetter breaks are dropped (kerning is lost,
|
||||
/// the text still draws), and the second value says so.
|
||||
fn subset(bytes: &[u8], chars: &HashSet<char>) -> Result<(Vec<u8>, Option<&'static str>), String> {
|
||||
let gpos = Tag::new(b"GPOS");
|
||||
for drop in [&[][..], &[gpos][..], &[gpos, Tag::new(b"GSUB")][..]] {
|
||||
let cut = run_klippa(bytes, chars, drop)?;
|
||||
if layout_is_sound(&cut) {
|
||||
return Ok((cut, note_static(drop)));
|
||||
}
|
||||
}
|
||||
Err("the subset's layout tables do not parse".into())
|
||||
}
|
||||
|
||||
fn note_static(drop: &[Tag]) -> Option<&'static str> {
|
||||
match drop.len() {
|
||||
// The canvas does not kern either, so without GPOS the text keeps the design's widths.
|
||||
0 | 1 => None,
|
||||
// GSUB also carries the shaping some scripts cannot draw without.
|
||||
_ => Some("the font's kerning and ligatures could not be subset and were dropped"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` cut down to `chars`, as WOFF2 when it encodes (TTF/OTF otherwise). Err names why the
|
||||
/// font could not be subset, so the caller can ship the whole file instead.
|
||||
pub fn pack(bytes: &[u8], chars: &HashSet<char>, cff: bool) -> Result<Packed, String> {
|
||||
let (ttf, note) = subset(bytes, chars)?;
|
||||
// A subset that lost a character the original had would draw it in the fallback font.
|
||||
let (orig, cut) = (
|
||||
ttf_parser::Face::parse(bytes, 0),
|
||||
ttf_parser::Face::parse(&ttf, 0),
|
||||
);
|
||||
let (Ok(orig), Ok(cut)) = (orig, cut) else {
|
||||
return Err("the subset does not parse".into());
|
||||
};
|
||||
if let Some(c) = chars
|
||||
.iter()
|
||||
.find(|c| orig.glyph_index(**c).is_some() && cut.glyph_index(**c).is_none())
|
||||
{
|
||||
return Err(format!("the subset lost U+{:04X}", *c as u32));
|
||||
}
|
||||
Ok(
|
||||
match ttf2woff2::encode(&ttf, ttf2woff2::BrotliQuality::default()) {
|
||||
Ok(woff) => Packed {
|
||||
bytes: woff,
|
||||
ext: "woff2",
|
||||
format: "woff2",
|
||||
note,
|
||||
},
|
||||
Err(_) if cff => Packed {
|
||||
bytes: ttf,
|
||||
ext: "otf",
|
||||
format: "opentype",
|
||||
note,
|
||||
},
|
||||
Err(_) => Packed {
|
||||
bytes: ttf,
|
||||
ext: "ttf",
|
||||
format: "truetype",
|
||||
note,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::text::{face_bytes, face_id};
|
||||
|
||||
fn inter() -> &'static [u8] {
|
||||
face_bytes(face_id(false, false))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subset_is_far_smaller_keeps_every_used_character_and_other_cases() {
|
||||
let chars = keep(&"Olá ção".chars().collect());
|
||||
assert!(
|
||||
chars.contains(&'o') && chars.contains(&'Ç'),
|
||||
"other case is kept"
|
||||
);
|
||||
let (cut, _) = subset(inter(), &chars).unwrap();
|
||||
assert!(
|
||||
layout_is_sound(&cut),
|
||||
"a broken GPOS must be dropped, not shipped"
|
||||
);
|
||||
let face = ttf_parser::Face::parse(&cut, 0).expect("the subset parses");
|
||||
for c in &chars {
|
||||
assert!(face.glyph_index(*c).is_some(), "{c:?} has no cmap entry");
|
||||
}
|
||||
assert!(
|
||||
cut.len() * 10 < inter().len(),
|
||||
"{} of {}",
|
||||
cut.len(),
|
||||
inter().len()
|
||||
);
|
||||
let packed = pack(inter(), &chars, false).unwrap();
|
||||
assert_eq!((packed.ext, packed.format), ("woff2", "woff2"));
|
||||
assert!(packed.bytes.len() < cut.len() && packed.bytes.starts_with(b"wOF2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_not_a_font_are_an_error_not_a_panic() {
|
||||
assert!(pack(b"nope", &keep(&BTreeSet::new()), false).is_err());
|
||||
assert!(fallback("X", false, b"nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fallback_scales_arial_to_the_font_and_divides_its_metrics_by_that_scale() {
|
||||
let f = fallback("Inter", false, inter()).unwrap();
|
||||
assert_eq!((f.family.as_str(), f.local), ("Inter Fallback", "Arial"));
|
||||
// Inter is a little wider than Arial and has 0.969 em ascent, -0.242 descent, no gap.
|
||||
assert!((100.0..115.0).contains(&f.size_adjust), "{f:?}");
|
||||
let scale = f.size_adjust / 100.0;
|
||||
assert!(
|
||||
(f.ascent - 96.9 / scale).abs() < 0.2 && (f.descent - 24.2 / scale).abs() < 0.2,
|
||||
"{f:?}"
|
||||
);
|
||||
assert_eq!(f.gap, 0.0);
|
||||
assert_eq!(
|
||||
fallback("Fira Code", true, inter()).unwrap().local,
|
||||
"Courier New"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Cache and fallback files for the static hosts, written beside the project's other files.
|
||||
//!
|
||||
//! Every framework already names its built assets after their content, so those paths can be
|
||||
//! cached for a year. HTML gets no rule on purpose: Netlify, Cloudflare Pages and Vercel all
|
||||
//! answer `max-age=0, must-revalidate` for it by default, and an extra catch-all rule would be
|
||||
//! merged with (not replace) the asset rule on Netlify and Cloudflare.
|
||||
|
||||
use super::model::{Framework, Options, Render};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const IMMUTABLE: &str = "public, max-age=31536000, immutable";
|
||||
|
||||
/// The content-hashed paths of a build, as globs from the site root. `assets/` holds the fonts
|
||||
/// and images the exporter names by hash; the rest is what each toolchain emits.
|
||||
fn hashed(o: &Options) -> Vec<&'static str> {
|
||||
let mut v = match (o.framework, o.render) {
|
||||
(Framework::Astro, _) => vec!["/_astro/*"],
|
||||
(Framework::Vue, Render::Static) => vec!["/_nuxt/*"],
|
||||
(Framework::React, Render::Static) => vec!["/_next/static/*"],
|
||||
// Angular hashes its root files (main-*.js, chunk-*.js, styles-*.css, polyfills-*.js).
|
||||
(Framework::Angular, _) => vec!["/*.js", "/*.css"],
|
||||
_ => vec![],
|
||||
};
|
||||
// Vite and the html writer put theirs in assets/ as well.
|
||||
v.push("/assets/*");
|
||||
v
|
||||
}
|
||||
|
||||
/// The same globs as a Vercel `source` (path-to-regexp).
|
||||
fn vercel_source(glob: &str) -> String {
|
||||
match glob.strip_prefix("/*.") {
|
||||
Some(ext) => format!(r"/(.*)\.{ext}"),
|
||||
None => glob.replace('*', "(.*)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_spa(o: &Options) -> bool {
|
||||
o.render == Render::Spa && o.framework != Framework::Html && o.framework != Framework::Astro
|
||||
}
|
||||
|
||||
/// `_` folders that GitHub Pages' Jekyll would otherwise hide.
|
||||
fn needs_nojekyll(o: &Options) -> bool {
|
||||
matches!(
|
||||
(o.framework, o.render),
|
||||
(Framework::Astro, _)
|
||||
| (Framework::Vue, Render::Static)
|
||||
| (Framework::React, Render::Static)
|
||||
)
|
||||
}
|
||||
|
||||
/// Adds `_headers`, `vercel.json`, the SPA fallback and `.nojekyll` to `files`.
|
||||
pub(super) fn add(o: &Options, files: &mut BTreeMap<String, Vec<u8>>) {
|
||||
// Netlify, Cloudflare and GitHub read theirs from the built folder, which the npm
|
||||
// frameworks fill from `public/`; Vercel reads `vercel.json` from the project root. The
|
||||
// html writer's output folder is both.
|
||||
let dir = if o.framework == Framework::Html {
|
||||
""
|
||||
} else {
|
||||
"public/"
|
||||
};
|
||||
let globs = hashed(o);
|
||||
let mut headers = String::new();
|
||||
for g in &globs {
|
||||
headers.push_str(&format!("{g}\n Cache-Control: {IMMUTABLE}\n"));
|
||||
}
|
||||
let mut put = |name: &str, text: String| {
|
||||
files.insert(format!("{dir}{name}"), text.into_bytes());
|
||||
};
|
||||
put("_headers", headers);
|
||||
let rules: Vec<_> = globs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
serde_json::json!({
|
||||
"source": vercel_source(g),
|
||||
"headers": [{"key": "Cache-Control", "value": IMMUTABLE}],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut vercel = serde_json::json!({"headers": rules});
|
||||
if is_spa(o) {
|
||||
// Served only when no file matches, so assets and real pages still win.
|
||||
put("_redirects", "/* /index.html 200\n".into());
|
||||
vercel["rewrites"] = serde_json::json!([{"source": "/(.*)", "destination": "/index.html"}]);
|
||||
}
|
||||
if needs_nojekyll(o) {
|
||||
put(".nojekyll", String::new());
|
||||
}
|
||||
files.insert("vercel.json".into(), format!("{vercel:#}\n").into_bytes());
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! The html writer: one static `.html` file per page and language, no JavaScript, a shared
|
||||
//! stylesheet (written directly, or built by `npm run build` from SCSS or Tailwind), the font
|
||||
//! faces and pictures under `assets/`, `robots.txt` and, with a `base_url`, `sitemap.xml`.
|
||||
|
||||
use super::markup::{self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text};
|
||||
use super::model::{HeadTag, Output, Site, Styling};
|
||||
use super::tree::{Element, LinkTarget};
|
||||
use crate::assets;
|
||||
use serde_json::json;
|
||||
|
||||
/// Where the compiled stylesheet lands when `npm run build` makes it.
|
||||
const BUILT_CSS: &str = "assets/site.css";
|
||||
/// Hex digits of the content hash in `assets/site-<hash>.css`.
|
||||
const CSS_HASH_CHARS: usize = 8;
|
||||
const INDEX_CSS: &str = "body { font-family: system-ui, sans-serif; margin: 2rem; }\n";
|
||||
|
||||
pub fn write(site: &Site) -> Result<Output, String> {
|
||||
let mut out = Output {
|
||||
files: site.assets.clone(),
|
||||
..Output::default()
|
||||
};
|
||||
let css = stylesheet(site, &mut out)?;
|
||||
for loc in 0..site.locales.len() {
|
||||
for page in 0..site.pages.len() {
|
||||
out.files.insert(
|
||||
site.url_path(page, loc),
|
||||
page_html(site, page, loc, &css).into_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let multi = site.locales.iter().any(|l| l.folder.is_some());
|
||||
if multi {
|
||||
out.files
|
||||
.insert("index.html".into(), language_index(site).into_bytes());
|
||||
}
|
||||
if let Some(base) = &site.base_url {
|
||||
let abs = |loc: usize, page: usize| format!("{base}/{}", site.url_path(page, loc));
|
||||
out.files.insert(
|
||||
"sitemap.xml".into(),
|
||||
site.sitemap(&abs, multi.then(|| format!("{base}/index.html")))
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
out.files
|
||||
.insert("robots.txt".into(), site.robots().into_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the stylesheet sources and returns the path pages link, relative to the site root.
|
||||
fn stylesheet(site: &Site, out: &mut Output) -> Result<String, String> {
|
||||
let stems: Vec<(usize, String)> = site
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, p.stem.clone()))
|
||||
.collect();
|
||||
// The stylesheet sits in `assets/`, so the pictures it names are one folder up.
|
||||
match site.options.styling {
|
||||
Styling::Scss => {
|
||||
for (path, content) in site.style.scss("../", &stems) {
|
||||
out.files
|
||||
.insert(format!("src/styles/{path}"), content.into_bytes());
|
||||
}
|
||||
out.files.insert(
|
||||
"package.json".into(),
|
||||
package_json(
|
||||
"sass --no-source-map src/styles/main.scss assets/site.css",
|
||||
&[("sass", "^1.107.0")],
|
||||
)?,
|
||||
);
|
||||
out.next_steps.push("Run `npm install`, then `npm run build` in the output folder: it compiles src/styles/main.scss to assets/site.css, which every page links. Run `npm run build` again after changing the styles.".into());
|
||||
Ok(BUILT_CSS.into())
|
||||
}
|
||||
Styling::Tailwind => {
|
||||
let input = format!(
|
||||
"@import \"tailwindcss\";\n{}{}{}{}",
|
||||
site.style.tailwind_theme(),
|
||||
site.style.tokens_css(),
|
||||
site.style.fonts_css("../"),
|
||||
site.style.overrides_css("../", |_| true)
|
||||
);
|
||||
out.files.insert("src/input.css".into(), input.into_bytes());
|
||||
out.files.insert(
|
||||
"package.json".into(),
|
||||
package_json(
|
||||
"tailwindcss -i src/input.css -o assets/site.css --minify",
|
||||
&[("@tailwindcss/cli", "^4.0.0"), ("tailwindcss", "^4.0.0")],
|
||||
)?,
|
||||
);
|
||||
out.next_steps.push("Run `npm install`, then `npm run build` in the output folder: Tailwind scans the pages and writes assets/site.css, which every page links. Run it again after changing a page.".into());
|
||||
Ok(BUILT_CSS.into())
|
||||
}
|
||||
_ => {
|
||||
let css = site.style.all_css("../");
|
||||
let path = format!(
|
||||
"assets/site-{}.css",
|
||||
&assets::hash(css.as_bytes())[..CSS_HASH_CHARS]
|
||||
);
|
||||
out.files.insert(path.clone(), css.into_bytes());
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn package_json(build: &str, dev: &[(&str, &str)]) -> Result<Vec<u8>, String> {
|
||||
let deps: serde_json::Map<String, serde_json::Value> = dev
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), json!(v)))
|
||||
.collect();
|
||||
let pkg = json!({"private": true, "scripts": {"build": build}, "devDependencies": deps});
|
||||
serde_json::to_vec_pretty(&pkg)
|
||||
.map(|mut b| {
|
||||
b.push(b'\n');
|
||||
b
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
struct HtmlHooks<'a> {
|
||||
site: &'a Site,
|
||||
loc: usize,
|
||||
/// What a page in this locale's folder puts before a site-root path.
|
||||
prefix: &'a str,
|
||||
}
|
||||
|
||||
impl Hooks for HtmlHooks<'_> {
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
let class = if self.site.options.styling == Styling::Tailwind {
|
||||
el.tailwind_classes()
|
||||
} else {
|
||||
el.class.clone()
|
||||
};
|
||||
(!class.is_empty()).then_some(Val::Lit(class))
|
||||
}
|
||||
|
||||
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
|
||||
let href = match target {
|
||||
LinkTarget::Page(i) => format!("{}.html", self.site.pages[*i].stem),
|
||||
LinkTarget::Url(u) => u.clone(),
|
||||
};
|
||||
Link {
|
||||
tag: "a".into(),
|
||||
attrs: vec![("href".into(), Val::Lit(href))],
|
||||
}
|
||||
}
|
||||
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(format!("{}{path}", self.prefix))
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
TextOut::Lit(
|
||||
self.site.messages[self.loc]
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn page_html(site: &Site, page: usize, loc: usize, css: &str) -> String {
|
||||
let l = &site.locales[loc];
|
||||
let prefix = if l.folder.is_some() { "../" } else { "" };
|
||||
let url = |from: usize, to: usize, p: usize| {
|
||||
link_to(
|
||||
site.base_url.as_deref(),
|
||||
site.locales[from].folder.as_deref(),
|
||||
site.locales[to].folder.as_deref(),
|
||||
&format!("{}.html", site.pages[p].stem),
|
||||
)
|
||||
};
|
||||
let mut tags = site.head_tags(page, loc, &url);
|
||||
tags.extend(site.asset_tags(page, prefix));
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "stylesheet".into()),
|
||||
("href", format!("{prefix}{css}")),
|
||||
],
|
||||
});
|
||||
let mut body = String::new();
|
||||
markup::render(
|
||||
Dialect::Html,
|
||||
&HtmlHooks { site, loc, prefix },
|
||||
&site.pages[page].root,
|
||||
1,
|
||||
&mut body,
|
||||
);
|
||||
shell(
|
||||
l.tag.as_deref(),
|
||||
l.class.as_deref(),
|
||||
&site.pages[page].title[loc],
|
||||
&tags,
|
||||
&body,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// The document around `body`. `inline_css` is for the one page that has no stylesheet.
|
||||
fn shell(
|
||||
lang: Option<&str>,
|
||||
class: Option<&str>,
|
||||
title: &str,
|
||||
tags: &[HeadTag],
|
||||
body: &str,
|
||||
inline_css: Option<&str>,
|
||||
) -> String {
|
||||
let attr = |k: &str, v: Option<&str>| {
|
||||
v.map(|v| format!(" {k}=\"{}\"", escape_attr(Dialect::Html, v)))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut out = format!(
|
||||
"<!doctype html>\n<html{}{}>\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n",
|
||||
attr("lang", lang),
|
||||
attr("class", class)
|
||||
);
|
||||
out.push_str(&format!(
|
||||
" <title>{}</title>\n",
|
||||
escape_text(Dialect::Html, title)
|
||||
));
|
||||
for t in tags {
|
||||
out.push_str(&format!(" {}\n", t.to_html()));
|
||||
}
|
||||
if let Some(css) = inline_css {
|
||||
out.push_str(&format!(
|
||||
" <style>\n {}\n </style>\n",
|
||||
css.trim_end().replace("</", r"<\/")
|
||||
));
|
||||
}
|
||||
out.push_str("</head>\n<body>\n");
|
||||
out.push_str(body);
|
||||
out.push_str("</body>\n</html>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The root page of a site with several languages or modes: one link per version.
|
||||
fn language_index(site: &Site) -> String {
|
||||
let url = |to: usize| {
|
||||
link_to(
|
||||
site.base_url.as_deref(),
|
||||
None,
|
||||
site.locales[to].folder.as_deref(),
|
||||
"index.html",
|
||||
)
|
||||
};
|
||||
let langs = site.language_locales();
|
||||
let mut tags = vec![
|
||||
HeadTag {
|
||||
tag: "meta",
|
||||
attrs: vec![
|
||||
("name", "description".into()),
|
||||
("content", "Choose a version of this site.".into()),
|
||||
],
|
||||
},
|
||||
HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "canonical".into()),
|
||||
(
|
||||
"href",
|
||||
link_to(site.base_url.as_deref(), None, None, "index.html"),
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
for &l in &langs {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", site.locales[l].tag.clone().unwrap_or_default()),
|
||||
("href", url(l)),
|
||||
],
|
||||
});
|
||||
}
|
||||
if let Some(&first) = langs.first() {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", "x-default".into()),
|
||||
("href", url(first)),
|
||||
],
|
||||
});
|
||||
}
|
||||
let items: String = site
|
||||
.locales
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let href = link_to(None, None, l.folder.as_deref(), "index.html");
|
||||
let lang = l
|
||||
.tag
|
||||
.as_ref()
|
||||
.filter(|_| langs.contains(&i))
|
||||
.map(|t| {
|
||||
format!(
|
||||
" lang=\"{0}\" hreflang=\"{0}\"",
|
||||
escape_attr(Dialect::Html, t)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" <li><a href=\"{}\"{lang}>{}</a></li>\n",
|
||||
escape_attr(Dialect::Html, &href),
|
||||
escape_text(Dialect::Html, &l.label)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let body = format!(
|
||||
" <main>\n <h1>{}</h1>\n <ul>\n{items} </ul>\n </main>\n",
|
||||
escape_text(Dialect::Html, &site.site_title)
|
||||
);
|
||||
shell(
|
||||
site.index_lang(),
|
||||
None,
|
||||
&site.site_title,
|
||||
&tags,
|
||||
&body,
|
||||
Some(INDEX_CSS),
|
||||
)
|
||||
}
|
||||
|
||||
/// A URL for `file` in folder `to`, as seen from a page in folder `from`: absolute under `base`,
|
||||
/// else relative.
|
||||
pub fn link_to(base: Option<&str>, from: Option<&str>, to: Option<&str>, file: &str) -> String {
|
||||
if let Some(base) = base {
|
||||
return format!(
|
||||
"{base}/{}{file}",
|
||||
to.map(|d| format!("{d}/")).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
match (from, to) {
|
||||
(f, t) if f == t => file.to_owned(),
|
||||
(f, Some(t)) => format!("{}{t}/{file}", if f.is_some() { "../" } else { "" }),
|
||||
(_, None) => file.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn css_cannot_close_the_style_block_of_the_index_page() {
|
||||
let out = shell(
|
||||
None,
|
||||
None,
|
||||
"t",
|
||||
&[],
|
||||
"",
|
||||
Some(".x { font-family: \"</style><script>x</script>\"; }"),
|
||||
);
|
||||
assert!(
|
||||
out.contains(r"<\/style>") && out.matches("</style>").count() == 1,
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_relative_between_folders_and_absolute_with_a_base() {
|
||||
assert_eq!(link_to(None, Some("en"), Some("en"), "a.html"), "a.html");
|
||||
assert_eq!(
|
||||
link_to(None, Some("en"), Some("pt-BR"), "a.html"),
|
||||
"../pt-BR/a.html"
|
||||
);
|
||||
assert_eq!(
|
||||
link_to(None, None, Some("pt-BR"), "index.html"),
|
||||
"pt-BR/index.html"
|
||||
);
|
||||
assert_eq!(
|
||||
link_to(Some("https://x.test"), Some("en"), Some("pt-BR"), "a.html"),
|
||||
"https://x.test/pt-BR/a.html"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The site icon: the layer named Favicon, written as the page icon.
|
||||
|
||||
use super::media::Media;
|
||||
use crate::mcp::codegen;
|
||||
use crate::model::{Document, NodeKind, Paint};
|
||||
|
||||
/// The path of the icon file for the first visible layer named `favicon` (any case) on the page:
|
||||
/// the picture itself when it is one image, else the layer drawn as SVG. None when there is no
|
||||
/// such layer or it cannot be drawn.
|
||||
pub fn favicon(media: &mut Media, doc: &Document) -> Option<String> {
|
||||
let id = doc.root.iter().flat_map(|r| doc.subtree(*r)).find(|id| {
|
||||
doc.get(*id)
|
||||
.is_some_and(|n| n.visible && n.name.trim().eq_ignore_ascii_case("favicon"))
|
||||
})?;
|
||||
let n = doc.get(id)?;
|
||||
if let [Paint::Image { asset, .. }] = n.style.fills.as_slice()
|
||||
&& !matches!(n.kind, NodeKind::Text { .. })
|
||||
{
|
||||
return media.image(asset, 0.0);
|
||||
}
|
||||
let svg = codegen::inline_svg(doc, id);
|
||||
svg.contains("<svg").then(|| media.svg(&svg))
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Prints an element tree as HTML, JSX, a Vue or Angular template, or Astro. Every piece of
|
||||
//! user text (layer names, copy, URLs, font names) is escaped for the dialect it lands in; the
|
||||
//! hooks only choose names and expressions, never raw user text.
|
||||
|
||||
use super::tree::{Child, Element, LinkTarget, Text};
|
||||
use crate::mcp::codegen::{Escape, escape};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dialect {
|
||||
Html,
|
||||
Jsx,
|
||||
Vue,
|
||||
Angular,
|
||||
Astro,
|
||||
}
|
||||
|
||||
/// An attribute value: a literal (escaped here) or an expression written by a hook.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Val {
|
||||
Lit(String),
|
||||
Expr(String),
|
||||
}
|
||||
|
||||
/// Text content: a literal (escaped here) or a snippet that is already valid in the dialect
|
||||
/// (`{t.key}`, `{{ t.key }}`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TextOut {
|
||||
Lit(String),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
/// How a link is written: its tag (`a`, `Link`, `NuxtLink`...) and attributes.
|
||||
pub struct Link {
|
||||
pub tag: String,
|
||||
pub attrs: Vec<(String, Val)>,
|
||||
}
|
||||
|
||||
/// What differs between frameworks. Defaults write plain HTML.
|
||||
pub trait Hooks {
|
||||
/// The `class` attribute of `el`, if it has one.
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
(!el.class.is_empty()).then(|| Val::Lit(el.class.clone()))
|
||||
}
|
||||
|
||||
fn tag(&self, el: &Element) -> String {
|
||||
el.tag.clone()
|
||||
}
|
||||
|
||||
/// The anchor for a link, with a locale-aware `href`. `el` is the anchor element itself.
|
||||
fn link(&self, el: &Element, target: &LinkTarget) -> Link;
|
||||
|
||||
/// A site-root relative asset path (`assets/..`) as the page must write it.
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(path.to_owned())
|
||||
}
|
||||
|
||||
/// The text for a message key.
|
||||
fn message(&self, key: &str) -> TextOut;
|
||||
}
|
||||
|
||||
pub fn render(d: Dialect, h: &dyn Hooks, el: &Element, depth: usize, out: &mut String) {
|
||||
let pad = " ".repeat(depth);
|
||||
let (tag, mut attrs) = match (&el.link, el.tag.as_str()) {
|
||||
(Some(target), "a") => {
|
||||
let l = h.link(el, target);
|
||||
(l.tag, l.attrs)
|
||||
}
|
||||
_ => (h.tag(el), Vec::new()),
|
||||
};
|
||||
let mut all: Vec<(String, Val)> = h
|
||||
.class(el)
|
||||
.map(|c| ("class".to_owned(), c))
|
||||
.into_iter()
|
||||
.collect();
|
||||
all.append(&mut attrs);
|
||||
for (k, v) in &el.attrs {
|
||||
let val = if k == "src" {
|
||||
h.asset(v)
|
||||
} else {
|
||||
Val::Lit(v.clone())
|
||||
};
|
||||
all.push((k.clone(), val));
|
||||
}
|
||||
let mut open = format!("{pad}<{tag}");
|
||||
for (k, v) in &all {
|
||||
open.push(' ');
|
||||
open.push_str(&attribute(d, k, v));
|
||||
}
|
||||
if is_void(&el.tag) && el.children.is_empty() {
|
||||
out.push_str(&open);
|
||||
out.push_str(if d == Dialect::Html { ">\n" } else { " />\n" });
|
||||
return;
|
||||
}
|
||||
open.push('>');
|
||||
if let [Child::Text(t)] = el.children.as_slice() {
|
||||
out.push_str(&format!("{open}{}</{tag}>\n", text(d, h, t)));
|
||||
return;
|
||||
}
|
||||
out.push_str(&open);
|
||||
out.push('\n');
|
||||
for c in &el.children {
|
||||
match c {
|
||||
Child::El(e) => render(d, h, e, depth + 1, out),
|
||||
Child::Text(t) => out.push_str(&format!("{pad} {}\n", text(d, h, t))),
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("{pad}</{tag}>\n"));
|
||||
}
|
||||
|
||||
fn is_void(tag: &str) -> bool {
|
||||
matches!(tag, "img" | "br" | "hr" | "input" | "meta" | "link")
|
||||
}
|
||||
|
||||
fn text(d: Dialect, h: &dyn Hooks, t: &Text) -> String {
|
||||
let out = match t {
|
||||
Text::Lit(s) => TextOut::Lit(s.clone()),
|
||||
Text::Msg(key) => h.message(key),
|
||||
};
|
||||
match out {
|
||||
TextOut::Raw(s) => s,
|
||||
TextOut::Lit(s) => escape_text(d, &s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text between tags.
|
||||
pub fn escape_text(d: Dialect, s: &str) -> String {
|
||||
match d {
|
||||
// A string expression: JSX would parse braces and entities in bare text.
|
||||
Dialect::Jsx => escape(Escape::Jsx, s),
|
||||
_ => entities(d, s),
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-quoted attribute value.
|
||||
pub fn escape_attr(d: Dialect, s: &str) -> String {
|
||||
entities(d, s)
|
||||
}
|
||||
|
||||
/// Numeric and named entities for what each template language would act on: HTML's own
|
||||
/// characters everywhere, braces for Vue, Angular and Astro, and Angular's `@`, plus the
|
||||
/// characters that would end an inline TypeScript template literal.
|
||||
fn entities(d: Dialect, s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
// Angular decodes entities before it looks for `{{`, so a literal brace must be an interpolation of a string.
|
||||
'{' if d == Dialect::Angular => out.push_str("{{ '{' }}"),
|
||||
'{' | '}' if matches!(d, Dialect::Vue | Dialect::Angular | Dialect::Astro) => {
|
||||
out.push_str(if c == '{' { "{" } else { "}" })
|
||||
}
|
||||
'@' if d == Dialect::Angular => out.push_str("@"),
|
||||
'\\' if d == Dialect::Angular => out.push_str("\"),
|
||||
'`' if d == Dialect::Angular => out.push_str("`"),
|
||||
'$' if d == Dialect::Angular => out.push_str("$"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn attribute(d: Dialect, name: &str, v: &Val) -> String {
|
||||
let name = attribute_name(d, name);
|
||||
match (v, d) {
|
||||
(Val::Lit(s), _) => format!("{name}=\"{}\"", escape_attr(d, s)),
|
||||
(Val::Expr(e), Dialect::Jsx | Dialect::Astro) => format!("{name}={{{e}}}"),
|
||||
(Val::Expr(e), Dialect::Vue) => format!(":{name}=\"{}\"", entities(Dialect::Html, e)),
|
||||
(Val::Expr(e), Dialect::Angular) => format!("[{name}]=\"{}\"", entities(Dialect::Html, e)),
|
||||
// Plain HTML has no expressions; show the text rather than run it.
|
||||
(Val::Expr(e), Dialect::Html) => format!("{name}=\"{}\"", entities(Dialect::Html, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn attribute_name(d: Dialect, name: &str) -> String {
|
||||
if d != Dialect::Jsx {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name {
|
||||
"class" => "className",
|
||||
"fetchpriority" => "fetchPriority",
|
||||
"tabindex" => "tabIndex",
|
||||
"for" => "htmlFor",
|
||||
"srcset" => "srcSet",
|
||||
other => other,
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Plain;
|
||||
impl Hooks for Plain {
|
||||
fn link(&self, _: &Element, t: &LinkTarget) -> Link {
|
||||
let href = match t {
|
||||
LinkTarget::Page(i) => format!("p{i}.html"),
|
||||
LinkTarget::Url(u) => u.clone(),
|
||||
};
|
||||
Link {
|
||||
tag: "a".into(),
|
||||
attrs: vec![("href".into(), Val::Lit(href))],
|
||||
}
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
TextOut::Raw(format!("{{t.{key}}}"))
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE: &str = "</script>{{x}}@if (a) {b}${x}`<img onerror=alert(1)>";
|
||||
|
||||
fn page(d: Dialect) -> String {
|
||||
let mut out = String::new();
|
||||
let mut root = Element {
|
||||
tag: "div".into(),
|
||||
class: "root".into(),
|
||||
attrs: vec![],
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children: vec![],
|
||||
};
|
||||
let alt = HOSTILE.to_owned();
|
||||
root.children.push(Child::El(Element {
|
||||
tag: "img".into(),
|
||||
class: "c".into(),
|
||||
attrs: vec![("alt".into(), alt), ("fetchpriority".into(), "high".into())],
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children: vec![],
|
||||
}));
|
||||
root.children.push(Child::El(Element {
|
||||
tag: "a".into(),
|
||||
class: String::new(),
|
||||
attrs: vec![],
|
||||
link: Some(LinkTarget::Url("https://x.test/?a=\"><b>&c".into())),
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children: vec![Child::Text(Text::Lit(HOSTILE.into()))],
|
||||
}));
|
||||
render(d, &Plain, &root, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escapes_text_and_attribute_values() {
|
||||
let out = page(Dialect::Html);
|
||||
assert!(
|
||||
!out.contains("<img onerror") && !out.contains("</script>") && !out.contains("\"><b>"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("</script>")
|
||||
&& out.contains("href=\"https://x.test/?a="><b>&c\""),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsx_writes_class_name_self_closes_and_turns_hostile_text_into_a_string() {
|
||||
let out = page(Dialect::Jsx);
|
||||
assert!(
|
||||
out.contains("<img className=\"c\"")
|
||||
&& out.contains("fetchPriority=\"high\"")
|
||||
&& out.contains(" />"),
|
||||
"{out}"
|
||||
);
|
||||
let json = serde_json::to_string(HOSTILE).unwrap();
|
||||
assert!(out.contains(&format!("{{{json}}}")), "{out}");
|
||||
assert!(!out.contains("alt=\"</"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_and_astro_cannot_interpolate_user_text() {
|
||||
for d in [Dialect::Vue, Dialect::Astro] {
|
||||
let out = page(d);
|
||||
assert!(
|
||||
!out.contains("{{") && !out.contains("{b}") && !out.contains("<img onerror"),
|
||||
"{d:?}: {out}"
|
||||
);
|
||||
assert!(out.contains("{{x}}"), "{d:?}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_text_has_no_binding_block_template_literal_or_control_flow_syntax() {
|
||||
let out = page(Dialect::Angular);
|
||||
let out = out.replace("{{ '{' }}", "");
|
||||
for bad in ["{{", "@if", "${", "`", "\\", "{b}", "<img onerror"] {
|
||||
assert!(!out.contains(bad), "`{bad}` in {out}");
|
||||
}
|
||||
assert!(
|
||||
out.contains("@if") && out.contains("$x}"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expressions_use_each_dialects_binding_syntax() {
|
||||
let (v, e) = (Val::Expr("styles.card".into()), "class");
|
||||
assert_eq!(attribute(Dialect::Jsx, e, &v), "className={styles.card}");
|
||||
assert_eq!(attribute(Dialect::Astro, e, &v), "class={styles.card}");
|
||||
assert_eq!(
|
||||
attribute(Dialect::Vue, e, &Val::Expr("a \"b\"".into())),
|
||||
":class=\"a "b"\""
|
||||
);
|
||||
assert_eq!(
|
||||
attribute(Dialect::Angular, e, &v),
|
||||
"[class]=\"styles.card\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_come_from_the_hook_verbatim() {
|
||||
let root = Element {
|
||||
tag: "p".into(),
|
||||
class: String::new(),
|
||||
attrs: vec![],
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children: vec![Child::Text(Text::Msg("hero".into()))],
|
||||
};
|
||||
let mut out = String::new();
|
||||
render(Dialect::Jsx, &Plain, &root, 0, &mut out);
|
||||
assert_eq!(out, "<p>{t.hero}</p>\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The files a site needs besides its pages: images (shrunk when far larger than shown), vector
|
||||
//! drawings and the font faces its text uses. Everything is content-hashed, so a re-export
|
||||
//! writes the same names and the same bytes.
|
||||
|
||||
use super::fontsub::{self, Fallback};
|
||||
use super::style::FontFace;
|
||||
use crate::model::{Document, NodeId, NodeKind, TextStyle};
|
||||
use crate::text::{BUILT_IN_FACES, face_bytes, face_id};
|
||||
use crate::{assets, fonts};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Pixels per displayed pixel kept in an image: 2x covers high-density screens.
|
||||
const DENSITY: f64 = 2.0;
|
||||
const JPEG_QUALITY: u8 = 85;
|
||||
pub const INTER: &str = "Inter";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Media {
|
||||
/// Site-root relative path to bytes.
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
/// Image ids that are not loaded.
|
||||
pub missing: BTreeSet<String>,
|
||||
shrunk: HashMap<(String, u32), String>,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
fn put(&mut self, folder: &str, bytes: Vec<u8>, ext: &str) -> String {
|
||||
let path = format!("{folder}/{}.{ext}", assets::hash(&bytes));
|
||||
self.files.entry(path.clone()).or_insert(bytes);
|
||||
path
|
||||
}
|
||||
|
||||
/// The path of image `id` for a picture shown `width` px wide: the original, or a copy
|
||||
/// scaled down to twice the width when it has more pixels than that. None when not loaded.
|
||||
pub fn image(&mut self, id: &str, width: f64) -> Option<String> {
|
||||
let Some(asset) = assets::get(id) else {
|
||||
self.missing.insert(id.to_owned());
|
||||
return None;
|
||||
};
|
||||
let target = (width * DENSITY).ceil();
|
||||
if width > 0.0
|
||||
&& f64::from(asset.width) > target
|
||||
&& let Some(path) = self.shrink(&asset, target as u32)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
Some(self.original(&asset))
|
||||
}
|
||||
|
||||
/// The path of image `id` at its own size (a CSS background).
|
||||
pub fn background(&mut self, id: &str) -> Option<String> {
|
||||
let Some(asset) = assets::get(id) else {
|
||||
self.missing.insert(id.to_owned());
|
||||
return None;
|
||||
};
|
||||
Some(self.original(&asset))
|
||||
}
|
||||
|
||||
fn original(&mut self, asset: &assets::ImageAsset) -> String {
|
||||
let path = format!("assets/{}.{}", asset.id, asset.ext);
|
||||
self.files
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| asset.bytes.clone());
|
||||
path
|
||||
}
|
||||
|
||||
fn shrink(&mut self, asset: &assets::ImageAsset, width: u32) -> Option<String> {
|
||||
let key = (asset.id.clone(), width);
|
||||
if let Some(p) = self.shrunk.get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let bytes = scaled(asset, width)?;
|
||||
let path = self.put("assets", bytes, asset.ext);
|
||||
self.shrunk.insert(key, path.clone());
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// A vector drawing as `assets/<hash>.svg`.
|
||||
pub fn svg(&mut self, svg: &str) -> String {
|
||||
// The exporter writes an inline `<svg>`; as a file it needs its namespace.
|
||||
let svg = if svg.contains("xmlns=") {
|
||||
svg.to_owned()
|
||||
} else {
|
||||
svg.replacen("<svg", "<svg xmlns=\"http://www.w3.org/2000/svg\"", 1)
|
||||
};
|
||||
self.put("assets", svg.into_bytes(), "svg")
|
||||
}
|
||||
}
|
||||
|
||||
/// The image re-encoded in its own format at `width`, if that is smaller than the original
|
||||
/// (gif is left alone: re-encoding loses frames).
|
||||
fn scaled(asset: &assets::ImageAsset, width: u32) -> Option<Vec<u8>> {
|
||||
use image::{DynamicImage, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType};
|
||||
let format = match asset.ext {
|
||||
"png" => ImageFormat::Png,
|
||||
"jpg" => ImageFormat::Jpeg,
|
||||
"webp" => ImageFormat::WebP,
|
||||
_ => return None,
|
||||
};
|
||||
let height = ((f64::from(asset.height) * f64::from(width) / f64::from(asset.width)).round()
|
||||
as u32)
|
||||
.max(1);
|
||||
let small = image::imageops::resize(&asset.rgba()?, width, height, FilterType::Lanczos3);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
if format == ImageFormat::Jpeg {
|
||||
// JPEG has no alpha channel.
|
||||
let rgb = DynamicImage::ImageRgba8(small).to_rgb8();
|
||||
JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
|
||||
.encode_image(&rgb)
|
||||
.ok()?;
|
||||
} else {
|
||||
DynamicImage::ImageRgba8(small)
|
||||
.write_to(&mut out, format)
|
||||
.ok()?;
|
||||
}
|
||||
let bytes = out.into_inner();
|
||||
(bytes.len() < asset.bytes.len()).then_some(bytes)
|
||||
}
|
||||
|
||||
/// The family a text layer is drawn in: its installed family, else Inter (which also stands in
|
||||
/// for a family that is not installed, so the page matches the canvas).
|
||||
pub fn family(style: &TextStyle) -> String {
|
||||
match style.font.as_deref() {
|
||||
Some(f) if !f.eq_ignore_ascii_case(INTER) => {
|
||||
fonts::styles(f).map_or_else(|| INTER.to_owned(), |(name, _)| name)
|
||||
}
|
||||
_ => INTER.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Font families named by text that are not installed.
|
||||
pub fn missing_families(doc: &Document, roots: &[NodeId]) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for n in roots
|
||||
.iter()
|
||||
.flat_map(|r| doc.subtree(*r))
|
||||
.filter_map(|id| doc.get(id))
|
||||
{
|
||||
if let (NodeKind::Text { .. }, Some(f)) = (&n.kind, n.text_style.font.as_deref())
|
||||
&& !f.eq_ignore_ascii_case(INTER)
|
||||
&& fonts::styles(f).is_none()
|
||||
{
|
||||
out.insert(f.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What the text uses a face for.
|
||||
#[derive(Default)]
|
||||
pub struct FaceUse {
|
||||
/// Characters in the first document, to pick the body face.
|
||||
pub chars: usize,
|
||||
/// Every character of every document (language), to subset the face to.
|
||||
pub glyphs: BTreeSet<char>,
|
||||
}
|
||||
|
||||
/// `(family, bold, italic)` of the faces the text uses, and what they are used for.
|
||||
pub fn font_uses(docs: &[Document], roots: &[NodeId]) -> BTreeMap<(String, bool, bool), FaceUse> {
|
||||
let mut uses: BTreeMap<(String, bool, bool), FaceUse> = BTreeMap::new();
|
||||
for (i, doc) in docs.iter().enumerate() {
|
||||
for n in roots
|
||||
.iter()
|
||||
.flat_map(|r| doc.subtree(*r))
|
||||
.filter_map(|id| doc.get(id))
|
||||
{
|
||||
let NodeKind::Text { text, .. } = &n.kind else {
|
||||
continue;
|
||||
};
|
||||
let fam = family(&n.text_style);
|
||||
let mut keys = vec![(fam.clone(), n.text_style.bold, n.text_style.italic)];
|
||||
for s in &n.text_style.spans {
|
||||
keys.push((
|
||||
fam.clone(),
|
||||
s.bold.unwrap_or(n.text_style.bold),
|
||||
s.italic.unwrap_or(n.text_style.italic),
|
||||
));
|
||||
}
|
||||
for (k, key) in keys.into_iter().enumerate() {
|
||||
let e = uses.entry(key).or_default();
|
||||
e.glyphs.extend(text.chars());
|
||||
if k == 0 && i == 0 {
|
||||
e.chars += text.chars().count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
uses
|
||||
}
|
||||
|
||||
/// A `@font-face` per face in `uses` (its file subset to the characters used, in `media`), the
|
||||
/// index of the face with the most characters (the body text, worth preloading), a fallback face
|
||||
/// per family and what went wrong on the way.
|
||||
pub fn faces(
|
||||
media: &mut Media,
|
||||
uses: &BTreeMap<(String, bool, bool), FaceUse>,
|
||||
) -> (Vec<FontFace>, Option<usize>, Vec<Fallback>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut fallbacks: Vec<Fallback> = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for ((family, bold, italic), used) in uses {
|
||||
let id = if family == INTER {
|
||||
face_id(*bold, *italic)
|
||||
} else {
|
||||
fonts::style_face(Some(family), *bold, *italic)
|
||||
};
|
||||
let bytes = if id < BUILT_IN_FACES {
|
||||
face_bytes(id)
|
||||
} else {
|
||||
fonts::loaded(id).bytes
|
||||
};
|
||||
let cff = ttf_parser::Face::parse(bytes, 0).is_ok_and(|f| f.tables().cff.is_some());
|
||||
let keep = fontsub::keep(&used.glyphs);
|
||||
let packed = fontsub::pack(bytes, &keep, cff).unwrap_or_else(|why| {
|
||||
warnings.push(format!(
|
||||
"font `{family}` could not be subset ({why}); the whole file is shipped"
|
||||
));
|
||||
let (ext, format) = if cff {
|
||||
("otf", "opentype")
|
||||
} else {
|
||||
("ttf", "truetype")
|
||||
};
|
||||
fontsub::Packed {
|
||||
bytes: bytes.to_vec(),
|
||||
ext,
|
||||
format,
|
||||
note: None,
|
||||
}
|
||||
});
|
||||
if let Some(note) = packed.note {
|
||||
warnings.push(format!("font `{family}`: {note}"));
|
||||
}
|
||||
if !fallbacks
|
||||
.iter()
|
||||
.any(|f| f.family == fontsub::fallback_name(family))
|
||||
{
|
||||
let lower = family.to_lowercase();
|
||||
let mono = ["mono", "code", "courier", "consolas"]
|
||||
.iter()
|
||||
.any(|k| lower.contains(k));
|
||||
fallbacks.extend(fontsub::fallback(family, mono, bytes));
|
||||
}
|
||||
let path = media.put("assets/fonts", packed.bytes, packed.ext);
|
||||
if used.chars > 0 && best.is_none_or(|(_, c)| used.chars > c) {
|
||||
best = Some((out.len(), used.chars));
|
||||
}
|
||||
out.push(FontFace {
|
||||
family: family.clone(),
|
||||
weight: if *bold { 700 } else { 400 },
|
||||
italic: *italic,
|
||||
path,
|
||||
format: packed.format,
|
||||
});
|
||||
}
|
||||
(out, best.map(|b| b.0), fallbacks, warnings)
|
||||
}
|
||||
|
||||
/// The CSS `font-family` value for a family: quoted, with a generic fallback so text shows
|
||||
/// while the face loads.
|
||||
pub fn font_stack(family: &str) -> String {
|
||||
let lower = family.to_lowercase();
|
||||
let generic = if ["mono", "code", "courier", "consolas"]
|
||||
.iter()
|
||||
.any(|k| lower.contains(k))
|
||||
{
|
||||
"ui-monospace, monospace"
|
||||
} else {
|
||||
"system-ui, sans-serif"
|
||||
};
|
||||
format!(
|
||||
"{}, {}, {generic}",
|
||||
crate::mcp::codegen::css_value("fontFamily", family),
|
||||
crate::mcp::codegen::css_string(&fontsub::fallback_name(family))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> String {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, 90, 255])
|
||||
});
|
||||
assets::register_rgba(&img)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_far_larger_than_shown_is_scaled_to_twice_its_size_and_the_rest_are_kept() {
|
||||
let id = png(400, 200);
|
||||
let mut m = Media::default();
|
||||
let small = m.image(&id, 100.0).unwrap();
|
||||
let (w, h) = {
|
||||
let img = image::load_from_memory(&m.files[&small]).unwrap();
|
||||
(img.width(), img.height())
|
||||
};
|
||||
assert_eq!((w, h), (200, 100));
|
||||
assert!(small.ends_with(".png") && small != format!("assets/{id}.png"));
|
||||
assert_eq!(m.image(&id, 100.0).unwrap(), small, "same size, same file");
|
||||
assert_eq!(
|
||||
m.image(&id, 300.0).unwrap(),
|
||||
format!("assets/{id}.png"),
|
||||
"under 2x is not resized"
|
||||
);
|
||||
assert!(m.image("nope", 10.0).is_none() && m.missing.contains("nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_that_is_not_installed_is_drawn_in_inter_and_stacks_have_fallbacks() {
|
||||
let style = TextStyle {
|
||||
font: Some("No Such Font".into()),
|
||||
..TextStyle::default()
|
||||
};
|
||||
assert_eq!(family(&style), "Inter");
|
||||
assert_eq!(
|
||||
font_stack("Inter"),
|
||||
"\"Inter\", \"Inter Fallback\", system-ui, sans-serif"
|
||||
);
|
||||
assert!(font_stack("Fira Code").ends_with("ui-monospace, monospace"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//! `export_site`: frames to a website folder, written in one call. `tree` turns the frames into
|
||||
//! a framework-neutral element tree and style model (semantic tags, merged classes, locale
|
||||
//! messages); a writer per framework prints it, and `write` puts the files on disk safely. The
|
||||
//! result is a short summary, so an agent never has to read the output back.
|
||||
|
||||
mod angular;
|
||||
mod astro;
|
||||
mod contrast;
|
||||
mod fluid;
|
||||
mod fontsub;
|
||||
mod host;
|
||||
mod html;
|
||||
mod icon;
|
||||
mod markup;
|
||||
mod media;
|
||||
mod model;
|
||||
mod react;
|
||||
mod semantic;
|
||||
mod style;
|
||||
mod tree;
|
||||
mod vue;
|
||||
|
||||
use super::exec::{self, Host};
|
||||
use super::{Out, ToolResult};
|
||||
use crate::editor::Editor;
|
||||
use crate::io::backup::safe_relative;
|
||||
use crate::model::{Document, NodeId};
|
||||
use model::{Frame, Framework, Locale, Options, Site, Styling, is_lang_tag};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
use style::unique;
|
||||
|
||||
const MANIFEST: &str = ".papercraft-site.json";
|
||||
const GENERATOR: &str = "papercraft export_site";
|
||||
/// Bumped when the layout of the folder changes.
|
||||
const FORMAT: u32 = 1;
|
||||
/// Longest file name stem made from a frame name.
|
||||
const STEM_CHARS: usize = 60;
|
||||
/// Folder names a language or mode cannot take: they hold the site's own files.
|
||||
const RESERVED_FOLDERS: [&str; 2] = ["assets", "src"];
|
||||
|
||||
pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
|
||||
let options = Options::parse(a)?;
|
||||
let path = a
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty());
|
||||
let dir = Path::new(path.ok_or("`path` is required: the folder to write the site into")?)
|
||||
.to_path_buf();
|
||||
exec::out_folder(&dir, MANIFEST, is_site_manifest)?;
|
||||
let base_url = match a
|
||||
.get("base_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(|b| b.trim().trim_end_matches('/'))
|
||||
.filter(|b| !b.is_empty())
|
||||
{
|
||||
Some(b) if b.starts_with("http://") || b.starts_with("https://") => Some(b.to_owned()),
|
||||
Some(b) => {
|
||||
return Err(format!(
|
||||
"`base_url` must start with http:// or https:// (got `{b}`)"
|
||||
));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let text = |k: &str| {
|
||||
a.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
let lang = match text("lang") {
|
||||
Some(l) if is_lang_tag(l) => Some(l.to_owned()),
|
||||
Some(l) => {
|
||||
return Err(format!(
|
||||
"`lang` must be a language tag such as `en` or `pt-BR` (got `{l}`)"
|
||||
));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let project = host.project().map(|p| p.manifest.name.clone());
|
||||
|
||||
let ed = host.editor();
|
||||
let page = match text("pageId") {
|
||||
Some(p) => exec::find_page(ed, p)?,
|
||||
None => ed.active_page,
|
||||
};
|
||||
let ids = pick_frames(ed, page, a)?;
|
||||
let (locales, docs) = variants(ed, page, a, lang)?;
|
||||
let frames = name_frames(&docs[0], ids);
|
||||
let titles = localized(a, "title", &locales)?;
|
||||
let descriptions = localized(a, "description", &locales)?;
|
||||
|
||||
let built = tree::build(tree::Params {
|
||||
docs: &docs,
|
||||
frames: &frames,
|
||||
locales: &locales,
|
||||
tailwind: options.styling == Styling::Tailwind,
|
||||
title: &titles,
|
||||
description: &descriptions,
|
||||
});
|
||||
let site = Site {
|
||||
options,
|
||||
site_title: titles[0]
|
||||
.clone()
|
||||
.or(project)
|
||||
.unwrap_or_else(|| "Site".into()),
|
||||
base_url,
|
||||
locales,
|
||||
pages: built.pages,
|
||||
messages: built.messages,
|
||||
assets: built.media.files,
|
||||
style: built.style,
|
||||
warnings: built.warnings,
|
||||
};
|
||||
let mut output = match options.framework {
|
||||
Framework::Html => html::write(&site)?,
|
||||
Framework::React => react::write(&site)?,
|
||||
Framework::Vue => vue::write(&site)?,
|
||||
Framework::Angular => angular::write(&site)?,
|
||||
Framework::Astro => astro::write(&site)?,
|
||||
};
|
||||
host::add(&options, &mut output.files);
|
||||
let mut warnings = site.warnings.clone();
|
||||
warnings.append(&mut output.warnings);
|
||||
|
||||
let pages = match options.framework {
|
||||
Framework::Html => output.files.keys().filter(|k| k.ends_with(".html")).count(),
|
||||
_ => site.pages.len() * site.locales.len(),
|
||||
};
|
||||
let bytes = output.files.values().map(Vec::len).sum::<usize>();
|
||||
let total = output.files.len();
|
||||
let (written, removed, kept) = write(&dir, output.files)?;
|
||||
warnings.extend(kept.iter().map(|k| format!("`{k}` was edited after the last export, so it was kept; delete it by hand if it is unwanted")));
|
||||
let mut out = json!({
|
||||
"dir": dir, "framework": chosen(a, "framework", "html"), "render": chosen(a, "render", "static"), "styling": chosen(a, "styling", "css"),
|
||||
"pages": pages, "files": total, "bytes": bytes, "written": written, "removed": removed,
|
||||
"links": {"resolved": built.resolved, "unresolved": built.unresolved},
|
||||
"urls": if site.base_url.is_some() { "absolute (sitemap.xml written)" } else { "relative (pass base_url for absolute hreflang and canonical URLs and a sitemap)" },
|
||||
"next_steps": output.next_steps,
|
||||
"warnings": warnings,
|
||||
});
|
||||
if site.locales.iter().any(|l| l.folder.is_some()) {
|
||||
out["modes"] = json!(
|
||||
site.locales
|
||||
.iter()
|
||||
.map(|l| l.folder.clone().unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
Ok(vec![Out::Text(
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())?,
|
||||
)])
|
||||
}
|
||||
|
||||
/// A text option per locale: a string for all of them, or an object keyed by mode name. A
|
||||
/// language without an entry takes the base language's, else the first entry in key order
|
||||
/// (the arguments arrive sorted, so "first given" is not recoverable).
|
||||
fn localized(a: &Value, key: &str, locales: &[Locale]) -> Result<Vec<Option<String>>, String> {
|
||||
let clean = |s: &str| Some(s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||
let wrong = || {
|
||||
format!(
|
||||
"`{key}` must be a string, or an object of strings keyed by mode name such as {{\"en\": \"...\", \"pt-BR\": \"...\"}}"
|
||||
)
|
||||
};
|
||||
match a.get(key) {
|
||||
None | Some(Value::Null) => Ok(vec![None; locales.len()]),
|
||||
Some(Value::String(s)) => Ok(vec![clean(s); locales.len()]),
|
||||
Some(Value::Object(map)) => {
|
||||
let mut entries = BTreeMap::new();
|
||||
for (k, v) in map {
|
||||
let v = v.as_str().ok_or_else(wrong)?;
|
||||
entries.insert(k.as_str(), clean(v));
|
||||
}
|
||||
// A misspelt language would silently get the base language's text.
|
||||
if locales.len() > 1
|
||||
&& let Some(k) = map.keys().find(|k| !locales.iter().any(|l| l.label == **k))
|
||||
{
|
||||
let names: Vec<&str> = locales.iter().map(|l| l.label.as_str()).collect();
|
||||
return Err(format!(
|
||||
"`{key}` names the language `{k}`, which this export does not have; its languages are {}",
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
let first = locales
|
||||
.first()
|
||||
.and_then(|l| entries.get(l.label.as_str()))
|
||||
.cloned()
|
||||
.flatten()
|
||||
.or_else(|| entries.values().flatten().next().cloned());
|
||||
Ok(locales
|
||||
.iter()
|
||||
.map(|l| {
|
||||
entries
|
||||
.get(l.label.as_str())
|
||||
.cloned()
|
||||
.flatten()
|
||||
.or_else(|| first.clone())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
Some(_) => Err(wrong()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An option as the caller wrote it (already validated by `Options::parse`), or its default.
|
||||
fn chosen(a: &Value, key: &str, default: &str) -> String {
|
||||
a.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(default)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// The frames to export: the ones named, else the page's declared screens, else its top-level
|
||||
/// frames (components excluded).
|
||||
fn pick_frames(ed: &Editor, page: usize, a: &Value) -> Result<Vec<NodeId>, String> {
|
||||
let doc = ed.page_doc(page);
|
||||
let mut ids = Vec::new();
|
||||
if let Some(keys) = a.get("frames").and_then(Value::as_array) {
|
||||
for k in keys {
|
||||
let k = k.as_str().ok_or("`frames` must be a list of frame ids")?;
|
||||
let (p, id) = exec::resolve(ed, k, page)?;
|
||||
if p != page || !doc.get(id).is_some_and(|n| n.is_frame()) {
|
||||
return Err(format!("`{k}` must be a frame on the exported page"));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ids = crate::editor::screens::declared(doc);
|
||||
if ids.is_empty() {
|
||||
ids = doc
|
||||
.root
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| doc.get(*id).is_some_and(|n| n.is_frame() && !n.component))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err("no frames to export: the page has no screens or top-level frames".into());
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// File and component names from frame names: the first page is `index`, the rest slugs, made
|
||||
/// unique.
|
||||
fn name_frames(doc: &Document, ids: Vec<NodeId>) -> Vec<Frame> {
|
||||
let mut taken = BTreeSet::from(["index".to_owned()]);
|
||||
let mut components = BTreeSet::new();
|
||||
ids.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| {
|
||||
let name = doc.get(id).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let stem = if i == 0 {
|
||||
"index".to_owned()
|
||||
} else {
|
||||
unique(&mut taken, slug(&name))
|
||||
};
|
||||
let component = component_name(&name, &mut components);
|
||||
Frame {
|
||||
id,
|
||||
name,
|
||||
stem,
|
||||
component,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// PascalCase ASCII ending in `Page`: a valid identifier in TypeScript, Vue, Angular and Astro,
|
||||
/// never a keyword, and unique even on a case-insensitive disk.
|
||||
fn component_name(name: &str, taken: &mut BTreeSet<String>) -> String {
|
||||
let words: String = name
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| w[..1].to_ascii_uppercase() + &w[1..])
|
||||
.collect();
|
||||
let words = match words.chars().next() {
|
||||
None => "Screen".to_owned(),
|
||||
Some(c) if c.is_ascii_digit() => format!("Screen{words}"),
|
||||
_ => words,
|
||||
};
|
||||
let base = format!("{words}Page");
|
||||
let (mut candidate, mut n) = (base.clone(), 2);
|
||||
while !taken.insert(candidate.to_lowercase()) {
|
||||
candidate = format!("{base}{n}");
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// The locales and, for each, the document as it is or in one mode, rendered without touching
|
||||
/// the editor (no switch to undo, nothing on the history). Without `modes` there is one locale.
|
||||
fn variants(
|
||||
ed: &mut Editor,
|
||||
page: usize,
|
||||
a: &Value,
|
||||
lang: Option<String>,
|
||||
) -> Result<(Vec<Locale>, Vec<Document>), String> {
|
||||
let Some(spec) = a.get("modes").filter(|m| !m.is_null()) else {
|
||||
let locale = Locale {
|
||||
tag: lang,
|
||||
hreflang: false,
|
||||
folder: None,
|
||||
label: String::new(),
|
||||
scope: String::new(),
|
||||
class: None,
|
||||
};
|
||||
return Ok((vec![locale], vec![ed.page_doc(page).clone()]));
|
||||
};
|
||||
let coll = spec["collection"]
|
||||
.as_str()
|
||||
.ok_or("`modes` needs a `collection` (name or id) and a `modes` list")?;
|
||||
let names: Vec<&str> = spec["modes"]
|
||||
.as_array()
|
||||
.map(|m| m.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
if names.is_empty() {
|
||||
return Err("`modes.modes` must list at least one mode name".into());
|
||||
}
|
||||
let c = ed
|
||||
.design
|
||||
.collections
|
||||
.iter()
|
||||
.find(|c| c.name == coll || c.id == coll)
|
||||
.ok_or_else(|| format!("no variable collection `{coll}`"))?
|
||||
.clone();
|
||||
let mut taken: BTreeSet<String> = RESERVED_FOLDERS.iter().map(|s| (*s).to_owned()).collect();
|
||||
let (mut locales, mut docs) = (Vec::new(), Vec::new());
|
||||
for name in names {
|
||||
let m = c
|
||||
.modes
|
||||
.iter()
|
||||
.find(|m| m.name == name || m.id == name)
|
||||
.ok_or_else(|| format!("collection `{coll}` has no mode `{name}`"))?;
|
||||
let is_lang = is_lang_tag(&m.name);
|
||||
let folder = unique(
|
||||
&mut taken,
|
||||
if is_lang {
|
||||
m.name.clone()
|
||||
} else {
|
||||
slug(&m.name)
|
||||
},
|
||||
);
|
||||
// A language is told apart by `:lang()`; any other mode by a class on `<html>`.
|
||||
let (scope, class) = match (locales.is_empty(), is_lang) {
|
||||
(true, _) => (String::new(), None),
|
||||
(false, true) => (format!(":root:lang({})", m.name), None),
|
||||
(false, false) => (
|
||||
format!(":root.mode-{folder}"),
|
||||
Some(format!("mode-{folder}")),
|
||||
),
|
||||
};
|
||||
let tag = if is_lang {
|
||||
Some(m.name.clone())
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
locales.push(Locale {
|
||||
tag,
|
||||
hreflang: is_lang,
|
||||
folder: Some(folder),
|
||||
label: m.name.clone(),
|
||||
scope,
|
||||
class,
|
||||
});
|
||||
docs.push(ed.page_doc_in(page, &c.id, &m.id));
|
||||
}
|
||||
Ok((locales, docs))
|
||||
}
|
||||
|
||||
/// A URL-safe file name stem: lower case letters and digits, runs of anything else as one `-`.
|
||||
fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars().flat_map(char::to_lowercase) {
|
||||
if c.is_alphanumeric() {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out: String = out.trim_matches('-').chars().take(STEM_CHARS).collect();
|
||||
if out.is_empty() {
|
||||
"page".into()
|
||||
} else if is_device_name(&out) {
|
||||
// Windows opens `con.html` as the console, not a file.
|
||||
format!("{out}-page")
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn is_device_name(stem: &str) -> bool {
|
||||
let s = stem.to_ascii_lowercase();
|
||||
matches!(s.as_str(), "con" | "prn" | "aux" | "nul")
|
||||
|| (s.len() == 4
|
||||
&& (s.starts_with("com") || s.starts_with("lpt"))
|
||||
&& matches!(s.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
/// Whether `bytes` is a manifest this exporter wrote.
|
||||
fn is_site_manifest(bytes: &[u8]) -> bool {
|
||||
let Ok(m) = serde_json::from_slice::<Value>(bytes) else {
|
||||
return false;
|
||||
};
|
||||
m["generator"] == GENERATOR
|
||||
&& m["format"] == FORMAT
|
||||
&& m["files"]
|
||||
.as_object()
|
||||
.is_some_and(|f| f.values().all(|e| e["hash"].is_string()))
|
||||
}
|
||||
|
||||
/// Writes the files whose bytes differ, then the manifest last (so a failed export never lists a
|
||||
/// file that is missing), and removes files the previous manifest listed that this export no
|
||||
/// longer has, unless their content changed since (the person edited them). Returns (written,
|
||||
/// removed, kept).
|
||||
fn write(
|
||||
dir: &Path,
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
) -> Result<(usize, usize, Vec<String>), String> {
|
||||
let old = std::fs::read(dir.join(MANIFEST))
|
||||
.ok()
|
||||
.filter(|b| is_site_manifest(b))
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let entries: BTreeMap<&String, Value> = files
|
||||
.iter()
|
||||
.map(|(k, v)| (k, json!({"hash": crate::assets::hash(v)})))
|
||||
.collect();
|
||||
let manifest = serde_json::to_vec_pretty(
|
||||
&json!({"generator": GENERATOR, "format": FORMAT, "files": entries}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut written = 0;
|
||||
let manifest_entry = (MANIFEST.to_owned(), manifest);
|
||||
for (rel, bytes) in files
|
||||
.iter()
|
||||
.chain(std::iter::once((&manifest_entry.0, &manifest_entry.1)))
|
||||
{
|
||||
let path =
|
||||
dir.join(safe_relative(rel).ok_or_else(|| format!("`{rel}` would leave the folder"))?);
|
||||
if std::fs::read(&path).is_ok_and(|b| &b == bytes) {
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
crate::io::atomic::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
written += 1;
|
||||
}
|
||||
let (mut removed, mut kept) = (0, Vec::new());
|
||||
for (rel, entry) in old["files"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, _)| !files.contains_key(*k))
|
||||
{
|
||||
// Read back from disk: only plain relative paths inside the folder are trusted.
|
||||
let Some(safe) = safe_relative(rel) else {
|
||||
continue;
|
||||
};
|
||||
let path = dir.join(safe);
|
||||
let Ok(current) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
if entry["hash"].as_str() != Some(crate::assets::hash(¤t).as_str()) {
|
||||
kept.push(rel.clone());
|
||||
} else if std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
Ok((written, removed, kept))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_device_names_get_a_suffix_in_any_case() {
|
||||
for bad in ["Con", "NUL", "aux", "Prn", "Com1", "lpt9"] {
|
||||
assert_eq!(slug(bad), format!("{}-page", bad.to_lowercase()));
|
||||
}
|
||||
assert_eq!(slug("Com10"), "com10");
|
||||
assert_eq!(slug("Contact"), "contact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_compares_case_insensitively() {
|
||||
let mut taken = BTreeSet::new();
|
||||
assert_eq!(unique(&mut taken, "pt-BR".into()), "pt-BR");
|
||||
assert_eq!(unique(&mut taken, "pt-br".into()), "pt-br-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_names_are_unique_ascii_identifiers() {
|
||||
let mut taken = BTreeSet::new();
|
||||
let names: Vec<String> = [
|
||||
"Home screen",
|
||||
"home-screen",
|
||||
"3 Cards",
|
||||
"\u{1F600}",
|
||||
"caf\u{e9} menu",
|
||||
]
|
||||
.iter()
|
||||
.map(|n| component_name(n, &mut taken))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"HomeScreenPage",
|
||||
"HomeScreenPage2",
|
||||
"Screen3CardsPage",
|
||||
"ScreenPage",
|
||||
"CafMenuPage"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.all(|n| n.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
&& !n.starts_with(|c: char| c.is_ascii_digit()))
|
||||
);
|
||||
}
|
||||
|
||||
fn scratch() -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-site-unit-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_no_manifest_listing_missing_files() {
|
||||
let dir = scratch();
|
||||
let files = BTreeMap::from([
|
||||
("a.html".to_owned(), b"a".to_vec()),
|
||||
("zz/../bad.html".to_owned(), b"b".to_vec()),
|
||||
]);
|
||||
assert!(write(&dir, files).is_err());
|
||||
assert!(!dir.join(MANIFEST).exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_page_edited_by_hand_survives_a_reexport() {
|
||||
let dir = scratch();
|
||||
write(
|
||||
&dir,
|
||||
BTreeMap::from([
|
||||
("a.html".to_owned(), b"a".to_vec()),
|
||||
("b.html".to_owned(), b"b".to_vec()),
|
||||
("c.html".to_owned(), b"c".to_vec()),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("b.html"), "mine").unwrap();
|
||||
let (_, removed, kept) =
|
||||
write(&dir, BTreeMap::from([("a.html".to_owned(), b"a".to_vec())])).unwrap();
|
||||
assert_eq!((removed, kept), (1, vec!["b.html".to_owned()]));
|
||||
assert_eq!(std::fs::read_to_string(dir.join("b.html")).unwrap(), "mine");
|
||||
assert!(!dir.join("c.html").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//! What a framework writer receives: the options, the pages as element trees, the locales and
|
||||
//! their messages, the style model, the asset bytes and the shared head, sitemap and robots.
|
||||
|
||||
use super::style::StyleModel;
|
||||
use super::tree::Element;
|
||||
use crate::mcp::codegen::{Escape, escape};
|
||||
use crate::model::NodeId;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Framework {
|
||||
Html,
|
||||
React,
|
||||
Vue,
|
||||
Angular,
|
||||
Astro,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Render {
|
||||
Static,
|
||||
Spa,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Styling {
|
||||
Css,
|
||||
Scss,
|
||||
Tailwind,
|
||||
VanillaExtract,
|
||||
StyledComponents,
|
||||
Emotion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Options {
|
||||
pub framework: Framework,
|
||||
pub render: Render,
|
||||
pub styling: Styling,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// The options of a call: defaults are html, static and css; combinations that do not exist
|
||||
/// are errors naming what to change.
|
||||
pub fn parse(a: &Value) -> Result<Self, String> {
|
||||
let pick = |key: &str, allowed: &[&str]| -> Result<Option<String>, String> {
|
||||
match a
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
None => Ok(None),
|
||||
Some(v) if allowed.contains(&v) => Ok(Some(v.to_owned())),
|
||||
Some(v) => Err(format!(
|
||||
"`{key}` must be one of {} (got `{v}`)",
|
||||
allowed.join(", ")
|
||||
)),
|
||||
}
|
||||
};
|
||||
let framework =
|
||||
match pick("framework", &["html", "react", "vue", "angular", "astro"])?.as_deref() {
|
||||
Some("react") => Framework::React,
|
||||
Some("vue") => Framework::Vue,
|
||||
Some("angular") => Framework::Angular,
|
||||
Some("astro") => Framework::Astro,
|
||||
_ => Framework::Html,
|
||||
};
|
||||
let render_given = pick("render", &["static", "spa"])?;
|
||||
let render = if render_given.as_deref() == Some("spa") {
|
||||
Render::Spa
|
||||
} else {
|
||||
Render::Static
|
||||
};
|
||||
let styling_given = pick(
|
||||
"styling",
|
||||
&[
|
||||
"css",
|
||||
"scss",
|
||||
"tailwind",
|
||||
"vanilla-extract",
|
||||
"styled-components",
|
||||
"emotion",
|
||||
],
|
||||
)?;
|
||||
let styling = match styling_given.as_deref() {
|
||||
Some("scss") => Styling::Scss,
|
||||
Some("tailwind") => Styling::Tailwind,
|
||||
Some("vanilla-extract") => Styling::VanillaExtract,
|
||||
Some("styled-components") => Styling::StyledComponents,
|
||||
Some("emotion") => Styling::Emotion,
|
||||
_ => Styling::Css,
|
||||
};
|
||||
if matches!(
|
||||
styling,
|
||||
Styling::VanillaExtract | Styling::StyledComponents | Styling::Emotion
|
||||
) && framework != Framework::React
|
||||
{
|
||||
return Err(format!(
|
||||
"`styling: {}` is CSS-in-JS for React: use `framework: react`, or pick css, scss or tailwind",
|
||||
styling_given.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if render == Render::Spa
|
||||
&& !matches!(
|
||||
framework,
|
||||
Framework::React | Framework::Vue | Framework::Angular
|
||||
)
|
||||
{
|
||||
return Err("`render: spa` applies to react, vue and angular; html and astro always write static pages".into());
|
||||
}
|
||||
Ok(Options {
|
||||
framework,
|
||||
render,
|
||||
styling,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen to export, before it is built.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
pub id: NodeId,
|
||||
pub name: String,
|
||||
/// File name stem and route segment: `index` for the first, then a unique slug.
|
||||
pub stem: String,
|
||||
/// PascalCase, unique, valid as a TypeScript, Vue, Angular or Astro identifier.
|
||||
pub component: String,
|
||||
}
|
||||
|
||||
/// One language or mode of the site.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Locale {
|
||||
/// BCP 47 tag of the content: a language mode's name, else the `lang` argument.
|
||||
pub tag: Option<String>,
|
||||
/// A language version (a mode named like `pt-BR`): `hreflang` links point to it.
|
||||
pub hreflang: bool,
|
||||
/// Folder or route prefix; None for a single-locale site.
|
||||
pub folder: Option<String>,
|
||||
pub label: String,
|
||||
/// Selector prefix of this locale's style overrides: empty for the first locale.
|
||||
pub scope: String,
|
||||
/// Class the `<html>` element carries so a non-language mode's scope matches.
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Page {
|
||||
pub name: String,
|
||||
pub stem: String,
|
||||
pub component: String,
|
||||
pub root: Element,
|
||||
/// Per locale.
|
||||
pub title: Vec<String>,
|
||||
pub description: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Site {
|
||||
pub options: Options,
|
||||
pub site_title: String,
|
||||
/// Without a trailing slash.
|
||||
pub base_url: Option<String>,
|
||||
pub locales: Vec<Locale>,
|
||||
pub pages: Vec<Page>,
|
||||
/// Per locale: message key to text. Only keys whose text differs between locales exist.
|
||||
pub messages: Vec<BTreeMap<String, String>>,
|
||||
/// Site-root relative path (`assets/..`) to bytes: pictures, vector files and fonts.
|
||||
pub assets: BTreeMap<String, Vec<u8>>,
|
||||
pub style: StyleModel,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a writer returns: files relative to the output folder, and what to do next.
|
||||
#[derive(Default)]
|
||||
pub struct Output {
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
pub next_steps: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A `<meta>` or `<link>` for the head, attributes in print order.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HeadTag {
|
||||
pub tag: &'static str,
|
||||
pub attrs: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// The address of a page: `(from locale, to locale, page index)` to the href a page of `from`
|
||||
/// writes for `page` in `to`.
|
||||
pub type UrlFn<'a> = &'a dyn Fn(usize, usize, usize) -> String;
|
||||
|
||||
fn tag(kind: &'static str, attrs: &[(&'static str, &str)]) -> HeadTag {
|
||||
HeadTag {
|
||||
tag: kind,
|
||||
attrs: attrs.iter().map(|(k, v)| (*k, (*v).to_owned())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl HeadTag {
|
||||
/// The tag as HTML, without indentation or a line break. A valueless attribute (`crossorigin`)
|
||||
/// is printed bare.
|
||||
pub fn to_html(&self) -> String {
|
||||
let mut out = format!("<{}", self.tag);
|
||||
for (k, v) in &self.attrs {
|
||||
if v.is_empty() {
|
||||
out.push_str(&format!(" {k}"));
|
||||
} else {
|
||||
out.push_str(&format!(" {k}=\"{}\"", escape(Escape::Html, v)));
|
||||
}
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `type` of a font or icon file from its extension.
|
||||
pub(super) fn mime(path: &str, family: &str) -> String {
|
||||
let ext = path.rsplit('.').next().unwrap_or_default();
|
||||
match ext {
|
||||
"svg" => "image/svg+xml".into(),
|
||||
"jpg" => "image/jpeg".into(),
|
||||
_ => format!("{family}/{ext}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Site {
|
||||
/// The site icon's address, with `prefix` before a site-root path. No icon layer: `data:,`
|
||||
/// keeps the browser from asking for `/favicon.ico`.
|
||||
pub fn icon_href(&self, prefix: &str) -> String {
|
||||
self.style
|
||||
.favicon
|
||||
.as_ref()
|
||||
.map_or_else(|| "data:,".to_owned(), |p| format!("{prefix}{p}"))
|
||||
}
|
||||
|
||||
/// The site icon and the font preloads of a page, with `prefix` before the site-root paths.
|
||||
/// Preloads: the face of the page's h1 and the face most body text uses, never more than two.
|
||||
pub fn asset_tags(&self, page: usize, prefix: &str) -> Vec<HeadTag> {
|
||||
let mut icon = vec![("rel", "icon".to_owned())];
|
||||
if let Some(p) = &self.style.favicon {
|
||||
icon.push(("type", mime(p, "image")));
|
||||
}
|
||||
icon.push(("href", self.icon_href(prefix)));
|
||||
let mut tags = vec![HeadTag {
|
||||
tag: "link",
|
||||
attrs: icon,
|
||||
}];
|
||||
let h1 = self.style.h1_faces.get(page).copied().flatten();
|
||||
let mut faces: Vec<usize> = h1.into_iter().chain(self.style.preload).collect();
|
||||
faces.dedup();
|
||||
for face in faces.iter().filter_map(|i| self.style.fonts.get(*i)) {
|
||||
tags.push(HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "preload".into()),
|
||||
("href", format!("{prefix}{}", face.path)),
|
||||
("as", "font".into()),
|
||||
("type", mime(&face.path, "font")),
|
||||
("crossorigin", String::new()),
|
||||
],
|
||||
});
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
/// Locales that are languages: the ones `hreflang` links between.
|
||||
pub fn language_locales(&self) -> Vec<usize> {
|
||||
(0..self.locales.len())
|
||||
.filter(|i| self.locales[*i].hreflang)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `lang` of the language picker page: the first language's tag.
|
||||
pub fn index_lang(&self) -> Option<&str> {
|
||||
let first = self.language_locales().first().copied().unwrap_or(0);
|
||||
self.locales.get(first)?.tag.as_deref()
|
||||
}
|
||||
|
||||
/// The path of a page under the site root: `about.html` or `pt-BR/about.html` for html, a
|
||||
/// route (`/about`, `/pt-BR/about`, `/` for the first page) for every other framework.
|
||||
pub fn url_path(&self, page: usize, locale: usize) -> String {
|
||||
let (stem, folder) = (
|
||||
&self.pages[page].stem,
|
||||
self.locales[locale].folder.as_deref(),
|
||||
);
|
||||
if self.options.framework == Framework::Html {
|
||||
return folder.map_or_else(|| format!("{stem}.html"), |f| format!("{f}/{stem}.html"));
|
||||
}
|
||||
let route = if page == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
stem.clone()
|
||||
};
|
||||
match folder {
|
||||
Some(f) if route.is_empty() => format!("/{f}"),
|
||||
Some(f) => format!("/{f}/{route}"),
|
||||
None => format!("/{route}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Meta and link tags of a page in one locale: description, canonical, `hreflang`
|
||||
/// alternates, Open Graph and Twitter card. `url` decides absolute or relative addresses.
|
||||
pub fn head_tags(&self, page: usize, loc: usize, url: UrlFn) -> Vec<HeadTag> {
|
||||
let (p, l) = (&self.pages[page], &self.locales[loc]);
|
||||
let canonical = url(loc, loc, page);
|
||||
let mut out = vec![
|
||||
tag(
|
||||
"meta",
|
||||
&[("name", "description"), ("content", &p.description[loc])],
|
||||
),
|
||||
tag("link", &[("rel", "canonical"), ("href", &canonical)]),
|
||||
];
|
||||
let langs = self.language_locales();
|
||||
for &to in &langs {
|
||||
out.push(tag(
|
||||
"link",
|
||||
&[
|
||||
("rel", "alternate"),
|
||||
(
|
||||
"hreflang",
|
||||
self.locales[to].tag.as_deref().unwrap_or_default(),
|
||||
),
|
||||
("href", &url(loc, to, page)),
|
||||
],
|
||||
));
|
||||
}
|
||||
if let Some(&first) = langs.first() {
|
||||
out.push(tag(
|
||||
"link",
|
||||
&[
|
||||
("rel", "alternate"),
|
||||
("hreflang", "x-default"),
|
||||
("href", &url(loc, first, page)),
|
||||
],
|
||||
));
|
||||
}
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("property", "og:title"), ("content", &p.title[loc])],
|
||||
));
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[
|
||||
("property", "og:description"),
|
||||
("content", &p.description[loc]),
|
||||
],
|
||||
));
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("property", "og:type"), ("content", "website")],
|
||||
));
|
||||
if let Some(t) = &l.tag {
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("property", "og:locale"), ("content", &t.replace('-', "_"))],
|
||||
));
|
||||
}
|
||||
for &other in langs.iter().filter(|o| **o != loc) {
|
||||
let t = self.locales[other]
|
||||
.tag
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.replace('-', "_");
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("property", "og:locale:alternate"), ("content", &t)],
|
||||
));
|
||||
}
|
||||
if self.base_url.is_some() {
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("property", "og:url"), ("content", &canonical)],
|
||||
));
|
||||
}
|
||||
out.push(tag(
|
||||
"meta",
|
||||
&[("name", "twitter:card"), ("content", "summary")],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
/// `sitemap.xml`: one entry per page and locale, each with its language alternates.
|
||||
/// `abs(locale, page)` is the absolute address; `root` is the language index page, if any.
|
||||
pub fn sitemap(&self, abs: &dyn Fn(usize, usize) -> String, root: Option<String>) -> String {
|
||||
let e = |t: &str| escape(Escape::Html, t);
|
||||
let mut out = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
|
||||
);
|
||||
let mut entry = |loc: String, alternates: Vec<(String, String)>| {
|
||||
out.push_str(&format!(" <url>\n <loc>{}</loc>\n", e(&loc)));
|
||||
for (lang, href) in alternates {
|
||||
out.push_str(&format!(
|
||||
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\"/>\n",
|
||||
e(&lang),
|
||||
e(&href)
|
||||
));
|
||||
}
|
||||
out.push_str(" </url>\n");
|
||||
};
|
||||
if let Some(root) = root {
|
||||
entry(root, Vec::new());
|
||||
}
|
||||
let langs = self.language_locales();
|
||||
for loc in 0..self.locales.len() {
|
||||
for page in 0..self.pages.len() {
|
||||
let alt = langs
|
||||
.iter()
|
||||
.map(|&l| {
|
||||
(
|
||||
self.locales[l].tag.clone().unwrap_or_default(),
|
||||
abs(l, page),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
entry(abs(loc, page), alt);
|
||||
}
|
||||
}
|
||||
out.push_str("</urlset>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// `robots.txt`: everything may be crawled; the sitemap is named once the address is known.
|
||||
pub fn robots(&self) -> String {
|
||||
let mut out = String::from("User-agent: *\nAllow: /\n");
|
||||
if let Some(base) = &self.base_url {
|
||||
out.push_str(&format!("Sitemap: {base}/sitemap.xml\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A BCP 47 tag the way designers write one: two letters, then region or script subtags
|
||||
/// (`en`, `pt-BR`, `zh-Hant`). Longer names (`Dark`, `compact`) are plain modes.
|
||||
pub fn is_lang_tag(s: &str) -> bool {
|
||||
let mut parts = s.split('-');
|
||||
let primary = parts.next().unwrap_or_default();
|
||||
primary.len() == 2
|
||||
&& primary.chars().all(|c| c.is_ascii_lowercase())
|
||||
&& parts.all(|p| {
|
||||
matches!(p.len(), 2 | 4) && p.chars().all(|c| c.is_ascii_alphabetic())
|
||||
|| p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse(v: Value) -> Result<Options, String> {
|
||||
Options::parse(&v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_plain_static_html() {
|
||||
let o = parse(json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(o.framework, o.render, o.styling),
|
||||
(Framework::Html, Render::Static, Styling::Css)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_in_js_needs_react_and_spa_needs_a_client_framework() {
|
||||
for s in ["vanilla-extract", "styled-components", "emotion"] {
|
||||
assert!(
|
||||
parse(json!({"styling": s}))
|
||||
.unwrap_err()
|
||||
.contains("framework: react"),
|
||||
"{s}"
|
||||
);
|
||||
assert!(parse(json!({"framework": "vue", "styling": s})).is_err());
|
||||
assert!(parse(json!({"framework": "react", "styling": s})).is_ok());
|
||||
}
|
||||
assert!(
|
||||
parse(json!({"render": "spa"}))
|
||||
.unwrap_err()
|
||||
.contains("react, vue and angular")
|
||||
);
|
||||
assert!(parse(json!({"framework": "astro", "render": "spa"})).is_err());
|
||||
assert!(parse(json!({"framework": "angular", "render": "spa", "styling": "scss"})).is_ok());
|
||||
assert!(
|
||||
parse(json!({"framework": "svelte"}))
|
||||
.unwrap_err()
|
||||
.contains("one of html, react")
|
||||
);
|
||||
assert!(parse(json!({"styling": "less"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_tags_are_short_codes_not_mode_names() {
|
||||
for ok in ["en", "pt-BR", "zh-Hant", "es-419"] {
|
||||
assert!(is_lang_tag(ok), "{ok}");
|
||||
}
|
||||
for no in ["Dark", "compact", "EN", "e", "en-"] {
|
||||
assert!(!is_lang_tag(no), "{no}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! One component per page frame.
|
||||
|
||||
use super::styles::Kit;
|
||||
use super::{Ctx, Files, js, put};
|
||||
use crate::mcp::site::markup::{self, Dialect, Hooks, Link, TextOut, Val};
|
||||
use crate::mcp::site::tree::{Element, LinkTarget};
|
||||
use std::cell::Cell;
|
||||
|
||||
/// The props a Next page component takes (the SPA reads both from the route).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Props {
|
||||
pub loc: bool,
|
||||
pub m: bool,
|
||||
}
|
||||
|
||||
struct Printer<'a> {
|
||||
cx: &'a Ctx<'a>,
|
||||
kit: &'a Kit<'a>,
|
||||
page: usize,
|
||||
uses_link: Cell<bool>,
|
||||
uses_loc: Cell<bool>,
|
||||
uses_m: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Hooks for Printer<'_> {
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
self.kit.class_val(self.page, el)
|
||||
}
|
||||
|
||||
fn tag(&self, el: &Element) -> String {
|
||||
self.kit.tag_name(el).unwrap_or_else(|| el.tag.clone())
|
||||
}
|
||||
|
||||
fn link(&self, el: &Element, target: &LinkTarget) -> Link {
|
||||
let tag = self.kit.link_tag(el);
|
||||
if tag == "Link" {
|
||||
self.uses_link.set(true);
|
||||
}
|
||||
let attrs = match target {
|
||||
LinkTarget::Url(u) => vec![("href".to_owned(), Val::Lit(u.clone()))],
|
||||
LinkTarget::Page(p) => {
|
||||
let value = if self.cx.multi {
|
||||
self.uses_loc.set(true);
|
||||
Val::Expr(format!("href(loc, {p})"))
|
||||
} else {
|
||||
Val::Lit(self.cx.route(0, *p))
|
||||
};
|
||||
vec![(if self.cx.next { "href" } else { "to" }.to_owned(), value)]
|
||||
}
|
||||
};
|
||||
Link { tag, attrs }
|
||||
}
|
||||
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(format!("/{path}"))
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
if !self.cx.multi {
|
||||
return TextOut::Lit(
|
||||
self.cx
|
||||
.site
|
||||
.messages
|
||||
.first()
|
||||
.and_then(|m| m.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
self.uses_m.set(true);
|
||||
TextOut::Raw(format!("{{m[{}]}}", js(key)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(cx: &Ctx, kit: &Kit, page: usize, files: &mut Files) -> Props {
|
||||
let comp = &cx.site.pages[page].component;
|
||||
let printer = Printer {
|
||||
cx,
|
||||
kit,
|
||||
page,
|
||||
uses_link: Cell::new(false),
|
||||
uses_loc: Cell::new(false),
|
||||
uses_m: Cell::new(false),
|
||||
};
|
||||
let mut jsx = String::new();
|
||||
markup::render(
|
||||
Dialect::Jsx,
|
||||
&printer,
|
||||
&cx.site.pages[page].root,
|
||||
if cx.next { 2 } else { 3 },
|
||||
&mut jsx,
|
||||
);
|
||||
let (uses_loc, uses_m) = (printer.uses_loc.get(), printer.uses_m.get());
|
||||
|
||||
let mut s = String::new();
|
||||
if kit.client() {
|
||||
s.push_str("'use client'\n\n");
|
||||
}
|
||||
if printer.uses_link.get() {
|
||||
s.push_str(if cx.next {
|
||||
"import Link from 'next/link'\n"
|
||||
} else {
|
||||
"import { Link } from 'react-router'\n"
|
||||
});
|
||||
}
|
||||
if uses_loc {
|
||||
s.push_str("import { href } from '../lib/site'\n");
|
||||
}
|
||||
if uses_m {
|
||||
s.push_str(&format!(
|
||||
"import {}\n",
|
||||
if cx.next {
|
||||
"type { Messages } from '../lib/messages'"
|
||||
} else {
|
||||
"{ catalogs } from '../lib/messages'"
|
||||
}
|
||||
));
|
||||
}
|
||||
if !cx.next {
|
||||
s.push_str(&format!(
|
||||
"import {{ PageHead{} }} from '../lib/head'\n",
|
||||
if cx.multi { ", useLoc" } else { "" }
|
||||
));
|
||||
}
|
||||
s.push_str(&kit.imports(page));
|
||||
s.push('\n');
|
||||
let prelude = kit.prelude(page);
|
||||
if !prelude.is_empty() {
|
||||
s.push_str(&prelude);
|
||||
s.push('\n');
|
||||
}
|
||||
if cx.next {
|
||||
s.push_str(&next_fn(comp, uses_loc, uses_m, &jsx));
|
||||
} else {
|
||||
s.push_str(&spa_fn(cx, comp, page, uses_m, &jsx));
|
||||
}
|
||||
put(files, &format!("{}components/{comp}.tsx", cx.prefix()), s);
|
||||
kit.write_page(cx, page, files);
|
||||
Props {
|
||||
loc: uses_loc,
|
||||
m: uses_m,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_fn(comp: &str, loc: bool, m: bool, jsx: &str) -> String {
|
||||
let (names, types): (Vec<&str>, Vec<&str>) =
|
||||
[(loc, "loc", "loc: number"), (m, "m", "m: Messages")]
|
||||
.iter()
|
||||
.filter(|x| x.0)
|
||||
.map(|x| (x.1, x.2))
|
||||
.unzip();
|
||||
let params = if names.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{{ {} }}: {{ {} }}", names.join(", "), types.join("; "))
|
||||
};
|
||||
format!("export default function {comp}({params}) {{\n return (\n{jsx} )\n}}\n")
|
||||
}
|
||||
|
||||
fn spa_fn(cx: &Ctx, comp: &str, page: usize, uses_m: bool, jsx: &str) -> String {
|
||||
let mut body = String::new();
|
||||
let loc = if cx.multi {
|
||||
body.push_str(" const loc = useLoc()\n");
|
||||
"loc"
|
||||
} else {
|
||||
"0"
|
||||
};
|
||||
if uses_m {
|
||||
body.push_str(" const m = catalogs[loc]\n");
|
||||
}
|
||||
format!(
|
||||
"export default function {comp}() {{\n{body} return (\n <>\n <PageHead loc={{{loc}}} page={{{page}}} />\n{jsx} </>\n )\n}}\n"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! The files both project shapes share: `lib/site.ts` (the site as data plus the head
|
||||
//! computation), the message catalogs, `public/` and `package.json`.
|
||||
|
||||
use super::styles::{Deps, Kit};
|
||||
use super::{Ctx, Files, js, put};
|
||||
use crate::mcp::site::model::mime;
|
||||
use crate::mcp::site::style::class_slug;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn shared(cx: &Ctx, kit: &Kit, files: &mut Files) -> Result<(), String> {
|
||||
let p = cx.prefix();
|
||||
put(files, &format!("{p}lib/site.ts"), site_ts(cx));
|
||||
if cx.multi {
|
||||
messages(cx, files)?;
|
||||
}
|
||||
public(cx, files);
|
||||
put(files, "package.json", package_json(cx, kit)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn site_ts(cx: &Ctx) -> String {
|
||||
let site = cx.site;
|
||||
let list = |items: Vec<String>| items.join(", ");
|
||||
let mut s = String::from("// Generated by PaperCraft export_site.\n");
|
||||
s.push_str(&format!(
|
||||
"export const SITE_TITLE = {}\n",
|
||||
js(&site.site_title)
|
||||
));
|
||||
s.push_str(&format!("/** The public address, or empty when no base_url was given (addresses are then root relative). */\nexport const SITE = {}\n\n", js(site.base_url.as_deref().unwrap_or_default())));
|
||||
s.push_str("export type LocaleInfo = { dir: string; tag: string | null; label: string; cls: string | null; hreflang: boolean }\nexport const locales: readonly LocaleInfo[] = [\n");
|
||||
for l in &site.locales {
|
||||
let opt = |v: &Option<String>| v.as_deref().map_or("null".to_owned(), js);
|
||||
s.push_str(&format!(
|
||||
" {{ dir: {}, tag: {}, label: {}, cls: {}, hreflang: {} }},\n",
|
||||
js(l.folder.as_deref().unwrap_or_default()),
|
||||
opt(&l.tag),
|
||||
js(&l.label),
|
||||
opt(&l.class),
|
||||
l.hreflang
|
||||
));
|
||||
}
|
||||
s.push_str("]\n\nexport type PageInfo = { path: string; title: readonly string[]; description: readonly string[] }\nexport const pages: readonly PageInfo[] = [\n");
|
||||
for (i, p) in site.pages.iter().enumerate() {
|
||||
let path = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", p.stem)
|
||||
};
|
||||
let strs = |v: &[String]| format!("[{}]", list(v.iter().map(|t| js(t)).collect()));
|
||||
s.push_str(&format!(
|
||||
" {{ path: {}, title: {}, description: {} }},\n",
|
||||
js(&path),
|
||||
strs(&p.title),
|
||||
strs(&p.description)
|
||||
));
|
||||
}
|
||||
s.push_str("]\n\n");
|
||||
if let Some(face) = site.style.preload.and_then(|i| site.style.fonts.get(i)) {
|
||||
s.push_str(&format!(
|
||||
"export const FONT_URL = {}\nexport const FONT_TYPE = {}\n\n",
|
||||
js(&format!("/{}", face.path)),
|
||||
js(&mime(&face.path, "font"))
|
||||
));
|
||||
}
|
||||
// Vite's index.html links the icon itself.
|
||||
if cx.next {
|
||||
s.push_str(&format!(
|
||||
"export const ICON = {}\n\n",
|
||||
js(&site.icon_href("/"))
|
||||
));
|
||||
}
|
||||
s.push_str(SITE_TS_TAIL);
|
||||
s
|
||||
}
|
||||
|
||||
const SITE_TS_TAIL: &str = "/** The address of a page in a locale, with a trailing slash. */
|
||||
export const href = (loc: number, page: number) => (locales[loc].dir ? `/${locales[loc].dir}/` : '/') + pages[page].path
|
||||
|
||||
export const localeIndex = (dir: string) => Math.max(0, locales.findIndex((l) => l.dir === dir))
|
||||
|
||||
/** What a page puts in <head>, besides the font preload. */
|
||||
export function headData(loc: number, page: number) {
|
||||
const p = pages[page]
|
||||
const url = (to: number) => SITE + href(to, page)
|
||||
const langs = locales.flatMap((l, i) => (l.hreflang ? [i] : []))
|
||||
const tag = (i: number) => locales[i].tag ?? ''
|
||||
const alternates: [string, string][] = langs.map((i) => [tag(i), url(i)])
|
||||
if (langs.length > 0) alternates.push(['x-default', url(langs[0])])
|
||||
const og = (t: string) => t.split('-').join('_')
|
||||
const own = locales[loc].tag
|
||||
return {
|
||||
title: p.title[loc],
|
||||
description: p.description[loc],
|
||||
canonical: url(loc),
|
||||
alternates,
|
||||
ogLocale: own ? og(own) : null,
|
||||
ogAlternates: langs.filter((i) => i !== loc).map((i) => og(tag(i))),
|
||||
ogUrl: SITE ? url(loc) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The language index at `/`. */
|
||||
export function indexHeadData() {
|
||||
const langs = locales.flatMap((l, i) => (l.hreflang ? [i] : []))
|
||||
const url = (i: number) => SITE + `/${locales[i].dir}/`
|
||||
const alternates: [string, string][] = langs.map((i) => [locales[i].tag ?? '', url(i)])
|
||||
if (langs.length > 0) alternates.push(['x-default', url(langs[0])])
|
||||
return { title: SITE_TITLE, description: 'Choose a version of this site.', canonical: SITE + '/', alternates }
|
||||
}
|
||||
";
|
||||
|
||||
/// One catalog per locale with every key in all of them, so the types agree.
|
||||
fn messages(cx: &Ctx, files: &mut Files) -> Result<(), String> {
|
||||
let site = cx.site;
|
||||
let keys: BTreeSet<&String> = site.messages.iter().flat_map(|m| m.keys()).collect();
|
||||
let mut imports = String::new();
|
||||
let mut names = Vec::new();
|
||||
for (i, l) in site.locales.iter().enumerate() {
|
||||
let dir = l.folder.as_deref().unwrap_or_default();
|
||||
let cat: Map<String, Value> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
(
|
||||
(*k).clone(),
|
||||
json!(site.messages[i].get(*k).cloned().unwrap_or_default()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut body =
|
||||
serde_json::to_string_pretty(&Value::Object(cat)).map_err(|e| e.to_string())?;
|
||||
body.push('\n');
|
||||
put(files, &format!("{}messages/{dir}.json", cx.prefix()), body);
|
||||
imports.push_str(&format!(
|
||||
"import c{i} from {}\n",
|
||||
js(&format!("../messages/{dir}.json"))
|
||||
));
|
||||
names.push(format!("c{i}"));
|
||||
}
|
||||
let ts = format!(
|
||||
"{imports}\nexport type Messages = typeof c0\nexport const catalogs: readonly Messages[] = [{}]\n",
|
||||
names.join(", ")
|
||||
);
|
||||
put(files, &format!("{}lib/messages.ts", cx.prefix()), ts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn public(cx: &Ctx, files: &mut Files) {
|
||||
let site = cx.site;
|
||||
for (path, bytes) in &site.assets {
|
||||
files.insert(format!("public/{path}"), bytes.clone());
|
||||
}
|
||||
put(files, "public/robots.txt", site.robots());
|
||||
let Some(base) = &site.base_url else { return };
|
||||
let abs = |loc: usize, page: usize| format!("{base}{}", cx.route(loc, page));
|
||||
put(
|
||||
files,
|
||||
"public/sitemap.xml",
|
||||
site.sitemap(&abs, cx.multi.then(|| format!("{base}/"))),
|
||||
);
|
||||
}
|
||||
|
||||
fn package_json(cx: &Ctx, kit: &Kit) -> Result<String, String> {
|
||||
let (extra, extra_dev) = kit.deps();
|
||||
let (mut deps, mut dev, scripts): (Deps, Deps, Value) = if cx.next {
|
||||
(
|
||||
vec![
|
||||
("next", "16.4.5"),
|
||||
("react", "19.3.0"),
|
||||
("react-dom", "19.3.0"),
|
||||
],
|
||||
vec![
|
||||
("@types/node", "^20"),
|
||||
("@types/react", "^19"),
|
||||
("@types/react-dom", "^19"),
|
||||
("typescript", "^5"),
|
||||
],
|
||||
json!({"dev": "next dev", "build": "next build && node scripts/flatten-prefetch.mjs"}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
vec![
|
||||
("react", "^19.3.1"),
|
||||
("react-dom", "^19.3.1"),
|
||||
("react-router", "^8.5.1"),
|
||||
],
|
||||
vec![
|
||||
("@types/node", "^24.19.2"),
|
||||
("@types/react", "^19.3.0"),
|
||||
("@types/react-dom", "^19.3.0"),
|
||||
("@vitejs/plugin-react", "^6.1.2"),
|
||||
("typescript", "~7.0.2"),
|
||||
("vite", "^8.3.6"),
|
||||
],
|
||||
json!({"dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview"}),
|
||||
)
|
||||
};
|
||||
deps.extend(extra);
|
||||
dev.extend(extra_dev);
|
||||
let map = |v: Deps| -> Value {
|
||||
Value::Object(
|
||||
v.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), json!(v)))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut pkg = json!({"name": class_slug(&cx.site.site_title), "version": "0.1.0", "private": true, "scripts": scripts, "dependencies": map(deps), "devDependencies": map(dev)});
|
||||
if !cx.next {
|
||||
pkg["type"] = json!("module");
|
||||
}
|
||||
let mut text = serde_json::to_string_pretty(&pkg).map_err(|e| e.to_string())?;
|
||||
text.push('\n');
|
||||
Ok(text)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The React writer: a Next.js 16 static export or a Vite + React Router single-page app, in any
|
||||
//! of the six stylings. One component per page frame; with several locales the routes are
|
||||
//! `/<folder>/..`, one JSON catalog per locale and a language index at `/`.
|
||||
//!
|
||||
//! Every string that reaches a TypeScript file or a config goes through [`js`] (a JSON string
|
||||
//! literal) and every piece of page text through the core's JSX escaping.
|
||||
|
||||
mod component;
|
||||
mod files;
|
||||
mod next;
|
||||
mod styles;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod vite;
|
||||
|
||||
use super::model::{Output, Render, Site, Styling};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub type Files = BTreeMap<String, Vec<u8>>;
|
||||
|
||||
/// What the generators share: the site and which of the two project shapes is being written.
|
||||
pub struct Ctx<'a> {
|
||||
pub site: &'a Site,
|
||||
pub next: bool,
|
||||
/// Every locale has a folder: routes are `/<folder>/..` and text comes from catalogs.
|
||||
pub multi: bool,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
fn new(site: &'a Site) -> Self {
|
||||
Ctx {
|
||||
site,
|
||||
next: site.options.render == Render::Static,
|
||||
multi: site.locales.iter().any(|l| l.folder.is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn styling(&self) -> Styling {
|
||||
self.site.options.styling
|
||||
}
|
||||
|
||||
/// Where the sources live: the project root for Next, `src/` for Vite.
|
||||
pub fn prefix(&self) -> &'static str {
|
||||
if self.next { "" } else { "src/" }
|
||||
}
|
||||
|
||||
/// The address of a page, always with a trailing slash.
|
||||
pub fn route(&self, loc: usize, page: usize) -> String {
|
||||
let dir = self.site.locales[loc]
|
||||
.folder
|
||||
.as_deref()
|
||||
.map(|d| format!("{d}/"))
|
||||
.unwrap_or_default();
|
||||
let stem = if page == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", self.site.pages[page].stem)
|
||||
};
|
||||
format!("/{dir}{stem}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A JavaScript string literal: JSON, which is valid JavaScript and escapes quotes, backslashes,
|
||||
/// control characters and (as `
`) the line separators.
|
||||
pub fn js(s: &str) -> String {
|
||||
serde_json::Value::String(s.to_owned()).to_string()
|
||||
}
|
||||
|
||||
pub fn put(files: &mut Files, path: &str, text: impl Into<String>) {
|
||||
files.insert(path.to_owned(), text.into().into_bytes());
|
||||
}
|
||||
|
||||
pub fn write(site: &Site) -> Result<Output, String> {
|
||||
let cx = Ctx::new(site);
|
||||
let mut out = Output::default();
|
||||
let mut files = Files::new();
|
||||
let kit = styles::Kit::new(&cx);
|
||||
files::shared(&cx, &kit, &mut files)?;
|
||||
kit.write_global(&cx, &mut files);
|
||||
let props: Vec<component::Props> = (0..site.pages.len())
|
||||
.map(|page| component::write(&cx, &kit, page, &mut files))
|
||||
.collect();
|
||||
kit.write_shared(&cx, &mut files);
|
||||
if cx.next {
|
||||
next::write(&cx, &kit, &props, &mut files, &mut out);
|
||||
} else {
|
||||
vite::write(&cx, &kit, &mut files, &mut out);
|
||||
}
|
||||
out.files = files;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The Next.js 16 static export: config, `lib/seo.ts`, the layouts and one route file per page.
|
||||
|
||||
use super::component::Props;
|
||||
use super::styles::Kit;
|
||||
use super::{Ctx, Files, js, put};
|
||||
use crate::mcp::site::model::{Output, Styling, mime};
|
||||
|
||||
pub fn write(cx: &Ctx, kit: &Kit, props: &[Props], files: &mut Files, out: &mut Output) {
|
||||
put(files, "tsconfig.json", TSCONFIG);
|
||||
put(files, "next.config.ts", next_config(cx.styling()));
|
||||
put(files, "scripts/flatten-prefetch.mjs", FLATTEN_PREFETCH);
|
||||
if cx.styling() == Styling::Tailwind {
|
||||
put(files, "postcss.config.mjs", POSTCSS);
|
||||
}
|
||||
put(
|
||||
files,
|
||||
".gitignore",
|
||||
"/node_modules\n/.next/\n/out/\n*.tsbuildinfo\nnext-env.d.ts\n",
|
||||
);
|
||||
put(files, "lib/seo.ts", SEO_TS);
|
||||
if cx.multi {
|
||||
multi(cx, kit, props, files);
|
||||
} else {
|
||||
single(cx, kit, files);
|
||||
}
|
||||
if cx.site.base_url.is_none() {
|
||||
out.warnings.push("No base_url: the canonical and hreflang links are left out of the Next.js metadata (Next needs absolute addresses). Pass base_url to include them.".into());
|
||||
}
|
||||
out.next_steps.push("Run `npm install`, then `npm run build` in the output folder: the static site is written to out/ (`npm run dev` serves it with hot reload).".into());
|
||||
}
|
||||
|
||||
fn next_config(styling: Styling) -> String {
|
||||
let (import, compiler, export) = match styling {
|
||||
Styling::VanillaExtract => (
|
||||
"import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin'\n\nconst withVanillaExtract = createVanillaExtractPlugin({ unstable_turbopack: { mode: 'auto' } })\n",
|
||||
"",
|
||||
"withVanillaExtract(nextConfig)",
|
||||
),
|
||||
Styling::StyledComponents => (
|
||||
"",
|
||||
" compiler: { styledComponents: true },\n",
|
||||
"nextConfig",
|
||||
),
|
||||
Styling::Emotion => ("", " compiler: { emotion: true },\n", "nextConfig"),
|
||||
_ => ("", "", "nextConfig"),
|
||||
};
|
||||
let gap = if import.is_empty() { "" } else { "\n" };
|
||||
format!(
|
||||
"import type {{ NextConfig }} from 'next'\n{import}{gap}const nextConfig: NextConfig = {{\n output: 'export',\n trailingSlash: true,\n images: {{ unoptimized: true }},\n{compiler}}}\n\nexport default {export}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The font preload, once, in the layout: React dedupes it with the preload Next emits for the page.
|
||||
fn preload(cx: &Ctx) -> (String, String) {
|
||||
let style = &cx.site.style;
|
||||
if style.preload.and_then(|i| style.fonts.get(i)).is_none() {
|
||||
return (String::new(), String::new());
|
||||
}
|
||||
(
|
||||
"import ReactDOM from 'react-dom'\nimport { FONT_TYPE, FONT_URL } from '@/lib/site'\n"
|
||||
.into(),
|
||||
" ReactDOM.preload(FONT_URL, { as: 'font', type: FONT_TYPE, crossOrigin: 'anonymous' })\n"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The page's h1 face as a `ReactDOM.preload` (import, call) when the layout does not preload
|
||||
/// that face already; empty strings otherwise.
|
||||
fn h1_preload(cx: &Ctx, page: usize) -> (&'static str, String) {
|
||||
let style = &cx.site.style;
|
||||
let face = style.h1_faces.get(page).copied().flatten();
|
||||
let Some(face) = face
|
||||
.filter(|i| Some(*i) != style.preload)
|
||||
.and_then(|i| style.fonts.get(i))
|
||||
else {
|
||||
return ("", String::new());
|
||||
};
|
||||
(
|
||||
"import ReactDOM from 'react-dom'\n",
|
||||
format!(
|
||||
" ReactDOM.preload({}, {{ as: 'font', type: {}, crossOrigin: 'anonymous' }})\n",
|
||||
js(&format!("/{}", face.path)),
|
||||
js(&mime(&face.path, "font"))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// `<html ...>` attributes for a locale known at build time.
|
||||
fn html_attrs(cx: &Ctx, loc: usize) -> String {
|
||||
let l = &cx.site.locales[loc];
|
||||
let mut s = String::new();
|
||||
if let Some(tag) = &l.tag {
|
||||
s.push_str(&format!(" lang={{{}}}", js(tag)));
|
||||
}
|
||||
if let Some(class) = &l.class {
|
||||
s.push_str(&format!(" className={{{}}}", js(class)));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn root_layout(cx: &Ctx, kit: &Kit, html: &str) -> String {
|
||||
let (imports, call) = preload(cx);
|
||||
format!(
|
||||
"import type {{ ReactNode }} from 'react'\n{imports}{}\nexport default function RootLayout({{ children }}: {{ children: ReactNode }}) {{\n{call} return (\n <html{html}>\n <body>{}</body>\n </html>\n )\n}}\n",
|
||||
kit.global_imports(),
|
||||
kit.wrap("{children}")
|
||||
)
|
||||
}
|
||||
|
||||
fn single(cx: &Ctx, kit: &Kit, files: &mut Files) {
|
||||
put(
|
||||
files,
|
||||
"app/layout.tsx",
|
||||
root_layout(cx, kit, &html_attrs(cx, 0)),
|
||||
);
|
||||
for (i, page) in cx.site.pages.iter().enumerate() {
|
||||
let dir = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", page.stem)
|
||||
};
|
||||
let (import, call) = h1_preload(cx, i);
|
||||
let body = format!(
|
||||
"import {c} from '@/components/{c}'\n{import}import {{ pageMetadata }} from '@/lib/seo'\n\nexport const metadata = pageMetadata(0, {i})\n\nexport default function Page() {{\n{call} return <{c} />\n}}\n",
|
||||
c = page.component
|
||||
);
|
||||
put(files, &format!("app/{dir}page.tsx"), body);
|
||||
}
|
||||
}
|
||||
|
||||
fn multi(cx: &Ctx, kit: &Kit, props: &[Props], files: &mut Files) {
|
||||
put(
|
||||
files,
|
||||
"app/(root)/layout.tsx",
|
||||
root_layout(cx, kit, &html_attrs(cx, 0)),
|
||||
);
|
||||
put(files, "app/(root)/page.tsx", LANGUAGE_INDEX);
|
||||
put(files, "app/[lang]/layout.tsx", lang_layout(cx, kit));
|
||||
for (i, page) in cx.site.pages.iter().enumerate() {
|
||||
let dir = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", page.stem)
|
||||
};
|
||||
put(
|
||||
files,
|
||||
&format!("app/[lang]/{dir}page.tsx"),
|
||||
lang_page(&page.component, i, props[i], h1_preload(cx, i)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn lang_layout(cx: &Ctx, kit: &Kit) -> String {
|
||||
let (imports, call) = preload(cx);
|
||||
format!(
|
||||
"import type {{ ReactNode }} from 'react'\nimport {{ notFound }} from 'next/navigation'\n{imports}{}import {{ localeIndex, locales }} from '@/lib/site'\n\nexport const dynamicParams = false\nexport const generateStaticParams = () => locales.map((l) => ({{ lang: l.dir }}))\n\nexport default async function LangLayout({{\n children,\n params,\n}}: {{\n children: ReactNode\n params: Promise<{{ lang: string }}>\n}}) {{\n const {{ lang }} = await params\n if (!locales.some((l) => l.dir === lang)) notFound()\n const locale = locales[localeIndex(lang)]\n{call} return (\n <html lang={{locale.tag ?? undefined}} className={{locale.cls ?? undefined}}>\n <body>{}</body>\n </html>\n )\n}}\n",
|
||||
kit.global_imports(),
|
||||
kit.wrap("{children}")
|
||||
)
|
||||
}
|
||||
|
||||
fn lang_page(comp: &str, page: usize, p: Props, (preload, call): (&str, String)) -> String {
|
||||
let mut imports = format!(
|
||||
"import {comp} from '@/components/{comp}'
|
||||
{preload}"
|
||||
);
|
||||
let mut attrs = String::new();
|
||||
if p.loc {
|
||||
attrs.push_str(" loc={loc}");
|
||||
}
|
||||
if p.m {
|
||||
imports.push_str(
|
||||
"import { catalogs } from '@/lib/messages'
|
||||
",
|
||||
);
|
||||
attrs.push_str(" m={catalogs[loc]}");
|
||||
}
|
||||
let (signature, locate) = if p.loc || p.m {
|
||||
(
|
||||
"{ params }: Props",
|
||||
" const { lang } = await params
|
||||
const loc = localeIndex(lang)
|
||||
",
|
||||
)
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
"import type {{ Metadata }} from 'next'
|
||||
{imports}import {{ pageMetadata }} from '@/lib/seo'
|
||||
import {{ localeIndex }} from '@/lib/site'
|
||||
|
||||
type Props = {{ params: Promise<{{ lang: string }}> }}
|
||||
|
||||
export async function generateMetadata({{ params }}: Props): Promise<Metadata> {{
|
||||
const {{ lang }} = await params
|
||||
return pageMetadata(localeIndex(lang), {page})
|
||||
}}
|
||||
|
||||
export default async function Page({signature}) {{
|
||||
{locate}{call} return <{comp}{attrs} />
|
||||
}}
|
||||
"
|
||||
)
|
||||
}
|
||||
|
||||
/// Next 16 on Windows writes the prefetch files of nested routes into folders
|
||||
/// (`__next.$d$lang\pricing\__PAGE__.txt`) because its export joins the segment path with the OS
|
||||
/// separator, while the router asks for `__next.$d$lang.pricing.__PAGE__.txt`: a 404 per link. This
|
||||
/// moves them to the flat name; where Next wrote flat names already there is nothing to move.
|
||||
const FLATTEN_PREFETCH: &str = "import { readdirSync, renameSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const files = (dir, parts = []) =>
|
||||
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? files(join(dir, e.name), [...parts, e.name]) : [[...parts, e.name]],
|
||||
)
|
||||
|
||||
function flatten(dir) {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory() || e.name === '_next') continue
|
||||
const path = join(dir, e.name)
|
||||
if (!e.name.startsWith('__next.')) {
|
||||
flatten(path)
|
||||
continue
|
||||
}
|
||||
for (const parts of files(path)) {
|
||||
renameSync(join(path, ...parts), join(dir, [e.name, ...parts].join('.')))
|
||||
}
|
||||
rmSync(path, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
flatten('out')
|
||||
";
|
||||
|
||||
const TSCONFIG: &str = r#"{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
"#;
|
||||
|
||||
const POSTCSS: &str = "const config = {
|
||||
plugins: { '@tailwindcss/postcss': {} },
|
||||
}
|
||||
|
||||
export default config
|
||||
";
|
||||
|
||||
/// Absolute alternates only: Next resolves relative ones against localhost, so without a base_url
|
||||
/// the canonical and hreflang links are left out.
|
||||
const SEO_TS: &str = "import type { Metadata } from 'next'
|
||||
import { ICON, SITE, headData, indexHeadData } from './site'
|
||||
|
||||
export function pageMetadata(loc: number, page: number): Metadata {
|
||||
const h = headData(loc, page)
|
||||
return {
|
||||
title: h.title,
|
||||
description: h.description,
|
||||
icons: ICON,
|
||||
alternates: SITE ? { canonical: h.canonical, languages: Object.fromEntries(h.alternates) } : undefined,
|
||||
openGraph: {
|
||||
title: h.title,
|
||||
description: h.description,
|
||||
type: 'website',
|
||||
url: h.ogUrl ?? undefined,
|
||||
locale: h.ogLocale ?? undefined,
|
||||
alternateLocale: h.ogAlternates,
|
||||
},
|
||||
twitter: { card: 'summary' },
|
||||
}
|
||||
}
|
||||
|
||||
export function indexMetadata(): Metadata {
|
||||
const h = indexHeadData()
|
||||
return {
|
||||
title: h.title,
|
||||
description: h.description,
|
||||
icons: ICON,
|
||||
alternates: SITE ? { canonical: h.canonical, languages: Object.fromEntries(h.alternates) } : undefined,
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
const LANGUAGE_INDEX: &str = "import type { Metadata } from 'next'
|
||||
import { indexMetadata } from '@/lib/seo'
|
||||
import { locales } from '@/lib/site'
|
||||
|
||||
export const metadata: Metadata = indexMetadata()
|
||||
|
||||
export default function LanguageIndex() {
|
||||
return (
|
||||
<main>
|
||||
<ul>
|
||||
{locales.map((l) => (
|
||||
<li key={l.dir}>
|
||||
<a href={`/${l.dir}/`} hrefLang={l.tag ?? undefined}>
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
";
|
||||
@@ -0,0 +1,766 @@
|
||||
//! How classes reach the components in each styling: CSS or SCSS modules, Tailwind utilities,
|
||||
//! vanilla-extract exports, or styled components. The kit scans every page once, so imports
|
||||
//! never name something a page does not use (the Vite project fails on unused locals).
|
||||
|
||||
use super::{Ctx, Files, js, put};
|
||||
use crate::mcp::site::markup::Val;
|
||||
use crate::mcp::site::model::{Site, Styling};
|
||||
use crate::mcp::site::style::{BASE_CSS, Class, StyleModel};
|
||||
use crate::mcp::site::tree::{Child, Element, LinkTarget};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
/// npm packages as (name, version range).
|
||||
pub(super) type Deps = Vec<(&'static str, &'static str)>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum Base {
|
||||
Tag(String),
|
||||
Link,
|
||||
}
|
||||
|
||||
/// A styled component: one class on one base element.
|
||||
struct Pair {
|
||||
name: String,
|
||||
class: String,
|
||||
base: Base,
|
||||
pages: BTreeSet<usize>,
|
||||
}
|
||||
|
||||
/// What one page needs, found by walking its tree in the order the markup printer does.
|
||||
#[derive(Default)]
|
||||
struct Scan {
|
||||
own: bool,
|
||||
shared: bool,
|
||||
}
|
||||
|
||||
pub struct Kit<'a> {
|
||||
site: &'a Site,
|
||||
styling: Styling,
|
||||
next: bool,
|
||||
classes: HashMap<&'a str, &'a Class>,
|
||||
idents: HashMap<&'a str, String>,
|
||||
pairs: Vec<Pair>,
|
||||
pair_of: HashMap<(String, Base), usize>,
|
||||
scans: Vec<Scan>,
|
||||
taken: BTreeSet<String>,
|
||||
}
|
||||
|
||||
const REGISTRY_STYLED: &str = "'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useServerInsertedHTML } from 'next/navigation'
|
||||
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
|
||||
|
||||
/** Collects styled-components rules while the server renders and injects them into <head>. */
|
||||
export function StyledRegistry({ children }: { children: ReactNode }) {
|
||||
const [sheet] = useState(() => new ServerStyleSheet())
|
||||
useServerInsertedHTML(() => {
|
||||
const styles = sheet.getStyleElement()
|
||||
sheet.instance.clearTag()
|
||||
return <>{styles}</>
|
||||
})
|
||||
if (typeof window !== 'undefined') return <>{children}</>
|
||||
return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>
|
||||
}
|
||||
";
|
||||
|
||||
const REGISTRY_EMOTION: &str = "'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { useServerInsertedHTML } from 'next/navigation'
|
||||
import createCache from '@emotion/cache'
|
||||
import { CacheProvider } from '@emotion/react'
|
||||
|
||||
/** Records the rules Emotion inserts while the server renders and flushes them into <head>. */
|
||||
export function EmotionRegistry({ children }: { children: ReactNode }) {
|
||||
const [{ cache, flush }] = useState(() => {
|
||||
const cache = createCache({ key: 'css' })
|
||||
cache.compat = true
|
||||
const prevInsert = cache.insert
|
||||
let inserted: string[] = []
|
||||
cache.insert = (...args) => {
|
||||
const serialized = args[1]
|
||||
if (cache.inserted[serialized.name] === undefined) inserted.push(serialized.name)
|
||||
return prevInsert(...args)
|
||||
}
|
||||
const flush = () => {
|
||||
const prev = inserted
|
||||
inserted = []
|
||||
return prev
|
||||
}
|
||||
return { cache, flush }
|
||||
})
|
||||
useServerInsertedHTML(() => {
|
||||
const names = flush()
|
||||
if (names.length === 0) return null
|
||||
let styles = ''
|
||||
for (const name of names) styles += cache.inserted[name]
|
||||
return <style key={cache.key} data-emotion={`${cache.key} ${names.join(' ')}`} dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
})
|
||||
return <CacheProvider value={cache}>{children}</CacheProvider>
|
||||
}
|
||||
";
|
||||
|
||||
/// Words an exported name must avoid: reserved words, plus what the generated file imports.
|
||||
const RESERVED: &[&str] = &[
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"new",
|
||||
"null",
|
||||
"return",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
"let",
|
||||
"static",
|
||||
"implements",
|
||||
"interface",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"await",
|
||||
"async",
|
||||
"arguments",
|
||||
"eval",
|
||||
"style",
|
||||
"StyleRule",
|
||||
"undefined",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
];
|
||||
|
||||
fn pascal(s: &str) -> String {
|
||||
s.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| w[..1].to_ascii_uppercase() + &w[1..])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn camel(s: &str) -> String {
|
||||
let p = pascal(s);
|
||||
p[..p.len().min(1)].to_ascii_lowercase() + p.get(1..).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A free name: `base`, or `base2`, `base3`...
|
||||
fn claim(taken: &mut BTreeSet<String>, base: String) -> String {
|
||||
let mut name = base.clone();
|
||||
let mut n = 2;
|
||||
while !taken.insert(name.clone()) {
|
||||
name = format!("{base}{n}");
|
||||
n += 1;
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn usable(c: &Class) -> bool {
|
||||
!c.decls.is_empty() || !c.overrides.is_empty()
|
||||
}
|
||||
|
||||
/// Declarations name assets from the site root; the project serves them from `/assets/`.
|
||||
fn url(v: &str) -> String {
|
||||
v.replace("url(assets/", "url(/assets/")
|
||||
}
|
||||
|
||||
/// `display-name` to `displayName`, and a vendor prefix (`-webkit-x`) to `WebkitX`.
|
||||
fn camel_prop(k: &str) -> String {
|
||||
let mut parts = k.split('-');
|
||||
let mut out = parts.next().unwrap_or_default().to_owned();
|
||||
for p in parts.filter(|p| !p.is_empty()) {
|
||||
out.push_str(&p[..1].to_ascii_uppercase());
|
||||
out.push_str(&p[1..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn entry(pad: &str, k: &str, v: &str) -> String {
|
||||
format!("{pad}{}: {},\n", js(k), js(&url(v)))
|
||||
}
|
||||
|
||||
fn is_own(c: &Class, page: usize) -> bool {
|
||||
c.pages.len() == 1 && c.pages.contains(&page)
|
||||
}
|
||||
|
||||
impl<'a> Kit<'a> {
|
||||
pub fn new(cx: &Ctx<'a>) -> Self {
|
||||
let site = cx.site;
|
||||
let mut kit = Kit {
|
||||
site,
|
||||
styling: site.options.styling,
|
||||
next: cx.next,
|
||||
classes: site
|
||||
.style
|
||||
.classes
|
||||
.iter()
|
||||
.map(|c| (c.name.as_str(), c))
|
||||
.collect(),
|
||||
idents: HashMap::new(),
|
||||
pairs: Vec::new(),
|
||||
pair_of: HashMap::new(),
|
||||
scans: Vec::new(),
|
||||
taken: site
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| p.component.clone())
|
||||
.chain(["Link".to_owned(), "Messages".to_owned(), "Props".to_owned()])
|
||||
.collect(),
|
||||
};
|
||||
if kit.styling == Styling::VanillaExtract {
|
||||
let mut taken: BTreeSet<String> = BTreeSet::new();
|
||||
for c in site.style.classes.iter().filter(|c| usable(c)) {
|
||||
let mut id = camel(&c.name);
|
||||
if id.is_empty()
|
||||
|| RESERVED.contains(&id.as_str())
|
||||
|| id.starts_with(|ch: char| ch.is_ascii_digit())
|
||||
{
|
||||
id.insert(0, '_');
|
||||
}
|
||||
kit.idents.insert(c.name.as_str(), claim(&mut taken, id));
|
||||
}
|
||||
}
|
||||
for (i, p) in site.pages.iter().enumerate() {
|
||||
let mut scan = Scan::default();
|
||||
kit.visit(i, &p.root, &mut scan);
|
||||
kit.scans.push(scan);
|
||||
}
|
||||
kit
|
||||
}
|
||||
|
||||
fn class_of(&self, name: &str) -> Option<&'a Class> {
|
||||
self.classes.get(name).copied().filter(|c| usable(c))
|
||||
}
|
||||
|
||||
fn css_in_js(&self) -> bool {
|
||||
matches!(self.styling, Styling::StyledComponents | Styling::Emotion)
|
||||
}
|
||||
|
||||
fn visit(&mut self, page: usize, el: &Element, scan: &mut Scan) {
|
||||
let is_link = el.link.is_some() && el.tag == "a";
|
||||
let internal = is_link && matches!(el.link, Some(LinkTarget::Page(_)));
|
||||
// Tailwind classes come from the element itself, not the class table.
|
||||
if let Some(c) = self
|
||||
.class_of(&el.class)
|
||||
.filter(|_| self.styling != Styling::Tailwind)
|
||||
{
|
||||
if self.css_in_js() {
|
||||
let base = if internal {
|
||||
Base::Link
|
||||
} else {
|
||||
Base::Tag(el.tag.clone())
|
||||
};
|
||||
self.pair(&c.name, base, page);
|
||||
} else if is_own(c, page) {
|
||||
scan.own = true;
|
||||
} else {
|
||||
scan.shared = true;
|
||||
}
|
||||
}
|
||||
for ch in &el.children {
|
||||
if let Child::El(e) = ch {
|
||||
self.visit(page, e, scan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pair(&mut self, class: &str, base: Base, page: usize) -> usize {
|
||||
let key = (class.to_owned(), base.clone());
|
||||
if let Some(&i) = self.pair_of.get(&key) {
|
||||
self.pairs[i].pages.insert(page);
|
||||
return i;
|
||||
}
|
||||
let suffix = match &base {
|
||||
Base::Link => "Link".to_owned(),
|
||||
Base::Tag(t) => pascal(t),
|
||||
};
|
||||
let name = claim(&mut self.taken, pascal(class) + &suffix);
|
||||
self.pairs.push(Pair {
|
||||
name,
|
||||
class: class.to_owned(),
|
||||
base,
|
||||
pages: BTreeSet::from([page]),
|
||||
});
|
||||
self.pair_of.insert(key, self.pairs.len() - 1);
|
||||
self.pairs.len() - 1
|
||||
}
|
||||
|
||||
/// The `class` attribute of an element, if its styling uses one.
|
||||
pub fn class_val(&self, page: usize, el: &Element) -> Option<Val> {
|
||||
if self.styling == Styling::Tailwind {
|
||||
return Some(el.tailwind_classes())
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(Val::Lit);
|
||||
}
|
||||
let c = self.class_of(&el.class)?;
|
||||
let ns = if is_own(c, page) { "s" } else { "g" };
|
||||
match self.styling {
|
||||
Styling::Css | Styling::Scss => Some(Val::Expr(format!("{ns}[{}]", js(&c.name)))),
|
||||
Styling::VanillaExtract => {
|
||||
Some(Val::Expr(format!("{ns}.{}", self.idents[c.name.as_str()])))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The styled component standing for a non-anchor element.
|
||||
pub fn tag_name(&self, el: &Element) -> Option<String> {
|
||||
let c = self.class_of(&el.class).filter(|_| self.css_in_js())?;
|
||||
let i = self
|
||||
.pair_of
|
||||
.get(&(c.name.clone(), Base::Tag(el.tag.clone())))?;
|
||||
Some(self.pairs[*i].name.clone())
|
||||
}
|
||||
|
||||
/// The tag an anchor prints as: its styled component, else `Link` for a page and `a` for a URL.
|
||||
pub fn link_tag(&self, el: &Element) -> String {
|
||||
let internal = matches!(el.link, Some(LinkTarget::Page(_)));
|
||||
let base = if internal {
|
||||
Base::Link
|
||||
} else {
|
||||
Base::Tag(el.tag.clone())
|
||||
};
|
||||
self.class_of(&el.class)
|
||||
.filter(|_| self.css_in_js())
|
||||
.and_then(|c| self.pair_of.get(&(c.name.clone(), base)))
|
||||
.map_or_else(
|
||||
|| if internal { "Link" } else { "a" }.to_owned(),
|
||||
|i| self.pairs[*i].name.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `'use client'` for components that define styled components under Next.
|
||||
pub fn client(&self) -> bool {
|
||||
self.css_in_js() && self.next
|
||||
}
|
||||
|
||||
/// The import lines for the page's styles (paths relative to `components/`).
|
||||
pub fn imports(&self, page: usize) -> String {
|
||||
let (scan, comp) = (&self.scans[page], &self.site.pages[page].component);
|
||||
let mut out = String::new();
|
||||
match self.styling {
|
||||
Styling::Css | Styling::Scss => {
|
||||
let ext = if self.styling == Styling::Css {
|
||||
"css"
|
||||
} else {
|
||||
"scss"
|
||||
};
|
||||
if scan.own {
|
||||
out.push_str(&format!("import s from './{comp}.module.{ext}'\n"));
|
||||
}
|
||||
if scan.shared {
|
||||
out.push_str(&format!("import g from './shared.module.{ext}'\n"));
|
||||
}
|
||||
}
|
||||
Styling::VanillaExtract => {
|
||||
if scan.own {
|
||||
out.push_str(&format!("import * as s from './{comp}.css'\n"));
|
||||
}
|
||||
if scan.shared {
|
||||
out.push_str("import * as g from './shared.css'\n");
|
||||
}
|
||||
}
|
||||
Styling::StyledComponents | Styling::Emotion => {
|
||||
if self
|
||||
.pairs
|
||||
.iter()
|
||||
.any(|p| p.pages.len() == 1 && p.pages.contains(&page))
|
||||
{
|
||||
out.push_str(&format!("import styled from '{}'\n", self.styled_pkg()));
|
||||
}
|
||||
let shared: Vec<&str> = self
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| p.pages.len() > 1 && p.pages.contains(&page))
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
if !shared.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"import {{ {} }} from './shared'\n",
|
||||
shared.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
Styling::Tailwind => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn styled_pkg(&self) -> &'static str {
|
||||
if self.styling == Styling::Emotion {
|
||||
"@emotion/styled"
|
||||
} else {
|
||||
"styled-components"
|
||||
}
|
||||
}
|
||||
|
||||
/// The styled components only this page uses, printed above the component.
|
||||
pub fn prelude(&self, page: usize) -> String {
|
||||
let own = self
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| p.pages.len() == 1 && p.pages.contains(&page));
|
||||
own.map(|p| self.define(p, "const"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn define(&self, p: &Pair, kw: &str) -> String {
|
||||
let Some(c) = self.classes.get(p.class.as_str()) else {
|
||||
return String::new();
|
||||
};
|
||||
let base = match &p.base {
|
||||
Base::Link => "(Link)".to_owned(),
|
||||
Base::Tag(t) => format!(".{t}"),
|
||||
};
|
||||
let mut s = format!("{kw} {} = styled{base}({{\n", p.name);
|
||||
for (k, v) in &c.decls {
|
||||
s.push_str(&entry(" ", k, v));
|
||||
}
|
||||
for (loc, decls) in &c.overrides {
|
||||
let Some(scope) = self.site.style.scopes.get(*loc) else {
|
||||
continue;
|
||||
};
|
||||
s.push_str(&format!(" {}: {{\n", js(&format!("{scope} &"))));
|
||||
for (k, v) in decls {
|
||||
s.push_str(&entry(" ", k, v));
|
||||
}
|
||||
s.push_str(" },\n");
|
||||
}
|
||||
s.push_str("})\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn module_css(&self, keep: impl Fn(&Class) -> bool) -> String {
|
||||
// A class name in a mode scope would be hashed like any other, so it is marked global.
|
||||
let scopes = self
|
||||
.site
|
||||
.style
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.strip_prefix(":root.")
|
||||
.map_or_else(|| s.clone(), |r| format!(":root:global(.{r})"))
|
||||
})
|
||||
.collect();
|
||||
let model = StyleModel {
|
||||
tokens: Vec::new(),
|
||||
fonts: Vec::new(),
|
||||
fallbacks: Vec::new(),
|
||||
preload: None,
|
||||
favicon: None,
|
||||
h1_faces: Vec::new(),
|
||||
classes: self.site.style.classes.clone(),
|
||||
scopes,
|
||||
};
|
||||
let css = model.classes_css("/", |c| usable(c) && keep(c));
|
||||
if self.styling == Styling::Scss {
|
||||
css.replace("#{", "\\23 {")
|
||||
} else {
|
||||
css
|
||||
}
|
||||
}
|
||||
|
||||
fn global_css(&self) -> String {
|
||||
let st = &self.site.style;
|
||||
if self.styling == Styling::Tailwind {
|
||||
return format!(
|
||||
"@import \"tailwindcss\";\n{}{}{}{}",
|
||||
st.tailwind_theme(),
|
||||
st.tokens_css(),
|
||||
st.fonts_css("/"),
|
||||
st.overrides_css("/", |_| true)
|
||||
);
|
||||
}
|
||||
format!("{}{}{BASE_CSS}", st.tokens_css(), st.fonts_css("/"))
|
||||
}
|
||||
|
||||
/// The page's own style file, if its styling keeps one per page.
|
||||
pub fn write_page(&self, cx: &Ctx, page: usize, files: &mut Files) {
|
||||
let (p, comp) = (cx.prefix(), &self.site.pages[page].component);
|
||||
if !self.scans[page].own {
|
||||
return;
|
||||
}
|
||||
let own = |c: &Class| is_own(c, page);
|
||||
match self.styling {
|
||||
Styling::Css => put(
|
||||
files,
|
||||
&format!("{p}components/{comp}.module.css"),
|
||||
self.module_css(own),
|
||||
),
|
||||
Styling::Scss => put(
|
||||
files,
|
||||
&format!("{p}components/{comp}.module.scss"),
|
||||
self.module_css(own),
|
||||
),
|
||||
Styling::VanillaExtract => put(
|
||||
files,
|
||||
&format!("{p}components/{comp}.css.ts"),
|
||||
self.vanilla(own),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The styles more than one page uses.
|
||||
pub fn write_shared(&self, cx: &Ctx, files: &mut Files) {
|
||||
let p = cx.prefix();
|
||||
let shared = |c: &Class| c.pages.len() != 1;
|
||||
let any = self.scans.iter().any(|s| s.shared);
|
||||
match self.styling {
|
||||
Styling::Css if any => put(
|
||||
files,
|
||||
&format!("{p}components/shared.module.css"),
|
||||
self.module_css(shared),
|
||||
),
|
||||
Styling::Scss if any => put(
|
||||
files,
|
||||
&format!("{p}components/shared.module.scss"),
|
||||
self.module_css(shared),
|
||||
),
|
||||
Styling::VanillaExtract if any => put(
|
||||
files,
|
||||
&format!("{p}components/shared.css.ts"),
|
||||
self.vanilla(shared),
|
||||
),
|
||||
Styling::StyledComponents | Styling::Emotion => self.write_shared_pairs(cx, files),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_shared_pairs(&self, cx: &Ctx, files: &mut Files) {
|
||||
let pairs: Vec<&Pair> = self.pairs.iter().filter(|p| p.pages.len() > 1).collect();
|
||||
if pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
if self.client() {
|
||||
s.push_str("'use client'\n\n");
|
||||
}
|
||||
if pairs.iter().any(|p| p.base == Base::Link) {
|
||||
s.push_str(if self.next {
|
||||
"import Link from 'next/link'\n"
|
||||
} else {
|
||||
"import { Link } from 'react-router'\n"
|
||||
});
|
||||
}
|
||||
s.push_str(&format!("import styled from '{}'\n\n", self.styled_pkg()));
|
||||
s.push_str(
|
||||
&pairs
|
||||
.iter()
|
||||
.map(|p| self.define(p, "export const"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
put(files, &format!("{}components/shared.ts", cx.prefix()), s);
|
||||
}
|
||||
|
||||
fn vanilla(&self, keep: impl Fn(&Class) -> bool) -> String {
|
||||
let mut s = String::from("import { style, type StyleRule } from '@vanilla-extract/css'\n");
|
||||
for c in self
|
||||
.site
|
||||
.style
|
||||
.classes
|
||||
.iter()
|
||||
.filter(|c| usable(c) && keep(c))
|
||||
{
|
||||
s.push_str(&format!(
|
||||
"\nexport const {} = style({{\n",
|
||||
self.idents[c.name.as_str()]
|
||||
));
|
||||
s.push_str(&ve_decls(&c.decls, " "));
|
||||
let selectors: String = c
|
||||
.overrides
|
||||
.iter()
|
||||
.filter_map(|(loc, d)| {
|
||||
self.site.style.scopes.get(*loc).map(|scope| {
|
||||
format!(
|
||||
" {}: {{\n{} }},\n",
|
||||
js(&format!("{scope} &")),
|
||||
ve_decls(d, " ")
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !selectors.is_empty() {
|
||||
s.push_str(&format!(" selectors: {{\n{selectors} }},\n"));
|
||||
}
|
||||
s.push_str("} as StyleRule)\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The import lines a layout (Next) or `main.tsx` (Vite) needs for the global styles.
|
||||
pub fn global_imports(&self) -> String {
|
||||
let at = if self.next { "@/" } else { "./" };
|
||||
match self.styling {
|
||||
Styling::StyledComponents | Styling::Emotion => {
|
||||
let reg = match (self.next, self.styling) {
|
||||
(true, Styling::Emotion) => {
|
||||
format!("import {{ EmotionRegistry }} from '{at}lib/registry'\n")
|
||||
}
|
||||
(true, _) => format!("import {{ StyledRegistry }} from '{at}lib/registry'\n"),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("{reg}import {{ GlobalStyle }} from '{at}styles/global'\n")
|
||||
}
|
||||
Styling::Scss => format!("import '{at}styles/global.scss'\n"),
|
||||
_ => format!("import '{at}styles/global.css'\n"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inner` (a JSX expression) with the style providers around it.
|
||||
pub fn wrap(&self, inner: &str) -> String {
|
||||
if !self.css_in_js() {
|
||||
return inner.to_owned();
|
||||
}
|
||||
if !self.next {
|
||||
return format!("<><GlobalStyle />{inner}</>");
|
||||
}
|
||||
let reg = if self.styling == Styling::Emotion {
|
||||
"EmotionRegistry"
|
||||
} else {
|
||||
"StyledRegistry"
|
||||
};
|
||||
format!("<{reg}><GlobalStyle />{inner}</{reg}>")
|
||||
}
|
||||
|
||||
pub fn write_global(&self, cx: &Ctx, files: &mut Files) {
|
||||
let p = cx.prefix();
|
||||
let css = self.global_css();
|
||||
match self.styling {
|
||||
Styling::Scss => put(
|
||||
files,
|
||||
&format!("{p}styles/global.scss"),
|
||||
css.replace("#{", "\\23 {"),
|
||||
),
|
||||
Styling::StyledComponents | Styling::Emotion => {
|
||||
let head = if self.client() {
|
||||
"'use client'\n\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let emotion = self.styling == Styling::Emotion;
|
||||
let body = if emotion {
|
||||
format!(
|
||||
"{head}import {{ Global }} from '@emotion/react'\n\nconst CSS = {}\n\nexport const GlobalStyle = () => <Global styles={{CSS}} />\n",
|
||||
js(&css)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{head}import {{ createGlobalStyle }} from 'styled-components'\n\nconst CSS = {}\n\nexport const GlobalStyle = createGlobalStyle`${{CSS}}`\n",
|
||||
js(&css)
|
||||
)
|
||||
};
|
||||
put(
|
||||
files,
|
||||
&format!("{p}styles/global.{}", if emotion { "tsx" } else { "ts" }),
|
||||
body,
|
||||
);
|
||||
if self.next {
|
||||
put(
|
||||
files,
|
||||
"lib/registry.tsx",
|
||||
if emotion {
|
||||
REGISTRY_EMOTION
|
||||
} else {
|
||||
REGISTRY_STYLED
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => put(files, &format!("{p}styles/global.css"), css),
|
||||
}
|
||||
}
|
||||
|
||||
/// Package dependencies and dev dependencies this styling adds.
|
||||
pub fn deps(&self) -> (Deps, Deps) {
|
||||
let next = self.next;
|
||||
match self.styling {
|
||||
Styling::Css => (vec![], vec![]),
|
||||
Styling::Scss => (vec![], vec![("sass", "^1.107.1")]),
|
||||
Styling::Tailwind => (
|
||||
vec![],
|
||||
vec![
|
||||
if next {
|
||||
("@tailwindcss/postcss", "^4.3.4")
|
||||
} else {
|
||||
("@tailwindcss/vite", "^4.3.4")
|
||||
},
|
||||
("tailwindcss", "^4.3.4"),
|
||||
],
|
||||
),
|
||||
Styling::VanillaExtract if next => (
|
||||
vec![
|
||||
("@vanilla-extract/css", "^1.21.3"),
|
||||
("@vanilla-extract/next-plugin", "^2.5.2"),
|
||||
],
|
||||
vec![],
|
||||
),
|
||||
Styling::VanillaExtract => (
|
||||
vec![("@vanilla-extract/css", "^1.21.3")],
|
||||
vec![("@vanilla-extract/vite-plugin", "^5.2.6")],
|
||||
),
|
||||
Styling::StyledComponents => (vec![("styled-components", "^6.6.0")], vec![]),
|
||||
Styling::Emotion if next => (
|
||||
vec![
|
||||
("@emotion/cache", "^11.14.0"),
|
||||
("@emotion/react", "^11.14.0"),
|
||||
("@emotion/styled", "^11.14.1"),
|
||||
],
|
||||
vec![],
|
||||
),
|
||||
Styling::Emotion => (
|
||||
vec![
|
||||
("@emotion/react", "^11.14.0"),
|
||||
("@emotion/styled", "^11.14.1"),
|
||||
],
|
||||
vec![],
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain properties of a vanilla-extract rule, then its custom properties under `vars`.
|
||||
fn ve_decls(decls: &[(String, String)], pad: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (k, v) in decls.iter().filter(|(k, _)| !k.starts_with("--")) {
|
||||
out.push_str(&entry(pad, &camel_prop(k), v));
|
||||
}
|
||||
let vars: String = decls
|
||||
.iter()
|
||||
.filter(|(k, _)| k.starts_with("--"))
|
||||
.map(|(k, v)| entry(&format!("{pad} "), k, v))
|
||||
.collect();
|
||||
if !vars.is_empty() {
|
||||
out.push_str(&format!("{pad}vars: {{\n{vars}{pad}}},\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
use super::write;
|
||||
use crate::mcp::site::model::{Framework, Locale, Options, Page, Render, Site, Styling};
|
||||
use crate::mcp::site::style::{Class, FontFace, StyleModel, Token};
|
||||
use crate::mcp::site::tree::{Child, Element, LinkTarget, Text};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const HOSTILE: &str = "</script>{x} `${x}` \"q\" 'a' \\ <b>&";
|
||||
|
||||
const RENDERS: [Render; 2] = [Render::Static, Render::Spa];
|
||||
const STYLINGS: [Styling; 6] = [
|
||||
Styling::Css,
|
||||
Styling::Scss,
|
||||
Styling::Tailwind,
|
||||
Styling::VanillaExtract,
|
||||
Styling::StyledComponents,
|
||||
Styling::Emotion,
|
||||
];
|
||||
|
||||
fn el(tag: &str, class: &str, children: Vec<Child>) -> Element {
|
||||
Element {
|
||||
tag: tag.into(),
|
||||
class: class.into(),
|
||||
attrs: Vec::new(),
|
||||
link: None,
|
||||
tw: "flex gap-4".into(),
|
||||
has_overrides: false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn lit(s: &str) -> Child {
|
||||
Child::Text(Text::Lit(s.into()))
|
||||
}
|
||||
|
||||
fn msg(k: &str) -> Child {
|
||||
Child::Text(Text::Msg(k.into()))
|
||||
}
|
||||
|
||||
fn link(target: LinkTarget, class: &str, text: Child) -> Child {
|
||||
let mut a = el("a", class, vec![text]);
|
||||
a.link = Some(target);
|
||||
Child::El(a)
|
||||
}
|
||||
|
||||
fn class(name: &str, decls: &[(&str, &str)], pages: &[usize], root_of: Option<usize>) -> Class {
|
||||
Class {
|
||||
name: name.into(),
|
||||
decls: decls
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect(),
|
||||
overrides: Vec::new(),
|
||||
pages: pages.iter().copied().collect::<BTreeSet<_>>(),
|
||||
root_of,
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> Element {
|
||||
let mut img = el("img", "pic", Vec::new());
|
||||
img.attrs = vec![
|
||||
("src".into(), "assets/a.png".into()),
|
||||
("alt".into(), HOSTILE.into()),
|
||||
];
|
||||
let mut h1 = el("h1", "card-title", vec![msg("home.hero_title")]);
|
||||
h1.attrs = vec![("data-x".into(), HOSTILE.into())];
|
||||
el(
|
||||
"div",
|
||||
"home",
|
||||
vec![
|
||||
Child::El(h1),
|
||||
Child::El(el("p", "card", vec![lit(HOSTILE), msg("1st.key")])),
|
||||
link(LinkTarget::Page(1), "cta", lit("Pricing")),
|
||||
link(
|
||||
LinkTarget::Url("https://example.com/?a=1&b=2".into()),
|
||||
"cta",
|
||||
lit("Docs"),
|
||||
),
|
||||
Child::El(img),
|
||||
Child::El(el("div", "bg", Vec::new())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn pricing() -> Element {
|
||||
el(
|
||||
"div",
|
||||
"pricing",
|
||||
vec![
|
||||
Child::El(el("h2", "card", vec![lit("Prices")])),
|
||||
link(LinkTarget::Page(0), "cta", msg("1st.key")),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Two locales (or one) with hostile titles, messages whose keys have dots and a leading digit, an
|
||||
/// image, a font, a link to a page, an external link and an image background.
|
||||
fn site(render: Render, styling: Styling, multi: bool, base: bool) -> Site {
|
||||
let locale = |tag: &str, i: usize| Locale {
|
||||
tag: Some(tag.into()),
|
||||
hreflang: multi,
|
||||
folder: multi.then(|| tag.to_owned()),
|
||||
label: if i == 0 {
|
||||
"English".into()
|
||||
} else {
|
||||
"Portugu\u{ea}s".into()
|
||||
},
|
||||
scope: if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":root:lang({tag})")
|
||||
},
|
||||
class: None,
|
||||
};
|
||||
let locales: Vec<Locale> = if multi {
|
||||
vec![locale("en", 0), locale("pt-BR", 1)]
|
||||
} else {
|
||||
vec![locale("en", 0)]
|
||||
};
|
||||
let n = locales.len();
|
||||
let each = |s: &str| vec![s.to_owned(); n];
|
||||
let page = |name: &str, stem: &str, component: &str, root: Element| Page {
|
||||
name: name.into(),
|
||||
stem: stem.into(),
|
||||
component: component.into(),
|
||||
root,
|
||||
title: each(HOSTILE),
|
||||
description: each("Tom's \"best\" ${x} `plan`"),
|
||||
};
|
||||
let mut messages = vec![BTreeMap::from([
|
||||
("home.hero_title".to_owned(), HOSTILE.to_owned()),
|
||||
("1st.key".to_owned(), "One".to_owned()),
|
||||
])];
|
||||
if multi {
|
||||
messages.push(BTreeMap::from([
|
||||
("home.hero_title".to_owned(), "Ol\u{e1} {x}".to_owned()),
|
||||
("1st.key".to_owned(), "Um".to_owned()),
|
||||
]));
|
||||
}
|
||||
let style = StyleModel {
|
||||
tokens: vec![Token {
|
||||
name: "c-ink".into(),
|
||||
value: "#111111".into(),
|
||||
color: true,
|
||||
overrides: if multi {
|
||||
vec![(1, "#eeeeee".into())]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}],
|
||||
fallbacks: Vec::new(),
|
||||
fonts: vec![FontFace {
|
||||
family: "Inter".into(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
path: "assets/fonts/abc.ttf".into(),
|
||||
format: "truetype",
|
||||
}],
|
||||
preload: Some(0),
|
||||
favicon: None,
|
||||
h1_faces: Vec::new(),
|
||||
classes: vec![
|
||||
class(
|
||||
"home",
|
||||
&[("display", "flex"), ("color", "var(--c-ink)")],
|
||||
&[0],
|
||||
Some(0),
|
||||
),
|
||||
class("pricing", &[("display", "block")], &[1], Some(1)),
|
||||
class(
|
||||
"card-title",
|
||||
&[("font-size", "32px"), ("font-family", "Inter, sans-serif")],
|
||||
&[0],
|
||||
None,
|
||||
),
|
||||
class("card", &[("padding", "8px")], &[0, 1], None),
|
||||
class("cta", &[("color", "red")], &[0, 1], None),
|
||||
class("pic", &[("width", "10px")], &[0], None),
|
||||
class(
|
||||
"bg",
|
||||
&[
|
||||
("background-image", "url(assets/a.png)"),
|
||||
("height", "20px"),
|
||||
],
|
||||
&[0],
|
||||
None,
|
||||
),
|
||||
],
|
||||
scopes: locales.iter().map(|l| l.scope.clone()).collect(),
|
||||
};
|
||||
let mut assets = BTreeMap::new();
|
||||
assets.insert("assets/a.png".to_owned(), vec![1, 2, 3]);
|
||||
assets.insert("assets/fonts/abc.ttf".to_owned(), vec![4, 5, 6]);
|
||||
Site {
|
||||
options: Options {
|
||||
framework: Framework::React,
|
||||
render,
|
||||
styling,
|
||||
},
|
||||
site_title: "Acme </title> \"Co\"".into(),
|
||||
base_url: base.then(|| "https://example.com".to_owned()),
|
||||
locales,
|
||||
pages: vec![
|
||||
page("Home", "index", "HomePage", home()),
|
||||
page("Pricing </script>", "pricing", "PricingPage", pricing()),
|
||||
],
|
||||
messages,
|
||||
assets,
|
||||
style,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_files(site: &Site) -> BTreeMap<String, String> {
|
||||
let out = write(site).expect("the site is written");
|
||||
out.files
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| String::from_utf8(v).ok().map(|t| (k, t)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn each_variant(multi: bool, check: impl Fn(&str, Render, Styling, &BTreeMap<String, String>)) {
|
||||
for render in RENDERS {
|
||||
for styling in STYLINGS {
|
||||
let files = text_files(&site(render, styling, multi, true));
|
||||
check(&format!("{render:?}-{styling:?}"), render, styling, &files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(render: Render) -> &'static str {
|
||||
if render == Render::Static { "" } else { "src/" }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variant_writes_its_shape_and_styling_files() {
|
||||
each_variant(true, |name, render, styling, files| {
|
||||
let p = dir(render);
|
||||
let has = |path: &str| {
|
||||
assert!(
|
||||
files.contains_key(path),
|
||||
"{name}: missing {path} in {:?}",
|
||||
files.keys().collect::<Vec<_>>()
|
||||
)
|
||||
};
|
||||
for path in ["package.json", "public/robots.txt", "public/sitemap.xml"] {
|
||||
has(path);
|
||||
}
|
||||
for path in [
|
||||
"lib/site.ts",
|
||||
"lib/messages.ts",
|
||||
"messages/en.json",
|
||||
"messages/pt-BR.json",
|
||||
"components/HomePage.tsx",
|
||||
"components/PricingPage.tsx",
|
||||
] {
|
||||
has(&format!("{p}{path}"));
|
||||
}
|
||||
if render == Render::Static {
|
||||
for path in [
|
||||
"next.config.ts",
|
||||
"tsconfig.json",
|
||||
"lib/seo.ts",
|
||||
"app/(root)/layout.tsx",
|
||||
"app/(root)/page.tsx",
|
||||
"app/[lang]/layout.tsx",
|
||||
"app/[lang]/page.tsx",
|
||||
"app/[lang]/pricing/page.tsx",
|
||||
] {
|
||||
has(path);
|
||||
}
|
||||
} else {
|
||||
for path in [
|
||||
"index.html",
|
||||
"vite.config.ts",
|
||||
"tsconfig.app.json",
|
||||
"src/main.tsx",
|
||||
"src/router.tsx",
|
||||
"src/lib/head.tsx",
|
||||
"src/pages/LangLayout.tsx",
|
||||
"src/pages/LanguageIndex.tsx",
|
||||
] {
|
||||
has(path);
|
||||
}
|
||||
}
|
||||
match styling {
|
||||
Styling::Css => assert!(
|
||||
files.contains_key(&format!("{p}components/HomePage.module.css"))
|
||||
&& files.contains_key(&format!("{p}styles/global.css")),
|
||||
"{name}"
|
||||
),
|
||||
Styling::Scss => assert!(
|
||||
files.contains_key(&format!("{p}components/HomePage.module.scss"))
|
||||
&& files.contains_key(&format!("{p}styles/global.scss")),
|
||||
"{name}"
|
||||
),
|
||||
Styling::Tailwind => assert!(
|
||||
files[&format!("{p}styles/global.css")].contains("@import \"tailwindcss\"")
|
||||
|| files[&format!("{p}styles/global.css")].contains("tailwindcss"),
|
||||
"{name}"
|
||||
),
|
||||
Styling::VanillaExtract => assert!(
|
||||
files.contains_key(&format!("{p}components/HomePage.css.ts")),
|
||||
"{name}"
|
||||
),
|
||||
Styling::StyledComponents => assert!(
|
||||
files[&format!("{p}components/HomePage.tsx")].contains("styled-components"),
|
||||
"{name}"
|
||||
),
|
||||
Styling::Emotion => assert!(
|
||||
files[&format!("{p}components/HomePage.tsx")].contains("@emotion/styled"),
|
||||
"{name}"
|
||||
),
|
||||
}
|
||||
let pkg: serde_json::Value =
|
||||
serde_json::from_str(&files["package.json"]).expect("package.json is JSON");
|
||||
let deps = pkg["dependencies"]
|
||||
.as_object()
|
||||
.expect("dependencies")
|
||||
.keys()
|
||||
.chain(
|
||||
pkg["devDependencies"]
|
||||
.as_object()
|
||||
.expect("devDependencies")
|
||||
.keys(),
|
||||
)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let want = match styling {
|
||||
Styling::Scss => Some("sass"),
|
||||
Styling::Tailwind => Some("tailwindcss"),
|
||||
Styling::VanillaExtract => Some("@vanilla-extract/css"),
|
||||
Styling::StyledComponents => Some("styled-components"),
|
||||
Styling::Emotion => Some("@emotion/styled"),
|
||||
Styling::Css => None,
|
||||
};
|
||||
if let Some(dep) = want {
|
||||
assert!(
|
||||
deps.iter().any(|d| d == dep),
|
||||
"{name}: {dep} missing from {deps:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Anchors in a mixed order: each must print the tag of its own element, not of whichever anchor
|
||||
/// happens to come at that position in some other walk of the tree.
|
||||
#[test]
|
||||
fn every_anchor_prints_the_tag_of_its_own_element() {
|
||||
let url = || LinkTarget::Url("https://example.com/".into());
|
||||
let anchors = [
|
||||
link(url(), "cta", lit("a")),
|
||||
link(LinkTarget::Page(1), "cta", lit("b")),
|
||||
link(url(), "card", lit("c")),
|
||||
link(LinkTarget::Page(1), "card", lit("d")),
|
||||
link(LinkTarget::Page(1), "cta", lit("e")),
|
||||
];
|
||||
let tags = |styling: Styling| {
|
||||
let mut s = site(Render::Static, styling, false, false);
|
||||
s.pages[0].root = el("div", "home", anchors.to_vec());
|
||||
let files = text_files(&s);
|
||||
files["components/HomePage.tsx"]
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| l.starts_with('<') && (l.contains(" href=") || l.contains(" to=")))
|
||||
.filter_map(|l| l[1..].split_whitespace().next().map(str::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(tags(Styling::Css), ["a", "Link", "a", "Link", "Link"]);
|
||||
let styled = tags(Styling::StyledComponents);
|
||||
assert_eq!(styled.len(), 5, "{styled:?}");
|
||||
// One styled component per class and kind of target: the repeated pair shares a name and the
|
||||
// others do not.
|
||||
assert_eq!(styled[1], styled[4], "{styled:?}");
|
||||
let unique: BTreeSet<_> = styled[..4].iter().collect();
|
||||
assert_eq!(unique.len(), 4, "{styled:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_never_reaches_code_unescaped() {
|
||||
each_variant(true, |name, _, _, files| {
|
||||
let index = files.get("index.html");
|
||||
if let Some(index) = index {
|
||||
assert!(
|
||||
index.contains("</title>") && index.matches("</title>").count() == 1,
|
||||
"{name}: the title is escaped in index.html"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keys_use_bracket_access() {
|
||||
each_variant(true, |name, render, _, files| {
|
||||
let home = &files[&format!("{}components/HomePage.tsx", dir(render))];
|
||||
assert!(
|
||||
home.contains("m[\"home.hero_title\"]") && home.contains("m[\"1st.key\"]"),
|
||||
"{name}: {home}"
|
||||
);
|
||||
assert!(
|
||||
!home.contains("m.home") && !home.contains("m.1st"),
|
||||
"{name}"
|
||||
);
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&files[&format!("{}messages/pt-BR.json", dir(render))])
|
||||
.expect("catalog is JSON");
|
||||
assert_eq!(json["1st.key"], "Um", "{name}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_locale_sites_use_root_routes_and_literal_text() {
|
||||
for render in RENDERS {
|
||||
for styling in STYLINGS {
|
||||
let name = format!("{render:?}-{styling:?}");
|
||||
let files = text_files(&site(render, styling, false, false));
|
||||
let p = dir(render);
|
||||
assert!(
|
||||
!files
|
||||
.keys()
|
||||
.any(|k| k.contains("messages") || k.contains("[lang]")),
|
||||
"{name}: {:?}",
|
||||
files.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!files.contains_key("public/sitemap.xml"),
|
||||
"{name}: no sitemap without base_url"
|
||||
);
|
||||
let home = &files[&format!("{p}components/HomePage.tsx")];
|
||||
assert!(
|
||||
home.contains("One") && !home.contains("catalogs"),
|
||||
"{name}: {home}"
|
||||
);
|
||||
if render == Render::Static {
|
||||
assert!(
|
||||
files.contains_key("app/page.tsx")
|
||||
&& files.contains_key("app/pricing/page.tsx"),
|
||||
"{name}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
files["src/router.tsx"].contains("path: \"/pricing\"")
|
||||
&& !files["src/router.tsx"].contains(":lang"),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_preloaded_once_per_shape() {
|
||||
each_variant(true, |name, render, _, files| {
|
||||
if render == Render::Static {
|
||||
assert!(
|
||||
files["app/[lang]/layout.tsx"].contains("ReactDOM.preload(FONT_URL"),
|
||||
"{name}"
|
||||
);
|
||||
assert!(
|
||||
!files.values().any(|t| t.contains("rel=\"preload\"")),
|
||||
"{name}: no second preload link"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
files["index.html"].matches("rel=\"preload\"").count(),
|
||||
1,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_names_are_valid_and_unique_per_file() {
|
||||
for render in RENDERS {
|
||||
for styling in [Styling::StyledComponents, Styling::Emotion] {
|
||||
let files = text_files(&site(render, styling, true, true));
|
||||
for (path, text) in files.iter().filter(|(k, _)| k.contains("components/")) {
|
||||
let names: Vec<&str> = text
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("const ").and_then(|r| r.split(" = ").next()))
|
||||
.collect();
|
||||
let unique: BTreeSet<&&str> = names.iter().collect();
|
||||
assert_eq!(names.len(), unique.len(), "{path}: {names:?}");
|
||||
assert!(
|
||||
names.iter().all(
|
||||
|n| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& n.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
),
|
||||
"{path}: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_modules_reach_assets_from_the_site_root() {
|
||||
let files = text_files(&site(Render::Static, Styling::Css, true, true));
|
||||
let css = &files["components/HomePage.module.css"];
|
||||
assert!(css.contains("url(/assets/a.png)"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_output_is_deterministic() {
|
||||
for render in RENDERS {
|
||||
for styling in STYLINGS {
|
||||
let a = write(&site(render, styling, true, true)).expect("first");
|
||||
let b = write(&site(render, styling, true, true)).expect("second");
|
||||
assert_eq!(a.files, b.files, "{render:?}-{styling:?}");
|
||||
assert_eq!(a.next_steps, b.next_steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_without_a_base_url_warns_and_the_sitemap_needs_one() {
|
||||
let out = write(&site(Render::Static, Styling::Css, true, false)).expect("written");
|
||||
assert!(
|
||||
out.warnings.iter().any(|w| w.contains("base_url")),
|
||||
"{:?}",
|
||||
out.warnings
|
||||
);
|
||||
assert!(!out.files.contains_key("public/sitemap.xml"));
|
||||
let with = text_files(&site(Render::Static, Styling::Css, true, true));
|
||||
assert!(
|
||||
with["public/sitemap.xml"].contains("https://example.com/pt-BR/pricing/"),
|
||||
"{}",
|
||||
with["public/sitemap.xml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_links_the_icon_and_preloads_each_pages_h1_face_with_its_own_type() {
|
||||
let mut s = site(Render::Static, Styling::Css, true, true);
|
||||
let face = |path: &str, weight| FontFace {
|
||||
family: "Inter".into(),
|
||||
weight,
|
||||
italic: false,
|
||||
path: path.into(),
|
||||
format: "woff2",
|
||||
};
|
||||
s.style.fonts = vec![
|
||||
face("assets/fonts/a.woff2", 400),
|
||||
face("assets/fonts/b.woff2", 700),
|
||||
];
|
||||
s.style.h1_faces = vec![Some(1), Some(0)];
|
||||
let files = text_files(&s);
|
||||
let site_ts = &files["lib/site.ts"];
|
||||
assert!(site_ts.contains(r#"FONT_TYPE = "font/woff2""#), "{site_ts}");
|
||||
assert!(site_ts.contains(r#"ICON = "data:,""#), "{site_ts}");
|
||||
assert_eq!(files["lib/seo.ts"].matches("icons: ICON").count(), 2);
|
||||
let home = &files["app/[lang]/page.tsx"];
|
||||
let bold = r#"ReactDOM.preload("/assets/fonts/b.woff2", { as: 'font', type: "font/woff2""#;
|
||||
assert!(home.contains(bold), "{home}");
|
||||
let pricing = &files["app/[lang]/pricing/page.tsx"];
|
||||
assert!(
|
||||
!pricing.contains("ReactDOM"),
|
||||
"the layout preloads that face: {pricing}"
|
||||
);
|
||||
s.style.favicon = Some("assets/f.svg".into());
|
||||
assert!(text_files(&s)["lib/site.ts"].contains(r#"ICON = "/assets/f.svg""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_flattens_prefetch_files_after_the_build() {
|
||||
let files = text_files(&site(Render::Static, Styling::Css, true, true));
|
||||
let build = &files["package.json"];
|
||||
assert!(
|
||||
build.contains("next build && node scripts/flatten-prefetch.mjs"),
|
||||
"{build}"
|
||||
);
|
||||
assert!(files["scripts/flatten-prefetch.mjs"].contains("__next."));
|
||||
let vite = text_files(&site(Render::Spa, Styling::Css, true, true));
|
||||
assert!(
|
||||
!vite.contains_key("scripts/flatten-prefetch.mjs")
|
||||
&& !vite["package.json"].contains("flatten")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_in_js_keeps_template_syntax_in_css_text_inert() {
|
||||
const NASTY: &str = r"A${x}`b\c";
|
||||
for render in RENDERS {
|
||||
for styling in [Styling::StyledComponents, Styling::Emotion] {
|
||||
let mut s = site(render, styling, true, true);
|
||||
s.style.fonts[0].family = NASTY.into();
|
||||
s.style.tokens[0].value = NASTY.into();
|
||||
s.style.classes[2].decls[1].1 = NASTY.into();
|
||||
let files = text_files(&s);
|
||||
let p = dir(render);
|
||||
let global = files
|
||||
.iter()
|
||||
.find(|(k, _)| k.starts_with(&format!("{p}styles/global.")))
|
||||
.expect("global styles")
|
||||
.1;
|
||||
// The only template literal is the styled-components `createGlobalStyle` tag, which holds `${CSS}` alone;
|
||||
// the CSS itself is a double-quoted JSON string, where a backtick or `${` is plain text.
|
||||
let templates: usize = global
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("const CSS = "))
|
||||
.map(|l| l.matches('`').count())
|
||||
.sum();
|
||||
assert_eq!(
|
||||
templates,
|
||||
if styling == Styling::StyledComponents {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
},
|
||||
"{render:?}-{styling:?}: {global}"
|
||||
);
|
||||
assert!(
|
||||
global.contains("A${x}"),
|
||||
"the text is kept, inside a JSON string: {global}"
|
||||
);
|
||||
let home = &files[&format!("{p}components/HomePage.tsx")];
|
||||
// Page text may hold backticks (inert in JSX), but no style definition is a template literal.
|
||||
assert!(
|
||||
home.lines()
|
||||
.filter(|l| l.starts_with("const "))
|
||||
.all(|l| !l.contains('`')),
|
||||
"{render:?}-{styling:?}: {home}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The Vite 8 + React Router 8 single-page app: config, `index.html`, the router, the head and
|
||||
//! the language routes.
|
||||
|
||||
use super::styles::Kit;
|
||||
use super::{Ctx, Files, js, put};
|
||||
use crate::mcp::site::markup::{Dialect, escape_attr, escape_text};
|
||||
use crate::mcp::site::model::{Output, Styling};
|
||||
|
||||
pub fn write(cx: &Ctx, kit: &Kit, files: &mut Files, out: &mut Output) {
|
||||
put(files, "index.html", index_html(cx));
|
||||
put(files, "vite.config.ts", vite_config(cx.styling()));
|
||||
put(files, "tsconfig.json", TSCONFIG);
|
||||
put(files, "tsconfig.app.json", TSCONFIG_APP);
|
||||
put(files, "tsconfig.node.json", TSCONFIG_NODE);
|
||||
put(files, ".gitignore", "node_modules\ndist\n*.tsbuildinfo\n");
|
||||
put(files, "src/main.tsx", main_tsx(kit));
|
||||
put(files, "src/router.tsx", router(cx));
|
||||
put(files, "src/lib/head.tsx", HEAD_TSX);
|
||||
if cx.multi {
|
||||
put(files, "src/pages/LangLayout.tsx", LANG_LAYOUT);
|
||||
put(files, "src/pages/LanguageIndex.tsx", LANGUAGE_INDEX);
|
||||
}
|
||||
out.next_steps.push("Run `npm install`, then `npm run build` in the output folder: the app is written to dist/ (`npm run dev` serves it with hot reload). It is a single-page app, so the host must answer every unknown path with dist/index.html.".into());
|
||||
}
|
||||
|
||||
fn index_html(cx: &Ctx) -> String {
|
||||
let site = cx.site;
|
||||
let lang = site
|
||||
.locales
|
||||
.first()
|
||||
.and_then(|l| l.tag.as_deref())
|
||||
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Html, t)))
|
||||
.unwrap_or_default();
|
||||
let mut head = String::new();
|
||||
for tag in site.asset_tags(0, "/") {
|
||||
head.push_str(&format!(" {}\n", tag.to_html()));
|
||||
}
|
||||
format!(
|
||||
"<!doctype html>\n<html{lang}>\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>{}</title>\n{head} </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n",
|
||||
escape_text(Dialect::Html, &site.site_title)
|
||||
)
|
||||
}
|
||||
|
||||
fn vite_config(styling: Styling) -> String {
|
||||
let (import, plugins) = match styling {
|
||||
Styling::Tailwind => (
|
||||
"import tailwindcss from '@tailwindcss/vite'\n",
|
||||
"react(), tailwindcss()",
|
||||
),
|
||||
Styling::VanillaExtract => (
|
||||
"import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'\n",
|
||||
"react(), vanillaExtractPlugin()",
|
||||
),
|
||||
_ => ("", "react()"),
|
||||
};
|
||||
format!(
|
||||
"import react from '@vitejs/plugin-react'\nimport {{ defineConfig }} from 'vite'\n{import}\nexport default defineConfig({{\n plugins: [{plugins}],\n}})\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn main_tsx(kit: &Kit) -> String {
|
||||
format!(
|
||||
"import {{ StrictMode }} from 'react'\nimport {{ createRoot }} from 'react-dom/client'\nimport {{ RouterProvider }} from 'react-router/dom'\nimport {{ router }} from './router'\n{}\ncreateRoot(document.getElementById('root')!).render(\n <StrictMode>\n {}\n </StrictMode>,\n)\n",
|
||||
kit.global_imports(),
|
||||
kit.wrap("<RouterProvider router={router} />")
|
||||
)
|
||||
}
|
||||
|
||||
fn router(cx: &Ctx) -> String {
|
||||
let pages = &cx.site.pages;
|
||||
let mut s = String::from("import { createBrowserRouter } from 'react-router'\n");
|
||||
for p in pages {
|
||||
s.push_str(&format!(
|
||||
"import {c} from './components/{c}'\n",
|
||||
c = p.component
|
||||
));
|
||||
}
|
||||
if cx.multi {
|
||||
s.push_str("import { LangLayout } from './pages/LangLayout'\nimport { LanguageIndex } from './pages/LanguageIndex'\n");
|
||||
}
|
||||
s.push_str("\nexport const router = createBrowserRouter([\n");
|
||||
let route = |i: usize, indent: &str| {
|
||||
let c = &pages[i].component;
|
||||
if i == 0 && cx.multi {
|
||||
format!("{indent}{{ index: true, element: <{c} /> }},\n")
|
||||
} else if i == 0 {
|
||||
format!("{indent}{{ path: '/', element: <{c} /> }},\n")
|
||||
} else {
|
||||
let path = if cx.multi {
|
||||
pages[i].stem.clone()
|
||||
} else {
|
||||
format!("/{}", pages[i].stem)
|
||||
};
|
||||
format!("{indent}{{ path: {}, element: <{c} /> }},\n", js(&path))
|
||||
}
|
||||
};
|
||||
if cx.multi {
|
||||
s.push_str(" { path: '/', element: <LanguageIndex /> },\n {\n path: '/:lang',\n element: <LangLayout />,\n children: [\n");
|
||||
for i in 0..pages.len() {
|
||||
s.push_str(&route(i, " "));
|
||||
}
|
||||
s.push_str(" ],\n },\n");
|
||||
} else {
|
||||
for i in 0..pages.len() {
|
||||
s.push_str(&route(i, " "));
|
||||
}
|
||||
}
|
||||
s.push_str(" { path: '*', element: <h1>404</h1> },\n])\n");
|
||||
s
|
||||
}
|
||||
|
||||
const TSCONFIG: &str = "{\n \"files\": [],\n \"references\": [\n { \"path\": \"./tsconfig.app.json\" },\n { \"path\": \"./tsconfig.node.json\" }\n ]\n}\n";
|
||||
|
||||
const TSCONFIG_APP: &str = r#"{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"#;
|
||||
|
||||
const TSCONFIG_NODE: &str = r#"{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
"#;
|
||||
|
||||
/// React 19 hoists `<title>`, `<meta>` and `<link>` rendered anywhere into `document.head`.
|
||||
const HEAD_TSX: &str = "import { useParams } from 'react-router'
|
||||
import { headData, localeIndex } from './site'
|
||||
|
||||
/** The locale of the current route (the `:lang` segment). */
|
||||
export function useLoc() {
|
||||
const { lang } = useParams()
|
||||
return localeIndex(lang ?? '')
|
||||
}
|
||||
|
||||
export function PageHead({ loc, page }: { loc: number; page: number }) {
|
||||
const h = headData(loc, page)
|
||||
return (
|
||||
<>
|
||||
<title>{h.title}</title>
|
||||
<meta name=\"description\" content={h.description} />
|
||||
<link rel=\"canonical\" href={h.canonical} />
|
||||
{h.alternates.map(([lang, href]) => (
|
||||
<link key={lang} rel=\"alternate\" hrefLang={lang} href={href} />
|
||||
))}
|
||||
<meta property=\"og:title\" content={h.title} />
|
||||
<meta property=\"og:description\" content={h.description} />
|
||||
<meta property=\"og:type\" content=\"website\" />
|
||||
{h.ogUrl && <meta property=\"og:url\" content={h.ogUrl} />}
|
||||
{h.ogLocale && <meta property=\"og:locale\" content={h.ogLocale} />}
|
||||
{h.ogAlternates.map((l) => (
|
||||
<meta key={l} property=\"og:locale:alternate\" content={l} />
|
||||
))}
|
||||
<meta name=\"twitter:card\" content=\"summary\" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
";
|
||||
|
||||
const LANG_LAYOUT: &str = "import { useEffect } from 'react'
|
||||
import { Outlet, useParams } from 'react-router'
|
||||
import { locales } from '../lib/site'
|
||||
|
||||
export function LangLayout() {
|
||||
const { lang } = useParams()
|
||||
const loc = locales.findIndex((l) => l.dir === lang)
|
||||
// <html> is not a hoistable tag, so its language and class are set from an effect.
|
||||
useEffect(() => {
|
||||
if (loc < 0) return
|
||||
document.documentElement.lang = locales[loc].tag ?? ''
|
||||
document.documentElement.className = locales[loc].cls ?? ''
|
||||
}, [loc])
|
||||
return loc < 0 ? <h1>404</h1> : <Outlet />
|
||||
}
|
||||
";
|
||||
|
||||
const LANGUAGE_INDEX: &str = "import { Link } from 'react-router'
|
||||
import { indexHeadData, locales } from '../lib/site'
|
||||
|
||||
export function LanguageIndex() {
|
||||
const h = indexHeadData()
|
||||
return (
|
||||
<main>
|
||||
<title>{h.title}</title>
|
||||
<meta name=\"description\" content={h.description} />
|
||||
<link rel=\"canonical\" href={h.canonical} />
|
||||
{h.alternates.map(([lang, href]) => (
|
||||
<link key={lang} rel=\"alternate\" hrefLang={lang} href={href} />
|
||||
))}
|
||||
<ul>
|
||||
{locales.map((l) => (
|
||||
<li key={l.dir}>
|
||||
<Link to={`/${l.dir}/`} hrefLang={l.tag ?? undefined}>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
";
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Which HTML tag a layer deserves: landmarks and buttons from layer names, headings and
|
||||
//! paragraphs from the size and place of the text. Only tags change; the layout never does.
|
||||
|
||||
use crate::model::{Document, LinkAction, Node, NodeId, NodeKind};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// Text starting above this many px from the top of the page is in the first screen.
|
||||
const FOLD_PX: f64 = 900.0;
|
||||
/// Longer copy is a paragraph, not a heading, whatever its size.
|
||||
const HEADING_MAX_CHARS: usize = 120;
|
||||
const HEADING_MIN_PX: f64 = 20.0;
|
||||
/// A heading is at least this much larger than the body text.
|
||||
const HEADING_RATIO: f64 = 1.2;
|
||||
const PARAGRAPH_CHARS: usize = 40;
|
||||
const WRAPPED_PARAGRAPH_CHARS: usize = 25;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Sem {
|
||||
/// Heading level per text layer.
|
||||
pub heads: HashMap<NodeId, u8>,
|
||||
/// Short lines straight under a larger heading that only restate it: paragraphs.
|
||||
pub subtitles: HashSet<NodeId>,
|
||||
/// The layer written as `<main>`: a layer named so, else the root or its tallest content child.
|
||||
pub main: Option<NodeId>,
|
||||
}
|
||||
|
||||
fn words(name: &str) -> impl Iterator<Item = String> + '_ {
|
||||
name.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(str::to_lowercase)
|
||||
}
|
||||
|
||||
/// The landmark or button a frame or group's name asks for.
|
||||
pub fn landmark(name: &str) -> Option<&'static str> {
|
||||
words(name).find_map(|w| match w.as_str() {
|
||||
"header" => Some("header"),
|
||||
"nav" | "navbar" | "navigation" => Some("nav"),
|
||||
"footer" => Some("footer"),
|
||||
"main" => Some("main"),
|
||||
"section" => Some("section"),
|
||||
"aside" | "sidebar" => Some("aside"),
|
||||
"button" | "btn" | "cta" => Some("button"),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `h1`..`h6` or `p` when a text layer is named so.
|
||||
pub fn text_name(name: &str) -> Option<&'static str> {
|
||||
words(name).find_map(|w| match w.as_str() {
|
||||
"h1" => Some("h1"),
|
||||
"h2" => Some("h2"),
|
||||
"h3" => Some("h3"),
|
||||
"h4" => Some("h4"),
|
||||
"h5" => Some("h5"),
|
||||
"h6" => Some("h6"),
|
||||
"p" | "paragraph" => Some("p"),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Group)
|
||||
}
|
||||
|
||||
pub fn is_button(n: &Node) -> bool {
|
||||
is_container(n) && landmark(&n.name) == Some("button")
|
||||
}
|
||||
|
||||
pub fn has_link(n: &Node) -> bool {
|
||||
n.link.is_some() || matches!(n.link_action, Some(LinkAction::Url { .. }))
|
||||
}
|
||||
|
||||
/// Whether a layer below `root` sits inside a button or link (headings there stay inline).
|
||||
fn in_action(doc: &Document, root: NodeId, id: NodeId) -> bool {
|
||||
let mut cur = doc.get(id).and_then(|n| n.parent);
|
||||
while let Some(n) = cur.and_then(|p| doc.get(p)) {
|
||||
if is_button(n) || has_link(n) {
|
||||
return true;
|
||||
}
|
||||
cur = if n.id == root { None } else { n.parent };
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a layer below `root` sits in a header or nav: its text is the brand, not a heading.
|
||||
fn in_chrome(doc: &Document, root: NodeId, id: NodeId) -> bool {
|
||||
let mut cur = doc.get(id).and_then(|n| n.parent);
|
||||
while let Some(n) = cur.and_then(|p| doc.get(p)) {
|
||||
if is_container(n) && matches!(landmark(&n.name), Some("header" | "nav")) {
|
||||
return true;
|
||||
}
|
||||
cur = if n.id == root { None } else { n.parent };
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The tag of text that is not a heading: a `p` when it reads as a sentence, else a `span`.
|
||||
pub fn text_tag(chars: usize, wraps: bool) -> &'static str {
|
||||
if chars > PARAGRAPH_CHARS || (wraps && chars > WRAPPED_PARAGRAPH_CHARS) {
|
||||
"p"
|
||||
} else {
|
||||
"span"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn semantics(doc: &Document, root: NodeId) -> Sem {
|
||||
let layers = doc.subtree(root);
|
||||
let top = doc.world_rect(root).map_or(0.0, |r| r.y);
|
||||
let mut sem = Sem {
|
||||
main: main_of(doc, root, &layers),
|
||||
..Sem::default()
|
||||
};
|
||||
|
||||
struct T {
|
||||
id: NodeId,
|
||||
size: f64,
|
||||
bold: bool,
|
||||
y: f64,
|
||||
short: bool,
|
||||
brand: bool,
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
let mut by_size: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut explicit_h1 = None;
|
||||
// Every text layer in reading order, with its size and parent.
|
||||
let mut order: Vec<(NodeId, f64, Option<NodeId>)> = Vec::new();
|
||||
for id in &layers {
|
||||
let Some(n) = doc.get(*id).filter(|n| n.visible) else {
|
||||
continue;
|
||||
};
|
||||
let NodeKind::Text {
|
||||
text, font_size, ..
|
||||
} = &n.kind
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let chars = text.chars().count();
|
||||
if chars == 0 {
|
||||
continue;
|
||||
}
|
||||
order.push((*id, *font_size, n.parent));
|
||||
*by_size
|
||||
.entry((font_size * 100.0).round() as i64)
|
||||
.or_default() += chars;
|
||||
let level = text_name(&n.name)
|
||||
.and_then(|t| t.strip_prefix('h'))
|
||||
.and_then(|l| l.parse::<u8>().ok());
|
||||
if let Some(l) = level {
|
||||
let l = if l == 1 && explicit_h1.replace(*id).is_some() {
|
||||
2
|
||||
} else {
|
||||
l
|
||||
};
|
||||
sem.heads.insert(*id, l);
|
||||
} else if words(&n.name).any(|w| w == "heading") && explicit_h1.is_none() {
|
||||
explicit_h1 = Some(*id);
|
||||
sem.heads.insert(*id, 1);
|
||||
} else if text_name(&n.name).is_none() && !in_action(doc, root, *id) {
|
||||
let y = doc.world_rect(*id).map_or(0.0, |r| r.y) - top;
|
||||
candidates.push(T {
|
||||
id: *id,
|
||||
size: *font_size,
|
||||
bold: n.text_style.bold,
|
||||
y,
|
||||
short: chars <= HEADING_MAX_CHARS,
|
||||
brand: in_chrome(doc, root, *id),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every page has one h1: the most prominent text, whatever its size. Size only decides
|
||||
// between the lower headings and paragraphs below.
|
||||
if explicit_h1.is_none() {
|
||||
let prominence = |a: &&T, b: &&T| {
|
||||
a.size
|
||||
.total_cmp(&b.size)
|
||||
.then(a.bold.cmp(&b.bold))
|
||||
.then(b.y.total_cmp(&a.y))
|
||||
};
|
||||
let pick = |pool: Vec<&T>| pool.into_iter().max_by(prominence).map(|t| t.id);
|
||||
let first_screen = |t: &&T| t.short && t.y < FOLD_PX;
|
||||
let named: Vec<&T> = candidates.iter().filter(|t| !t.brand).collect();
|
||||
let h1 = pick(named.iter().copied().filter(first_screen).collect())
|
||||
.or_else(|| pick(named.iter().copied().filter(|t| t.short).collect()))
|
||||
.or_else(|| pick(named));
|
||||
if let Some(h1) = h1 {
|
||||
sem.heads.insert(h1, 1);
|
||||
candidates.retain(|t| t.id != h1);
|
||||
}
|
||||
}
|
||||
// The size most characters use is the body; a heading must clearly stand out of it.
|
||||
let body = by_size
|
||||
.iter()
|
||||
.max_by_key(|(size, chars)| (**chars, std::cmp::Reverse(**size)))
|
||||
.map_or(16.0, |(s, _)| *s as f64 / 100.0);
|
||||
candidates
|
||||
.retain(|t| !t.brand && t.short && t.size >= HEADING_MIN_PX.max(body * HEADING_RATIO));
|
||||
let mut sizes: Vec<i64> = candidates
|
||||
.iter()
|
||||
.map(|t| (t.size * 100.0).round() as i64)
|
||||
.collect();
|
||||
sizes.sort_unstable_by(|a, b| b.cmp(a));
|
||||
sizes.dedup();
|
||||
for t in &candidates {
|
||||
let rank = sizes
|
||||
.iter()
|
||||
.position(|s| *s == (t.size * 100.0).round() as i64)
|
||||
.unwrap_or(0);
|
||||
sem.heads.insert(t.id, (2 + rank).min(6) as u8);
|
||||
}
|
||||
// A line right under a larger heading, in the same parent, is its subtitle.
|
||||
let guessed: HashSet<NodeId> = candidates.iter().map(|t| t.id).collect();
|
||||
for w in order.windows(2) {
|
||||
let ((prev, prev_size, prev_parent), (id, size, parent)) = (w[0], w[1]);
|
||||
if guessed.contains(&id)
|
||||
&& sem.heads.contains_key(&prev)
|
||||
&& prev_size > size
|
||||
&& prev_parent == parent
|
||||
{
|
||||
sem.heads.remove(&id);
|
||||
sem.subtitles.insert(id);
|
||||
}
|
||||
}
|
||||
// The outline never skips a level downward: h1 then h3 becomes h1 then h2.
|
||||
let mut above = None;
|
||||
for (id, ..) in &order {
|
||||
if let Some(level) = sem.heads.get_mut(id) {
|
||||
if let Some(a) = above {
|
||||
*level = (*level).min(a + 1);
|
||||
}
|
||||
above = Some(*level);
|
||||
}
|
||||
}
|
||||
sem
|
||||
}
|
||||
|
||||
/// A layer named `main`, else the root when no header or footer is split off, else the tallest
|
||||
/// content child.
|
||||
fn main_of(doc: &Document, root: NodeId, layers: &[NodeId]) -> Option<NodeId> {
|
||||
let named = |id: &&NodeId| {
|
||||
doc.get(**id)
|
||||
.is_some_and(|n| is_container(n) && landmark(&n.name) == Some("main"))
|
||||
};
|
||||
if let Some(id) = layers.iter().skip(1).find(named) {
|
||||
return Some(*id);
|
||||
}
|
||||
let kids: Vec<&Node> = doc
|
||||
.get(root)?
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|c| doc.get(*c))
|
||||
.filter(|n| is_container(n))
|
||||
.collect();
|
||||
let chrome = |n: &&Node| {
|
||||
matches!(
|
||||
landmark(&n.name),
|
||||
Some("header" | "footer" | "nav" | "aside")
|
||||
)
|
||||
};
|
||||
if !kids
|
||||
.iter()
|
||||
.any(|n| matches!(landmark(&n.name), Some("header" | "footer")))
|
||||
{
|
||||
return Some(root);
|
||||
}
|
||||
kids.iter()
|
||||
.filter(|n| !chrome(n))
|
||||
.max_by(|a, b| a.rect.h.total_cmp(&b.rect.h))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_pick_landmarks_and_text_names_pick_headings() {
|
||||
assert_eq!(landmark("Site Header"), Some("header"));
|
||||
assert_eq!(landmark("Footer / links"), Some("footer"));
|
||||
assert_eq!(landmark("Get started CTA"), Some("button"));
|
||||
assert_eq!(landmark("Card"), None);
|
||||
assert_eq!(landmark("Headers"), None);
|
||||
assert_eq!(text_name("H2 title"), Some("h2"));
|
||||
assert_eq!(text_name("Paragraph"), Some("p"));
|
||||
assert_eq!(text_name("Hero"), None);
|
||||
assert_eq!(text_tag(10, false), "span");
|
||||
assert_eq!(text_tag(30, true), "p");
|
||||
assert_eq!(text_tag(41, false), "p");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! The style model of a site and its serializers: tokens, font faces, base rules and one class
|
||||
//! per distinct set of declarations, with the per-locale differences kept apart so a framework
|
||||
//! writer can print plain CSS, SCSS partials or a Tailwind theme from the same data.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
/// Ordered `(property, value)` pairs, properties in kebab case.
|
||||
pub type Decls = Vec<(String, String)>;
|
||||
|
||||
/// Sizes in the design are border-box, and a phone must not scroll sideways. `a` and `button`
|
||||
/// are reset because layers draw their own text and fills.
|
||||
pub const BASE_CSS: &str = "*, *::before, *::after { box-sizing: border-box; }\n\
|
||||
body { margin: 0; }\n\
|
||||
img, svg { max-width: 100%; }\n\
|
||||
h1, h2, h3, h4, h5, h6, p { margin: 0; }\n\
|
||||
a { color: inherit; text-decoration: none; }\n\
|
||||
button { padding: 0; border: 0; background: none; font: inherit; color: inherit; cursor: pointer; text-align: inherit; }\n";
|
||||
|
||||
/// A custom property: `--name`, with the value other locales give it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Token {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// A colour variable (Tailwind's `--color-*` namespace) rather than a plain token.
|
||||
pub color: bool,
|
||||
/// `(locale index, value)` where a locale differs from the first.
|
||||
pub overrides: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
/// One `@font-face`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FontFace {
|
||||
pub family: String,
|
||||
pub weight: u16,
|
||||
pub italic: bool,
|
||||
/// Site-root relative: `assets/fonts/<hash>.<ext>`.
|
||||
pub path: String,
|
||||
pub format: &'static str,
|
||||
}
|
||||
|
||||
/// A class: the declarations one or more layers share.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Class {
|
||||
/// Readable, from the first layer's name, unique site-wide, a valid CSS identifier.
|
||||
pub name: String,
|
||||
pub decls: Decls,
|
||||
/// Only the declarations a locale changes (a removed one is `initial`).
|
||||
pub overrides: Vec<(usize, Decls)>,
|
||||
/// Pages whose markup uses the class.
|
||||
pub pages: BTreeSet<usize>,
|
||||
/// The class of that page's root frame (holds the responsive rule).
|
||||
pub root_of: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StyleModel {
|
||||
pub tokens: Vec<Token>,
|
||||
pub fonts: Vec<FontFace>,
|
||||
/// A metric-matched system font per family, drawn until the web font loads.
|
||||
pub fallbacks: Vec<super::fontsub::Fallback>,
|
||||
/// Index into `fonts` of the body text face, to preload.
|
||||
pub preload: Option<usize>,
|
||||
/// Per page, index into `fonts` of the face its h1 is set in (the second font to preload).
|
||||
pub h1_faces: Vec<Option<usize>>,
|
||||
/// Site-root relative path of the layer named Favicon, written as the site icon.
|
||||
pub favicon: Option<String>,
|
||||
pub classes: Vec<Class>,
|
||||
/// Per locale, the selector prefix its overrides sit under: empty for the first locale,
|
||||
/// `:root:lang(pt-BR)` for a language, `:root.mode-dark` for any other mode.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
/// What makes two classes the same: declarations, per-locale overrides and the page root.
|
||||
type Signature = (Decls, Vec<(usize, Decls)>, Option<usize>);
|
||||
|
||||
/// Collects classes while the pages are built.
|
||||
#[derive(Default)]
|
||||
pub struct Classes {
|
||||
pub list: Vec<Class>,
|
||||
by_sig: HashMap<Signature, usize>,
|
||||
taken: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Classes {
|
||||
/// Claims a name up front (page roots, so a layer cannot take them).
|
||||
pub fn reserve(&mut self, hint: &str) -> String {
|
||||
unique(&mut self.taken, class_slug(hint))
|
||||
}
|
||||
|
||||
/// The class for these declarations: an existing identical one, else a new one named after `hint`.
|
||||
/// `name` is a name from [`Self::reserve`] to use for a new class.
|
||||
pub fn get(
|
||||
&mut self,
|
||||
hint: &str,
|
||||
name: Option<String>,
|
||||
page: usize,
|
||||
decls: Decls,
|
||||
overrides: Vec<(usize, Decls)>,
|
||||
root_of: Option<usize>,
|
||||
) -> String {
|
||||
let key = (decls.clone(), overrides.clone(), root_of);
|
||||
if let Some(&i) = self.by_sig.get(&key) {
|
||||
self.list[i].pages.insert(page);
|
||||
return self.list[i].name.clone();
|
||||
}
|
||||
let name = name.unwrap_or_else(|| self.reserve(hint));
|
||||
self.by_sig.insert(key, self.list.len());
|
||||
self.list.push(Class {
|
||||
name: name.clone(),
|
||||
decls,
|
||||
overrides,
|
||||
pages: BTreeSet::from([page]),
|
||||
root_of,
|
||||
});
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// A class-safe slug: lower case letters and digits, one `-` between runs, never starting with a digit.
|
||||
pub fn class_slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars().flat_map(char::to_lowercase) {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
let out: String = out.chars().take(40).collect();
|
||||
let out = out.trim_end_matches('-');
|
||||
match out.chars().next() {
|
||||
None => "layer".into(),
|
||||
Some(c) if c.is_ascii_digit() => format!("n-{out}"),
|
||||
_ => out.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `stem`, or `stem-2`, `stem-3`... when taken (names compare lower case).
|
||||
pub fn unique(taken: &mut BTreeSet<String>, stem: String) -> String {
|
||||
let mut name = stem.clone();
|
||||
let mut n = 2;
|
||||
while !taken.insert(name.to_lowercase()) {
|
||||
name = format!("{stem}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Removes what could end a rule or a `<style>` block from a value that is not a quoted string.
|
||||
pub fn safe_value(v: &str) -> String {
|
||||
v.chars()
|
||||
.map(|c| {
|
||||
if matches!(c, '{' | '}' | ';' | '<' | '>') {
|
||||
' '
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A custom property name piece the stylesheet can hold.
|
||||
fn rebase(value: &str, prefix: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
value.to_owned()
|
||||
} else {
|
||||
value.replace("url(assets/", &format!("url({prefix}assets/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn block(selector: &str, decls: &Decls, prefix: &str, indent: &str, out: &mut String) {
|
||||
out.push_str(&format!("{indent}{selector} {{\n"));
|
||||
for (k, v) in decls {
|
||||
out.push_str(&format!("{indent} {k}: {};\n", rebase(v, prefix)));
|
||||
}
|
||||
out.push_str(&format!("{indent}}}\n"));
|
||||
}
|
||||
|
||||
impl StyleModel {
|
||||
/// `:root` custom properties, then one block per locale that changes some.
|
||||
pub fn tokens_css(&self) -> String {
|
||||
let mut out = String::new();
|
||||
if !self.tokens.is_empty() {
|
||||
let decls: Decls = self
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|t| (format!("--{}", t.name), t.value.clone()))
|
||||
.collect();
|
||||
block(":root", &decls, "", "", &mut out);
|
||||
}
|
||||
for (i, scope) in self.scopes.iter().enumerate().skip(1) {
|
||||
let decls: Decls = self
|
||||
.tokens
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
t.overrides
|
||||
.iter()
|
||||
.find(|o| o.0 == i)
|
||||
.map(|o| (format!("--{}", t.name), o.1.clone()))
|
||||
})
|
||||
.collect();
|
||||
if !decls.is_empty() {
|
||||
block(&scope_selector(scope, ""), &decls, "", "", &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `@font-face` rules; `prefix` is what the stylesheet must put before `assets/` to reach the site root.
|
||||
pub fn fonts_css(&self, prefix: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for f in &self.fonts {
|
||||
out.push_str(&format!(
|
||||
"@font-face {{\n font-family: {};\n font-style: {};\n font-weight: {};\n font-display: swap;\n src: url({prefix}{}) format(\"{}\");\n}}\n",
|
||||
super::super::codegen::css_string(&f.family),
|
||||
if f.italic { "italic" } else { "normal" },
|
||||
f.weight,
|
||||
f.path,
|
||||
f.format
|
||||
));
|
||||
}
|
||||
for f in &self.fallbacks {
|
||||
out.push_str(&format!(
|
||||
"@font-face {{
|
||||
font-family: {};
|
||||
src: local({});
|
||||
size-adjust: {}%;
|
||||
ascent-override: {}%;
|
||||
descent-override: {}%;
|
||||
line-gap-override: {}%;
|
||||
}}
|
||||
",
|
||||
super::super::codegen::css_string(&f.family),
|
||||
super::super::codegen::css_string(f.local),
|
||||
f.size_adjust,
|
||||
f.ascent,
|
||||
f.descent,
|
||||
f.gap
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The rules of the classes `keep` accepts, and after them the locale overrides of those classes.
|
||||
pub fn classes_css(&self, prefix: &str, keep: impl Fn(&Class) -> bool) -> String {
|
||||
let mut out = String::new();
|
||||
for c in self.classes.iter().filter(|c| keep(c)) {
|
||||
block(&format!(".{}", c.name), &c.decls, prefix, "", &mut out);
|
||||
}
|
||||
out.push_str(&self.overrides_css(prefix, keep));
|
||||
out
|
||||
}
|
||||
|
||||
/// Only the locale overrides of the classes `keep` accepts.
|
||||
pub fn overrides_css(&self, prefix: &str, keep: impl Fn(&Class) -> bool) -> String {
|
||||
let mut out = String::new();
|
||||
for c in self.classes.iter().filter(|c| keep(c)) {
|
||||
for (loc, decls) in &c.overrides {
|
||||
if let Some(scope) = self.scopes.get(*loc) {
|
||||
block(
|
||||
&scope_selector(scope, &format!(".{}", c.name)),
|
||||
decls,
|
||||
prefix,
|
||||
"",
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Everything the site shares: tokens, fonts, base rules and the classes more than one page
|
||||
/// uses (or none does).
|
||||
pub fn global_css(&self, prefix: &str) -> String {
|
||||
format!(
|
||||
"{}{}{BASE_CSS}{}",
|
||||
self.tokens_css(),
|
||||
self.fonts_css(prefix),
|
||||
self.classes_css(prefix, |c| c.pages.len() != 1)
|
||||
)
|
||||
}
|
||||
|
||||
/// The rules only page `page` uses, its root first.
|
||||
pub fn page_css(&self, page: usize, prefix: &str) -> String {
|
||||
self.classes_css(prefix, |c| c.pages.len() == 1 && c.pages.contains(&page))
|
||||
}
|
||||
|
||||
/// The whole site in one stylesheet (the html writer's `assets/site-<hash>.css`).
|
||||
pub fn all_css(&self, prefix: &str) -> String {
|
||||
format!(
|
||||
"{}{}{BASE_CSS}{}",
|
||||
self.tokens_css(),
|
||||
self.fonts_css(prefix),
|
||||
self.classes_css(prefix, |_| true)
|
||||
)
|
||||
}
|
||||
|
||||
/// A Tailwind v4 `@theme` block: colour variables as `--color-*`, other tokens as they are.
|
||||
/// Locale values stay in `tokens_css` (they are plain custom properties, not theme keys).
|
||||
pub fn tailwind_theme(&self) -> String {
|
||||
let mut out = String::from("@theme {\n");
|
||||
for t in &self.tokens {
|
||||
let name = if t.color {
|
||||
format!("--color-{}", t.name)
|
||||
} else {
|
||||
format!("--{}", t.name)
|
||||
};
|
||||
out.push_str(&format!(" {name}: {};\n", t.value));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// SCSS partials as `(path relative to the styles folder, content)`: `main.scss` `@use`s a
|
||||
/// partial each for tokens, fonts, base, the shared classes and every page, whose own classes
|
||||
/// nest under the page's root class. `pages` are `(page index, file stem)`.
|
||||
pub fn scss(&self, prefix: &str, pages: &[(usize, String)]) -> Vec<(String, String)> {
|
||||
let sass = |s: String| s.replace("#{", "\\23 {");
|
||||
let mut files = vec![
|
||||
("_tokens.scss".to_owned(), sass(self.tokens_css())),
|
||||
("_fonts.scss".to_owned(), sass(self.fonts_css(prefix))),
|
||||
("_base.scss".to_owned(), BASE_CSS.to_owned()),
|
||||
(
|
||||
"_shared.scss".to_owned(),
|
||||
sass(self.classes_css(prefix, |c| c.pages.len() != 1)),
|
||||
),
|
||||
];
|
||||
let mut main =
|
||||
String::from("@use 'tokens';\n@use 'fonts';\n@use 'base';\n@use 'shared';\n");
|
||||
for (page, stem) in pages {
|
||||
let mine = |c: &Class| c.pages.len() == 1 && c.pages.contains(page);
|
||||
let mut out = String::new();
|
||||
if let Some(root) = self.classes.iter().find(|c| c.root_of == Some(*page)) {
|
||||
// Declarations first, then nested rules: Sass warns about the other order.
|
||||
out.push_str(&format!(".{} {{\n", root.name));
|
||||
for (k, v) in &root.decls {
|
||||
out.push_str(&format!(" {k}: {};\n", rebase(v, prefix)));
|
||||
}
|
||||
for c in self
|
||||
.classes
|
||||
.iter()
|
||||
.filter(|c| mine(c) && c.root_of.is_none())
|
||||
{
|
||||
block(&format!(".{}", c.name), &c.decls, prefix, " ", &mut out);
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
out.push_str(&self.overrides_css(prefix, mine));
|
||||
let file = format!("_{}.scss", class_slug(stem));
|
||||
files.push((format!("pages/{file}"), sass(out)));
|
||||
main.push_str(&format!("@use 'pages/{}';\n", class_slug(stem)));
|
||||
}
|
||||
files.push(("main.scss".into(), main));
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
/// `scope` followed by a class selector: `:root:lang(pt-BR) .x`.
|
||||
fn scope_selector(scope: &str, class: &str) -> String {
|
||||
format!("{scope}{}{class}", if class.is_empty() { "" } else { " " })
|
||||
}
|
||||
|
||||
/// Per-locale differences between two declaration lists: what changes or is added, and
|
||||
/// `initial` for what the locale drops.
|
||||
pub fn diff(base: &Decls, other: &Decls) -> Decls {
|
||||
let have: BTreeMap<&str, &str> = other
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
let mut out: Decls = other
|
||||
.iter()
|
||||
.filter(|(k, v)| base.iter().find(|b| b.0 == *k).is_none_or(|b| b.1 != *v))
|
||||
.cloned()
|
||||
.collect();
|
||||
out.extend(
|
||||
base.iter()
|
||||
.filter(|(k, _)| !have.contains_key(k.as_str()))
|
||||
.map(|(k, _)| (k.clone(), "initial".to_owned())),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(items: &[(&str, &str)]) -> Decls {
|
||||
items
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_declarations_share_a_class_named_after_the_first_layer() {
|
||||
let mut c = Classes::default();
|
||||
let a = c.get("Card Title", None, 0, d(&[("color", "red")]), vec![], None);
|
||||
let b = c.get("Other", None, 1, d(&[("color", "red")]), vec![], None);
|
||||
let other = c.get("Card Title", None, 0, d(&[("color", "blue")]), vec![], None);
|
||||
assert_eq!(
|
||||
(a.as_str(), b.as_str(), other.as_str()),
|
||||
("card-title", "card-title", "card-title-2")
|
||||
);
|
||||
assert_eq!(c.list[0].pages.len(), 2);
|
||||
assert_eq!(class_slug("3 Cards"), "n-3-cards");
|
||||
assert_eq!(class_slug("\u{1F600}"), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_diff_keeps_changes_and_resets_dropped_properties() {
|
||||
let base = d(&[("color", "red"), ("width", "1px")]);
|
||||
let pt = d(&[("color", "blue"), ("width", "1px"), ("gap", "2px")]);
|
||||
assert_eq!(diff(&base, &pt), d(&[("color", "blue"), ("gap", "2px")]));
|
||||
assert_eq!(
|
||||
diff(&base, &d(&[("color", "red")])),
|
||||
d(&[("width", "initial")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scss_nests_page_rules_under_the_root_and_guards_interpolation() {
|
||||
let mut c = Classes::default();
|
||||
c.get("Home", None, 0, d(&[("width", "100%")]), vec![], Some(0));
|
||||
c.get(
|
||||
"Title",
|
||||
None,
|
||||
0,
|
||||
d(&[("content", "\"#{x}\"")]),
|
||||
vec![(1, d(&[("color", "red")]))],
|
||||
None,
|
||||
);
|
||||
c.get("Shared", None, 0, d(&[("a", "b")]), vec![], None);
|
||||
c.get("Shared", None, 1, d(&[("a", "b")]), vec![], None);
|
||||
let m = StyleModel {
|
||||
classes: c.list,
|
||||
scopes: vec![String::new(), ":root:lang(pt-BR)".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let files: BTreeMap<_, _> = m.scss("../", &[(0, "index".into())]).into_iter().collect();
|
||||
let page = &files["pages/_index.scss"];
|
||||
assert!(
|
||||
page.contains(".home {\n width: 100%;\n .title {"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
page.contains(":root:lang(pt-BR) .title {") && !page.contains("#{"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(files["_shared.scss"].contains(".shared {"));
|
||||
assert!(
|
||||
files["main.scss"].starts_with("@use 'tokens';")
|
||||
&& files["main.scss"].contains("@use 'pages/index';")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
//! Frames to a framework-neutral element tree and its style model.
|
||||
//!
|
||||
//! The tree is built from the first locale's document. Every other locale is compared with it
|
||||
//! node by node: differing text becomes a message key, differing declarations become
|
||||
//! locale-scoped rules, and anything else (structure, images) keeps the first locale's version
|
||||
//! with a warning.
|
||||
|
||||
use super::fluid;
|
||||
use super::media::{self, Media};
|
||||
use super::model::{Frame, Locale, Page};
|
||||
use super::semantic::{self, Sem};
|
||||
use super::style::{Classes, Decls, StyleModel, Token, diff, safe_value};
|
||||
use super::{contrast, icon};
|
||||
use crate::mcp::lint::DESKTOP_MIN_W;
|
||||
use crate::mcp::{codegen, css};
|
||||
use crate::model::link::valid_url;
|
||||
use crate::model::{Document, ImageMode, LinkAction, Node, NodeId, NodeKind, Paint};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// An image whose top edge is above this many px is in the first screen: it loads at once.
|
||||
const FOLD_PX: f64 = 900.0;
|
||||
const PC_LINK: &str = "pc-link";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Text {
|
||||
Lit(String),
|
||||
/// A key into the per-locale messages.
|
||||
Msg(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LinkTarget {
|
||||
/// Index into the site's pages.
|
||||
Page(usize),
|
||||
/// An external address that passed `valid_url`.
|
||||
Url(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Child {
|
||||
El(Element),
|
||||
Text(Text),
|
||||
}
|
||||
|
||||
/// One HTML element. Attribute names are lower case HTML; a dialect renames them.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Element {
|
||||
pub tag: String,
|
||||
/// The merged class (empty when styling is Tailwind and nothing differs per locale).
|
||||
pub class: String,
|
||||
/// `src` holds a site-root relative path (`assets/..`) the writer rebases.
|
||||
pub attrs: Vec<(String, String)>,
|
||||
/// Set on `a` elements: where the anchor goes.
|
||||
pub link: Option<LinkTarget>,
|
||||
/// Tailwind utilities for this element, empty unless styling is Tailwind.
|
||||
pub tw: String,
|
||||
/// The class carries per-locale rules, so Tailwind output keeps it next to `tw`.
|
||||
pub has_overrides: bool,
|
||||
pub children: Vec<Child>,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
fn new(tag: &str) -> Self {
|
||||
Element {
|
||||
tag: tag.into(),
|
||||
class: String::new(),
|
||||
attrs: Vec::new(),
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `tw`, then the merged class when it holds locale rules.
|
||||
pub fn tailwind_classes(&self) -> String {
|
||||
match (self.tw.is_empty(), self.has_overrides) {
|
||||
(false, true) => format!("{} {}", self.tw, self.class),
|
||||
(true, true) => self.class.clone(),
|
||||
_ => self.tw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Params<'a> {
|
||||
pub docs: &'a [Document],
|
||||
pub frames: &'a [Frame],
|
||||
pub locales: &'a [Locale],
|
||||
pub tailwind: bool,
|
||||
/// Per locale; None where the caller gave none.
|
||||
pub title: &'a [Option<String>],
|
||||
pub description: &'a [Option<String>],
|
||||
}
|
||||
|
||||
pub struct Built {
|
||||
pub pages: Vec<Page>,
|
||||
pub style: StyleModel,
|
||||
pub messages: Vec<BTreeMap<String, String>>,
|
||||
pub media: Media,
|
||||
pub warnings: Vec<String>,
|
||||
pub resolved: usize,
|
||||
pub unresolved: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn build(p: Params) -> Built {
|
||||
let mut w = Walk::new(&p);
|
||||
let mut pages = Vec::new();
|
||||
for (i, f) in p.frames.iter().enumerate() {
|
||||
let root = w.page(i, f);
|
||||
let title = |(loc, doc): (usize, &Document)| {
|
||||
let name = doc.get(f.id).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match (p.title[loc].as_deref(), i) {
|
||||
(Some(t), 0) => t.to_owned(),
|
||||
(Some(t), _) => format!("{name} | {t}"),
|
||||
(None, _) => name,
|
||||
}
|
||||
};
|
||||
pages.push(Page {
|
||||
name: f.name.clone(),
|
||||
stem: f.stem.clone(),
|
||||
component: f.component.clone(),
|
||||
root,
|
||||
title: p.docs.iter().enumerate().map(title).collect(),
|
||||
description: p
|
||||
.docs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(loc, d)| p.description[loc].clone().unwrap_or_else(|| describe(d, f)))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
w.finish(pages, &p)
|
||||
}
|
||||
|
||||
/// The first line of visible copy, as a default meta description; the frame name otherwise.
|
||||
fn describe(doc: &Document, f: &Frame) -> String {
|
||||
const DESCRIPTION_CHARS: usize = 160;
|
||||
let copy = doc
|
||||
.subtree(f.id)
|
||||
.into_iter()
|
||||
.filter_map(|id| doc.get(id))
|
||||
.find_map(|n| match &n.kind {
|
||||
NodeKind::Text { text, .. } if !text.trim().is_empty() => {
|
||||
Some(text.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
copy.map_or_else(
|
||||
|| f.name.clone(),
|
||||
|c| c.chars().take(DESCRIPTION_CHARS).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Cx {
|
||||
in_link: bool,
|
||||
in_button: bool,
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
docs: &'a [Document],
|
||||
frames: &'a [Frame],
|
||||
tailwind: bool,
|
||||
classes: Classes,
|
||||
roots: Vec<String>,
|
||||
messages: Vec<BTreeMap<String, String>>,
|
||||
message_values: HashMap<String, Vec<String>>,
|
||||
media: Media,
|
||||
warnings: Vec<String>,
|
||||
warned: HashSet<(NodeId, usize)>,
|
||||
resolved: usize,
|
||||
unresolved: Vec<String>,
|
||||
// The page being walked.
|
||||
page: usize,
|
||||
page_name: String,
|
||||
stem: String,
|
||||
top: f64,
|
||||
sem: Sem,
|
||||
hero_seen: bool,
|
||||
fixed_layout: usize,
|
||||
/// Per page: the face `(family, bold, italic)` of its h1.
|
||||
h1_faces: Vec<Option<(String, bool, bool)>>,
|
||||
}
|
||||
|
||||
impl<'a> Walk<'a> {
|
||||
fn new(p: &Params<'a>) -> Self {
|
||||
let mut classes = Classes::default();
|
||||
classes.reserve(PC_LINK);
|
||||
let roots = p.frames.iter().map(|f| classes.reserve(&f.name)).collect();
|
||||
Walk {
|
||||
docs: p.docs,
|
||||
frames: p.frames,
|
||||
tailwind: p.tailwind,
|
||||
classes,
|
||||
roots,
|
||||
messages: vec![BTreeMap::new(); p.docs.len()],
|
||||
message_values: HashMap::new(),
|
||||
media: Media::default(),
|
||||
warnings: Vec::new(),
|
||||
warned: HashSet::new(),
|
||||
resolved: 0,
|
||||
unresolved: Vec::new(),
|
||||
page: 0,
|
||||
page_name: String::new(),
|
||||
stem: String::new(),
|
||||
top: 0.0,
|
||||
sem: Sem::default(),
|
||||
hero_seen: false,
|
||||
fixed_layout: 0,
|
||||
h1_faces: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn page(&mut self, i: usize, f: &Frame) -> Element {
|
||||
let doc = &self.docs[0];
|
||||
self.page = i;
|
||||
self.page_name = f.name.clone();
|
||||
self.stem = f.stem.clone();
|
||||
self.top = doc.world_rect(f.id).map_or(0.0, |r| r.y);
|
||||
self.sem = semantic::semantics(doc, f.id);
|
||||
let h1 = self
|
||||
.sem
|
||||
.heads
|
||||
.iter()
|
||||
.find(|(_, level)| **level == 1)
|
||||
.and_then(|(id, _)| doc.get(*id));
|
||||
self.h1_faces.push(h1.map(|n| {
|
||||
(
|
||||
media::family(&n.text_style),
|
||||
n.text_style.bold,
|
||||
n.text_style.italic,
|
||||
)
|
||||
}));
|
||||
self.hero_seen = false;
|
||||
self.fixed_layout = 0;
|
||||
let root = self
|
||||
.node(f.id, Cx::default(), true)
|
||||
.unwrap_or_else(|| Element::new("div"));
|
||||
if self.fixed_layout > 0 {
|
||||
self.warnings.push(format!("{}: {} frame(s) without auto layout are absolutely positioned and will not reflow on a phone", f.name, self.fixed_layout));
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn finish(mut self, pages: Vec<Page>, p: &Params) -> Built {
|
||||
let roots: Vec<NodeId> = p.frames.iter().map(|f| f.id).collect();
|
||||
for f in media::missing_families(&self.docs[0], &roots) {
|
||||
self.warnings.push(format!("font `{f}` is not installed, so its text is drawn in Inter; install it before exporting to keep the typeface"));
|
||||
}
|
||||
let uses = media::font_uses(self.docs, &roots);
|
||||
let (fonts, preload, fallbacks, font_warnings) = media::faces(&mut self.media, &uses);
|
||||
self.warnings.extend(font_warnings);
|
||||
for id in std::mem::take(&mut self.media.missing) {
|
||||
self.warnings.push(format!(
|
||||
"image {id} is not loaded; its pages keep the original reference"
|
||||
));
|
||||
}
|
||||
let h1_faces = self
|
||||
.h1_faces
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let (family, bold, italic) = key.as_ref()?;
|
||||
let weight = if *bold { 700 } else { 400 };
|
||||
fonts
|
||||
.iter()
|
||||
.position(|f| f.family == *family && f.weight == weight && f.italic == *italic)
|
||||
})
|
||||
.collect();
|
||||
let labels: Vec<String> = p.locales.iter().map(|l| l.label.clone()).collect();
|
||||
self.warnings
|
||||
.extend(contrast::warnings(self.docs, p.frames, &labels));
|
||||
// A custom property nothing reads is dead weight (copy text lives in the messages, not in CSS).
|
||||
// shortcut: a textual search of the built tree and classes, not a walk; upgrade if a token
|
||||
// name ever has to be told apart from one that merely contains it.
|
||||
let used = format!("{pages:?}{:?}", self.classes.list);
|
||||
let mut tokens = tokens(self.docs);
|
||||
tokens.retain(|t| {
|
||||
[")", ",", " "]
|
||||
.iter()
|
||||
.any(|end| used.contains(&format!("var(--{}{end}", t.name)))
|
||||
});
|
||||
let style = StyleModel {
|
||||
tokens,
|
||||
fonts,
|
||||
fallbacks,
|
||||
preload,
|
||||
h1_faces,
|
||||
favicon: icon::favicon(&mut self.media, &self.docs[0]),
|
||||
classes: self.classes.list,
|
||||
scopes: p.locales.iter().map(|l| l.scope.clone()).collect(),
|
||||
};
|
||||
Built {
|
||||
pages,
|
||||
style,
|
||||
messages: self.messages,
|
||||
media: self.media,
|
||||
warnings: self.warnings,
|
||||
resolved: self.resolved,
|
||||
unresolved: self.unresolved,
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_once(&mut self, id: NodeId, locale: usize, msg: String) {
|
||||
if self.warned.insert((id, locale)) {
|
||||
self.warnings.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer `id` of locale `i` when it can be compared with the first locale's: same kind,
|
||||
/// same children and same pictures. Otherwise the first locale's version is used.
|
||||
fn twin(&mut self, id: NodeId, n: &Node, i: usize) -> Option<&'a Node> {
|
||||
let docs = self.docs;
|
||||
let other = docs[i].get(id)?;
|
||||
let images = |n: &Node| {
|
||||
n.style
|
||||
.fills
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let Paint::Image { asset, .. } = p {
|
||||
Some(asset.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let same = std::mem::discriminant(&n.kind) == std::mem::discriminant(&other.kind)
|
||||
&& n.children == other.children
|
||||
&& images(n) == images(other);
|
||||
if !same {
|
||||
let msg = format!(
|
||||
"{} / {}: differs between locales in structure or pictures; the first locale is used",
|
||||
self.page_name, n.name
|
||||
);
|
||||
self.warn_once(id, i, msg);
|
||||
}
|
||||
same.then_some(other)
|
||||
}
|
||||
|
||||
fn node(&mut self, id: NodeId, cx: Cx, is_root: bool) -> Option<Element> {
|
||||
let docs = self.docs;
|
||||
let n = docs[0].get(id)?;
|
||||
let link = self.link(n, cx.in_link);
|
||||
let button = semantic::is_button(n);
|
||||
let inner = Cx {
|
||||
in_link: cx.in_link || link.is_some(),
|
||||
in_button: cx.in_button || button,
|
||||
};
|
||||
let mut el = self.element(id, n, inner, is_root, link.is_some() && button)?;
|
||||
let mut out = match link {
|
||||
// An icon drawn as `<img>` keeps its picture inside an anchor instead of becoming one.
|
||||
Some(target) if button && el.tag != "img" => {
|
||||
el.tag = "a".into();
|
||||
el.attrs.retain(|(k, _)| k != "type");
|
||||
el.link = Some(target);
|
||||
el
|
||||
}
|
||||
Some(target) => {
|
||||
let mut a = Element::new("a");
|
||||
if self.tailwind {
|
||||
a.tw = "contents".into();
|
||||
} else {
|
||||
a.class = self.classes.get(
|
||||
PC_LINK,
|
||||
Some(PC_LINK.into()),
|
||||
self.page,
|
||||
vec![("display".into(), "contents".into())],
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
a.link = Some(target);
|
||||
a.children.push(Child::El(el));
|
||||
a
|
||||
}
|
||||
None => el,
|
||||
};
|
||||
if (out.link.is_some() || out.tag == "button") && !has_name(&out) {
|
||||
self.label(&mut out, n);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A link or button with no text or described picture in it is announced as just "link" or
|
||||
/// "button", so the layer (or icon) name becomes its label.
|
||||
fn label(&mut self, el: &mut Element, n: &Node) {
|
||||
let name = codegen::icon_of(&self.docs[0], n).map_or_else(
|
||||
|| n.name.trim().to_owned(),
|
||||
|(_, icon)| icon.replace('-', " "),
|
||||
);
|
||||
if is_generic_name(&name) {
|
||||
let what = if el.tag == "button" { "button" } else { "link" };
|
||||
self.warnings.push(format!("{} / {}: a {what} with no text and a generic layer name has no accessible name; rename the layer to say what it does", self.page_name, n.name));
|
||||
} else {
|
||||
el.attrs.push(("aria-label".into(), name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the layer's link goes, counting it as resolved or not and warning about the ones a
|
||||
/// static page cannot keep.
|
||||
fn link(&mut self, n: &Node, in_link: bool) -> Option<LinkTarget> {
|
||||
let label = format!("{} / {}", self.page_name, n.name);
|
||||
let target = if let Some(to) = n.link {
|
||||
match self.frames.iter().position(|f| f.id == to) {
|
||||
Some(i) => {
|
||||
self.resolved += 1;
|
||||
LinkTarget::Page(i)
|
||||
}
|
||||
None => {
|
||||
let name = self.docs[0]
|
||||
.get(to)
|
||||
.map_or("a missing frame", |t| t.name.as_str());
|
||||
self.unresolved
|
||||
.push(format!("{label} -> {name} (not exported)"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match &n.link_action {
|
||||
Some(LinkAction::Url { url }) => match valid_url(url) {
|
||||
Ok(u) => LinkTarget::Url(u),
|
||||
Err(e) => {
|
||||
self.warnings.push(format!("{label}: link dropped: {e}"));
|
||||
return None;
|
||||
}
|
||||
},
|
||||
Some(other) => {
|
||||
let kind = match other {
|
||||
LinkAction::Back => "back",
|
||||
LinkAction::Overlay { .. } => "overlay",
|
||||
_ => "close",
|
||||
};
|
||||
self.warnings.push(format!("{label}: a `{kind}` link only works in a prototype and is left out of the site"));
|
||||
return None;
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
if in_link {
|
||||
self.warnings.push(format!("{label}: a link inside a link; the page keeps only the outer one (nested anchors are invalid HTML)"));
|
||||
return None;
|
||||
}
|
||||
Some(target)
|
||||
}
|
||||
|
||||
fn element(
|
||||
&mut self,
|
||||
id: NodeId,
|
||||
n: &Node,
|
||||
cx: Cx,
|
||||
is_root: bool,
|
||||
own_link: bool,
|
||||
) -> Option<Element> {
|
||||
if let Some(el) = self.vector(id, n, is_root) {
|
||||
return Some(el);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text { .. } => Some(self.text(id, n, cx, is_root)),
|
||||
NodeKind::Sticky { .. } => Some(self.sticky(id, n, is_root)),
|
||||
NodeKind::Rect | NodeKind::Ellipse if n.children.is_empty() => {
|
||||
let img = self.image(id, n, is_root);
|
||||
Some(img.unwrap_or_else(|| self.container(id, n, cx, is_root, own_link)))
|
||||
}
|
||||
_ => Some(self.container(id, n, cx, is_root, own_link)),
|
||||
}
|
||||
}
|
||||
|
||||
fn container(
|
||||
&mut self,
|
||||
id: NodeId,
|
||||
n: &Node,
|
||||
cx: Cx,
|
||||
is_root: bool,
|
||||
own_link: bool,
|
||||
) -> Element {
|
||||
let tag = if self.sem.main == Some(id) {
|
||||
"main"
|
||||
} else {
|
||||
match semantic::landmark(&n.name).filter(|_| semantic::is_container(n)) {
|
||||
Some("button")
|
||||
if !own_link
|
||||
&& !self.docs[0]
|
||||
.subtree(id)
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|c| self.docs[0].get(*c).is_some_and(semantic::has_link)) =>
|
||||
{
|
||||
"button"
|
||||
}
|
||||
Some(t @ ("header" | "nav" | "footer" | "section" | "aside")) => t,
|
||||
Some("main") => "section",
|
||||
_ => "div",
|
||||
}
|
||||
};
|
||||
let mut el = Element::new(tag);
|
||||
if tag == "button" {
|
||||
el.attrs.push(("type".into(), "button".into()));
|
||||
}
|
||||
if n.is_frame() && !n.children.is_empty() && n.layout.is_none() {
|
||||
self.fixed_layout += 1;
|
||||
}
|
||||
self.style(&mut el, id, n, is_root, false);
|
||||
for c in &n.children {
|
||||
if let Some(child) = self.node(*c, cx, false) {
|
||||
el.children.push(Child::El(child));
|
||||
}
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
fn text(&mut self, id: NodeId, n: &Node, cx: Cx, is_root: bool) -> Element {
|
||||
let NodeKind::Text {
|
||||
text, auto_width, ..
|
||||
} = &n.kind
|
||||
else {
|
||||
unreachable!("text() is called for text layers")
|
||||
};
|
||||
let chars = text.chars().count();
|
||||
let tag = if cx.in_link || cx.in_button {
|
||||
"span".to_owned()
|
||||
} else if let Some(level) = self.sem.heads.get(&id) {
|
||||
format!("h{level}")
|
||||
} else if self.sem.subtitles.contains(&id) {
|
||||
"p".to_owned()
|
||||
} else {
|
||||
semantic::text_name(&n.name)
|
||||
.filter(|t| *t == "p")
|
||||
.unwrap_or_else(|| semantic::text_tag(chars, !auto_width))
|
||||
.to_owned()
|
||||
};
|
||||
let mut el = Element::new(&tag);
|
||||
self.style(&mut el, id, n, is_root, false);
|
||||
el.children.push(Child::Text(self.content(id, n)));
|
||||
el
|
||||
}
|
||||
|
||||
fn sticky(&mut self, id: NodeId, n: &Node, is_root: bool) -> Element {
|
||||
let mut el = Element::new("div");
|
||||
self.style(&mut el, id, n, is_root, false);
|
||||
el.children.push(Child::Text(self.content(id, n)));
|
||||
el
|
||||
}
|
||||
|
||||
/// The layer's text: literal, or a message key when a locale words it differently.
|
||||
fn content(&mut self, id: NodeId, n: &Node) -> Text {
|
||||
let own = layer_text(n);
|
||||
let mut values = vec![own.clone()];
|
||||
for i in 1..self.docs.len() {
|
||||
values.push(
|
||||
self.twin(id, n, i)
|
||||
.map(layer_text)
|
||||
.unwrap_or_else(|| own.clone()),
|
||||
);
|
||||
}
|
||||
if values.iter().all(|v| *v == own) {
|
||||
return Text::Lit(own);
|
||||
}
|
||||
let base = n
|
||||
.bindings
|
||||
.get("text")
|
||||
.map(|b| key_part(b))
|
||||
.filter(|b| !b.is_empty())
|
||||
.unwrap_or_else(|| format!("{}.{}", key_part(&self.stem), key_part(&n.name)));
|
||||
let mut key = base.clone();
|
||||
let mut suffix = 2;
|
||||
// Two layers sharing a key must share the words; otherwise the later one gets its own.
|
||||
while self.message_values.get(&key).is_some_and(|v| *v != values) {
|
||||
key = format!("{base}_{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
self.messages[i].insert(key.clone(), v.clone());
|
||||
}
|
||||
self.message_values.insert(key.clone(), values);
|
||||
Text::Msg(key)
|
||||
}
|
||||
|
||||
/// A picture: a rect or ellipse with nothing but one image fill, as `<img>`. A tiled fill
|
||||
/// stays a CSS background.
|
||||
fn image(&mut self, id: NodeId, n: &Node, is_root: bool) -> Option<Element> {
|
||||
let [Paint::Image { asset, mode, .. }] = n.style.fills.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if *mode == ImageMode::Tile {
|
||||
return None;
|
||||
}
|
||||
let src = self
|
||||
.media
|
||||
.image(asset, n.rect.w)
|
||||
.unwrap_or_else(|| format!("paper-asset://{asset}"));
|
||||
let mut el = Element::new("img");
|
||||
let y = self.docs[0].world_rect(id).map_or(0.0, |r| r.y) - self.top;
|
||||
let hero = !self.hero_seen && y < FOLD_PX;
|
||||
self.hero_seen |= hero;
|
||||
let alt = if is_generic_name(&n.name) {
|
||||
self.warnings.push(format!("{} / {}: the layer has a generic name, so the image has an empty `alt`; rename the layer to describe the picture", self.page_name, n.name));
|
||||
String::new()
|
||||
} else {
|
||||
n.name.clone()
|
||||
};
|
||||
el.attrs = vec![("src".into(), src), ("alt".into(), alt)];
|
||||
push_size(&mut el, n);
|
||||
if hero {
|
||||
el.attrs.push(("fetchpriority".into(), "high".into()));
|
||||
} else {
|
||||
el.attrs.push(("loading".into(), "lazy".into()));
|
||||
}
|
||||
el.attrs.push(("decoding".into(), "async".into()));
|
||||
self.style(&mut el, id, n, is_root, true);
|
||||
Some(el)
|
||||
}
|
||||
|
||||
/// Vector drawings and icon frames, as an SVG file shown with `<img>`: the same in every
|
||||
/// dialect, with no attribute rewriting and nothing in it that could close a template.
|
||||
fn vector(&mut self, id: NodeId, n: &Node, is_root: bool) -> Option<Element> {
|
||||
let drawn = codegen::icon_of(&self.docs[0], n).is_some()
|
||||
|| !matches!(
|
||||
n.kind,
|
||||
NodeKind::Frame { .. }
|
||||
| NodeKind::Group
|
||||
| NodeKind::Rect
|
||||
| NodeKind::Ellipse
|
||||
| NodeKind::Text { .. }
|
||||
| NodeKind::Sticky { .. }
|
||||
);
|
||||
if !drawn {
|
||||
return None;
|
||||
}
|
||||
let svg = codegen::inline_svg(&self.docs[0], id);
|
||||
if !svg.contains("<svg") {
|
||||
return None;
|
||||
}
|
||||
let mut el = Element::new("img");
|
||||
el.attrs = vec![
|
||||
("src".into(), self.media.svg(&svg)),
|
||||
("alt".into(), String::new()),
|
||||
];
|
||||
push_size(&mut el, n);
|
||||
self.style(&mut el, id, n, is_root, true);
|
||||
Some(el)
|
||||
}
|
||||
|
||||
/// The class (and Tailwind utilities) of `el`, from the layer's declarations in every locale.
|
||||
fn style(&mut self, el: &mut Element, id: NodeId, n: &Node, is_root: bool, replaced: bool) {
|
||||
let base = self.decls(0, id, is_root, replaced).unwrap_or_default();
|
||||
let mut overrides = Vec::new();
|
||||
for i in 1..self.docs.len() {
|
||||
if self.twin(id, n, i).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(d) = self.decls(i, id, is_root, replaced) {
|
||||
let changed = diff(&base, &d);
|
||||
if !changed.is_empty() {
|
||||
overrides.push((i, changed));
|
||||
}
|
||||
}
|
||||
}
|
||||
el.has_overrides = !overrides.is_empty();
|
||||
if self.tailwind {
|
||||
el.tw = self.tailwind_classes(n, is_root, replaced);
|
||||
}
|
||||
if self.tailwind && !el.has_overrides {
|
||||
return;
|
||||
}
|
||||
let root = is_root.then_some(self.page);
|
||||
let name = root.map(|p| self.roots[p].clone());
|
||||
el.class = self
|
||||
.classes
|
||||
.get(&n.name, name, self.page, base, overrides, root);
|
||||
}
|
||||
|
||||
fn tailwind_classes(&self, n: &Node, is_root: bool, replaced: bool) -> String {
|
||||
let doc = &self.docs[0];
|
||||
let mut tw = codegen::tw_classes(doc, n, is_root);
|
||||
if matches!(n.kind, NodeKind::Text { .. }) {
|
||||
// codegen names only a font the text sets, without fallbacks, and Tailwind's default
|
||||
// is the system font: give every text the CSS dialects' stack instead.
|
||||
tw = tw
|
||||
.split(' ')
|
||||
.filter(|c| !c.starts_with("font-['"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
tw.push(' ');
|
||||
tw.push_str(&tw_font(&media::family(&n.text_style)));
|
||||
}
|
||||
let fluid = fluid::decls(doc, n, replaced);
|
||||
if !fluid.is_empty() {
|
||||
// Replace what the fluid rule overrides: Tailwind has no cascade order to lean on.
|
||||
let drop_h = fluid.iter().any(|d| d.0 == "height");
|
||||
let drop_s = fluid.iter().any(|d| d.0 == "font-size");
|
||||
// Type this big is always an arbitrary `text-[Npx]`, never a named size.
|
||||
tw = tw
|
||||
.split(' ')
|
||||
.filter(|c| {
|
||||
!(drop_s && c.starts_with("text-[") && c.ends_with("px]"))
|
||||
&& !(drop_h && c.starts_with("h-["))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
for c in fluid::tailwind(&fluid) {
|
||||
tw.push(' ');
|
||||
tw.push_str(&c);
|
||||
}
|
||||
}
|
||||
if is_root && n.layout.is_some() {
|
||||
// The page rule of `extras`, as Tailwind classes.
|
||||
tw = tw
|
||||
.split(' ')
|
||||
.filter(|c| {
|
||||
!["w-", "h-", "min-h-", "max-w-"]
|
||||
.iter()
|
||||
.any(|p| c.starts_with(p))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let column = if n.rect.w < DESKTOP_MIN_W {
|
||||
format!(" max-w-[{}px] mx-auto", round2(n.rect.w))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
tw.push_str(&format!(
|
||||
" w-full{column} h-auto min-h-[max(100vh,{}px)]",
|
||||
round2(n.rect.h)
|
||||
));
|
||||
}
|
||||
tw.trim().to_owned()
|
||||
}
|
||||
|
||||
/// The declarations of layer `id` in locale `i`.
|
||||
fn decls(&mut self, i: usize, id: NodeId, is_root: bool, replaced: bool) -> Option<Decls> {
|
||||
let docs = self.docs;
|
||||
let doc = &docs[i];
|
||||
let n = doc.get(id)?;
|
||||
let mut m = css::computed(
|
||||
n,
|
||||
codegen::parent_layout(doc, n).as_ref(),
|
||||
n.parent.is_none(),
|
||||
);
|
||||
if is_root {
|
||||
for k in ["position", "left", "top"] {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Ellipse) {
|
||||
m.insert("borderRadius".into(), "50%".into());
|
||||
}
|
||||
if replaced {
|
||||
// An `<img>` draws its own pixels; a CSS fill would paint a box behind them.
|
||||
m.remove("background");
|
||||
m.remove("backgroundColor");
|
||||
}
|
||||
let mut out = Decls::new();
|
||||
for (k, v) in &m {
|
||||
let Some(val) = v.as_str() else { continue };
|
||||
let value = if k == "fontFamily" {
|
||||
media::font_stack(&media::family(&n.text_style))
|
||||
} else {
|
||||
self.value(val)
|
||||
};
|
||||
out.push((codegen::camel_to_kebab(k), value));
|
||||
}
|
||||
self.extras(&mut out, doc, n, is_root, replaced);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn extras(&self, out: &mut Decls, doc: &Document, n: &Node, is_root: bool, replaced: bool) {
|
||||
let set = |out: &mut Decls, k: &str, v: String| match out.iter_mut().find(|d| d.0 == k) {
|
||||
Some(d) => d.1 = v,
|
||||
None => out.push((k.to_owned(), v)),
|
||||
};
|
||||
if let [Paint::Image { mode, .. }] = n.style.fills.as_slice() {
|
||||
let (size, repeat) = match mode {
|
||||
ImageMode::Fill => ("cover", "no-repeat"),
|
||||
ImageMode::Fit => ("contain", "no-repeat"),
|
||||
ImageMode::Stretch => ("100% 100%", "no-repeat"),
|
||||
ImageMode::Tile => ("auto", "repeat"),
|
||||
};
|
||||
if replaced {
|
||||
let fit = match mode {
|
||||
ImageMode::Fill => "cover",
|
||||
ImageMode::Fit => "contain",
|
||||
_ => "fill",
|
||||
};
|
||||
set(out, "object-fit", fit.into());
|
||||
} else if !n.children.is_empty()
|
||||
|| *mode != ImageMode::Tile
|
||||
|| matches!(n.kind, NodeKind::Rect | NodeKind::Ellipse)
|
||||
{
|
||||
set(out, "background-size", size.into());
|
||||
set(out, "background-repeat", repeat.into());
|
||||
set(out, "background-position", "center".into());
|
||||
}
|
||||
}
|
||||
for (k, v) in fluid::decls(doc, n, replaced) {
|
||||
set(out, &k, v);
|
||||
}
|
||||
if layer_text(n).contains('\n') {
|
||||
set(out, "white-space", "pre-line".into());
|
||||
}
|
||||
if is_root && n.layout.is_some() {
|
||||
// A page takes the window's width and at least its height, so its background reaches
|
||||
// every edge, and grows instead of clipping. A screen narrower than a desktop stays a
|
||||
// column of its designed width: a phone layout stretched across a monitor falls apart.
|
||||
set(out, "width", "100%".into());
|
||||
if n.rect.w < DESKTOP_MIN_W {
|
||||
set(out, "max-width", format!("{}px", round2(n.rect.w)));
|
||||
}
|
||||
set(out, "height", "auto".into());
|
||||
set(
|
||||
out,
|
||||
"min-height",
|
||||
format!("max(100vh, {}px)", round2(n.rect.h)),
|
||||
);
|
||||
if n.rect.w < DESKTOP_MIN_W {
|
||||
set(out, "margin", "0 auto".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A declaration value: pictures named by id become site paths, and nothing that could end
|
||||
/// a rule or a style block survives.
|
||||
fn value(&mut self, val: &str) -> String {
|
||||
const SCHEME: &str = "paper-asset://";
|
||||
let mut out = String::with_capacity(val.len());
|
||||
let mut rest = val;
|
||||
while let Some(i) = rest.find(SCHEME) {
|
||||
out.push_str(&rest[..i]);
|
||||
let after = &rest[i + SCHEME.len()..];
|
||||
let end = after
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
|
||||
.unwrap_or(after.len());
|
||||
match self.media.background(&after[..end]) {
|
||||
Some(path) => out.push_str(&path),
|
||||
None => out.push_str(&rest[i..i + SCHEME.len() + end]),
|
||||
}
|
||||
rest = &after[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
safe_value(&out)
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_text(n: &Node) -> String {
|
||||
match &n.kind {
|
||||
NodeKind::Text { text, .. } | NodeKind::Sticky { text, .. } => text.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_size(el: &mut Element, n: &Node) {
|
||||
for (k, v) in [("width", n.rect.w), ("height", n.rect.h)] {
|
||||
if v >= 1.0 {
|
||||
el.attrs.push((k.into(), format!("{}", v.round() as i64)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn round2(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
/// A name the app gave a layer ("Rectangle 12", "Image 3"): not a description of the picture.
|
||||
/// Text, or a picture with a description, that a screen reader can announce.
|
||||
fn has_name(el: &Element) -> bool {
|
||||
el.children.iter().any(|c| match c {
|
||||
Child::Text(Text::Lit(s)) => !s.trim().is_empty(),
|
||||
Child::Text(Text::Msg(_)) => true,
|
||||
Child::El(e) => e.attrs.iter().any(|(k, v)| k == "alt" && !v.is_empty()) || has_name(e),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_generic_name(name: &str) -> bool {
|
||||
const GENERIC: [&str; 14] = [
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"image",
|
||||
"img",
|
||||
"frame",
|
||||
"group",
|
||||
"vector",
|
||||
"photo",
|
||||
"picture",
|
||||
"layer",
|
||||
"shape",
|
||||
"fill",
|
||||
"mask",
|
||||
"copy",
|
||||
];
|
||||
let mut words = name
|
||||
.split_whitespace()
|
||||
.map(str::to_lowercase)
|
||||
.filter(|w| !w.chars().all(|c| c.is_ascii_digit()))
|
||||
.peekable();
|
||||
words.peek().is_none() || words.all(|w| GENERIC.contains(&w.as_str()))
|
||||
}
|
||||
|
||||
/// A message key part: lower case ASCII letters and digits, `_` between runs.
|
||||
fn key_part(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars().flat_map(char::to_lowercase) {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('_') && !out.is_empty() {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
let out = out.trim_end_matches('_');
|
||||
if out.is_empty() {
|
||||
"text".into()
|
||||
} else {
|
||||
out.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// `media::font_stack` as a Tailwind class: `'` quotes, as classes sit in double-quoted
|
||||
/// attributes, and `_` for spaces.
|
||||
// shortcut: a family name holding `_`, `'` or `\` comes out wrong; quote those if one turns up.
|
||||
fn tw_font(family: &str) -> String {
|
||||
let stack = media::font_stack(family)
|
||||
.replace('"', "'")
|
||||
.replace(", ", ",")
|
||||
.replace(' ', "_");
|
||||
format!("font-[family-name:{stack}]")
|
||||
}
|
||||
|
||||
/// Variables and tokens of the first locale, with the values other locales give them.
|
||||
fn tokens(docs: &[Document]) -> Vec<Token> {
|
||||
let first = &docs[0];
|
||||
let others = |get: &dyn Fn(&Document) -> Option<String>| -> Vec<(usize, String)> {
|
||||
let base = get(first);
|
||||
(1..docs.len())
|
||||
.filter_map(|i| {
|
||||
get(&docs[i])
|
||||
.filter(|v| Some(v) != base.as_ref())
|
||||
.map(|v| (i, v))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let colors = first.variables.iter().map(|(k, c)| Token {
|
||||
name: codegen::css_ident(k),
|
||||
value: c.to_hex(),
|
||||
color: true,
|
||||
overrides: others(&|d| d.variables.get(k).map(|c| c.to_hex())),
|
||||
});
|
||||
let plain = first.tokens.iter().map(|(k, v)| Token {
|
||||
name: codegen::css_ident(k),
|
||||
value: safe_value(v),
|
||||
color: false,
|
||||
overrides: others(&|d| d.tokens.get(k).map(|v| safe_value(v))),
|
||||
});
|
||||
colors.chain(plain).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generic_layer_names_are_not_alt_text() {
|
||||
for generic in [
|
||||
"Rectangle 12",
|
||||
"Image 3",
|
||||
"image",
|
||||
"Frame",
|
||||
"Ellipse 4 copy",
|
||||
"Group 1",
|
||||
"123",
|
||||
] {
|
||||
assert!(is_generic_name(generic), "{generic}");
|
||||
}
|
||||
for described in ["Team photo", "Hero image", "Logo"] {
|
||||
assert!(!is_generic_name(described), "{described}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keys_are_lower_case_ascii() {
|
||||
assert_eq!(key_part("Hero / Title"), "hero_title");
|
||||
assert_eq!(key_part("hero.title"), "hero_title");
|
||||
assert_eq!(key_part("\u{3053}\u{3093}"), "text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The vue writer: a Nuxt 4 project (`render: static`, `nuxt generate` prerenders every route)
|
||||
//! or a Vite 8 single-page app (`render: spa`, vue-router and @unhead/vue). One SFC per page
|
||||
//! frame; with several locales the routes are `/<locale>/...`, each locale has a JSON catalog
|
||||
//! and `/` is a language index.
|
||||
//!
|
||||
//! User text reaches the output only through the core's Vue escaping (markup, entities) or as a
|
||||
//! JSON string (`js`), never pasted into a template, a script or a config.
|
||||
|
||||
mod nuxt;
|
||||
mod pages;
|
||||
mod project;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod vite;
|
||||
|
||||
use super::model::{Output, Render, Site, Styling};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Backslash, `u003c`: how a JS string says `<`.
|
||||
const ESCAPED_LT: &str = "\x5cu003c";
|
||||
|
||||
/// Escapes a value as a JS or TS literal. `<` becomes `<` so nothing can end a `<script>`.
|
||||
pub(super) fn js<T: Serialize + ?Sized>(v: &T) -> String {
|
||||
serde_json::to_string(v)
|
||||
.unwrap_or_else(|_| "null".into())
|
||||
.replace('<', ESCAPED_LT)
|
||||
}
|
||||
|
||||
/// Pretty JSON for `.ts` data files, with the same `<` rule.
|
||||
pub(super) fn js_pretty<T: Serialize + ?Sized>(v: &T) -> String {
|
||||
serde_json::to_string_pretty(v)
|
||||
.unwrap_or_else(|_| "null".into())
|
||||
.replace('<', ESCAPED_LT)
|
||||
}
|
||||
|
||||
/// What the file writers share: the site and the target.
|
||||
pub(super) struct Cx<'a> {
|
||||
pub site: &'a Site,
|
||||
pub spa: bool,
|
||||
/// Several locales: `[lang]` routes, catalogs and a language index.
|
||||
pub multi: bool,
|
||||
}
|
||||
|
||||
impl<'a> Cx<'a> {
|
||||
fn new(site: &'a Site) -> Self {
|
||||
Cx {
|
||||
site,
|
||||
spa: site.options.render == Render::Spa,
|
||||
multi: site.locales.iter().any(|l| l.folder.is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder holding the app's source: `app` for Nuxt, `src` for Vite.
|
||||
pub fn src(&self) -> &'static str {
|
||||
if self.spa { "src" } else { "app" }
|
||||
}
|
||||
|
||||
/// The route of a page with the trailing slash: `/`, `/pricing/`, `/pt-BR/pricing/`.
|
||||
pub fn route(&self, page: usize, loc: usize) -> String {
|
||||
let p = self.site.url_path(page, loc);
|
||||
if p.ends_with('/') { p } else { format!("{p}/") }
|
||||
}
|
||||
|
||||
/// The address a head tag or the sitemap uses: absolute with a `base_url`, else the route.
|
||||
pub fn address(&self, page: usize, loc: usize) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
self.site.base_url.as_deref().unwrap_or_default(),
|
||||
self.route(page, loc)
|
||||
)
|
||||
}
|
||||
|
||||
/// The address of a locale's root route (`/en/`), for the language index.
|
||||
pub fn locale_route(&self, loc: usize) -> String {
|
||||
self.site.locales[loc]
|
||||
.folder
|
||||
.as_ref()
|
||||
.map_or_else(|| "/".into(), |f| format!("/{f}/"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(site: &Site) -> Result<Output, String> {
|
||||
project::validate(site)?;
|
||||
let cx = Cx::new(site);
|
||||
let mut out = Output::default();
|
||||
for (path, bytes) in &site.assets {
|
||||
out.files.insert(format!("public/{path}"), bytes.clone());
|
||||
}
|
||||
let abs = |loc: usize, page: usize| cx.address(page, loc);
|
||||
if site.base_url.is_some() {
|
||||
let root = cx
|
||||
.multi
|
||||
.then(|| format!("{}/", site.base_url.as_deref().unwrap_or_default()));
|
||||
out.files.insert(
|
||||
"public/sitemap.xml".into(),
|
||||
site.sitemap(&abs, root).into_bytes(),
|
||||
);
|
||||
}
|
||||
out.files
|
||||
.insert("public/robots.txt".into(), site.robots().into_bytes());
|
||||
project::write_shared(&cx, &mut out)?;
|
||||
if cx.spa {
|
||||
vite::write(&cx, &mut out)?;
|
||||
} else {
|
||||
nuxt::write(&cx, &mut out)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The stylesheet the app imports, by styling.
|
||||
pub(super) fn global_stylesheet(site: &Site) -> (String, String) {
|
||||
let st = &site.style;
|
||||
match site.options.styling {
|
||||
Styling::Scss => ("main.scss".into(), sass_safe(st.global_css("/"))),
|
||||
Styling::Tailwind => (
|
||||
"main.css".into(),
|
||||
format!(
|
||||
"@import \"tailwindcss\";\n{}{}{}{}",
|
||||
st.tailwind_theme(),
|
||||
st.tokens_css(),
|
||||
st.fonts_css("/"),
|
||||
st.overrides_css("/", |_| true)
|
||||
),
|
||||
),
|
||||
_ => ("main.css".into(), st.global_css("/")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sass would read `#{` in a value as interpolation.
|
||||
pub(super) fn sass_safe(css: String) -> String {
|
||||
css.replace("#{", "\\23 {")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! `render: static`: a Nuxt 4 project that `nuxt generate` prerenders, one HTML file per route.
|
||||
|
||||
use super::pages::{index_sfc, page_path, page_sfc};
|
||||
use super::project::package_json;
|
||||
use super::{Cx, js};
|
||||
use crate::mcp::site::model::{Output, Styling};
|
||||
|
||||
const TSCONFIG: &str = "{\n \"files\": [],\n \"references\": [\n { \"path\": \"./.nuxt/tsconfig.app.json\" },\n { \"path\": \"./.nuxt/tsconfig.server.json\" },\n { \"path\": \"./.nuxt/tsconfig.shared.json\" },\n { \"path\": \"./.nuxt/tsconfig.node.json\" }\n ]\n}\n";
|
||||
|
||||
pub(super) fn write(cx: &Cx, out: &mut Output) -> Result<(), String> {
|
||||
let site = cx.site;
|
||||
let styling = site.options.styling;
|
||||
let mut dev: Vec<(&str, &str)> = Vec::new();
|
||||
match styling {
|
||||
Styling::Scss => dev.push(("sass", "^1.107.1")),
|
||||
Styling::Tailwind => {
|
||||
dev.extend([("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")])
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let scripts = [
|
||||
("build", "nuxt build"),
|
||||
("dev", "nuxt dev"),
|
||||
("generate", "nuxt generate"),
|
||||
("postinstall", "nuxt prepare"),
|
||||
("preview", "nuxt preview"),
|
||||
];
|
||||
let deps = [
|
||||
("nuxt", "^4.6.4"),
|
||||
("vue", "^3.5.44"),
|
||||
("vue-router", "^5.4.0"),
|
||||
];
|
||||
let name = crate::mcp::site::style::class_slug(&site.site_title);
|
||||
out.files.insert(
|
||||
"package.json".into(),
|
||||
package_json(&name, &scripts, &deps, &dev, false)?,
|
||||
);
|
||||
out.files
|
||||
.insert("tsconfig.json".into(), TSCONFIG.as_bytes().to_vec());
|
||||
out.files.insert(
|
||||
".gitignore".into(),
|
||||
b"node_modules\n.nuxt\n.output\ndist\n".to_vec(),
|
||||
);
|
||||
out.files
|
||||
.insert("nuxt.config.ts".into(), config(cx).into_bytes());
|
||||
out.files.insert(
|
||||
"app/app.vue".into(),
|
||||
b"<template>\n <NuxtPage />\n</template>\n".to_vec(),
|
||||
);
|
||||
for page in 0..site.pages.len() {
|
||||
out.files
|
||||
.insert(page_path(cx, page), page_sfc(cx, page).into_bytes());
|
||||
}
|
||||
if cx.multi {
|
||||
out.files
|
||||
.insert("app/pages/index.vue".into(), index_sfc(cx).into_bytes());
|
||||
}
|
||||
out.next_steps.push("In the output folder run `npm install`, then `npm run generate`: it prerenders every page and language into .output/public, ready for any static host. `npm run dev` serves it while you edit.".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config(cx: &Cx) -> String {
|
||||
let site = cx.site;
|
||||
let mut routes: Vec<String> = Vec::new();
|
||||
if cx.multi {
|
||||
routes.push("/".into());
|
||||
}
|
||||
for loc in 0..site.locales.len() {
|
||||
routes.extend((0..site.pages.len()).map(|p| cx.route(p, loc)));
|
||||
}
|
||||
let (css, import, plugin) = match site.options.styling {
|
||||
Styling::Scss => ("main.scss", "", ""),
|
||||
Styling::Tailwind => (
|
||||
"main.css",
|
||||
"import tailwindcss from '@tailwindcss/vite'\n\n",
|
||||
" vite: { plugins: [tailwindcss()] },\n",
|
||||
),
|
||||
_ => ("main.css", "", ""),
|
||||
};
|
||||
format!(
|
||||
"{import}export default defineNuxtConfig({{\n compatibilityDate: '2025-07-15',\n devtools: {{ enabled: false }},\n css: [{}],\n // The pages have no event handler (prototype overlays, back and close links are dropped)\n // and every link is a plain anchor, so no script is shipped. The app manifest serves only\n // client navigation, and payload files only hydration.\n features: {{ noScripts: true }},\n experimental: {{ payloadExtraction: false, appManifest: false }},\n nitro: {{ prerender: {{ failOnError: true, routes: {} }} }},\n{plugin}}})\n",
|
||||
js(&format!("~/assets/css/{css}")),
|
||||
js(&routes)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! The single-file components: one per page frame and the language index.
|
||||
|
||||
use super::{Cx, js, sass_safe};
|
||||
use crate::mcp::site::markup::{
|
||||
self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text,
|
||||
};
|
||||
use crate::mcp::site::model::{Site, Styling};
|
||||
use crate::mcp::site::tree::{Element, LinkTarget};
|
||||
use std::cell::Cell;
|
||||
|
||||
struct VueHooks<'a> {
|
||||
cx: &'a Cx<'a>,
|
||||
uses_lang: Cell<bool>,
|
||||
uses_t: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Hooks for VueHooks<'_> {
|
||||
fn class(&self, el: &Element) -> Option<Val> {
|
||||
let class = if self.cx.site.options.styling == Styling::Tailwind {
|
||||
el.tailwind_classes()
|
||||
} else {
|
||||
el.class.clone()
|
||||
};
|
||||
(!class.is_empty()).then_some(Val::Lit(class))
|
||||
}
|
||||
|
||||
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
|
||||
let site = self.cx.site;
|
||||
match target {
|
||||
LinkTarget::Url(u) => Link {
|
||||
tag: "a".into(),
|
||||
attrs: vec![("href".into(), Val::Lit(u.clone()))],
|
||||
},
|
||||
LinkTarget::Page(i) => {
|
||||
let tag = if self.cx.spa {
|
||||
"RouterLink"
|
||||
} else {
|
||||
"NuxtLink"
|
||||
}
|
||||
.to_owned();
|
||||
let to = if self.cx.multi {
|
||||
self.uses_lang.set(true);
|
||||
// The route is `/<lang>/<stem>/`, with the current language filled in at run time.
|
||||
let rest = if *i == 0 {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("/{}/", site.pages[*i].stem)
|
||||
};
|
||||
// Single quotes: the stem is validated to `[A-Za-z0-9_-]`, and vue-tsc does not decode `"` in bindings.
|
||||
Val::Expr(format!("'/' + lang + '{rest}'"))
|
||||
} else {
|
||||
Val::Lit(self.cx.route(*i, 0))
|
||||
};
|
||||
Link {
|
||||
tag,
|
||||
attrs: vec![("to".into(), to)],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn asset(&self, path: &str) -> Val {
|
||||
Val::Lit(format!("/{path}"))
|
||||
}
|
||||
|
||||
fn message(&self, key: &str) -> TextOut {
|
||||
if self.cx.multi {
|
||||
self.uses_t.set(true);
|
||||
// Bracket access with a JSON key: a key such as `1.title` is not a property name.
|
||||
TextOut::Raw(format!("{{{{ t[{}] }}}}", js(key)))
|
||||
} else {
|
||||
TextOut::Lit(
|
||||
self.cx
|
||||
.site
|
||||
.messages
|
||||
.first()
|
||||
.and_then(|m| m.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a page's file lives, relative to the project root.
|
||||
pub(super) fn page_path(cx: &Cx, page: usize) -> String {
|
||||
let p = &cx.site.pages[page];
|
||||
if cx.spa {
|
||||
return format!("src/views/{}Page.vue", p.component);
|
||||
}
|
||||
let name = if page == 0 { "index" } else { p.stem.as_str() };
|
||||
if cx.multi {
|
||||
format!("app/pages/[lang]/{name}.vue")
|
||||
} else {
|
||||
format!("app/pages/{name}.vue")
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file in the pages folder puts before a path under the source folder.
|
||||
fn rel(cx: &Cx) -> &'static str {
|
||||
if !cx.spa && cx.multi { "../../" } else { "../" }
|
||||
}
|
||||
|
||||
pub(super) fn page_sfc(cx: &Cx, page: usize) -> String {
|
||||
let site = cx.site;
|
||||
let hooks = VueHooks {
|
||||
cx,
|
||||
uses_lang: Cell::new(false),
|
||||
uses_t: Cell::new(false),
|
||||
};
|
||||
let mut body = String::new();
|
||||
markup::render(Dialect::Vue, &hooks, &site.pages[page].root, 1, &mut body);
|
||||
let r = rel(cx);
|
||||
let mut script = format!(
|
||||
"import {{ HEAD }} from '{r}head'\nimport {{ usePageHead }} from '{r}composables/usePageHead'\n"
|
||||
);
|
||||
if cx.multi {
|
||||
script.push_str(&format!(
|
||||
"import {{ useLocale }} from '{r}composables/useLocale'\n"
|
||||
));
|
||||
if !cx.spa {
|
||||
script.push_str(&format!("import {{ isLocale }} from '{r}i18n'\n\n// An unknown `/xx/` is a 404, not a page in the default language.\ndefinePageMeta({{ validate: (route) => isLocale(route.params.lang) }})\n"));
|
||||
}
|
||||
let mut names = vec!["index"];
|
||||
if hooks.uses_lang.get() {
|
||||
names.push("lang");
|
||||
}
|
||||
if hooks.uses_t.get() {
|
||||
names.push("t");
|
||||
}
|
||||
script.push_str(&format!(
|
||||
"\nconst {{ {} }} = useLocale()\nusePageHead(() => HEAD[index.value]![{page}]!)\n",
|
||||
names.join(", ")
|
||||
));
|
||||
} else {
|
||||
script.push_str(&format!("\nusePageHead(() => HEAD[0]![{page}]!)\n"));
|
||||
}
|
||||
let style = style_block(site, &site.style.page_css(page, "/"));
|
||||
format!(
|
||||
"<script setup lang=\"ts\">\n{script}</script>\n\n<template>\n{body}</template>\n{style}"
|
||||
)
|
||||
}
|
||||
|
||||
/// A `<style scoped>` block for the page's own rules; Tailwind pages carry utilities instead.
|
||||
fn style_block(site: &Site, css: &str) -> String {
|
||||
if css.is_empty() || site.options.styling == Styling::Tailwind {
|
||||
return String::new();
|
||||
}
|
||||
let (lang, css) = if site.options.styling == Styling::Scss {
|
||||
(" lang=\"scss\"", sass_safe(css.to_owned()))
|
||||
} else {
|
||||
("", css.to_owned())
|
||||
};
|
||||
// A value cannot end the block: `</` becomes `<\/`, which CSS reads the same in a string.
|
||||
format!(
|
||||
"\n<style{lang} scoped>\n{}</style>\n",
|
||||
css.replace("</", "<\\/")
|
||||
)
|
||||
}
|
||||
|
||||
/// `/`: one link per version of the site.
|
||||
pub(super) fn index_sfc(cx: &Cx) -> String {
|
||||
let site = cx.site;
|
||||
let tag = if cx.spa { "RouterLink" } else { "NuxtLink" };
|
||||
let langs = site.language_locales();
|
||||
let items: String = site
|
||||
.locales
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let lang = l
|
||||
.tag
|
||||
.as_ref()
|
||||
.filter(|_| langs.contains(&i))
|
||||
.map(|t| {
|
||||
format!(
|
||||
" lang=\"{0}\" hreflang=\"{0}\"",
|
||||
escape_attr(Dialect::Vue, t)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" <li><{tag} to=\"{}\"{lang}>{}</{tag}></li>\n",
|
||||
escape_attr(Dialect::Vue, &cx.locale_route(i)),
|
||||
escape_text(Dialect::Vue, &l.label)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let imports = "import { INDEX_HEAD } from '../head'\nimport { usePageHead } from '../composables/usePageHead'\n";
|
||||
format!(
|
||||
"<script setup lang=\"ts\">\n{imports}\nusePageHead(() => INDEX_HEAD)\n</script>\n\n<template>\n <main>\n <h1>{}</h1>\n <ul>\n{items} </ul>\n </main>\n</template>\n\n<style scoped>\nmain {{\n font-family: system-ui, sans-serif;\n margin: 2rem;\n}}\n</style>\n",
|
||||
escape_text(Dialect::Vue, &site.site_title)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Files both targets share: the catalogs, `i18n`, the head data, the composables and the
|
||||
//! global stylesheet. Only the imports differ (Nuxt auto-import aliases versus packages).
|
||||
|
||||
use super::{Cx, global_stylesheet, js, js_pretty};
|
||||
use crate::mcp::site::model::{HeadTag, Output, Site};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Rejects what could not be a file name, a route segment or an identifier, before any file is
|
||||
/// written. Names come from layer and page names, so this is a boundary.
|
||||
pub(super) fn validate(site: &Site) -> Result<(), String> {
|
||||
let word = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
};
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for p in &site.pages {
|
||||
let ident = p
|
||||
.component
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& p.component.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
if !ident || !seen.insert(p.component.to_lowercase()) {
|
||||
return Err(format!(
|
||||
"page component name `{}` is not a unique identifier",
|
||||
p.component
|
||||
));
|
||||
}
|
||||
if !word(&p.stem) {
|
||||
return Err(format!(
|
||||
"page route `{}` has characters a route segment cannot hold",
|
||||
p.stem
|
||||
));
|
||||
}
|
||||
}
|
||||
for l in &site.locales {
|
||||
if l.folder.as_deref().is_some_and(|f| !word(f)) {
|
||||
return Err(format!(
|
||||
"locale folder `{}` has characters a route segment cannot hold",
|
||||
l.folder.as_deref().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
}
|
||||
let key_ok = |k: &str| {
|
||||
!k.is_empty()
|
||||
&& k.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
|
||||
};
|
||||
if let Some(bad) = site
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|m| m.keys())
|
||||
.find(|k| !key_ok(k))
|
||||
{
|
||||
return Err(format!(
|
||||
"message key `{bad}` has characters a catalog key cannot hold"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn write_shared(cx: &Cx, out: &mut Output) -> Result<(), String> {
|
||||
let src = cx.src();
|
||||
let (css_name, css) = global_stylesheet(cx.site);
|
||||
out.files.insert(
|
||||
format!(
|
||||
"{src}/assets/{}{css_name}",
|
||||
if cx.spa { "" } else { "css/" }
|
||||
),
|
||||
css.into_bytes(),
|
||||
);
|
||||
out.files
|
||||
.insert(format!("{src}/head.ts"), head_ts(cx).into_bytes());
|
||||
out.files.insert(
|
||||
format!("{src}/composables/usePageHead.ts"),
|
||||
use_page_head(cx).into_bytes(),
|
||||
);
|
||||
if cx.multi {
|
||||
out.files.insert(
|
||||
format!("{src}/i18n/index.ts"),
|
||||
i18n_ts(cx.site).into_bytes(),
|
||||
);
|
||||
out.files.insert(
|
||||
format!("{src}/composables/useLocale.ts"),
|
||||
use_locale(cx).into_bytes(),
|
||||
);
|
||||
for (i, l) in cx.site.locales.iter().enumerate() {
|
||||
let json =
|
||||
serde_json::to_string_pretty(&cx.site.messages[i]).map_err(|e| e.to_string())?;
|
||||
out.files.insert(
|
||||
format!(
|
||||
"{src}/i18n/messages/{}.json",
|
||||
l.folder.as_deref().unwrap_or_default()
|
||||
),
|
||||
format!("{json}\n").into_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn i18n_ts(site: &Site) -> String {
|
||||
let folders: Vec<&str> = site
|
||||
.locales
|
||||
.iter()
|
||||
.map(|l| l.folder.as_deref().unwrap_or_default())
|
||||
.collect();
|
||||
let mut out = String::new();
|
||||
for (i, f) in folders.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"import m{i} from {}\n",
|
||||
js(&format!("./messages/{f}.json"))
|
||||
));
|
||||
}
|
||||
out.push_str(&format!("\n// The route segment of each locale, in catalog order; the first is the default.\nexport const LOCALES: string[] = {}\n", js(&folders)));
|
||||
let names: Vec<String> = (0..folders.len()).map(|i| format!("m{i}")).collect();
|
||||
out.push_str(&format!(
|
||||
"export const MESSAGES: Record<string, string>[] = [{}]\n\n",
|
||||
names.join(", ")
|
||||
));
|
||||
out.push_str("export const isLocale = (v: unknown): boolean => typeof v === 'string' && LOCALES.includes(v)\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn use_locale(cx: &Cx) -> String {
|
||||
let import = if cx.spa {
|
||||
"import { useRoute } from 'vue-router'"
|
||||
} else {
|
||||
"import { useRoute } from '#imports'"
|
||||
};
|
||||
format!(
|
||||
"import {{ computed }} from 'vue'\n{import}\nimport {{ LOCALES, MESSAGES }} from '../i18n'\n\n\
|
||||
// The locale of the current route; an unknown `:lang` falls back to the default one.\n\
|
||||
export function useLocale() {{\n const route = useRoute()\n \
|
||||
const index = computed(() => Math.max(0, LOCALES.indexOf(String(route.params.lang))))\n \
|
||||
const lang = computed(() => LOCALES[index.value] as string)\n \
|
||||
const t = computed(() => MESSAGES[index.value] as Record<string, string>)\n \
|
||||
return {{ index, lang, t }}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn use_page_head(cx: &Cx) -> String {
|
||||
let import = if cx.spa {
|
||||
"import { useHead } from '@unhead/vue'"
|
||||
} else {
|
||||
"import { useHead } from '#imports'"
|
||||
};
|
||||
format!(
|
||||
"{import}\nimport type {{ HeadData }} from '../head'\n\n\
|
||||
// The head data is generated, so it is already valid; unhead's per-tag unions do not fit\n// plain string records, hence the cast.\n\
|
||||
export function usePageHead(data: () => HeadData) {{\n \
|
||||
const input = () => {{\n const d = data()\n \
|
||||
return {{ title: d.title, htmlAttrs: {{ lang: d.lang, class: d.htmlClass }}, meta: d.meta, link: d.link }}\n }}\n \
|
||||
useHead(input as never)\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// One `{ title, lang, htmlClass, meta, link }` per page and locale, plus the language index.
|
||||
fn head_ts(cx: &Cx) -> String {
|
||||
let site = cx.site;
|
||||
let url = |_from: usize, to: usize, page: usize| cx.address(page, to);
|
||||
let mut per_locale = Vec::new();
|
||||
for loc in 0..site.locales.len() {
|
||||
let l = &site.locales[loc];
|
||||
let mut pages = Vec::new();
|
||||
for page in 0..site.pages.len() {
|
||||
let mut tags = site.head_tags(page, loc, &url);
|
||||
tags.extend(site.asset_tags(page, "/"));
|
||||
let mut d = head_data(&site.pages[page].title[loc], &tags);
|
||||
if let Some(t) = &l.tag {
|
||||
d["lang"] = json!(t);
|
||||
}
|
||||
if let Some(c) = &l.class {
|
||||
d["htmlClass"] = json!(c);
|
||||
}
|
||||
pages.push(d);
|
||||
}
|
||||
per_locale.push(Value::Array(pages));
|
||||
}
|
||||
let mut out = String::from(
|
||||
"export interface HeadData {\n title: string\n lang?: string\n htmlClass?: string\n meta: Record<string, string>[]\n link: Record<string, string>[]\n}\n\n\
|
||||
// Indexed by locale, then page.\n",
|
||||
);
|
||||
out.push_str(&format!(
|
||||
"export const HEAD: HeadData[][] = {}\n",
|
||||
js_pretty(&Value::Array(per_locale))
|
||||
));
|
||||
if cx.multi {
|
||||
out.push_str(&format!(
|
||||
"\nexport const INDEX_HEAD: HeadData = {}\n",
|
||||
js_pretty(&index_head(cx))
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn head_data(title: &str, tags: &[HeadTag]) -> Value {
|
||||
let to_obj = |t: &HeadTag| -> Value {
|
||||
let m: serde_json::Map<String, Value> = t
|
||||
.attrs
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
(*k).to_owned(),
|
||||
json!(if v.is_empty() {
|
||||
"anonymous"
|
||||
} else {
|
||||
v.as_str()
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Value::Object(m)
|
||||
};
|
||||
let of = |kind: &str| Value::Array(tags.iter().filter(|t| t.tag == kind).map(to_obj).collect());
|
||||
json!({"title": title, "meta": of("meta"), "link": of("link")})
|
||||
}
|
||||
|
||||
/// The head of the language index at `/`: canonical and the alternates of every language.
|
||||
fn index_head(cx: &Cx) -> Value {
|
||||
let site = cx.site;
|
||||
let base = site.base_url.as_deref().unwrap_or_default();
|
||||
let langs = site.language_locales();
|
||||
let alt = |tag: &str, loc: usize| HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![
|
||||
("rel", "alternate".into()),
|
||||
("hreflang", tag.to_owned()),
|
||||
("href", format!("{base}{}", cx.locale_route(loc))),
|
||||
],
|
||||
};
|
||||
let mut tags = vec![
|
||||
HeadTag {
|
||||
tag: "meta",
|
||||
attrs: vec![
|
||||
("name", "description".into()),
|
||||
("content", "Choose a version of this site.".into()),
|
||||
],
|
||||
},
|
||||
HeadTag {
|
||||
tag: "link",
|
||||
attrs: vec![("rel", "canonical".into()), ("href", format!("{base}/"))],
|
||||
},
|
||||
];
|
||||
tags.extend(
|
||||
langs
|
||||
.iter()
|
||||
.map(|&l| alt(site.locales[l].tag.as_deref().unwrap_or_default(), l)),
|
||||
);
|
||||
if let Some(&first) = langs.first() {
|
||||
tags.push(alt("x-default", first));
|
||||
}
|
||||
let mut d = head_data(&site.site_title, &tags);
|
||||
if let Some(t) = site.index_lang() {
|
||||
d["lang"] = json!(t);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The text of a `package.json`; JSON object keys come out sorted, so the file is stable.
|
||||
pub(super) fn package_json(
|
||||
name: &str,
|
||||
scripts: &[(&str, &str)],
|
||||
deps: &[(&str, &str)],
|
||||
dev: &[(&str, &str)],
|
||||
engines: bool,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let map = |items: &[(&str, &str)]| -> Value {
|
||||
Value::Object(
|
||||
items
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), json!(v)))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut pkg = json!({"name": name, "version": "0.0.0", "private": true, "type": "module", "scripts": map(scripts), "dependencies": map(deps)});
|
||||
if !dev.is_empty() {
|
||||
pkg["devDependencies"] = map(dev);
|
||||
}
|
||||
if engines {
|
||||
pkg["engines"] = json!({"node": "^22.18.0 || >=24.12.0"});
|
||||
}
|
||||
let mut b = serde_json::to_vec_pretty(&pkg).map_err(|e| e.to_string())?;
|
||||
b.push(b'\n');
|
||||
Ok(b)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use super::write;
|
||||
use crate::mcp::site::model::{Framework, Locale, Options, Output, Page, Render, Site, Styling};
|
||||
use crate::mcp::site::style::{Classes, FontFace, StyleModel, Token};
|
||||
use crate::mcp::site::tree::{Child, Element, LinkTarget, Text};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const HOSTILE: &str = "{{x}}</script><style>`${x}` v-html=\"y\"";
|
||||
|
||||
fn el(tag: &str, class: &str, attrs: &[(&str, &str)], children: Vec<Child>) -> Element {
|
||||
Element {
|
||||
tag: tag.into(),
|
||||
class: class.into(),
|
||||
attrs: attrs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect(),
|
||||
link: None,
|
||||
tw: String::new(),
|
||||
has_overrides: false,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn root(extra: Vec<Child>) -> Element {
|
||||
let mut link = el("a", "", &[], vec![Child::Text(Text::Lit("Go".into()))]);
|
||||
link.link = Some(LinkTarget::Page(1));
|
||||
let mut ext = el("a", "", &[], vec![Child::Text(Text::Lit("Out".into()))]);
|
||||
ext.link = Some(LinkTarget::Url("https://x.test/?a=\"b".into()));
|
||||
let mut kids = vec![
|
||||
Child::El(el(
|
||||
"h1",
|
||||
"",
|
||||
&[],
|
||||
vec![Child::Text(Text::Msg("home.hero_title".into()))],
|
||||
)),
|
||||
Child::El(el(
|
||||
"img",
|
||||
"",
|
||||
&[("src", "assets/hero.png"), ("alt", HOSTILE)],
|
||||
vec![],
|
||||
)),
|
||||
Child::El(link),
|
||||
Child::El(ext),
|
||||
Child::El(el(
|
||||
"p",
|
||||
"",
|
||||
&[],
|
||||
vec![Child::Text(Text::Lit(HOSTILE.into()))],
|
||||
)),
|
||||
];
|
||||
kids.extend(extra);
|
||||
el("div", "home", &[], kids)
|
||||
}
|
||||
|
||||
fn site(render: Render, styling: Styling, multi: bool) -> Site {
|
||||
let locale = |tag: &str, folder: Option<&str>| Locale {
|
||||
tag: Some(tag.into()),
|
||||
hreflang: true,
|
||||
folder: folder.map(str::to_owned),
|
||||
label: tag.into(),
|
||||
scope: String::new(),
|
||||
class: None,
|
||||
};
|
||||
let locales = if multi {
|
||||
vec![locale("en", Some("en")), locale("pt-BR", Some("pt-BR"))]
|
||||
} else {
|
||||
vec![locale("en", None)]
|
||||
};
|
||||
let n = locales.len();
|
||||
let page = |name: &str, stem: &str, comp: &str| Page {
|
||||
name: name.into(),
|
||||
stem: stem.into(),
|
||||
component: comp.into(),
|
||||
root: root(vec![]),
|
||||
title: vec![format!("{name} | Acme"); n],
|
||||
description: vec![format!("About {name}"); n],
|
||||
};
|
||||
let mut classes = Classes::default();
|
||||
classes.get(
|
||||
"Home",
|
||||
None,
|
||||
0,
|
||||
vec![("width".into(), "100%".into())],
|
||||
vec![],
|
||||
Some(0),
|
||||
);
|
||||
let style = StyleModel {
|
||||
tokens: vec![Token {
|
||||
name: "brand".into(),
|
||||
value: "#4f46e5".into(),
|
||||
color: true,
|
||||
overrides: vec![],
|
||||
}],
|
||||
fallbacks: Vec::new(),
|
||||
fonts: vec![FontFace {
|
||||
family: "Varela Round".into(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
path: "assets/fonts/f.ttf".into(),
|
||||
format: "truetype",
|
||||
}],
|
||||
preload: Some(0),
|
||||
favicon: None,
|
||||
h1_faces: Vec::new(),
|
||||
classes: classes.list,
|
||||
scopes: vec![String::new(); n],
|
||||
};
|
||||
let messages = if multi {
|
||||
vec![
|
||||
BTreeMap::from([("home.hero_title".to_owned(), "Hello".to_owned())]),
|
||||
BTreeMap::from([("home.hero_title".to_owned(), "Ola".to_owned())]),
|
||||
]
|
||||
} else {
|
||||
vec![BTreeMap::from([(
|
||||
"home.hero_title".to_owned(),
|
||||
"Hello".to_owned(),
|
||||
)])]
|
||||
};
|
||||
Site {
|
||||
options: Options {
|
||||
framework: Framework::Vue,
|
||||
render,
|
||||
styling,
|
||||
},
|
||||
site_title: "Acme".into(),
|
||||
base_url: Some("https://example.com".into()),
|
||||
locales,
|
||||
pages: vec![
|
||||
page("Home", "index", "Home"),
|
||||
page("Pricing", "pricing", "Pricing"),
|
||||
],
|
||||
messages,
|
||||
assets: BTreeMap::from([
|
||||
("assets/hero.png".to_owned(), vec![1, 2, 3]),
|
||||
("assets/fonts/f.ttf".to_owned(), vec![4]),
|
||||
]),
|
||||
style,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn text(o: &Output, path: &str) -> String {
|
||||
String::from_utf8(
|
||||
o.files
|
||||
.get(path)
|
||||
.unwrap_or_else(|| panic!("missing {path}: {:?}", o.files.keys().collect::<Vec<_>>()))
|
||||
.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nuxt_project_has_prerendered_routes_pages_catalogs_and_public_files() {
|
||||
let o = write(&site(Render::Static, Styling::Css, true)).unwrap();
|
||||
for f in [
|
||||
"package.json",
|
||||
"nuxt.config.ts",
|
||||
"tsconfig.json",
|
||||
"app/app.vue",
|
||||
"app/pages/index.vue",
|
||||
"app/pages/[lang]/index.vue",
|
||||
"app/pages/[lang]/pricing.vue",
|
||||
"app/i18n/index.ts",
|
||||
"app/i18n/messages/pt-BR.json",
|
||||
"app/head.ts",
|
||||
"app/composables/useLocale.ts",
|
||||
"app/composables/usePageHead.ts",
|
||||
"app/assets/css/main.css",
|
||||
"public/sitemap.xml",
|
||||
"public/robots.txt",
|
||||
"public/assets/hero.png",
|
||||
] {
|
||||
assert!(o.files.contains_key(f), "{f}");
|
||||
}
|
||||
let cfg = text(&o, "nuxt.config.ts");
|
||||
assert!(
|
||||
cfg.contains("\"/\",\"/en/\",\"/en/pricing/\",\"/pt-BR/\",\"/pt-BR/pricing/\"")
|
||||
&& cfg.contains("failOnError: true")
|
||||
&& cfg.contains("payloadExtraction: false")
|
||||
&& cfg.contains("appManifest: false")
|
||||
&& cfg.contains("features: { noScripts: true }"),
|
||||
"{cfg}"
|
||||
);
|
||||
let page = text(&o, "app/pages/[lang]/index.vue");
|
||||
assert!(
|
||||
page.contains("{{ t[\"home.hero_title\"] }}")
|
||||
&& page.contains("definePageMeta")
|
||||
&& page.contains("<NuxtLink :to=\"'/' + lang + '/pricing/'\">"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
text(&o, "app/head.ts").contains("\"hreflang\": \"x-default\"")
|
||||
&& text(&o, "app/head.ts").contains("\"crossorigin\": \"anonymous\"")
|
||||
);
|
||||
assert!(text(&o, "app/i18n/messages/pt-BR.json").contains("Ola"));
|
||||
assert!(text(&o, "public/sitemap.xml").contains("https://example.com/pt-BR/pricing/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_locale_routes_sit_at_the_root_with_literal_text() {
|
||||
let o = write(&site(Render::Static, Styling::Css, false)).unwrap();
|
||||
assert!(
|
||||
o.files.contains_key("app/pages/index.vue")
|
||||
&& o.files.contains_key("app/pages/pricing.vue")
|
||||
);
|
||||
assert!(
|
||||
!o.files.contains_key("app/i18n/index.ts") && !o.files.keys().any(|k| k.contains("[lang]"))
|
||||
);
|
||||
let page = text(&o, "app/pages/index.vue");
|
||||
assert!(
|
||||
page.contains("<h1>Hello</h1>")
|
||||
&& page.contains("<NuxtLink to=\"/pricing/\">")
|
||||
&& !page.contains("useLocale"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(text(&o, "nuxt.config.ts").contains("[\"/\",\"/pricing/\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spa_project_has_a_router_views_and_unhead() {
|
||||
let o = write(&site(Render::Spa, Styling::Css, true)).unwrap();
|
||||
for f in [
|
||||
"index.html",
|
||||
"vite.config.ts",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
"env.d.ts",
|
||||
"src/main.ts",
|
||||
"src/App.vue",
|
||||
"src/router.ts",
|
||||
"src/views/HomePage.vue",
|
||||
"src/views/PricingPage.vue",
|
||||
"src/views/LanguageIndex.vue",
|
||||
"src/assets/main.css",
|
||||
"src/i18n/index.ts",
|
||||
] {
|
||||
assert!(o.files.contains_key(f), "{f}");
|
||||
}
|
||||
let r = text(&o, "src/router.ts");
|
||||
assert!(
|
||||
r.contains("path: \"pricing\", component: () => import(\"./views/PricingPage.vue\")")
|
||||
&& r.contains("isLocale"),
|
||||
"{r}"
|
||||
);
|
||||
assert!(text(&o, "src/views/HomePage.vue").contains("<RouterLink :to="));
|
||||
assert!(
|
||||
text(&o, "package.json").contains("\"@unhead/vue\"")
|
||||
&& text(&o, "package.json").contains("vue-tsc --build && vite build")
|
||||
);
|
||||
assert!(o.warnings.iter().any(|w| w.contains("browser")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styling_picks_the_stylesheet_the_dependencies_and_the_style_blocks() {
|
||||
let css = write(&site(Render::Static, Styling::Css, true)).unwrap();
|
||||
assert!(
|
||||
text(&css, "app/pages/[lang]/index.vue").contains("<style scoped>")
|
||||
&& text(&css, "app/assets/css/main.css").contains("@font-face")
|
||||
);
|
||||
let scss = write(&site(Render::Spa, Styling::Scss, true)).unwrap();
|
||||
assert!(
|
||||
scss.files.contains_key("src/assets/main.scss")
|
||||
&& text(&scss, "package.json").contains("\"sass\"")
|
||||
&& text(&scss, "src/main.ts").contains("main.scss")
|
||||
);
|
||||
assert!(text(&scss, "src/views/HomePage.vue").contains("<style lang=\"scss\" scoped>"));
|
||||
let tw = write(&site(Render::Static, Styling::Tailwind, true)).unwrap();
|
||||
assert!(
|
||||
text(&tw, "app/assets/css/main.css").starts_with("@import \"tailwindcss\";")
|
||||
&& text(&tw, "nuxt.config.ts").contains("tailwindcss()")
|
||||
&& !text(&tw, "app/pages/[lang]/index.vue").contains("<style")
|
||||
);
|
||||
assert!(text(&tw, "package.json").contains("@tailwindcss/vite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_stays_inert_in_every_variant() {
|
||||
for render in [Render::Static, Render::Spa] {
|
||||
for styling in [Styling::Css, Styling::Scss, Styling::Tailwind] {
|
||||
let o = write(&site(render, styling, true)).unwrap();
|
||||
for (path, content) in &o.files {
|
||||
if !path.ends_with(".vue") {
|
||||
continue;
|
||||
}
|
||||
let c = String::from_utf8_lossy(content);
|
||||
assert!(
|
||||
!c.contains("{{x}}") && !c.contains("v-html=\"y\"") && !c.contains("<style>`"),
|
||||
"{path}: {c}"
|
||||
);
|
||||
assert!(
|
||||
c.contains("{{x}}") || !c.contains("<p>"),
|
||||
"{path}"
|
||||
);
|
||||
assert_eq!(
|
||||
c.matches("</script>").count(),
|
||||
usize::from(c.contains("<script")),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_names_are_rejected_or_escaped_in_data_files() {
|
||||
let mut s = site(Render::Static, Styling::Css, true);
|
||||
s.pages[0].title = vec![HOSTILE.into(), HOSTILE.into()];
|
||||
s.site_title = "</script>".into();
|
||||
let o = write(&s).unwrap();
|
||||
for f in ["app/head.ts", "nuxt.config.ts"] {
|
||||
assert!(!text(&o, f).contains("</script>"), "{f}");
|
||||
}
|
||||
assert!(text(&o, "app/pages/index.vue").contains("</script>"));
|
||||
let mut bad = site(Render::Spa, Styling::Css, true);
|
||||
bad.pages[1].component = "Home".into();
|
||||
assert!(
|
||||
write(&bad)
|
||||
.err()
|
||||
.expect("rejected")
|
||||
.contains("unique identifier")
|
||||
);
|
||||
let mut bad = site(Render::Spa, Styling::Css, true);
|
||||
bad.pages[1].component = "Pri'cing".into();
|
||||
assert!(write(&bad).is_err());
|
||||
let mut bad = site(Render::Spa, Styling::Css, true);
|
||||
bad.messages[0].insert("a\"]}}x".into(), "y".into());
|
||||
assert!(write(&bad).err().expect("rejected").contains("message key"));
|
||||
let mut bad = site(Render::Static, Styling::Css, true);
|
||||
bad.pages[1].stem = "../x".into();
|
||||
assert!(write(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keys_are_read_with_bracket_access() {
|
||||
let mut s = site(Render::Static, Styling::Css, true);
|
||||
s.pages[0].root = root(vec![Child::El(el(
|
||||
"span",
|
||||
"",
|
||||
&[],
|
||||
vec![Child::Text(Text::Msg("1st.title".into()))],
|
||||
))]);
|
||||
for m in &mut s.messages {
|
||||
m.insert("1st.title".into(), "x".into());
|
||||
}
|
||||
let page = text(&write(&s).unwrap(), "app/pages/[lang]/index.vue");
|
||||
assert!(
|
||||
page.contains("{{ t[\"1st.title\"] }}") && !page.contains("t.1st"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_site_writes_the_same_bytes() {
|
||||
for render in [Render::Static, Render::Spa] {
|
||||
let a = write(&site(render, Styling::Scss, true)).unwrap();
|
||||
let b = write(&site(render, Styling::Scss, true)).unwrap();
|
||||
assert_eq!(a.files, b.files);
|
||||
assert!(!a.next_steps.is_empty() && a.next_steps[0].contains("npm install"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_in_a_style_block_cannot_end_it() {
|
||||
for styling in [Styling::Css, Styling::Scss] {
|
||||
let mut s = site(Render::Static, styling, true);
|
||||
let mut c = Classes::default();
|
||||
c.get(
|
||||
"Home",
|
||||
None,
|
||||
0,
|
||||
vec![("content".into(), "\"</style><script>${x}`\"".into())],
|
||||
vec![],
|
||||
Some(0),
|
||||
);
|
||||
s.style.classes = c.list;
|
||||
s.style.fonts[0].family = "</style>${x}`".into();
|
||||
s.style.tokens[0].value = "</style>${x}`".into();
|
||||
let o = write(&s).unwrap();
|
||||
let page = text(&o, "app/pages/[lang]/index.vue");
|
||||
assert_eq!(page.matches("</style>").count(), 1, "{page}");
|
||||
assert!(page.contains("<\\/style>"), "{page}");
|
||||
// The shared stylesheet is a file of its own, so a style block cannot be ended there.
|
||||
assert!(
|
||||
o.files
|
||||
.keys()
|
||||
.any(|k| k.starts_with("app/assets/css/main."))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! `render: spa`: a Vite 8 app with vue-router 5; @unhead/vue sets the head in the browser.
|
||||
|
||||
use super::pages::{index_sfc, page_path, page_sfc};
|
||||
use super::project::package_json;
|
||||
use super::{Cx, js};
|
||||
use crate::mcp::site::model::{Output, Styling};
|
||||
|
||||
const TSCONFIG: &str = "{\n \"files\": [],\n \"references\": [\n { \"path\": \"./tsconfig.node.json\" },\n { \"path\": \"./tsconfig.app.json\" }\n ]\n}\n";
|
||||
const TSCONFIG_APP: &str = "{\n \"extends\": \"@vue/tsconfig/tsconfig.dom.json\",\n \"include\": [\"env.d.ts\", \"src/**/*\", \"src/**/*.vue\"],\n \"compilerOptions\": {\n \"noUncheckedIndexedAccess\": true,\n \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\"\n }\n}\n";
|
||||
const TSCONFIG_NODE: &str = "{\n \"extends\": \"@tsconfig/node24/tsconfig.json\",\n \"include\": [\"vite.config.*\"],\n \"compilerOptions\": {\n \"module\": \"preserve\",\n \"moduleResolution\": \"bundler\",\n \"allowImportingTsExtensions\": true,\n \"types\": [\"node\"],\n \"noEmit\": true,\n \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\"\n }\n}\n";
|
||||
|
||||
pub(super) fn write(cx: &Cx, out: &mut Output) -> Result<(), String> {
|
||||
let site = cx.site;
|
||||
let styling = site.options.styling;
|
||||
let mut dev = vec![
|
||||
("@tsconfig/node24", "^24.0.5"),
|
||||
("@types/node", "^24.13.4"),
|
||||
("@vitejs/plugin-vue", "^6.0.8"),
|
||||
("@vue/tsconfig", "^0.9.1"),
|
||||
("typescript", "~6.0.0"),
|
||||
("vite", "^8.2.2"),
|
||||
("vue-tsc", "^3.3.11"),
|
||||
];
|
||||
match styling {
|
||||
Styling::Scss => dev.push(("sass", "^1.107.1")),
|
||||
Styling::Tailwind => {
|
||||
dev.extend([("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")])
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let scripts = [
|
||||
("build", "vue-tsc --build && vite build"),
|
||||
("dev", "vite"),
|
||||
("preview", "vite preview"),
|
||||
];
|
||||
let deps = [
|
||||
("@unhead/vue", "^3.4.3"),
|
||||
("vue", "^3.5.42"),
|
||||
("vue-router", "^5.3.1"),
|
||||
];
|
||||
let name = crate::mcp::site::style::class_slug(&site.site_title);
|
||||
out.files.insert(
|
||||
"package.json".into(),
|
||||
package_json(&name, &scripts, &deps, &dev, true)?,
|
||||
);
|
||||
out.files
|
||||
.insert("tsconfig.json".into(), TSCONFIG.as_bytes().to_vec());
|
||||
out.files
|
||||
.insert("tsconfig.app.json".into(), TSCONFIG_APP.as_bytes().to_vec());
|
||||
out.files.insert(
|
||||
"tsconfig.node.json".into(),
|
||||
TSCONFIG_NODE.as_bytes().to_vec(),
|
||||
);
|
||||
out.files.insert(
|
||||
"env.d.ts".into(),
|
||||
b"/// <reference types=\"vite/client\" />\n".to_vec(),
|
||||
);
|
||||
out.files.insert(
|
||||
".gitignore".into(),
|
||||
b"node_modules\ndist\n*.tsbuildinfo\n".to_vec(),
|
||||
);
|
||||
out.files.insert(
|
||||
"vite.config.ts".into(),
|
||||
vite_config(styling == Styling::Tailwind).into_bytes(),
|
||||
);
|
||||
out.files
|
||||
.insert("index.html".into(), index_html(cx).into_bytes());
|
||||
let css = if styling == Styling::Scss {
|
||||
"main.scss"
|
||||
} else {
|
||||
"main.css"
|
||||
};
|
||||
out.files.insert("src/main.ts".into(), format!("import {}\n\nimport {{ createApp }} from 'vue'\nimport {{ createHead }} from '@unhead/vue/client'\nimport App from './App.vue'\nimport router from './router'\n\ncreateApp(App).use(createHead()).use(router).mount('#app')\n", js(&format!("./assets/{css}"))).into_bytes());
|
||||
out.files.insert(
|
||||
"src/App.vue".into(),
|
||||
b"<template>\n <RouterView />\n</template>\n".to_vec(),
|
||||
);
|
||||
out.files
|
||||
.insert("src/router.ts".into(), router(cx).into_bytes());
|
||||
for page in 0..site.pages.len() {
|
||||
out.files
|
||||
.insert(page_path(cx, page), page_sfc(cx, page).into_bytes());
|
||||
}
|
||||
if cx.multi {
|
||||
out.files.insert(
|
||||
"src/views/LanguageIndex.vue".into(),
|
||||
index_sfc(cx).into_bytes(),
|
||||
);
|
||||
}
|
||||
out.next_steps.push("In the output folder run `npm install`, then `npm run build`: the app is written to dist/. `npm run dev` serves it while you edit. Host dist/ with a fallback that serves index.html for unknown paths, or deep links return 404.".into());
|
||||
out.warnings.push("render: spa sets the title, description, canonical and hreflang tags in the browser: crawlers that do not run JavaScript only see the fallback title. Use render: static for search engines.".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vite_config(tailwind: bool) -> String {
|
||||
if tailwind {
|
||||
"import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport tailwindcss from '@tailwindcss/vite'\n\nexport default defineConfig({ plugins: [vue(), tailwindcss()] })\n".into()
|
||||
} else {
|
||||
"import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\nexport default defineConfig({ plugins: [vue()] })\n".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The static shell: the title and language a visitor sees before the app starts.
|
||||
fn index_html(cx: &Cx) -> String {
|
||||
use crate::mcp::site::markup::{Dialect, escape_attr, escape_text};
|
||||
let site = cx.site;
|
||||
let lang = site
|
||||
.locales
|
||||
.first()
|
||||
.and_then(|l| l.tag.as_deref())
|
||||
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Html, t)))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<!DOCTYPE html>\n<html{lang}>\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>{}</title>\n </head>\n <body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.ts\"></script>\n </body>\n</html>\n",
|
||||
escape_text(Dialect::Html, &site.site_title)
|
||||
)
|
||||
}
|
||||
|
||||
fn router(cx: &Cx) -> String {
|
||||
let site = cx.site;
|
||||
let lazy = |page: usize| {
|
||||
format!(
|
||||
"() => import({})",
|
||||
js(&format!(
|
||||
".{}",
|
||||
page_path(cx, page).trim_start_matches("src")
|
||||
))
|
||||
)
|
||||
};
|
||||
let mut out = String::from("import { createRouter, createWebHistory } from 'vue-router'\n");
|
||||
let routes = if cx.multi {
|
||||
out.push_str("import { isLocale } from './i18n'\nimport LanguageIndex from './views/LanguageIndex.vue'\n");
|
||||
let children: Vec<String> = (0..site.pages.len())
|
||||
.map(|p| {
|
||||
format!(
|
||||
" {{ path: {}, component: {} }},\n",
|
||||
js(if p == 0 {
|
||||
""
|
||||
} else {
|
||||
site.pages[p].stem.as_str()
|
||||
}),
|
||||
lazy(p)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
" {{ path: '/', component: LanguageIndex }},\n {{\n path: '/:lang',\n // An unknown language goes to the language index.\n beforeEnter: (to) => isLocale(to.params.lang) || '/',\n children: [\n{} ],\n }},\n",
|
||||
children.concat()
|
||||
)
|
||||
} else {
|
||||
(0..site.pages.len())
|
||||
.map(|p| {
|
||||
format!(
|
||||
" {{ path: {}, component: {} }},\n",
|
||||
js(&format!(
|
||||
"/{}",
|
||||
if p == 0 {
|
||||
""
|
||||
} else {
|
||||
site.pages[p].stem.as_str()
|
||||
}
|
||||
)),
|
||||
lazy(p)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
out.push_str(&format!("\nexport default createRouter({{\n history: createWebHistory(import.meta.env.BASE_URL),\n routes: [\n{routes} {{ path: '/:pathMatch(.*)*', redirect: '/' }},\n ],\n}})\n"));
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Languages as variable modes: `get_strings` reads a page's text for translation and
|
||||
//! `set_strings` extracts it into a string collection, fills a language, or fills a pseudo-locale.
|
||||
//! A language is a mode of the collection, never a copy of the page; text layers follow it through
|
||||
//! the `text` binding, so `set_variable_mode` switches and re-fits them.
|
||||
|
||||
use super::ToolResult;
|
||||
use super::exec::{Args, page_arg, resolve, text};
|
||||
use crate::editor::Editor;
|
||||
use crate::model::{Document, NodeId};
|
||||
use crate::variables::{self, Collection, DesignSystem, Kind, Value as DsVal, Variable};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const DEFAULT_COLLECTION: &str = "Strings";
|
||||
const FIELD: &str = "text";
|
||||
/// A pseudo-locale is this much longer than its base text, like a typical translation.
|
||||
const PSEUDO_GROWTH_PERCENT: usize = 35;
|
||||
const KEY_SLUG_MAX: usize = 32;
|
||||
|
||||
/// A text layer in scope and the names from the scope's frame down to it.
|
||||
struct TextLayer {
|
||||
id: NodeId,
|
||||
path: Vec<String>,
|
||||
}
|
||||
|
||||
fn layers_under(doc: &Document, id: NodeId, path: &mut Vec<String>, out: &mut Vec<TextLayer>) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
path.push(n.name.clone());
|
||||
if n.kind.text().is_some_and(|t| !t.trim().is_empty()) {
|
||||
out.push(TextLayer { id, path: path.clone() });
|
||||
}
|
||||
for c in &n.children {
|
||||
layers_under(doc, *c, path, out);
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
|
||||
/// The page and its text layers in document order, from `frame` or from every top layer.
|
||||
fn scope(ed: &Editor, a: &Args) -> Result<(usize, Vec<TextLayer>), String> {
|
||||
let def = page_arg(ed, a)?;
|
||||
let (page, roots) = match a.str("frame").filter(|f| !f.is_empty()) {
|
||||
Some(f) => {
|
||||
let (page, id) = resolve(ed, f, def)?;
|
||||
(page, vec![id])
|
||||
}
|
||||
None => (def, ed.page_doc(def).root.clone()),
|
||||
};
|
||||
let doc = ed.page_doc(page);
|
||||
let mut out = Vec::new();
|
||||
for r in roots {
|
||||
layers_under(doc, r, &mut Vec::new(), &mut out);
|
||||
}
|
||||
Ok((page, out))
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
let dashed: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let parts: Vec<&str> = dashed.split('-').filter(|p| !p.is_empty()).collect();
|
||||
let joined = parts.join("-");
|
||||
let cut: String = joined.chars().take(KEY_SLUG_MAX).collect();
|
||||
let cut = cut.trim_end_matches('-');
|
||||
if cut.is_empty() { "text".into() } else { cut.into() }
|
||||
}
|
||||
|
||||
fn local_collection<'a>(ds: &'a DesignSystem, key: &str) -> Result<&'a Collection, String> {
|
||||
ds.collections
|
||||
.iter()
|
||||
.find(|c| c.library.is_none() && (c.name == key || c.id == key))
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<&str> = ds.collections.iter().filter(|c| c.library.is_none()).map(|c| c.name.as_str()).collect();
|
||||
format!("no collection `{key}` (collections: {})", names.join(", "))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_of(v: &Variable, mode: &str) -> Option<String> {
|
||||
match v.values.get(mode) {
|
||||
Some(DsVal::String(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_name<'a>(c: &'a Collection, id: &'a str) -> &'a str {
|
||||
c.modes.iter().find(|m| m.id == id).map_or(id, |m| m.name.as_str())
|
||||
}
|
||||
|
||||
pub(super) fn get_strings(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (page, layers) = scope(ed, a)?;
|
||||
let ds = &ed.design;
|
||||
let only = a.str("collection").filter(|c| !c.is_empty()).map(|c| local_collection(ds, c).map(|c| c.id.clone())).transpose()?;
|
||||
let doc = ed.page_doc(page);
|
||||
let mut bound: Vec<(&Collection, &Variable)> = Vec::new();
|
||||
let mut unbound = Map::new();
|
||||
for l in &layers {
|
||||
let Some(n) = doc.get(l.id) else { continue };
|
||||
match n.bindings.get(FIELD).and_then(|css| ds.find_by_css(css)) {
|
||||
Some((c, v)) if v.kind == Kind::String => {
|
||||
let fits = only.as_ref().is_none_or(|o| *o == c.id);
|
||||
if fits && !bound.iter().any(|(_, b)| b.id == v.id) {
|
||||
bound.push((c, v));
|
||||
}
|
||||
}
|
||||
_ if only.is_none() => {
|
||||
unbound.insert(super::exec::node_key(ed, page, l.id), json!(n.kind.text()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(mode) = a.str("missing").filter(|m| !m.is_empty()) {
|
||||
return missing_only(&bound, mode);
|
||||
}
|
||||
let mut strings = Map::new();
|
||||
let mut missing: BTreeMap<String, Vec<&str>> = BTreeMap::new();
|
||||
for (c, v) in &bound {
|
||||
let per_mode: Map<String, Value> = c
|
||||
.modes
|
||||
.iter()
|
||||
.filter_map(|m| string_of(v, &m.id).map(|s| (m.name.clone(), json!(s))))
|
||||
.collect();
|
||||
strings.insert(v.name.clone(), Value::Object(per_mode));
|
||||
for m in c.modes.iter().filter(|m| string_of(v, &m.id).is_none()) {
|
||||
missing.entry(m.name.clone()).or_default().push(&v.name);
|
||||
}
|
||||
}
|
||||
let mut collections: Vec<&str> = bound.iter().map(|(c, _)| c.name.as_str()).collect();
|
||||
collections.sort_unstable();
|
||||
collections.dedup();
|
||||
text(json!({"collections": collections, "strings": strings, "unbound": unbound, "missing": missing}))
|
||||
}
|
||||
|
||||
fn missing_only(bound: &[(&Collection, &Variable)], mode: &str) -> ToolResult {
|
||||
let mut missing = Map::new();
|
||||
let mut base = None;
|
||||
for (c, v) in bound {
|
||||
let Some(m) = c.modes.iter().find(|m| m.name == mode || m.id == mode) else { continue };
|
||||
base.get_or_insert_with(|| mode_name(c, &c.default_mode).to_string());
|
||||
if string_of(v, &m.id).is_none() {
|
||||
missing.insert(v.name.clone(), json!(string_of(v, &c.default_mode).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
let Some(base) = base else {
|
||||
return Err(format!("no mode `{mode}` in the bound collections; create it with set_strings"));
|
||||
};
|
||||
text(json!({"mode": mode, "base": base, "missing": missing}))
|
||||
}
|
||||
|
||||
pub(super) fn set_strings(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
if a.bool("extract") == Some(true) {
|
||||
return extract(ed, a);
|
||||
}
|
||||
let collection = a.str("collection").filter(|c| !c.is_empty()).ok_or("`collection` is required (or pass extract: true)")?;
|
||||
let mode = a.str("mode").filter(|m| !m.is_empty()).ok_or("`mode` is required")?;
|
||||
let c = local_collection(&ed.design, collection)?;
|
||||
let writes: Vec<(String, String)> = if a.bool("pseudo") == Some(true) {
|
||||
// A pseudo-locale of the base mode would overwrite the text it is made from.
|
||||
if c.modes.iter().any(|m| m.id == c.default_mode && (m.name == mode || m.id == mode)) {
|
||||
return Err(format!("`{mode}` is the base mode of `{}`; pass another mode for the pseudo-locale", c.name));
|
||||
}
|
||||
pseudo_writes(c)
|
||||
} else {
|
||||
let strings = a.get("strings").and_then(Value::as_object).ok_or("pass `strings` {key: text}, `pseudo` true or `extract` true")?;
|
||||
checked_strings(c, strings)?
|
||||
};
|
||||
let cid = c.id.clone();
|
||||
let count = writes.len();
|
||||
ed.edit_design("Set strings", |ds| {
|
||||
let mid = ensure_mode(ds, &cid, mode)?;
|
||||
for (vid, text) in writes {
|
||||
ds.set_value(&vid, &mid, DsVal::String(text))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
text(json!({"collection": collection, "mode": mode, "set": count}))
|
||||
}
|
||||
|
||||
/// `(variable id, text)` for every key, or an error naming all the bad keys and applying nothing.
|
||||
fn checked_strings(c: &Collection, strings: &Map<String, Value>) -> Result<Vec<(String, String)>, String> {
|
||||
let (mut unknown, mut not_string, mut not_text) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut out = Vec::new();
|
||||
for (key, val) in strings {
|
||||
let css = crate::design_system::sanitize_name(&key.replace('/', "-"));
|
||||
let Some(v) = c.variables.iter().find(|v| v.name == *key || variables::css_name(v) == css) else {
|
||||
unknown.push(key.as_str());
|
||||
continue;
|
||||
};
|
||||
match val.as_str() {
|
||||
_ if v.kind != Kind::String => not_string.push(key.as_str()),
|
||||
Some(t) => out.push((v.id.clone(), t.to_string())),
|
||||
None => not_text.push(key.as_str()),
|
||||
}
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
for (what, keys) in [("unknown keys", &unknown), ("not string variables", ¬_string), ("values that are not text", ¬_text)] {
|
||||
if !keys.is_empty() {
|
||||
problems.push(format!("{what}: {}", keys.join(", ")));
|
||||
}
|
||||
}
|
||||
if problems.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
Err(format!("nothing applied; collection `{}` has {}", c.name, problems.join("; ")))
|
||||
}
|
||||
|
||||
fn pseudo_writes(c: &Collection) -> Vec<(String, String)> {
|
||||
c.variables
|
||||
.iter()
|
||||
.filter(|v| v.kind == Kind::String)
|
||||
.filter_map(|v| string_of(v, &c.default_mode).filter(|s| !s.is_empty()).map(|s| (v.id.clone(), pseudo(&s))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A mode by name or id, created when missing. A new mode starts with no value of its own (not
|
||||
/// the copies `add_mode` seeds), so `get_strings` can tell what is still untranslated; a variable
|
||||
/// without a value in the active mode shows its base text.
|
||||
fn ensure_mode(ds: &mut DesignSystem, cid: &str, name: &str) -> Result<String, variables::Error> {
|
||||
let c = ds.collection(cid).ok_or_else(|| variables::Error::NotFound(cid.into()))?;
|
||||
if let Some(m) = c.modes.iter().find(|m| m.name == name || m.id == name) {
|
||||
return Ok(m.id.clone());
|
||||
}
|
||||
let id = ds.add_mode(cid, name)?;
|
||||
if let Some(c) = ds.collections.iter_mut().find(|c| c.id == cid) {
|
||||
for v in c.variables.iter_mut().filter(|v| v.kind == Kind::String) {
|
||||
v.values.remove(&id);
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn accent(c: char) -> char {
|
||||
match c {
|
||||
'a' => 'á', 'e' => 'é', 'i' => 'í', 'o' => 'ó', 'u' => 'ú', 'y' => 'ý', 'c' => 'ç', 'n' => 'ñ',
|
||||
'A' => 'Á', 'E' => 'É', 'I' => 'Í', 'O' => 'Ó', 'U' => 'Ú', 'C' => 'Ç', 'N' => 'Ñ',
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// `[Ñéw ñéw ...]`: accented, bracketed, with a tail of the text's own letters for the extra length.
|
||||
fn pseudo(base: &str) -> String {
|
||||
let accented: String = base.chars().map(accent).collect();
|
||||
let extra = (base.chars().count() * PSEUDO_GROWTH_PERCENT).div_ceil(100);
|
||||
let tail: String = accented.chars().cycle().take(extra).collect();
|
||||
format!("[{accented} {tail}]")
|
||||
}
|
||||
|
||||
/// One unbound text layer to extract: its key in the collection and the text it holds.
|
||||
struct Planned {
|
||||
id: NodeId,
|
||||
key: String,
|
||||
css: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn extract(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (page, layers) = scope(ed, a)?;
|
||||
let doc = ed.page_doc(page);
|
||||
let todo: Vec<(NodeId, Vec<String>, String)> = layers
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let n = doc.get(l.id)?;
|
||||
let t = n.kind.text()?;
|
||||
(!n.bindings.contains_key(FIELD)).then(|| (l.id, l.path, t.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let name = a.str("collection").filter(|c| !c.is_empty()).unwrap_or(DEFAULT_COLLECTION).to_string();
|
||||
if todo.is_empty() {
|
||||
return text(json!({"collection": name, "strings": {}}));
|
||||
}
|
||||
let base_mode = a.str("mode").filter(|m| !m.is_empty()).unwrap_or(variables::DEFAULT_MODE).to_string();
|
||||
let plan: RefCell<Vec<Planned>> = RefCell::new(Vec::new());
|
||||
let calls = Cell::new(0usize);
|
||||
// The bindings go in the same step as the variables: `edit_design_each` visits every page once,
|
||||
// in order, so the visit counter says when the page being extracted is the one in hand.
|
||||
let bind = |tx: &mut crate::history::Tx| {
|
||||
let visit = calls.replace(calls.get() + 1);
|
||||
if visit != page {
|
||||
return;
|
||||
}
|
||||
for p in plan.borrow().iter() {
|
||||
if let Some(n) = tx.node_mut(p.id) {
|
||||
n.bindings.insert(FIELD.into(), p.css.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
let (collection, _) = ed
|
||||
.edit_design_each(
|
||||
"Extract strings",
|
||||
|ds| {
|
||||
let existing = ds.collections.iter().find(|c| c.library.is_none() && (c.name == name || c.id == name)).map(|c| c.id.clone());
|
||||
let cid = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = ds.add_collection(&name);
|
||||
if let Some(c) = ds.collections.iter_mut().find(|c| c.id == id) {
|
||||
c.modes[0].name = base_mode.clone();
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let base = ds.collection(&cid).map(|c| c.default_mode.clone()).ok_or_else(|| variables::Error::NotFound(cid.clone()))?;
|
||||
for (id, path, text) in &todo {
|
||||
let key = unique_key(ds, &path.iter().map(|p| slug(p)).collect::<Vec<_>>().join("/"));
|
||||
let vid = ds.add_variable(&cid, &key, Kind::String)?;
|
||||
ds.set_value(&vid, &base, DsVal::String(text.clone()))?;
|
||||
// Other modes start untranslated and show the base text (see `ensure_mode`).
|
||||
if let Some(c) = ds.collections.iter_mut().find(|c| c.id == cid)
|
||||
&& let Some(v) = c.variables.iter_mut().find(|v| v.id == vid)
|
||||
{
|
||||
v.values.retain(|m, _| *m == base);
|
||||
}
|
||||
let css = ds.find(&vid).map(|(_, v)| variables::css_name(v)).ok_or_else(|| variables::Error::NotFound(vid.clone()))?;
|
||||
plan.borrow_mut().push(Planned { id: *id, key, css, text: text.clone() });
|
||||
}
|
||||
Ok(ds.collection(&cid).map(|c| c.name.clone()).unwrap_or_default())
|
||||
},
|
||||
&bind,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let strings: Map<String, Value> = plan.borrow().iter().map(|p| (p.key.clone(), json!(p.text))).collect();
|
||||
text(json!({"collection": collection, "strings": strings}))
|
||||
}
|
||||
|
||||
/// `base`, or `base-2`, `base-3`... when a variable with that CSS name already exists.
|
||||
fn unique_key(ds: &DesignSystem, base: &str) -> String {
|
||||
let taken = |name: &str| ds.find_by_css(&crate::design_system::sanitize_name(&name.replace('/', "-"))).is_some();
|
||||
if !taken(base) {
|
||||
return base.into();
|
||||
}
|
||||
(2..).map(|i| format!("{base}-{i}")).find(|k| !taken(k)).unwrap_or_else(|| base.into())
|
||||
}
|
||||
@@ -50,7 +50,7 @@ fn reach(name: &str, args: &Value) -> Reach {
|
||||
/// The shared resource a tool changes, which a claim can hold for one agent.
|
||||
fn resource_of(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"set_variables" | "delete_variables" | "create_collection" | "set_variable_mode" | "set_tokens" | "import_design_system"
|
||||
"set_variables" | "set_strings" | "delete_variables" | "create_collection" | "set_variable_mode" | "set_tokens" | "import_design_system"
|
||||
| "enable_library" | "update_library" | "disable_library" => Some("variables"),
|
||||
"create_component" | "import_components" | "merge_variants" | "set_component_props" | "apply_ui_kit" => Some("components"),
|
||||
"create_page" | "rename_pages" | "delete_pages" => Some("pages"),
|
||||
|
||||
+166
-27
@@ -23,7 +23,8 @@ and grids become rows of cells (reported in warnings); no tables.
|
||||
- get_basic_info lists the person's design lessons (rules kept from past critiques): follow \
|
||||
every one. When finish_working_on_nodes returns `next`, offer a critique (ask_user, then \
|
||||
critique_design); keep a finding as a lesson (design_lessons) only when the person approves.
|
||||
- Text uses Inter (regular, bold, italic). Use px for font sizes.
|
||||
- Text uses Inter (regular, bold, italic) unless the brief needs another typeface: then \
|
||||
install_font and set font-family. Use px for font sizes.
|
||||
- Edit with update_styles, set_text_content, rename_nodes, duplicate_nodes, move_nodes, \
|
||||
delete_nodes. Whiteboard shapes, sticky notes and arrows: create_shape. Hand-drawn style: \
|
||||
update_styles with {\"sketch\": true, \"roughness\": 1, \"fillStyle\": \"hachure\"}.
|
||||
@@ -56,6 +57,16 @@ fn page_id() -> Value {
|
||||
json!({"type": "string", "description": "Omitted = the page the user is viewing."})
|
||||
}
|
||||
|
||||
/// `maxTokens` of a read tool that can return a lot.
|
||||
fn max_tokens() -> Value {
|
||||
json!({"type": "number", "description": "Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest."})
|
||||
}
|
||||
|
||||
/// `offset` of a read tool whose text result is cut into pages.
|
||||
fn offset() -> Value {
|
||||
json!({"type": "number", "description": "Line to start at: the `nextOffset` of a truncated result."})
|
||||
}
|
||||
|
||||
fn styles_schema(desc: &str) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -71,7 +82,7 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
|
||||
&& !m.contains_key("fileId")
|
||||
&& !matches!(
|
||||
name,
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate"
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "install_font" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate"
|
||||
)
|
||||
{
|
||||
m.insert("fileId".into(), file_id());
|
||||
@@ -229,19 +240,35 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_context",
|
||||
"The one compact read to make before editing (about 2k tokens): the page, the target \
|
||||
(`id`, else the first selected layer, else the whole page) with its screen, the \
|
||||
selection, an outline of the target two levels deep with child counts and an \
|
||||
`estimatedTokens` per layer (what a get_tree_summary of it costs, so you know what \
|
||||
is cheap to read next), the variables used inside it with their value in the active \
|
||||
mode and collection, every collection with its modes and the active one, the \
|
||||
components used with instance counts, and a lint summary of that target only (counts \
|
||||
per rule and the first 5 findings). Read deeper nodes afterwards, one at a time.",
|
||||
json!({"id": node_id("A layer or screen to describe; omit for the selection, else the page.")}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_node_info",
|
||||
"Details of one node: type, name, size, visibility, lock state, parent, children ids, \
|
||||
text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.",
|
||||
json!({"nodeId": node_id("The node to inspect.")}),
|
||||
text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist. \
|
||||
Reports `estimatedTokens`; over `maxTokens` the child ids are cut (`truncated`).",
|
||||
json!({"nodeId": node_id("The node to inspect."), "maxTokens": max_tokens()}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_children",
|
||||
"Direct children of a node (or of the page root when nodeId is \"root\"), with ids, \
|
||||
names, types, child counts and positions.",
|
||||
json!({"nodeId": node_id("The parent node, or \"root\"."), "pageId": page_id()}),
|
||||
names, types, child counts and positions. Reports `estimatedTokens`; over `maxTokens` the \
|
||||
list is cut (`truncated`).",
|
||||
json!({"nodeId": node_id("The parent node, or \"root\"."), "pageId": page_id(), "maxTokens": max_tokens()}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
@@ -267,11 +294,14 @@ pub fn definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"get_tree_summary",
|
||||
"Compact indented text tree of a subtree: type, name, id and size per node. Much \
|
||||
cheaper than get_jsx for orientation. nodeId \"root\" summarizes the whole page.",
|
||||
cheaper than get_jsx for orientation. nodeId \"root\" summarizes the whole page. A second \
|
||||
result block reports `estimatedTokens`; over `maxTokens` the tree is cut at a whole \
|
||||
line and the block carries `truncated`, `nextOffset` (pass it as `offset`) and a hint.",
|
||||
json!({
|
||||
"nodeId": node_id("Root of the summary, or \"root\"."),
|
||||
"depth": {"type": "number", "description": "Levels shown, counting nodeId (or each top-level layer for \"root\") as level 1 (default 3, max 10). Deeper levels collapse to \"… N children\"."},
|
||||
"fullText": {"type": "boolean", "description": "Show each text layer's complete text instead of its first 40 characters (for copy review)."},
|
||||
"maxTokens": max_tokens(), "offset": offset(),
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
@@ -298,10 +328,13 @@ pub fn definitions() -> Vec<Value> {
|
||||
"Export a node and its descendants to code: \"inline\" (default, React JSX with inline \
|
||||
styles), \"tailwind\" (React JSX with Tailwind CSS utility classes), \"react\" (React \
|
||||
TSX component), \"angular\" (Angular v17+ standalone component), \"html\" \
|
||||
(self-contained <style> + HTML markup), or \"egui\" (Rust egui 0.36 code with tokens).",
|
||||
(self-contained <style> + HTML markup), or \"egui\" (Rust egui 0.36 code with tokens). A second result block reports `estimatedTokens`; over \
|
||||
`maxTokens` the code is cut at a whole line and the block carries `truncated`, \
|
||||
`nextOffset` (pass it as `offset`) and a hint to export a narrower node.",
|
||||
json!({
|
||||
"nodeId": node_id("The node to convert."),
|
||||
"format": {"type": "string", "enum": ["inline", "inline-styles", "tailwind", "react", "angular", "html", "egui"], "description": "Output format: \"inline\" (default), \"tailwind\", \"react\", \"angular\", \"html\", or \"egui\"."},
|
||||
"maxTokens": max_tokens(), "offset": offset(),
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
@@ -325,11 +358,14 @@ pub fn definitions() -> Vec<Value> {
|
||||
(sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory \
|
||||
enforcement rules, and reference target code. Always call this before implementing \
|
||||
a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing \
|
||||
from a screenshot.",
|
||||
from a screenshot. Reports `estimatedTokens` (json format: a field; other formats: a second \
|
||||
result block); over `maxTokens` the output is cut at a whole line (json: whole array \
|
||||
elements) and flagged `truncated` with how to read the rest.",
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id to export."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json", "compact"], "description": "Output format: \"markdown\" (default), \"json\", or \"compact\" (terse text for an implementer: notes, tokens used, kit components, one line per node; no reference code; about 12x fewer tokens than json)."},
|
||||
"maxTokens": max_tokens(), "offset": offset(),
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
@@ -593,12 +629,23 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"get_font_family_info",
|
||||
"Whether font families are available and their weights/styles. papercraft renders text \
|
||||
with Inter (400, 700, normal and italic); other families fall back to Inter.",
|
||||
"Whether font families are available and their weights/styles. Inter (400, 700, normal \
|
||||
and italic) is built in and families added with install_font follow; others fall \
|
||||
back to Inter.",
|
||||
json!({"familyNames": {"type": "array", "items": {"type": "string"}}}),
|
||||
&["familyNames"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"install_font",
|
||||
"Add a font and keep it in the open project, so it travels with the project. Takes one \
|
||||
.ttf or .otf per call (one style of a family; a variable font gives its default style); \
|
||||
call again for its bold and italic files. Returns the family; use it as font-family \
|
||||
in write_html and update_styles. Check the font's licence allows the use.",
|
||||
json!({"source": {"type": "string", "description": "A local file path, or an http(s) URL of the font file itself (not a web page, zip or WOFF)."}}),
|
||||
&["source"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"get_guide",
|
||||
"Read a guide. Topics: \"papercraft-mcp-instructions\" (read first), \"html\" (what \
|
||||
@@ -610,7 +657,8 @@ pub fn definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export nodes to files. `nodes` maps node ids to export settings \
|
||||
([{format: png|svg|pdf, scale: \"2x\" | \"512w\" | \"512h\" | \"720p\"}]; [] = 2x PNG). \
|
||||
([{format: png|svg|pdf, scale: \"2x\" | \"512w\" | \"512h\" | \"720p\", \
|
||||
name: file stem, transparent: no white background}]; [] = 2x PNG). \
|
||||
Files go to `directory` (default: the Downloads folder). Returns the paths written.",
|
||||
json!({
|
||||
"nodes": {"type": "object", "additionalProperties": {"type": "array", "items": {
|
||||
@@ -618,10 +666,13 @@ pub fn definitions() -> Vec<Value> {
|
||||
"properties": {
|
||||
"format": {"type": "string", "enum": ["png", "svg", "pdf"]},
|
||||
"scale": {"type": "string"},
|
||||
"name": {"type": "string", "description": "File stem (default: the node name)."},
|
||||
"transparent": {"type": "boolean", "description": "No white background (PNG, SVG)."},
|
||||
},
|
||||
"required": ["format"],
|
||||
}}},
|
||||
"directory": {"type": "string", "description": "Output folder (absolute path)."},
|
||||
"transparent": {"type": "boolean", "description": "Default for every node."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodes"],
|
||||
@@ -710,6 +761,50 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["dir"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"export_site",
|
||||
"Write frames of a page as a website in one call; returns a short summary, never the \
|
||||
markup. Before the first export ASK THE PERSON which `framework`, `render` and \
|
||||
`styling` they want, and recommend from what they have: the framework their repo or \
|
||||
team uses (read package.json), else html for a finished site nobody will develop or \
|
||||
astro for a content site they will grow. Client JavaScript per page (gzip): html and \
|
||||
astro and static vue 0; static angular ~77 KB, react ~135 KB; spa vue ~40 KB, angular \
|
||||
~63 KB, react ~97 KB. render static (prerendered) for anything public that needs \
|
||||
search or link previews, spa only for an app behind a login. styling: what their \
|
||||
project uses, else css; scss and tailwind work everywhere; CSS-in-JS is react only: \
|
||||
vanilla-extract (recommended, zero runtime), styled-components or emotion. Also writes \
|
||||
_headers, vercel.json (hashed assets cached a year, SPA fallback) and .nojekyll for \
|
||||
the hosts. Defaults \
|
||||
html, static, css. One page per frame (the first is the home page) with semantic tags \
|
||||
and one h1, <img> alt and link labels from layer names, self-hosted fonts, Open Graph \
|
||||
and robots.txt; `base_url` makes canonical and hreflang absolute and adds sitemap.xml. \
|
||||
Prototype links to exported frames and Url links become links. With `modes` (a \
|
||||
collection whose modes are languages such as en and pt-BR) each mode is a locale: text \
|
||||
that differs becomes a message per locale, styles that differ become :lang() rules. \
|
||||
A project (any framework, scss, tailwind) needs a build: follow `next_steps`. The \
|
||||
folder must be new, empty or an earlier export_site folder; a re-export removes only \
|
||||
what the previous one wrote. Per framework: references/export-site.md in the \
|
||||
papercraft skill. Returns {dir, framework, render, styling, pages, files, bytes, \
|
||||
written, removed, links: {resolved, unresolved}, urls, next_steps, warnings}.",
|
||||
json!({
|
||||
"path": {"type": "string", "description": "Folder to write into (created if missing)."},
|
||||
"frames": {"type": "array", "items": {"type": "string"}, "description": "Frame ids, in page order. Default: the page's screens (set_screens), else its top-level frames."},
|
||||
"modes": {"type": "object", "properties": {
|
||||
"collection": {"type": "string", "description": "Variable collection name or id."},
|
||||
"modes": {"type": "array", "items": {"type": "string"}, "description": "Mode names to export, e.g. [\"en\", \"pt-BR\"]."},
|
||||
}, "required": ["collection", "modes"]},
|
||||
"base_url": {"type": "string", "description": "Where the site will be served (https://example.com): absolute hreflang and canonical URLs, and sitemap.xml."},
|
||||
"framework": {"type": "string", "enum": ["html", "react", "vue", "angular", "astro"], "description": "Output: html (static pages, no build step), or a project for react, vue, angular or astro. Ask the person; default html."},
|
||||
"render": {"type": "string", "enum": ["static", "spa"], "description": "static: pages pre-rendered for search (landing, marketing, docs). spa: client-rendered app (behind a login). Only for react, vue and angular. Ask the person; default static."},
|
||||
"styling": {"type": "string", "enum": ["css", "scss", "tailwind", "vanilla-extract", "styled-components", "emotion"], "description": "css (default, no build step), scss, tailwind, or CSS-in-JS for react only: vanilla-extract (recommended, zero runtime), styled-components, emotion. Ask the person; use what their project uses."},
|
||||
"lang": {"type": "string", "description": "Language tag of a single-language site (en, pt-BR): sets <html lang> and og:locale. With `modes`, a mode named like a language tag sets it per folder."},
|
||||
"title": {"type": ["string", "object"], "additionalProperties": {"type": "string"}, "description": "Site title: the home page title and the suffix of the others. Default: the frame names. With `modes`, an object keyed by mode name ({\"en\": \"...\", \"pt-BR\": \"...\"}) gives each language its own; a language without an entry takes the base language's."},
|
||||
"description": {"type": ["string", "object"], "additionalProperties": {"type": "string"}, "description": "Meta description for every page. Default: each page's first line of copy. With `modes`, an object keyed by mode name gives each language its own; a language without an entry takes the base language's."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["path"],
|
||||
false,
|
||||
),
|
||||
// ------------------------------------------------------------ writing
|
||||
tool(
|
||||
"create_artboard",
|
||||
@@ -750,7 +845,8 @@ pub fn definitions() -> Vec<Value> {
|
||||
placeholder (each reported in warnings). No tables.\n\
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, \
|
||||
letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); \
|
||||
<b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.\n\
|
||||
<b>/<i>/<u>/<s>/<span style> make rich text. font-family applies to a whole text layer: \
|
||||
the first installed family in the list, Inter by default.\n\
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border \
|
||||
(and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, \
|
||||
mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.\n\
|
||||
@@ -771,6 +867,16 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["html", "targetNodeId", "mode"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"import_html",
|
||||
"Open an HTML page exported by a prompt-to-design tool as layers: a .html file or a .zip with its stylesheets and images. <style> blocks and linked stylesheets are inlined (tag, .class and #id selectors; media queries and combinators are reported), images in the zip become assets, and each HTML file is written with write_html into its own new top-level frame on the page (side by side, named after the file). Returns the frames per file, `ignoredStyles` grouped by property and `warnings`.",
|
||||
json!({
|
||||
"path": {"type": "string", "description": "A local .html or .zip file."},
|
||||
"pageId": {"type": "string", "description": "The page for the frames. Default: the active page."},
|
||||
}),
|
||||
&["path"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"create_shape",
|
||||
"Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, star, \
|
||||
@@ -932,6 +1038,16 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_strings",
|
||||
"The page text for translation, with no styles or geometry. Returns `strings` (text layers bound to a string variable, by variable name, with the value per mode name), `unbound` (layer id -> text of text layers bound to nothing; set_strings {extract} binds them), `missing` (per mode name, the keys with no value of their own, which show the base text) and `collections`. `missing` set to a mode name returns only {mode, base, missing: {key: base text}}, so a translation is done in steps. Scope: `frame` (a layer id; default every layer of the page), `pageId`; `collection` keeps one collection.",
|
||||
json!({"frame": {"type": "string", "description": "Layer id to read inside. Omitted = the whole page."},
|
||||
"collection": {"type": "string", "description": "Collection name or id to keep."},
|
||||
"missing": {"type": "string", "description": "A mode name: only the keys that mode lacks, with their base text."},
|
||||
"pageId": page_id()}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"set_variables",
|
||||
"Define or change variables (names like `surface`, `accent-text`, or grouped \
|
||||
@@ -985,9 +1101,9 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"set_variable_mode",
|
||||
"Switch the active variable mode (defined with set_variables {mode}): every variable \
|
||||
takes that mode's value and all fills following them change, on every page. One call \
|
||||
turns a whole design from light to dark.",
|
||||
"Switch the active variable mode (defined with set_variables {mode}, in any \
|
||||
collection): that collection's variables take the mode's value and everything bound \
|
||||
to them changes, on every page. One call turns a whole design from light to dark.",
|
||||
json!({"mode": {"type": "string"}}),
|
||||
&["mode"],
|
||||
false,
|
||||
@@ -1017,8 +1133,9 @@ pub fn definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"get_components",
|
||||
"Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), \
|
||||
`pageId`, `pageName`, size, and `uses` (instances across all pages).",
|
||||
json!({"pageId": page_id()}),
|
||||
`pageId`, `pageName`, size, and `uses` (instances across all pages). Reports \
|
||||
`estimatedTokens`; over `maxTokens` the list is cut (`truncated`).",
|
||||
json!({"pageId": page_id(), "maxTokens": max_tokens()}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
@@ -1139,13 +1256,21 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"set_prototype_links",
|
||||
"Prototype links for Present mode: clicking a layer (a button, a row, a tab) goes to a \
|
||||
screen on the same page. `to: null` removes a link. Link every entry point you design \
|
||||
(back arrows, list rows, tabs) so the flow can be clicked through.",
|
||||
"Prototype links for Present mode: what clicking a layer (a button, a row, a tab) does. Per \
|
||||
link give at most one of: `to` (go to a screen, a frame on the same page), `overlay` \
|
||||
(open a frame centred over the current one with a dimmed backdrop; one taller than the \
|
||||
viewport scrolls; backdrop click, Escape or a `close` link closes it), `url` (https, \
|
||||
http, mailto or tel only) or `action` (`back` to the previous screen, `close` to close \
|
||||
the overlay). None, or `to: null`, removes the link. All links are checked before any \
|
||||
is applied; targets on other pages are refused. Link every entry point you design \
|
||||
(back arrows, list rows, tabs, modal buttons) so the flow can be clicked through.",
|
||||
json!({
|
||||
"links": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"nodeId": {"type": "string"},
|
||||
"to": {"type": ["string", "null"], "description": "The screen (a frame on the same page)."},
|
||||
"overlay": {"type": "string", "description": "A frame on the same page to open as an overlay."},
|
||||
"url": {"type": "string", "description": "External address: https, http, mailto or tel."},
|
||||
"action": {"type": "string", "enum": ["back", "close"], "description": "back: previous screen. close: close the overlay."},
|
||||
}, "required": ["nodeId"]}},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
@@ -1231,6 +1356,20 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["nodeIds", "field"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_strings",
|
||||
"Translate a page in one undoable call; languages are modes of a string collection, never copies. Does not switch the active mode (set_variable_mode does). One of: (a) `extract` true with `frame` (default the page) and `collection` (default `Strings`, created with a base mode named `mode`, default `Default`): every unbound text layer gets a string variable named from its layer path (`hero/title`, repeats numbered), holding its text in the base mode, and is bound to it; returns the keys with their texts. (b) `collection`, `mode` and `strings` {key: text}: sets those values, creating the mode if missing; an unknown key, a non-string variable or an unknown collection is an error that lists the offenders and changes nothing. (c) `collection`, `mode` and `pseudo` true: fills the mode with the base text accented, about 35% longer and in brackets, to show overflow before a real translation. Bound text re-fits when the mode is switched, so run lint_design and get_screenshot per mode.",
|
||||
json!({"extract": {"type": "boolean"},
|
||||
"frame": {"type": "string", "description": "extract: layer id to read inside. Omitted = the whole page."},
|
||||
"collection": {"type": "string", "description": "Collection name or id."},
|
||||
"mode": {"type": "string", "description": "Mode name (the language); extract: the base mode's name when the collection is created."},
|
||||
"strings": {"type": "object", "propertyNames": {"type": "string"}, "additionalProperties": {"type": "string"},
|
||||
"description": "{key: text}, keys as returned by get_strings."},
|
||||
"pseudo": {"type": "boolean"},
|
||||
"pageId": page_id()}),
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"detach_instance",
|
||||
"Detach one or more component instances into plain frames, breaking their link to \
|
||||
@@ -1439,18 +1578,18 @@ pub fn edits_doc(name: &str, args: &Value) -> bool {
|
||||
/// `None` for a tool with no class, so a tool added without one fails the test below.
|
||||
fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
Some(match name {
|
||||
"list_resources" | "get_basic_info" | "get_inventory" | "get_selection" | "get_node_info" | "get_children"
|
||||
"list_resources" | "get_basic_info" | "get_inventory" | "get_selection" | "get_context" | "get_node_info" | "get_children"
|
||||
| "get_bounds" | "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_strings" | "get_components" | "search_icons"
|
||||
| "diff_frames" | "publish_library" | "list_libraries" | "get_changes" | "critique_design" | "design_lessons" | "claim_nodes" | "release_claims" | "list_claims" => false,
|
||||
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_presentation" | "export_design_system" | "open_file"
|
||||
| "create_file" | "rename_file" | "finish_working_on_nodes" | "mark_handoff" | "list_handoffs" | "get_handoff_changes" | "export_handoff" => false,
|
||||
"install_font" | "select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_presentation" | "export_design_system" | "open_file"
|
||||
| "create_file" | "rename_file" | "finish_working_on_nodes" | "mark_handoff" | "list_handoffs" | "get_handoff_changes" | "export_handoff" | "export_site" => false,
|
||||
"lint_design" => args["fix"] == true,
|
||||
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
|
||||
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html" | "import_html"
|
||||
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
|
||||
| "annotate" | "set_annotation_template" | "upsert_annotation_template" | "delete_annotation_template" | "update_annotation" | "delete_annotations" | "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
|
||||
| "create_collection" | "delete_variables" | "bind_variable"
|
||||
| "create_collection" | "delete_variables" | "bind_variable" | "set_strings"
|
||||
| "enable_library" | "update_library" | "disable_library"
|
||||
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
|
||||
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
|
||||
|
||||
+141
-1
@@ -21,7 +21,82 @@ const CHILD_LISTS: [&str; 2] = ["children", "root"];
|
||||
pub fn merge(base: &Value, local: &Value, remote: &Value) -> Value {
|
||||
let mut remote = remote.clone();
|
||||
renumber_clashes(base, local, &mut remote);
|
||||
merge_value(Some(base), Some(local), Some(&remote)).unwrap_or(Value::Null)
|
||||
let mut merged = merge_value(Some(base), Some(local), Some(&remote)).unwrap_or(Value::Null);
|
||||
repair_tree(&mut merged);
|
||||
merged
|
||||
}
|
||||
|
||||
/// Child lists merge as sets, so a node moved to different parents on the two sides ends up in
|
||||
/// both lists, and a node added inside a deleted frame points at nothing. Each node's `parent`
|
||||
/// field is the truth: lists keep only their own children, and orphans move to the page root
|
||||
/// so the other side's work is not lost.
|
||||
fn repair_tree(page: &mut Value) {
|
||||
let Some(nodes) = page.get(NODES).and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
let ids: BTreeSet<u64> = nodes.iter().filter_map(|n| n.get("id")?.as_u64()).collect();
|
||||
let parent_of = |n: &Value| n.get("parent").and_then(Value::as_u64);
|
||||
// A parent that is gone, or a missing parent field, puts the node at the root.
|
||||
let mut parents: BTreeMap<u64, Option<u64>> = nodes
|
||||
.iter()
|
||||
.filter_map(|n| Some((n.get("id")?.as_u64()?, parent_of(n).filter(|p| ids.contains(p)))))
|
||||
.collect();
|
||||
// Two sides can parent each other's nodes into a loop cut off from the root: the nodes on
|
||||
// a loop go to the root, so both sides' layers stay reachable.
|
||||
for start in parents.keys().copied().collect::<Vec<_>>() {
|
||||
let mut path = vec![start];
|
||||
while let Some(Some(p)) = parents.get(path.last().unwrap_or(&start)).copied() {
|
||||
if let Some(at) = path.iter().position(|n| *n == p) {
|
||||
for n in path.split_off(at) {
|
||||
parents.insert(n, None);
|
||||
}
|
||||
break;
|
||||
}
|
||||
path.push(p);
|
||||
}
|
||||
}
|
||||
let listed = |v: Option<&Value>, owner: Option<u64>| -> Vec<u64> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let kept = v.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64);
|
||||
kept.filter(|id| parents.get(id) == Some(&owner) && seen.insert(*id)).collect()
|
||||
};
|
||||
let mut lists: BTreeMap<Option<u64>, Vec<u64>> = BTreeMap::new();
|
||||
lists.insert(None, listed(page.get("root"), None));
|
||||
for n in nodes {
|
||||
if let Some(id) = n.get("id").and_then(Value::as_u64) {
|
||||
lists.insert(Some(id), listed(n.get("children"), Some(id)));
|
||||
}
|
||||
}
|
||||
for (id, parent) in &parents {
|
||||
let list = lists.entry(*parent).or_default();
|
||||
if !list.contains(id) {
|
||||
list.push(*id);
|
||||
}
|
||||
}
|
||||
let Some(nodes) = page.get_mut(NODES).and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for n in nodes {
|
||||
let Some(id) = n.get("id").and_then(Value::as_u64) else {
|
||||
continue;
|
||||
};
|
||||
let Some(obj) = n.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
match parents.get(&id) {
|
||||
Some(Some(p)) => obj.insert("parent".into(), Value::from(*p)),
|
||||
_ => obj.remove("parent"),
|
||||
};
|
||||
let children = &lists[&Some(id)];
|
||||
if children.is_empty() {
|
||||
obj.remove("children");
|
||||
} else {
|
||||
obj.insert("children".into(), Value::from(children.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(root) = page.as_object_mut() {
|
||||
root.insert("root".into(), Value::from(lists[&None].clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_value(b: Option<&Value>, l: Option<&Value>, r: Option<&Value>) -> Option<Value> {
|
||||
@@ -255,6 +330,51 @@ mod tests {
|
||||
assert_eq!(m["root"], json!([1]));
|
||||
}
|
||||
|
||||
fn tree(nodes: Value, root: Value) -> Value {
|
||||
page(nodes, root, 10)
|
||||
}
|
||||
|
||||
fn children(m: &Value, id: u64) -> Value {
|
||||
let n = m["nodes"].as_array().unwrap().iter().find(|n| n["id"] == id).unwrap();
|
||||
n.get("children").cloned().unwrap_or(json!([]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_moved_into_different_frames_stays_in_one_parent() {
|
||||
let base = tree(
|
||||
json!([{"id": 1, "children": []}, {"id": 2, "children": []}, {"id": 3}]),
|
||||
json!([1, 2, 3]),
|
||||
);
|
||||
let local = tree(
|
||||
json!([{"id": 1, "children": [3]}, {"id": 2, "children": []}, {"id": 3, "parent": 1}]),
|
||||
json!([1, 2]),
|
||||
);
|
||||
let remote = tree(
|
||||
json!([{"id": 1, "children": []}, {"id": 2, "children": [3]}, {"id": 3, "parent": 2}]),
|
||||
json!([1, 2]),
|
||||
);
|
||||
let m = merge(&base, &local, &remote);
|
||||
assert_eq!(m["nodes"][2]["parent"], 1, "local wins the clash");
|
||||
assert_eq!(children(&m, 1), json!([3]));
|
||||
assert_eq!(children(&m, 2), json!([]), "the other frame no longer lists it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_added_in_a_frame_deleted_by_the_other_side_moves_to_the_root() {
|
||||
let base = tree(json!([{"id": 1, "children": []}]), json!([1]));
|
||||
let local = tree(json!([]), json!([]));
|
||||
let remote = tree(
|
||||
json!([{"id": 1, "children": [2]}, {"id": 2, "parent": 1}]),
|
||||
json!([1]),
|
||||
);
|
||||
let m = merge(&base, &local, &remote);
|
||||
let nodes = m["nodes"].as_array().unwrap();
|
||||
assert_eq!(nodes.len(), 1, "the frame stays deleted, the new layer survives");
|
||||
assert_eq!(nodes[0]["id"], 2);
|
||||
assert!(nodes[0].get("parent").is_none());
|
||||
assert_eq!(m["root"], json!([2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_property_changed_on_both_sides_keeps_the_local_value() {
|
||||
let base = page(json!([{"id": 1, "x": 0}]), json!([1]), 2);
|
||||
@@ -262,4 +382,24 @@ mod tests {
|
||||
let remote = page(json!([{"id": 1, "x": 2}]), json!([1]), 2);
|
||||
assert_eq!(merge(&base, &local, &remote)["nodes"][0]["x"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_set_to_each_other_on_the_two_sides_do_not_form_a_loop() {
|
||||
let base = page(json!([{"id": 1}, {"id": 2}]), json!([1, 2]), 3);
|
||||
let local = page(
|
||||
json!([{"id": 1, "parent": 2}, {"id": 2, "children": [1]}]),
|
||||
json!([2]),
|
||||
3,
|
||||
);
|
||||
let remote = page(
|
||||
json!([{"id": 1, "children": [2]}, {"id": 2, "parent": 1}]),
|
||||
json!([1]),
|
||||
3,
|
||||
);
|
||||
let m = merge(&base, &local, &remote);
|
||||
let nodes = m["nodes"].as_array().unwrap();
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert!(nodes.iter().all(|n| n.get("parent").is_none()), "{m}");
|
||||
assert_eq!(m["root"], json!([1, 2]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ impl Document {
|
||||
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = self.nodes.get(&id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
// A file can hold a parent loop: stop when a node repeats.
|
||||
while let Some(p) = cur.filter(|p| !out.contains(p) && *p != id) {
|
||||
out.push(p);
|
||||
cur = self.nodes.get(&p).and_then(|n| n.parent);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user