rhales 0.5.4 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +4 -4
- data/.github/workflows/release-gem.yml +158 -0
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +60 -1
- data/Gemfile.lock +7 -7
- data/README.md +67 -8
- data/examples/token-loader.rue +119 -0
- data/lib/rhales/configuration.rb +16 -0
- data/lib/rhales/core/rue_document.rb +7 -1
- data/lib/rhales/core/template_engine.rb +10 -8
- data/lib/rhales/hydration/hydration_registry.rb +0 -4
- data/lib/rhales/parsers/rue_format_parser.rb +1 -1
- data/lib/rhales/utils/schema_extractor.rb +121 -3
- data/lib/rhales/utils/schema_generator.rb +152 -5
- data/lib/rhales/version.rb +1 -1
- data/lib/tasks/rhales_schema.rake +9 -1
- data/package.json +1 -1
- data/pnpm-lock.yaml +5 -5
- data/rhales.gemspec +3 -6
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 249890f733fb9b88bbabe22bcc327aa5d0422c00ea595e4b67751e335e790feb
|
|
4
|
+
data.tar.gz: 69259f0ab5cc091cc2c4fab33e3eee13859e28ef789e522119e6702f695155ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c424d4d348c48686ddb60fe068d2839627c552d94a03a507b9cf54f1d39d11dc4d18aaefccc8731ec0aa8fb79d72ef18ea838460d0e36f5d69b5575d7d15ac3d
|
|
7
|
+
data.tar.gz: 65139df9054a39981ca7f6fd51818544aca039847d4a1d60521e879c2d542f3ca1f2ca9bbeb030154cc735404882d5ca0ede2fcabe64ef55a8194c622518c963
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -26,7 +26,7 @@ jobs:
|
|
|
26
26
|
strategy:
|
|
27
27
|
fail-fast: true
|
|
28
28
|
matrix:
|
|
29
|
-
ruby: ['3.4', '3.5']
|
|
29
|
+
ruby: ['3.2', '3.3', '3.4', '3.5']
|
|
30
30
|
|
|
31
31
|
steps:
|
|
32
32
|
- uses: actions/checkout@v4
|
|
@@ -50,7 +50,7 @@ jobs:
|
|
|
50
50
|
strategy:
|
|
51
51
|
fail-fast: true
|
|
52
52
|
matrix:
|
|
53
|
-
ruby: ['3.4', '3.5']
|
|
53
|
+
ruby: ['3.2', '3.3', '3.4', '3.5']
|
|
54
54
|
|
|
55
55
|
steps:
|
|
56
56
|
- uses: actions/checkout@v4
|
|
@@ -77,7 +77,7 @@ jobs:
|
|
|
77
77
|
strategy:
|
|
78
78
|
fail-fast: false
|
|
79
79
|
matrix:
|
|
80
|
-
ruby: ['3.4', '3.5']
|
|
80
|
+
ruby: ['3.2', '3.3', '3.4', '3.5']
|
|
81
81
|
|
|
82
82
|
steps:
|
|
83
83
|
- uses: actions/checkout@v4
|
|
@@ -99,7 +99,7 @@ jobs:
|
|
|
99
99
|
strategy:
|
|
100
100
|
fail-fast: false
|
|
101
101
|
matrix:
|
|
102
|
-
ruby: ['3.4', '3.5']
|
|
102
|
+
ruby: ['3.2', '3.3', '3.4', '3.5']
|
|
103
103
|
|
|
104
104
|
steps:
|
|
105
105
|
- uses: actions/checkout@v4
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Release Gem Workflow
|
|
2
|
+
#
|
|
3
|
+
# Automatically builds and publishes the rhales gem to RubyGems.org when a
|
|
4
|
+
# GitHub release is published with a semantic version tag (vMAJOR.MINOR.PATCH,
|
|
5
|
+
# e.g. v0.6.1, v1.2.3).
|
|
6
|
+
#
|
|
7
|
+
# Follows the canonical RubyGems Trusted Publishing workflow:
|
|
8
|
+
# https://guides.rubygems.org/trusted-publishing/
|
|
9
|
+
#
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# SETUP INSTRUCTIONS (one-time)
|
|
12
|
+
# ============================================================================
|
|
13
|
+
#
|
|
14
|
+
# This workflow uses RubyGems Trusted Publishing (OIDC). No long-lived API
|
|
15
|
+
# key needs to be stored in GitHub.
|
|
16
|
+
#
|
|
17
|
+
# ----------------------------------------------------------------------------
|
|
18
|
+
# 1. Configure the trusted publisher on RubyGems.org
|
|
19
|
+
# ----------------------------------------------------------------------------
|
|
20
|
+
#
|
|
21
|
+
# a. Sign in to https://rubygems.org and enable MFA on your account
|
|
22
|
+
# (required for trusted publishing).
|
|
23
|
+
#
|
|
24
|
+
# b. Open the gem's trusted publisher page (works for both existing gems
|
|
25
|
+
# and the first-ever publish):
|
|
26
|
+
# Existing gem: https://rubygems.org/gems/rhales/trusted_publishers
|
|
27
|
+
# First publish: https://rubygems.org/profile/oidc/pending_trusted_publishers/new
|
|
28
|
+
#
|
|
29
|
+
# c. Click "Create" and fill in:
|
|
30
|
+
# Publisher type: GitHub Actions
|
|
31
|
+
# Repository owner: onetimesecret
|
|
32
|
+
# Repository name: rhales
|
|
33
|
+
# Workflow filename: release-gem.yml
|
|
34
|
+
# Environment: rubygems.org (must match `environment.name` below)
|
|
35
|
+
#
|
|
36
|
+
# d. Save. RubyGems will now accept short-lived OIDC tokens issued by this
|
|
37
|
+
# workflow running in this repository.
|
|
38
|
+
#
|
|
39
|
+
# ----------------------------------------------------------------------------
|
|
40
|
+
# 2. Configure the GitHub environment
|
|
41
|
+
# ----------------------------------------------------------------------------
|
|
42
|
+
#
|
|
43
|
+
# a. In GitHub: Settings -> Environments -> New environment
|
|
44
|
+
# Name: `rubygems.org` (must match `environment.name` below)
|
|
45
|
+
#
|
|
46
|
+
# b. Under "Deployment branches and tags", restrict to tags matching:
|
|
47
|
+
# v*.*.*
|
|
48
|
+
# This prevents the environment (and its OIDC token) from being used
|
|
49
|
+
# from any branch or non-release tag.
|
|
50
|
+
#
|
|
51
|
+
# c. Optional but recommended: add required reviewers to gate publishes
|
|
52
|
+
# behind manual approval.
|
|
53
|
+
#
|
|
54
|
+
# No GitHub secrets are required - OIDC handles authentication.
|
|
55
|
+
#
|
|
56
|
+
# ----------------------------------------------------------------------------
|
|
57
|
+
# 3. Cutting a release
|
|
58
|
+
# ----------------------------------------------------------------------------
|
|
59
|
+
#
|
|
60
|
+
# a. Bump Rhales::VERSION in lib/rhales/version.rb (semver: MAJOR.MINOR.PATCH).
|
|
61
|
+
# b. Update CHANGELOG.md and commit on main.
|
|
62
|
+
# c. On GitHub, draft a Release with tag `vX.Y.Z` (matching the constant)
|
|
63
|
+
# and publish it. This workflow runs automatically: it verifies the tag
|
|
64
|
+
# matches the gemspec version, builds the gem, and pushes it.
|
|
65
|
+
#
|
|
66
|
+
# ----------------------------------------------------------------------------
|
|
67
|
+
# Fallback: API key (only if Trusted Publishing isn't an option)
|
|
68
|
+
# ----------------------------------------------------------------------------
|
|
69
|
+
#
|
|
70
|
+
# 1. Create an API key at https://rubygems.org/profile/api_keys with scope
|
|
71
|
+
# "Push rubygem" restricted to the `rhales` gem.
|
|
72
|
+
# 2. Store it as a repo secret named `RUBYGEMS_API_KEY`.
|
|
73
|
+
# 3. Drop the `id-token: write` permission and `environment:` block, and
|
|
74
|
+
# replace the `rubygems/release-gem` step with:
|
|
75
|
+
#
|
|
76
|
+
# - name: Publish to RubyGems
|
|
77
|
+
# env:
|
|
78
|
+
# GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
79
|
+
# run: gem push rhales-*.gem
|
|
80
|
+
#
|
|
81
|
+
# ----------------------------------------------------------------------------
|
|
82
|
+
# Action provenance (SHA pins)
|
|
83
|
+
# ----------------------------------------------------------------------------
|
|
84
|
+
#
|
|
85
|
+
# All third-party actions below are pinned to a full commit SHA, per GitHub's
|
|
86
|
+
# secure-use guidance for release-critical workflows. The accompanying
|
|
87
|
+
# comment records the tag the SHA was resolved from so renovate/dependabot
|
|
88
|
+
# can keep them current.
|
|
89
|
+
#
|
|
90
|
+
# actions/checkout - official GitHub action
|
|
91
|
+
# ruby/setup-ruby - official Ruby org action (GitHub-verified creator)
|
|
92
|
+
# rubygems/release-gem - official RubyGems action for trusted publishing
|
|
93
|
+
#
|
|
94
|
+
# ============================================================================
|
|
95
|
+
|
|
96
|
+
name: Release Gem
|
|
97
|
+
|
|
98
|
+
on:
|
|
99
|
+
release:
|
|
100
|
+
types: [published]
|
|
101
|
+
|
|
102
|
+
# Coarse default. Each job re-declares the narrowest permissions it needs.
|
|
103
|
+
permissions:
|
|
104
|
+
contents: read
|
|
105
|
+
|
|
106
|
+
# Don't allow two release runs to race - one published tag, one publish.
|
|
107
|
+
concurrency:
|
|
108
|
+
group: release-gem-${{ github.event.release.tag_name }}
|
|
109
|
+
cancel-in-progress: false
|
|
110
|
+
|
|
111
|
+
jobs:
|
|
112
|
+
release:
|
|
113
|
+
name: Build and push gem
|
|
114
|
+
runs-on: ubuntu-latest
|
|
115
|
+
timeout-minutes: 10
|
|
116
|
+
|
|
117
|
+
environment:
|
|
118
|
+
name: rubygems.org
|
|
119
|
+
url: https://rubygems.org/gems/rhales
|
|
120
|
+
|
|
121
|
+
permissions:
|
|
122
|
+
contents: write # rubygems/release-gem attaches built .gem to the GitHub release
|
|
123
|
+
id-token: write # OIDC token for RubyGems Trusted Publishing
|
|
124
|
+
|
|
125
|
+
steps:
|
|
126
|
+
- name: Checkout
|
|
127
|
+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
128
|
+
with:
|
|
129
|
+
persist-credentials: false
|
|
130
|
+
|
|
131
|
+
- name: Set up Ruby
|
|
132
|
+
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
|
133
|
+
with:
|
|
134
|
+
bundler-cache: true
|
|
135
|
+
ruby-version: ruby # latest stable Ruby installed by setup-ruby
|
|
136
|
+
|
|
137
|
+
- name: Verify release tag matches Rhales::VERSION
|
|
138
|
+
env:
|
|
139
|
+
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
140
|
+
run: |
|
|
141
|
+
set -euo pipefail
|
|
142
|
+
case "${RELEASE_TAG}" in
|
|
143
|
+
v[0-9]*.[0-9]*.[0-9]*) ;;
|
|
144
|
+
*)
|
|
145
|
+
echo "Release tag '${RELEASE_TAG}' is not a vMAJOR.MINOR.PATCH semver tag." >&2
|
|
146
|
+
exit 1
|
|
147
|
+
;;
|
|
148
|
+
esac
|
|
149
|
+
tag_version="${RELEASE_TAG#v}"
|
|
150
|
+
gem_version="$(ruby -r ./lib/rhales/version.rb -e 'print Rhales::VERSION')"
|
|
151
|
+
if [ "${tag_version}" != "${gem_version}" ]; then
|
|
152
|
+
echo "Release tag ${RELEASE_TAG} (${tag_version}) != Rhales::VERSION (${gem_version})." >&2
|
|
153
|
+
exit 1
|
|
154
|
+
fi
|
|
155
|
+
echo "Releasing rhales ${gem_version} from tag ${RELEASE_TAG}"
|
|
156
|
+
|
|
157
|
+
- name: Build and push gem to RubyGems
|
|
158
|
+
uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.2] - 2026-05-25
|
|
11
|
+
|
|
10
12
|
### Added
|
|
13
|
+
- **Automated gem release workflow** (`.github/workflows/release-gem.yml`):
|
|
14
|
+
builds and publishes the gem to RubyGems.org via Trusted Publishing
|
|
15
|
+
(OIDC) whenever a GitHub Release is published with a `vMAJOR.MINOR.PATCH`
|
|
16
|
+
tag. Verifies the tag matches `Rhales::VERSION` before pushing.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Dependencies**: bumped `unicode-emoji` from 4.0.4 to 4.2.0 and
|
|
20
|
+
`unicode-display_width` from 3.1.4 to 3.2.0. The previous
|
|
21
|
+
`unicode-emoji` cap of `< Ruby 4.0` broke `bundle install` on Ruby 4.x;
|
|
22
|
+
4.2.0 lifts that cap.
|
|
23
|
+
- **Gemfile.lock**: platform list normalized via
|
|
24
|
+
`bundle lock --normalize-platforms` so platform-specific gems no longer
|
|
25
|
+
trigger setup-ruby warnings on CI.
|
|
26
|
+
|
|
27
|
+
## [0.6.1] - 2026-05-25
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Server-rendered random tokens example** (`examples/token-loader.rue`):
|
|
31
|
+
pattern-teaching example showing how to generate per-request data with
|
|
32
|
+
`SecureRandom.hex` in a Ruby view model, validate a nested
|
|
33
|
+
`z.array(z.object(...))` shape, and walk it with nested `{{#each}}` blocks
|
|
34
|
+
that fall through to the current outer item without explicit bindings. The
|
|
35
|
+
original loader-animation use case is documented as a CSS-only follow-on in
|
|
36
|
+
the `<logic>` block. (#49)
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **Minimum Ruby version lowered from 3.4 to 3.2** - broader compatibility with stable Ruby releases; CI now exercises 3.2, 3.3, 3.4, and 3.5
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- `{{#each}}` block variable `@last` now correctly returns `true` for the final
|
|
43
|
+
iteration instead of always `false`. Enables comma-separated output via
|
|
44
|
+
`{{#unless @last}},{{/unless}}` and similar patterns. `EachContext` now
|
|
45
|
+
accepts and stores the collection's total length.
|
|
46
|
+
|
|
47
|
+
## [0.6.0] - 2026-03-21
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- **External Schema References**: Schema definitions can now reference external TypeScript/JavaScript files via the `src` attribute
|
|
51
|
+
- Enables single-source-of-truth patterns where TypeScript schemas drive both frontend types and Rhales validation
|
|
52
|
+
- Path resolution relative to template file with security checks to prevent path traversal
|
|
53
|
+
- Rake task output now shows inline vs external schema sources
|
|
54
|
+
- Example: `<schema src="schemas/user.schema.ts" lang="js-zod" window="__USER__">`
|
|
55
|
+
- **Multi-directory Schema Search**: New `schema_search_paths` configuration option
|
|
56
|
+
- Allows searching multiple directories for external schema files
|
|
57
|
+
- Resolution order: template-relative first, then search paths in order
|
|
58
|
+
- Security checks apply to all configured paths
|
|
59
|
+
- **tsx Import Mode**: New bundling mode for external schemas with imports
|
|
60
|
+
- `schema_use_tsx_import = true` enables esbuild bundling
|
|
61
|
+
- `schema_tsconfig_path` allows custom TypeScript configuration
|
|
62
|
+
- Externalizes zod to prevent dual-instance issues
|
|
63
|
+
- Cross-platform file:// URL support for Windows ESM compatibility
|
|
11
64
|
- **Production Logging**: Structured logging via `Rhales.logger=` for security auditing and debugging
|
|
12
65
|
- View rendering events with template details, timing, and hydration size
|
|
13
66
|
- Schema validation warnings for production debugging (missing/extra keys)
|
|
@@ -23,16 +76,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
76
|
- Comprehensive test coverage for collision detection and merge strategies
|
|
24
77
|
|
|
25
78
|
### Changed
|
|
79
|
+
- **Ruby 3.4+ required** (was 3.3.4) - aligns with current LTS ecosystem; no Ruby 3.3 features relied upon, but 3.4 is recommended for YJIT improvements and json_schemer performance
|
|
80
|
+
- Updated zod to 4.3.6
|
|
81
|
+
- Relaxed json_schemer dependency from ~> 2.3 to ~> 2
|
|
26
82
|
- Replaced `json-schema` gem with `json_schemer` for better JSON Schema Draft 2020-12 support
|
|
27
83
|
- Improved validation error messages with more structured output from json_schemer
|
|
28
84
|
- Validation performance improved to <0.05ms average (was ~2ms with json-schema)
|
|
29
85
|
|
|
86
|
+
### Removed
|
|
87
|
+
- Unused `HydrationRegistry.clear!` method
|
|
88
|
+
|
|
30
89
|
### Security
|
|
31
90
|
- Window collision detection prevents accidental data exposure by making overwrites explicit
|
|
32
91
|
- All merge operations happen client-side after server-side interpolation and JSON serialization
|
|
33
92
|
- Request-scoped registry prevents cross-request data leakage
|
|
34
93
|
|
|
35
|
-
## [0.1.0] -
|
|
94
|
+
## [0.1.0] - 2025-07-21
|
|
36
95
|
|
|
37
96
|
### Added
|
|
38
97
|
- Initial release of Rhales
|
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
rhales (0.
|
|
5
|
-
json_schemer (~> 2
|
|
4
|
+
rhales (0.6.2)
|
|
5
|
+
json_schemer (~> 2)
|
|
6
6
|
logger
|
|
7
7
|
tilt (~> 2)
|
|
8
8
|
|
|
@@ -153,14 +153,14 @@ GEM
|
|
|
153
153
|
prettier_print (>= 1.2.0)
|
|
154
154
|
tilt (2.6.1)
|
|
155
155
|
tsort (0.2.0)
|
|
156
|
-
unicode-display_width (3.
|
|
157
|
-
unicode-emoji (~> 4.
|
|
158
|
-
unicode-emoji (4.0
|
|
156
|
+
unicode-display_width (3.2.0)
|
|
157
|
+
unicode-emoji (~> 4.1)
|
|
158
|
+
unicode-emoji (4.2.0)
|
|
159
159
|
yard (0.9.37)
|
|
160
160
|
zeitwerk (2.7.3)
|
|
161
161
|
|
|
162
162
|
PLATFORMS
|
|
163
|
-
arm64-darwin
|
|
163
|
+
arm64-darwin
|
|
164
164
|
ruby
|
|
165
165
|
|
|
166
166
|
DEPENDENCIES
|
|
@@ -186,4 +186,4 @@ DEPENDENCIES
|
|
|
186
186
|
yard (~> 0.9)
|
|
187
187
|
|
|
188
188
|
BUNDLED WITH
|
|
189
|
-
2.
|
|
189
|
+
2.7.2
|
data/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# Rhales - Ruby Single File Components
|
|
2
2
|
|
|
3
3
|
> [!CAUTION]
|
|
4
|
-
> **Early Development Release** - Rhales is in active development (v0.
|
|
4
|
+
> **Early Development Release** - Rhales is in active development (v0.6). The API underwent breaking changes from v0.4. While functional and tested, it's recommended for experimental use and contributions. Please report issues and provide feedback through GitHub.
|
|
5
5
|
|
|
6
6
|
Rhales is a **type-safe contract enforcement framework** for server-rendered pages with client-side data hydration. It uses `.rue` files (Ruby Single File Components) that combine Zod v4 schemas, Handlebars templates, and documentation into a single contract-first format.
|
|
7
7
|
|
|
8
8
|
**About the name:** It all started with a simple mustache template many years ago. Mustache's successor, "Handlebars," is a visual analog for a mustache. "Two Whales Kissing" is another visual analog for a mustache, and since we're working with Ruby, we call it "Rhales" (Ruby + Whales). It's a perfect name with absolutely no ambiguity or risk of confusion.
|
|
9
9
|
|
|
10
|
-
## What's New in v0.
|
|
10
|
+
## What's New in v0.6
|
|
11
11
|
|
|
12
|
-
- ✅ **Schema
|
|
13
|
-
- ✅ **
|
|
14
|
-
- ✅ **
|
|
15
|
-
- ✅ **
|
|
16
|
-
|
|
17
|
-
-
|
|
12
|
+
- ✅ **External Schema References**: Reference TypeScript schema files via `src` attribute for single-source-of-truth patterns
|
|
13
|
+
- ✅ **Multi-directory Search**: Configure `schema_search_paths` to search multiple directories for shared schemas
|
|
14
|
+
- ✅ **tsx Import Mode**: Bundle external schemas with imports via esbuild (`schema_use_tsx_import`)
|
|
15
|
+
- ✅ **Ruby 3.2+ Supported**: Minimum Ruby version
|
|
16
|
+
|
|
17
|
+
**v0.5 features:** Schema-first design, type safety, simplified API, clear context layers, schema tooling.
|
|
18
18
|
|
|
19
19
|
**Breaking changes from v0.4:** See [Migration Guide](#migration-from-v04-to-v05) below.
|
|
20
20
|
|
|
@@ -134,6 +134,65 @@ const schema = z.object({
|
|
|
134
134
|
| `version` | No | Schema version | `"2"` |
|
|
135
135
|
| `envelope` | No | Response wrapper type | `"SuccessEnvelope"` |
|
|
136
136
|
| `layout` | No | Layout template reference | `"layouts/main"` |
|
|
137
|
+
| `src` | No | External schema file path | `"schemas/user.schema.ts"` |
|
|
138
|
+
|
|
139
|
+
### External Schema References
|
|
140
|
+
|
|
141
|
+
Instead of defining schemas inline, you can reference external TypeScript/JavaScript files. This enables single-source-of-truth patterns where the same schema file drives both frontend TypeScript types (via `z.infer<>`) and Rhales validation.
|
|
142
|
+
|
|
143
|
+
```xml
|
|
144
|
+
<!-- templates/dashboard.rue -->
|
|
145
|
+
<schema src="schemas/dashboard.schema.ts" lang="js-zod" window="__DASHBOARD__">
|
|
146
|
+
</schema>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<div>{{user.name}}</div>
|
|
150
|
+
</template>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// templates/schemas/dashboard.schema.ts
|
|
155
|
+
import { z } from 'zod';
|
|
156
|
+
|
|
157
|
+
const schema = z.object({
|
|
158
|
+
user: z.object({
|
|
159
|
+
name: z.string(),
|
|
160
|
+
email: z.string().email()
|
|
161
|
+
}),
|
|
162
|
+
items: z.array(z.string())
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export default schema;
|
|
166
|
+
|
|
167
|
+
// TypeScript frontend can import and use: z.infer<typeof schema>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The `src` path is resolved relative to the template file. Security checks prevent path traversal outside the templates directory.
|
|
171
|
+
|
|
172
|
+
#### Multi-directory Search
|
|
173
|
+
|
|
174
|
+
Configure additional directories to search for shared schema files:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
Rhales.configure do |config|
|
|
178
|
+
config.schema_search_paths = ['./shared/schemas', './lib/schemas']
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Resolution order: template-relative first, then search paths in order.
|
|
183
|
+
|
|
184
|
+
#### tsx Import Mode
|
|
185
|
+
|
|
186
|
+
For external schemas that import other modules, enable esbuild bundling:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
Rhales.configure do |config|
|
|
190
|
+
config.schema_use_tsx_import = true
|
|
191
|
+
config.schema_tsconfig_path = './tsconfig.json' # optional
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This bundles the schema with all its imports while externalizing zod to prevent dual-instance issues.
|
|
137
196
|
|
|
138
197
|
### Zod Schema Examples
|
|
139
198
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!-- examples/token-loader.rue -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Example: Server-Rendered Random Tokens with Nested Iteration
|
|
5
|
+
|
|
6
|
+
Demonstrates four rhales patterns in one small template:
|
|
7
|
+
|
|
8
|
+
1. Generating per-request random data on the server (SecureRandom.hex)
|
|
9
|
+
and passing it to the template via the client: context, instead of
|
|
10
|
+
relying on client JavaScript to fill the page in.
|
|
11
|
+
|
|
12
|
+
2. Validating a nested-array-of-objects shape with
|
|
13
|
+
z.array(z.object(...)) in the <schema> block.
|
|
14
|
+
|
|
15
|
+
3. Iterating with {{#each}} over a top-level array.
|
|
16
|
+
|
|
17
|
+
4. Iterating again inside the outer block to walk a nested array.
|
|
18
|
+
The inner {{#each}} resolves its variable name against the current
|
|
19
|
+
outer item first, so no explicit binding (`as |cell|`) or path
|
|
20
|
+
prefix (`this.glyphs`) is needed.
|
|
21
|
+
|
|
22
|
+
Each render produces a different output. The most common use case is
|
|
23
|
+
a pre-boot loader animated with CSS (see the "Use as a loader" note
|
|
24
|
+
below), but the same shape applies anywhere you want server-generated
|
|
25
|
+
per-request decorative data: identicons, correlation IDs displayed on
|
|
26
|
+
error pages, example IDs in onboarding flows, etc.
|
|
27
|
+
-->
|
|
28
|
+
|
|
29
|
+
<schema lang="js-zod" window="loaderTokens">
|
|
30
|
+
const schema = z.object({
|
|
31
|
+
cells: z.array(z.object({
|
|
32
|
+
glyphs: z.array(z.string().length(1)).length(17)
|
|
33
|
+
})).length(5)
|
|
34
|
+
});
|
|
35
|
+
</schema>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<style nonce="{{nonce}}">
|
|
39
|
+
.tokens {
|
|
40
|
+
display: inline-flex;
|
|
41
|
+
gap: .3rem;
|
|
42
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
43
|
+
letter-spacing: 0.05em;
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
46
|
+
|
|
47
|
+
<div class="tokens">
|
|
48
|
+
{{#each cells}}<span class="cell">{{#each glyphs}}{{.}}{{/each}}</span>{{/each}}
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<logic>
|
|
53
|
+
# Server-Rendered Random Tokens
|
|
54
|
+
#
|
|
55
|
+
# Backend Usage (Ruby):
|
|
56
|
+
#
|
|
57
|
+
# require 'securerandom'
|
|
58
|
+
#
|
|
59
|
+
# cells = Array.new(5) {
|
|
60
|
+
# { glyphs: Array.new(17) { SecureRandom.hex(1)[0] } }
|
|
61
|
+
# }
|
|
62
|
+
#
|
|
63
|
+
# view = Rhales::View.new(
|
|
64
|
+
# request,
|
|
65
|
+
# client: { cells: cells },
|
|
66
|
+
# server: { pageTitle: 'Loading...' }
|
|
67
|
+
# )
|
|
68
|
+
#
|
|
69
|
+
# html = view.render('token-loader')
|
|
70
|
+
#
|
|
71
|
+
# Why these patterns:
|
|
72
|
+
#
|
|
73
|
+
# 1. Randomness lives in the view model, not the template. Rhales
|
|
74
|
+
# templates don't expose Random.new or SecureRandom directly --
|
|
75
|
+
# generate the data in Ruby and hand it to the template via the
|
|
76
|
+
# client: context. Templates stay pure-render and the data flow
|
|
77
|
+
# is explicit.
|
|
78
|
+
#
|
|
79
|
+
# 2. CSPRNG over Kernel#rand. Even for decorative output, SecureRandom
|
|
80
|
+
# signals intent and avoids questions later about why a templating
|
|
81
|
+
# layer is pulling from a seeded RNG.
|
|
82
|
+
#
|
|
83
|
+
# 3. Fresh data per cell, not a shared pool. Generating 5 x 17 = 85
|
|
84
|
+
# fresh glyphs (vs. 17 glyphs shuffled five ways) avoids visible
|
|
85
|
+
# rhyming between adjacent columns.
|
|
86
|
+
#
|
|
87
|
+
# 4. Nested {{#each}} resolves against the current outer item. Inside
|
|
88
|
+
# {{#each cells}}, the inner {{#each glyphs}} looks up "glyphs" on
|
|
89
|
+
# each cell hash automatically -- no need to write {{#each
|
|
90
|
+
# this.glyphs}} or pass an explicit binding. See
|
|
91
|
+
# lib/rhales/core/template_engine.rb EachContext#get for the
|
|
92
|
+
# fallthrough order (current item -> parent context).
|
|
93
|
+
#
|
|
94
|
+
# 5. Cache layer caveat. If a fragment cache wraps the response, the
|
|
95
|
+
# "fresh on every render" guarantee becomes "fresh per cache miss."
|
|
96
|
+
# Worst case is the same tokens until the cache TTL expires, which
|
|
97
|
+
# matches the behavior of a fully static template. For anything
|
|
98
|
+
# where per-request uniqueness actually matters, exclude this block
|
|
99
|
+
# from the cache.
|
|
100
|
+
#
|
|
101
|
+
# Use as a loader:
|
|
102
|
+
#
|
|
103
|
+
# The original motivation for this template was a pre-boot SPA loader
|
|
104
|
+
# that "looks like" something is being computed. To wire that up, wrap
|
|
105
|
+
# the output in role="status" with a visually-hidden label so screen
|
|
106
|
+
# readers announce it correctly, and add CSS that scrolls each cell's
|
|
107
|
+
# glyphs vertically before locking onto a final character. The 17
|
|
108
|
+
# glyphs per cell are sized for a 16-step CSS animation with one
|
|
109
|
+
# resting frame:
|
|
110
|
+
#
|
|
111
|
+
# <div class="tokens" role="status" aria-label="Loading">
|
|
112
|
+
# ... iteration ...
|
|
113
|
+
# <span class="visually-hidden">Loading</span>
|
|
114
|
+
# </div>
|
|
115
|
+
#
|
|
116
|
+
# The animation itself is just CSS keyframes on the iterated spans and
|
|
117
|
+
# is independent of rhales -- the rhales-specific work ends at
|
|
118
|
+
# rendering the markup with fresh random characters per request.
|
|
119
|
+
</logic>
|
data/lib/rhales/configuration.rb
CHANGED
|
@@ -157,6 +157,9 @@ module Rhales
|
|
|
157
157
|
# Hydration mismatch reporting settings
|
|
158
158
|
attr_accessor :hydration_mismatch_format, :hydration_authority
|
|
159
159
|
|
|
160
|
+
# External schema settings
|
|
161
|
+
attr_accessor :schema_search_paths, :schema_tsconfig_path, :schema_use_tsx_import
|
|
162
|
+
|
|
160
163
|
def initialize
|
|
161
164
|
# Set sensible defaults
|
|
162
165
|
@default_locale = 'en'
|
|
@@ -191,6 +194,11 @@ module Rhales
|
|
|
191
194
|
@hydration_mismatch_format = :compact # :compact, :multiline, :sidebyside, :json
|
|
192
195
|
@hydration_authority = :schema # :schema or :data
|
|
193
196
|
|
|
197
|
+
# External schema defaults
|
|
198
|
+
@schema_search_paths = [] # Additional paths to search for external schemas
|
|
199
|
+
@schema_tsconfig_path = nil # Path to tsconfig.json for tsx import execution
|
|
200
|
+
@schema_use_tsx_import = false # Use tsx import (runs through project tsconfig)
|
|
201
|
+
|
|
194
202
|
# Yield to block for configuration if provided
|
|
195
203
|
yield(self) if block_given?
|
|
196
204
|
end
|
|
@@ -257,6 +265,13 @@ module Rhales
|
|
|
257
265
|
end
|
|
258
266
|
end
|
|
259
267
|
|
|
268
|
+
# Validate schema search paths exist if specified
|
|
269
|
+
@schema_search_paths.each do |path|
|
|
270
|
+
unless Dir.exist?(path)
|
|
271
|
+
errors << "Schema search path does not exist: #{path}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
260
275
|
# Validate cache TTL
|
|
261
276
|
if @cache_ttl && @cache_ttl <= 0
|
|
262
277
|
errors << 'cache_ttl must be positive'
|
|
@@ -269,6 +284,7 @@ module Rhales
|
|
|
269
284
|
def freeze!
|
|
270
285
|
@features.freeze
|
|
271
286
|
@template_paths.freeze
|
|
287
|
+
@schema_search_paths.freeze
|
|
272
288
|
freeze
|
|
273
289
|
end
|
|
274
290
|
|
|
@@ -39,7 +39,7 @@ module Rhales
|
|
|
39
39
|
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
|
40
40
|
|
|
41
41
|
# Known schema section attributes
|
|
42
|
-
KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends].freeze
|
|
42
|
+
KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends src].freeze
|
|
43
43
|
|
|
44
44
|
attr_reader :content, :file_path, :grammar, :ast
|
|
45
45
|
|
|
@@ -151,6 +151,12 @@ module Rhales
|
|
|
151
151
|
schema_attributes['extends']
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# External schema file reference (optional)
|
|
155
|
+
# When present, schema code is loaded from this path instead of inline content
|
|
156
|
+
def schema_src
|
|
157
|
+
schema_attributes['src']
|
|
158
|
+
end
|
|
159
|
+
|
|
154
160
|
def section?(name)
|
|
155
161
|
@grammar.sections.key?(name)
|
|
156
162
|
end
|
|
@@ -202,9 +202,11 @@ module Rhales
|
|
|
202
202
|
items = get_variable_value(items_var)
|
|
203
203
|
|
|
204
204
|
if items.respond_to?(:each)
|
|
205
|
-
items.
|
|
205
|
+
items_array = items.to_a
|
|
206
|
+
total = items_array.size
|
|
207
|
+
items_array.map.with_index do |item, index|
|
|
206
208
|
# Create context for each iteration
|
|
207
|
-
item_context = create_each_context(item, index, items_var)
|
|
209
|
+
item_context = create_each_context(item, index, items_var, total)
|
|
208
210
|
engine = self.class.new('', item_context, partial_resolver: @partial_resolver)
|
|
209
211
|
engine.send(:render_content_nodes, block_content)
|
|
210
212
|
end.join
|
|
@@ -312,8 +314,8 @@ module Rhales
|
|
|
312
314
|
end
|
|
313
315
|
|
|
314
316
|
# Create context for each iteration
|
|
315
|
-
def create_each_context(item, index, items_var)
|
|
316
|
-
EachContext.new(@context, item, index, items_var)
|
|
317
|
+
def create_each_context(item, index, items_var, total = nil)
|
|
318
|
+
EachContext.new(@context, item, index, items_var, total)
|
|
317
319
|
end
|
|
318
320
|
|
|
319
321
|
# HTML escape for XSS protection
|
|
@@ -323,13 +325,14 @@ module Rhales
|
|
|
323
325
|
|
|
324
326
|
# Context wrapper for {{#each}} iterations
|
|
325
327
|
class EachContext
|
|
326
|
-
attr_reader :parent_context, :current_item, :current_index, :items_var
|
|
328
|
+
attr_reader :parent_context, :current_item, :current_index, :items_var, :total
|
|
327
329
|
|
|
328
|
-
def initialize(parent_context, current_item, current_index, items_var)
|
|
330
|
+
def initialize(parent_context, current_item, current_index, items_var, total = nil)
|
|
329
331
|
@parent_context = parent_context
|
|
330
332
|
@current_item = current_item
|
|
331
333
|
@current_index = current_index
|
|
332
334
|
@items_var = items_var
|
|
335
|
+
@total = total
|
|
333
336
|
end
|
|
334
337
|
|
|
335
338
|
def get(variable_name)
|
|
@@ -342,8 +345,7 @@ module Rhales
|
|
|
342
345
|
when '@first'
|
|
343
346
|
return @current_index == 0
|
|
344
347
|
when '@last'
|
|
345
|
-
|
|
346
|
-
return false
|
|
348
|
+
return @total ? @current_index == @total - 1 : false
|
|
347
349
|
end
|
|
348
350
|
|
|
349
351
|
# Check if it's a property of the current item
|
|
@@ -343,7 +343,7 @@ module Rhales
|
|
|
343
343
|
end
|
|
344
344
|
|
|
345
345
|
# Preprocess content to strip XML/HTML comments outside of sections
|
|
346
|
-
# Uses Ruby 3.
|
|
346
|
+
# Uses Ruby 3.2+ pattern matching for robust, secure parsing
|
|
347
347
|
def preprocess_content(content)
|
|
348
348
|
tokens = tokenize_content(content)
|
|
349
349
|
|
|
@@ -71,7 +71,18 @@ module Rhales
|
|
|
71
71
|
return nil unless doc.section?('schema')
|
|
72
72
|
|
|
73
73
|
template_name = derive_template_name(file_path)
|
|
74
|
-
|
|
74
|
+
src = doc.schema_src
|
|
75
|
+
resolved_path = nil
|
|
76
|
+
schema_code = nil
|
|
77
|
+
|
|
78
|
+
if src
|
|
79
|
+
# External schema: resolve path and read content
|
|
80
|
+
resolved_path = resolve_schema_src_path(file_path, src)
|
|
81
|
+
schema_code = read_schema_from_src(resolved_path, src, template_name)
|
|
82
|
+
else
|
|
83
|
+
# Inline schema: use content from the schema section
|
|
84
|
+
schema_code = doc.section('schema')
|
|
85
|
+
end
|
|
75
86
|
|
|
76
87
|
{
|
|
77
88
|
template_name: template_name,
|
|
@@ -83,7 +94,9 @@ module Rhales
|
|
|
83
94
|
window: doc.schema_window,
|
|
84
95
|
merge: doc.schema_merge_strategy,
|
|
85
96
|
layout: doc.schema_layout,
|
|
86
|
-
extends: doc.schema_extends
|
|
97
|
+
extends: doc.schema_extends,
|
|
98
|
+
src: src,
|
|
99
|
+
resolved_path: resolved_path
|
|
87
100
|
}
|
|
88
101
|
end
|
|
89
102
|
|
|
@@ -97,15 +110,20 @@ module Rhales
|
|
|
97
110
|
|
|
98
111
|
# Count how many .rue files have schema sections
|
|
99
112
|
#
|
|
100
|
-
# @return [Hash] Count information
|
|
113
|
+
# @return [Hash] Count information including external vs inline breakdown
|
|
101
114
|
def schema_stats
|
|
102
115
|
all_files = find_rue_files
|
|
103
116
|
schemas = extract_all
|
|
104
117
|
|
|
118
|
+
external_count = schemas.count { |s| s[:src] }
|
|
119
|
+
inline_count = schemas.count { |s| s[:src].nil? }
|
|
120
|
+
|
|
105
121
|
{
|
|
106
122
|
total_files: all_files.count,
|
|
107
123
|
files_with_schemas: schemas.count,
|
|
108
124
|
files_without_schemas: all_files.count - schemas.count,
|
|
125
|
+
external_schemas: external_count,
|
|
126
|
+
inline_schemas: inline_count,
|
|
109
127
|
schemas_by_lang: schemas.group_by { |s| s[:lang] }.transform_values(&:count)
|
|
110
128
|
}
|
|
111
129
|
end
|
|
@@ -128,5 +146,105 @@ module Rhales
|
|
|
128
146
|
relative_path = file_pathname.relative_path_from(templates_pathname)
|
|
129
147
|
relative_path.to_s.sub(/\.rue$/, '')
|
|
130
148
|
end
|
|
149
|
+
|
|
150
|
+
# Resolve external schema src path
|
|
151
|
+
#
|
|
152
|
+
# Resolution order:
|
|
153
|
+
# 1. Relative to template file directory
|
|
154
|
+
# 2. Search through configured schema_search_paths
|
|
155
|
+
#
|
|
156
|
+
# @param template_path [String] Absolute path to the .rue template
|
|
157
|
+
# @param src [String] The src attribute value from the schema tag
|
|
158
|
+
# @return [String] Absolute path to the external schema file
|
|
159
|
+
# @raise [ExtractionError] If path traversal is detected or file not found
|
|
160
|
+
def resolve_schema_src_path(template_path, src)
|
|
161
|
+
template_dir = File.dirname(template_path)
|
|
162
|
+
resolved = File.expand_path(src, template_dir)
|
|
163
|
+
searched_paths = [resolved]
|
|
164
|
+
|
|
165
|
+
# First, check if the path exists relative to template
|
|
166
|
+
if File.exist?(resolved) && path_within_allowed_directories?(resolved)
|
|
167
|
+
return resolved
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# If the relative path does not exist or is not allowed,
|
|
171
|
+
# search through configured schema_search_paths
|
|
172
|
+
search_paths = Rhales.configuration.schema_search_paths || []
|
|
173
|
+
search_paths.each do |search_path|
|
|
174
|
+
expanded_search_path = File.expand_path(search_path)
|
|
175
|
+
candidate = File.join(expanded_search_path, src)
|
|
176
|
+
searched_paths << candidate
|
|
177
|
+
|
|
178
|
+
if File.exist?(candidate) && path_within_allowed_directories?(candidate)
|
|
179
|
+
return candidate
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Security check on the template-relative path
|
|
184
|
+
unless path_within_allowed_directories?(resolved)
|
|
185
|
+
raise ExtractionError,
|
|
186
|
+
"Schema src path traversal not allowed: '#{src}' resolves outside allowed directories"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# File not found in any location - raise helpful error listing all searched paths
|
|
190
|
+
raise ExtractionError,
|
|
191
|
+
"Schema file not found: '#{src}'. Searched:\n - #{searched_paths.join("\n - ")}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check if a path is within any allowed directory
|
|
195
|
+
#
|
|
196
|
+
# Allowed directories include:
|
|
197
|
+
# - The templates directory
|
|
198
|
+
# - Any configured schema_search_paths
|
|
199
|
+
#
|
|
200
|
+
# @param path [String] Path to check
|
|
201
|
+
# @return [Boolean] True if path is within an allowed directory
|
|
202
|
+
def path_within_allowed_directories?(path)
|
|
203
|
+
return true if path_within_directory?(path, @templates_dir)
|
|
204
|
+
|
|
205
|
+
search_paths = Rhales.configuration.schema_search_paths || []
|
|
206
|
+
search_paths.any? do |search_path|
|
|
207
|
+
expanded_search_path = File.expand_path(search_path)
|
|
208
|
+
path_within_directory?(path, expanded_search_path)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Read schema content from external file
|
|
213
|
+
#
|
|
214
|
+
# @param resolved_path [String] Absolute path to the schema file
|
|
215
|
+
# @param src [String] Original src attribute value (for error messages)
|
|
216
|
+
# @param template_name [String] Template name (for error messages)
|
|
217
|
+
# @return [String] Schema file content
|
|
218
|
+
# @raise [ExtractionError] If file cannot be read
|
|
219
|
+
def read_schema_from_src(resolved_path, src, template_name)
|
|
220
|
+
unless File.exist?(resolved_path)
|
|
221
|
+
raise ExtractionError,
|
|
222
|
+
"External schema file not found: '#{src}' (resolved to: #{resolved_path}) " \
|
|
223
|
+
"referenced by template '#{template_name}'"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
File.read(resolved_path)
|
|
227
|
+
rescue Errno::EACCES => e
|
|
228
|
+
raise ExtractionError,
|
|
229
|
+
"Permission denied reading external schema '#{src}': #{e.message}"
|
|
230
|
+
rescue Errno::EISDIR
|
|
231
|
+
raise ExtractionError,
|
|
232
|
+
"External schema path '#{src}' is a directory, not a file"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Check if a path is within a given directory (security check)
|
|
236
|
+
#
|
|
237
|
+
# @param path [String] Path to check
|
|
238
|
+
# @param directory [String] Directory that should contain the path
|
|
239
|
+
# @return [Boolean] True if path is within directory
|
|
240
|
+
def path_within_directory?(path, directory)
|
|
241
|
+
expanded_path = File.expand_path(path)
|
|
242
|
+
expanded_dir = File.expand_path(directory)
|
|
243
|
+
|
|
244
|
+
# Ensure directory ends with separator for accurate prefix matching
|
|
245
|
+
expanded_dir_with_sep = expanded_dir.end_with?(File::SEPARATOR) ? expanded_dir : "#{expanded_dir}#{File::SEPARATOR}"
|
|
246
|
+
|
|
247
|
+
expanded_path.start_with?(expanded_dir_with_sep) || expanded_path == expanded_dir
|
|
248
|
+
end
|
|
131
249
|
end
|
|
132
250
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'open3'
|
|
6
|
+
require 'securerandom'
|
|
6
7
|
require 'tempfile'
|
|
7
8
|
require 'fileutils'
|
|
8
9
|
require_relative 'schema_extractor'
|
|
@@ -76,7 +77,8 @@ module Rhales
|
|
|
76
77
|
rescue => e
|
|
77
78
|
results[:failed] += 1
|
|
78
79
|
results[:success] = false
|
|
79
|
-
|
|
80
|
+
source_info = schema_info[:src] ? " (from #{schema_info[:src]})" : ""
|
|
81
|
+
error_msg = "Failed to generate schema for #{schema_info[:template_name]}#{source_info}: #{e.message}"
|
|
80
82
|
results[:errors] << error_msg
|
|
81
83
|
warn error_msg
|
|
82
84
|
end
|
|
@@ -95,14 +97,20 @@ module Rhales
|
|
|
95
97
|
FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
|
|
96
98
|
|
|
97
99
|
temp_file = Tempfile.new(['schema', '.mts'], temp_dir)
|
|
100
|
+
bundled_file = nil
|
|
98
101
|
|
|
99
102
|
begin
|
|
100
|
-
# Write TypeScript script
|
|
101
|
-
|
|
103
|
+
# Write TypeScript script - use import mode for external schemas when configured
|
|
104
|
+
if use_tsx_import_mode?(schema_info)
|
|
105
|
+
script, bundled_file = build_typescript_import_script(schema_info)
|
|
106
|
+
else
|
|
107
|
+
script = build_typescript_script(schema_info)
|
|
108
|
+
end
|
|
109
|
+
temp_file.write(script)
|
|
102
110
|
temp_file.close
|
|
103
111
|
|
|
104
|
-
# Execute with tsx via pnpm
|
|
105
|
-
stdout, stderr, status =
|
|
112
|
+
# Execute with tsx via pnpm, optionally with tsconfig
|
|
113
|
+
stdout, stderr, status = execute_tsx(temp_file.path)
|
|
106
114
|
|
|
107
115
|
unless status.success?
|
|
108
116
|
raise GenerationError, "TypeScript execution failed: #{stderr}"
|
|
@@ -117,17 +125,132 @@ module Rhales
|
|
|
117
125
|
json_schema
|
|
118
126
|
ensure
|
|
119
127
|
temp_file.unlink if temp_file
|
|
128
|
+
File.unlink(bundled_file) if bundled_file && File.exist?(bundled_file)
|
|
120
129
|
end
|
|
121
130
|
end
|
|
122
131
|
|
|
123
132
|
private
|
|
124
133
|
|
|
134
|
+
# Determine if we should use tsx import mode for this schema
|
|
135
|
+
#
|
|
136
|
+
# Import mode is used when:
|
|
137
|
+
# 1. schema_use_tsx_import is enabled in configuration
|
|
138
|
+
# 2. The schema has an external src (not inline)
|
|
139
|
+
# 3. The resolved_path exists
|
|
140
|
+
#
|
|
141
|
+
# @param schema_info [Hash] Schema information
|
|
142
|
+
# @return [Boolean]
|
|
143
|
+
def use_tsx_import_mode?(schema_info)
|
|
144
|
+
return false unless Rhales.configuration.schema_use_tsx_import
|
|
145
|
+
return false unless schema_info[:src]
|
|
146
|
+
return false unless schema_info[:resolved_path]
|
|
147
|
+
|
|
148
|
+
File.exist?(schema_info[:resolved_path])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Execute tsx with optional tsconfig
|
|
152
|
+
#
|
|
153
|
+
# @param script_path [String] Path to the TypeScript script to execute
|
|
154
|
+
# @return [Array] stdout, stderr, status from Open3.capture3
|
|
155
|
+
def execute_tsx(script_path)
|
|
156
|
+
tsconfig_path = Rhales.configuration.schema_tsconfig_path
|
|
157
|
+
|
|
158
|
+
if tsconfig_path
|
|
159
|
+
if File.exist?(tsconfig_path)
|
|
160
|
+
Open3.capture3('pnpm', 'exec', 'tsx', '--tsconfig', tsconfig_path, script_path)
|
|
161
|
+
else
|
|
162
|
+
warn "[Rhales] Warning: schema_tsconfig_path '#{tsconfig_path}' does not exist, ignoring"
|
|
163
|
+
Open3.capture3('pnpm', 'exec', 'tsx', script_path)
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
Open3.capture3('pnpm', 'exec', 'tsx', script_path)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Build TypeScript script from bundled external schema
|
|
171
|
+
#
|
|
172
|
+
# Uses esbuild to bundle the external schema with all imports resolved,
|
|
173
|
+
# writes to a temp file, then imports via default export. This allows
|
|
174
|
+
# external files to name their schema variable anything they want.
|
|
175
|
+
#
|
|
176
|
+
# External schema files must use default export:
|
|
177
|
+
# const mySchema = z.object({ ... });
|
|
178
|
+
# export default mySchema;
|
|
179
|
+
#
|
|
180
|
+
# @param schema_info [Hash] Schema information with resolved_path
|
|
181
|
+
# @return [Array<String, String>] [script content, bundled_file_path] - caller must clean up bundled file
|
|
182
|
+
def build_typescript_import_script(schema_info)
|
|
183
|
+
safe_name = schema_info[:template_name].gsub("'", "\\'")
|
|
184
|
+
schema_path = schema_info[:resolved_path]
|
|
185
|
+
|
|
186
|
+
# Bundle external schema with esbuild to temp file - resolves all imports
|
|
187
|
+
# Use SecureRandom for uniqueness across concurrent invocations
|
|
188
|
+
temp_dir = File.join(Dir.pwd, 'tmp')
|
|
189
|
+
FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
|
|
190
|
+
unique_suffix = "#{Process.pid}_#{SecureRandom.hex(4)}"
|
|
191
|
+
bundled_file = File.join(temp_dir, "bundled_#{File.basename(schema_path, '.*')}_#{unique_suffix}.mjs")
|
|
192
|
+
|
|
193
|
+
stdout, stderr, status = Open3.capture3(
|
|
194
|
+
'pnpm', 'exec', 'esbuild', schema_path,
|
|
195
|
+
'--bundle', '--format=esm', '--platform=node',
|
|
196
|
+
'--external:zod',
|
|
197
|
+
"--outfile=#{bundled_file}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
unless status.success?
|
|
201
|
+
raise GenerationError, "esbuild bundling failed for #{schema_path}: #{stderr}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Convert to file:// URL for cross-platform ESM import compatibility
|
|
205
|
+
bundled_file_url = path_to_file_url(bundled_file)
|
|
206
|
+
|
|
207
|
+
script = <<~TYPESCRIPT
|
|
208
|
+
// Auto-generated schema generator for #{safe_name}
|
|
209
|
+
// Source: #{schema_info[:src]} (bundled via esbuild)
|
|
210
|
+
import { z } from 'zod/v4';
|
|
211
|
+
import schema from '#{bundled_file_url}';
|
|
212
|
+
|
|
213
|
+
// Generate JSON Schema
|
|
214
|
+
try {
|
|
215
|
+
const jsonSchema = z.toJSONSchema(schema, {
|
|
216
|
+
target: 'draft-2020-12',
|
|
217
|
+
unrepresentable: 'any',
|
|
218
|
+
cycles: 'ref',
|
|
219
|
+
reused: 'inline',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Add metadata
|
|
223
|
+
const schemaWithMeta = {
|
|
224
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
225
|
+
$id: `https://rhales.dev/schemas/#{safe_name}.json`,
|
|
226
|
+
title: '#{safe_name}',
|
|
227
|
+
description: 'Schema for #{safe_name} template',
|
|
228
|
+
...jsonSchema,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Output JSON to stdout
|
|
232
|
+
console.log(JSON.stringify(schemaWithMeta, null, 2));
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error('Schema generation error:', error.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
TYPESCRIPT
|
|
238
|
+
|
|
239
|
+
[script, bundled_file]
|
|
240
|
+
end
|
|
241
|
+
|
|
125
242
|
def build_typescript_script(schema_info)
|
|
126
243
|
# Escape single quotes in template name for TypeScript string
|
|
127
244
|
safe_name = schema_info[:template_name].gsub("'", "\\'")
|
|
245
|
+
source_comment = if schema_info[:src]
|
|
246
|
+
"// Source: #{schema_info[:src]} (external)"
|
|
247
|
+
else
|
|
248
|
+
"// Source: inline schema"
|
|
249
|
+
end
|
|
128
250
|
|
|
129
251
|
<<~TYPESCRIPT
|
|
130
252
|
// Auto-generated schema generator for #{safe_name}
|
|
253
|
+
#{source_comment}
|
|
131
254
|
import { z } from 'zod/v4';
|
|
132
255
|
|
|
133
256
|
// Schema code from .rue template
|
|
@@ -185,10 +308,34 @@ module Rhales
|
|
|
185
308
|
unless status.success?
|
|
186
309
|
raise GenerationError, "tsx not found. Run: pnpm install tsx --save-dev"
|
|
187
310
|
end
|
|
311
|
+
|
|
312
|
+
# Check esbuild is available when tsx import mode is enabled
|
|
313
|
+
if Rhales.configuration.schema_use_tsx_import
|
|
314
|
+
stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'esbuild', '--version')
|
|
315
|
+
unless status.success?
|
|
316
|
+
raise GenerationError, "esbuild not found (required for external schema bundling). Run: pnpm install esbuild --save-dev"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
188
319
|
end
|
|
189
320
|
|
|
190
321
|
def ensure_output_directory!
|
|
191
322
|
FileUtils.mkdir_p(@output_dir) unless File.directory?(@output_dir)
|
|
192
323
|
end
|
|
324
|
+
|
|
325
|
+
# Convert absolute path to file:// URL for cross-platform ESM imports
|
|
326
|
+
#
|
|
327
|
+
# On Windows, paths like C:\foo\bar need to become file:///C:/foo/bar
|
|
328
|
+
# On Unix, paths like /foo/bar become file:///foo/bar
|
|
329
|
+
#
|
|
330
|
+
# @param path [String] Absolute file path
|
|
331
|
+
# @return [String] file:// URL
|
|
332
|
+
def path_to_file_url(path)
|
|
333
|
+
normalized = path.tr('\\', '/')
|
|
334
|
+
if normalized.match?(%r{^[A-Za-z]:}) # Windows drive letter
|
|
335
|
+
"file:///#{normalized}"
|
|
336
|
+
else
|
|
337
|
+
"file://#{normalized}"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
193
340
|
end
|
|
194
341
|
end
|
data/lib/rhales/version.rb
CHANGED
|
@@ -45,7 +45,8 @@ namespace :rhales do
|
|
|
45
45
|
|
|
46
46
|
puts "Found #{schemas.size} schema section(s):"
|
|
47
47
|
schemas.each do |schema|
|
|
48
|
-
|
|
48
|
+
source_type = schema[:src] ? "external: #{schema[:src]}" : "inline"
|
|
49
|
+
puts " - #{schema[:template_name]} (#{schema[:lang]}, #{source_type})"
|
|
49
50
|
end
|
|
50
51
|
puts
|
|
51
52
|
|
|
@@ -186,6 +187,13 @@ namespace :rhales do
|
|
|
186
187
|
puts "Files without <schema>: #{stats[:files_without_schemas]}"
|
|
187
188
|
puts
|
|
188
189
|
|
|
190
|
+
if stats[:files_with_schemas] > 0
|
|
191
|
+
puts "Schema sources:"
|
|
192
|
+
puts " External (src attribute): #{stats[:external_schemas]}"
|
|
193
|
+
puts " Inline: #{stats[:inline_schemas]}"
|
|
194
|
+
puts
|
|
195
|
+
end
|
|
196
|
+
|
|
189
197
|
if stats[:schemas_by_lang].any?
|
|
190
198
|
puts "By language:"
|
|
191
199
|
stats[:schemas_by_lang].each do |lang, count|
|
data/package.json
CHANGED
data/pnpm-lock.yaml
CHANGED
|
@@ -9,8 +9,8 @@ importers:
|
|
|
9
9
|
.:
|
|
10
10
|
dependencies:
|
|
11
11
|
zod:
|
|
12
|
-
specifier: ^4.
|
|
13
|
-
version: 4.
|
|
12
|
+
specifier: ^4.3.6
|
|
13
|
+
version: 4.3.6
|
|
14
14
|
devDependencies:
|
|
15
15
|
'@prettier/plugin-ruby':
|
|
16
16
|
specifier: ^4.0.4
|
|
@@ -208,8 +208,8 @@ packages:
|
|
|
208
208
|
engines: {node: '>=18.0.0'}
|
|
209
209
|
hasBin: true
|
|
210
210
|
|
|
211
|
-
zod@4.
|
|
212
|
-
resolution: {integrity: sha512-
|
|
211
|
+
zod@4.3.6:
|
|
212
|
+
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
|
213
213
|
|
|
214
214
|
snapshots:
|
|
215
215
|
|
|
@@ -342,4 +342,4 @@ snapshots:
|
|
|
342
342
|
optionalDependencies:
|
|
343
343
|
fsevents: 2.3.3
|
|
344
344
|
|
|
345
|
-
zod@4.
|
|
345
|
+
zod@4.3.6: {}
|
data/rhales.gemspec
CHANGED
|
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
|
|
|
21
21
|
|
|
22
22
|
spec.homepage = 'https://github.com/onetimesecret/rhales'
|
|
23
23
|
spec.license = 'MIT'
|
|
24
|
-
spec.required_ruby_version = '>= 3.
|
|
24
|
+
spec.required_ruby_version = '>= 3.2'
|
|
25
25
|
|
|
26
26
|
spec.metadata['source_code_uri'] = 'https://github.com/onetimesecret/rhales'
|
|
27
27
|
spec.metadata['changelog_uri'] = 'https://github.com/onetimesecret/rhales/blob/main/CHANGELOG.md'
|
|
@@ -41,14 +41,11 @@ Gem::Specification.new do |spec|
|
|
|
41
41
|
spec.require_paths = ['lib']
|
|
42
42
|
|
|
43
43
|
# Runtime dependencies
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
spec.add_dependency 'json_schemer', '~> 2' # JSON Schema validation in middleware
|
|
45
46
|
spec.add_dependency 'logger' # Standard library logger for logging support
|
|
46
47
|
spec.add_dependency 'tilt', '~> 2' # Templating engine for rendering RSFCs
|
|
47
48
|
|
|
48
|
-
# Optional dependencies for performance optimization
|
|
49
|
-
# Install oj for 10-20x faster JSON parsing and 5-10x faster generation
|
|
50
|
-
# spec.add_dependency 'oj', '~> 3.13'
|
|
51
|
-
|
|
52
49
|
# Development dependencies should be specified in Gemfile instead of gemspec
|
|
53
50
|
# See: https://bundler.io/guides/creating_gem.html#testing-our-gem
|
|
54
51
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rhales
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- delano
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2
|
|
18
|
+
version: '2'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '2
|
|
25
|
+
version: '2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: logger
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- ".github/workflows/claude-code-review.yml"
|
|
71
71
|
- ".github/workflows/claude.yml"
|
|
72
72
|
- ".github/workflows/code-smells.yml"
|
|
73
|
+
- ".github/workflows/release-gem.yml"
|
|
73
74
|
- ".github/workflows/ruby-lint.yml"
|
|
74
75
|
- ".github/workflows/yardoc.yml"
|
|
75
76
|
- ".gitignore"
|
|
@@ -120,6 +121,7 @@ files:
|
|
|
120
121
|
- examples/dashboard-with-charts.rue
|
|
121
122
|
- examples/form-with-validation.rue
|
|
122
123
|
- examples/simple-page.rue
|
|
124
|
+
- examples/token-loader.rue
|
|
123
125
|
- examples/vue.rue
|
|
124
126
|
- generate-json-schemas.ts
|
|
125
127
|
- json_schemer_migration_summary.md
|
|
@@ -189,14 +191,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
189
191
|
requirements:
|
|
190
192
|
- - ">="
|
|
191
193
|
- !ruby/object:Gem::Version
|
|
192
|
-
version: 3.
|
|
194
|
+
version: '3.2'
|
|
193
195
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
194
196
|
requirements:
|
|
195
197
|
- - ">="
|
|
196
198
|
- !ruby/object:Gem::Version
|
|
197
199
|
version: '0'
|
|
198
200
|
requirements: []
|
|
199
|
-
rubygems_version:
|
|
201
|
+
rubygems_version: 4.0.10
|
|
200
202
|
specification_version: 4
|
|
201
203
|
summary: Rhales - Server-rendered components with client-side hydration (RSFCs)
|
|
202
204
|
test_files: []
|