diff --git a/.gitignore b/.gitignore index 4e49596..8b098d7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ .nrepl-port README.html target + +# Specs UI (generated by /specs ui) +specs/index.html +specs/_sidebar.md +specs/.nojekyll diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..194d4a4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `element` - lookup element metadata by keyword tag (e.g., `:ns/button`) +- `element-tags` - list all registered element keywords +- `elements` - list elements with optional namespace filtering (symbol or regex) +- `search-elements` - search elements by documentation text (string or regex) + +### Removed + +- **BREAKING:** `describe` function (use `element` instead) +- **BREAKING:** `attributes` function (use `(:attributes (element :ns/tag))` instead) + +### Migration + +The new API uses keyword tags instead of symbols, matching how elements are used in hiccup markup: + +```clojure +;; Old (removed) +(yeah/describe 'my-button) +(yeah/attributes 'my-button) + +;; New +(yeah/element :my-ns/my-button) +(:attributes (yeah/element :my-ns/my-button)) +``` + +## [0.1.0] - 2024-12-19 + +### Added + +- Initial release +- `defelem` macro for defining Chassis alias elements with malli schemas +- `children` macro for placeholder in element definitions +- Attribute options via `html.yeah.attrs/option` multimethod +- Schema properties via `html.yeah/property` multimethod +- Support for `let`, `when-let`, `when-some`, `if-let`, `if-some` binding hoisting +- Child schema support via `::html.yeah/children` property +- Integration with `malli.instrument` and `malli.dev` + +[Unreleased]: https://github.com/brianium/html.yeah/compare/0.1.0...HEAD +[0.1.0]: https://github.com/brianium/html.yeah/releases/tag/0.1.0 diff --git a/README.md b/README.md index 33f1255..7df5c9b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ schema. Schema syntax follows [malli vector syntax](https://github.com/metosin/m (children)]) ;;; Inspect it brother -(ya/attributes 'daisy-button) ; => get the attached malli schema +(:attributes (ya/element ::daisy-button)) ; => get the attached malli schema ;;; Render it brother (c/html [daisy-button {:color :neutral} "Html Yeah Brother"]) @@ -51,7 +51,8 @@ See [dev.clj](dev/src/dev.clj) or [yeah_test.clj](test/html/yeah_test.clj) for m - [Why?](#why) - [The defelem macro](#the-defelem-macro) - [Everything is compiled](#everything-is-compiled) - - [Schemas](#schemas) + - [Schemas](#schemas) +- [Discoverability API](#discoverability-api) - [Extending defelem](#extending-defelem) - [Attribute options](#attribute-options) - [Properties](#properties) @@ -92,17 +93,20 @@ Destructuring forms are stripped from schema metadata. *Note:* These bindings cannot be used in the schema itself, just the element body. -Given an element's symbol, you can inspect its attribute schema from the REPL: +Given an element's keyword tag, you can inspect its metadata from the REPL: ``` clojure -(yeah/attributes 'simple-button) ; => [:map [:type [:enum :submit :button]]] -``` - -You can look at *ALL* metadata for the element via `describe`: - -``` clojure -(yeah/describe 'simple-button) -; => {:html.yeah/attributes [map...], :html.yeah/children [:* :any]} +(yeah/element :dev/simple-button) +; => {:tag :dev/simple-button +; :attributes [:map [:type [:enum :submit :button]]] +; :children [:* :any] +; :doc "A simple button with a schema for type" +; :var #'dev/simple-button +; :ns 'dev} + +;; Get just the attribute schema +(:attributes (yeah/element :dev/simple-button)) +; => [:map [:type [:enum :submit :button]]] ``` If you evaluate the var you will get the namespace qualified tag used by Chassis: @@ -212,6 +216,53 @@ The shape of `(children)` mirrors whatever content is passed to the Chassis alia This ensures render functions are picked up by `malli.instrument` and `malli.dev`. +## Discoverability API + +`html.yeah` provides functions to discover and inspect elements at runtime using keyword tags (the same keywords used in hiccup markup). + +### `element` - Lookup by keyword + +``` clojure +(yeah/element :dev/simple-button) +; => {:tag :dev/simple-button +; :attributes [:map [:type [:enum :submit :button]]] +; :children [:* :any] +; :doc "A simple button with a schema for type" +; :var #'dev/simple-button +; :render-var #'dev/render-simple-button-html +; :ns 'dev} +``` + +### `element-tags` - List all element keywords + +``` clojure +(yeah/element-tags) +; => (:dev/simple-button :dev/daisy-button :ui/alert ...) +``` + +### `elements` - List with optional filtering + +``` clojure +;; All elements +(yeah/elements) + +;; Filter by namespace (symbol) +(yeah/elements {:ns 'dev}) + +;; Filter by namespace (regex) +(yeah/elements {:ns #"ui\..*"}) +``` + +### `search-elements` - Search by documentation + +``` clojure +;; Case-insensitive string search +(yeah/search-elements "button") + +;; Regex search +(yeah/search-elements #"[Bb]utton") +``` + ## Extending defelem `html.yeah` supports extending elements 2 different ways: attribute options and properties. Attribute options are used to affect attribute transformation, and properties affect schema transformation. Both occur at compile time. @@ -307,7 +358,7 @@ We can create userland inheritance with a custom merge property: (defmethod yeah/property ::merge [_ schema element-syms] (m/form - (->> (mapv yeah/attributes element-syms) + (->> (mapv #(:attributes (yeah/element @(resolve %))) element-syms) (reduce (fn [result target] (mu/merge target result)) schema)))) @@ -359,10 +410,10 @@ Attempting to render an element with invalid attributes or children will produce ![malli error](docs/prettyerror.png) -You can use `html.yeah/describe` on an element symbol if you want direct access to the render function for instrumenting purposes. +You can use `html.yeah/element` to get direct access to the render function for instrumenting purposes. ``` clojure -(html.yeah/describe 'daisy-button) ; => {:html.yeah/render dev/render-daisy-button-html} +(:render-var (yeah/element :dev/daisy-button)) ; => #'dev/render-daisy-button-html ``` ## Generating elements @@ -376,9 +427,9 @@ Another cool outcome of having a schema handy is the ability to generate valid s (defn generate "We can make custom fun with the fact that a schema is attached to the element" - [symbol & children] - (when-some [s (yeah/attributes symbol)] - [@(resolve symbol) (mg/generate s) children])) - -(generate 'daisy-button "Click me") ; => [:dev/daisy-button {:color :neutral :size :xl} "Click me"] + [tag & children] + (when-some [{:keys [attributes]} (yeah/element tag)] + [tag (mg/generate attributes) children])) + +(generate :dev/daisy-button "Click me") ; => [:dev/daisy-button {:color :neutral :size :xl} "Click me"] ``` diff --git a/dev/src/dev.clj b/dev/src/dev.clj index 20a2021..c38e33a 100644 --- a/dev/src/dev.clj +++ b/dev/src/dev.clj @@ -85,24 +85,33 @@ (defn generate "We can make custom fun with the fact that a schema is attached to the element" - [symbol & children] - (when-some [s (yeah/attributes symbol)] - [@(resolve symbol) (mg/generate s) children])) + [tag & children] + (when-some [{:keys [attributes]} (yeah/element tag)] + [tag (mg/generate attributes) children])) (comment - ;;; Describe the entire element - (yeah/describe 'daisy-button) + ;;; Get full element metadata + (yeah/element :dev/daisy-button) ;;; Get the attribute schema for an element - (yeah/attributes 'daisy-button) + (:attributes (yeah/element :dev/daisy-button)) + + ;;; List all element tags + (yeah/element-tags) + + ;;; List elements in this namespace + (yeah/elements {:ns 'dev}) + + ;;; Search elements by doc + (yeah/search-elements "button") ;;; Generate a sample element (c/html - (generate 'daisy-button "Click Me")) + (generate :dev/daisy-button "Click Me")) ;;; Render this invalid button and check the error output in the REPL (c/html - [daisy-button {:color :mauve} "Click Me"]) + [:dev/daisy-button {:color :mauve} "Click Me"]) (do "not make me create a new line just to evaluate my comments")) diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 0000000..e22ab72 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,7 @@ +# Specifications + +*Auto-generated on 2026-01-24 20:21* + +## Completed + +- [Better Discoverability API](better-discoverability-api/README.md) diff --git a/specs/better-discoverability-api/README.md b/specs/better-discoverability-api/README.md new file mode 100644 index 0000000..d07069d --- /dev/null +++ b/specs/better-discoverability-api/README.md @@ -0,0 +1,55 @@ +--- +title: "Better Discoverability API" +status: completed +date: 2026-01-24 +priority: 50 +--- + +# Better Discoverability API + +## Overview + +Add a discoverability API to html.yeah that enables: +- Keyword-based metadata lookup (`:ui/alert` -> full schema and metadata) +- Listing all components defined with `defelem` +- Searching/grepping by `:doc` metadata + +This replaces the current symbol-based `describe`/`attributes` functions with a simpler keyword-only API that matches how elements are used in hiccup markup. + +## Goals + +- Look up rich metadata by chassis alias keyword (e.g., `:ui/alert` -> schema info) +- List all `defelem`-defined components with their chassis alias keywords +- Search elements by `:doc` metadata (string or regex) +- Zero new mutable state - leverage existing infrastructure +- Minimal API surface - one lookup function, users destructure what they need +- REPL-friendly - no stale entries on reload + +## Non-Goals + +- Complex indexing or search infrastructure (linear scan is sufficient) +- External persistence (in-memory via Chassis multimethod is sufficient) +- Schema validation during lookup (defer to malli.instrument) +- Backward compatibility with symbol-based `describe`/`attributes` functions + +## Key Decisions + +See `research.md` for detailed analysis. + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Storage approach | Hybrid (Chassis multimethod + var metadata) | Zero new mutable state, no stale entries | +| Enumeration | `(keys (methods c/resolve-alias))` | Chassis already tracks all aliases | +| Lookup | Resolve keyword → var → metadata | `defelem` already attaches metadata | +| Symbol API | Remove entirely | Keywords match hiccup usage; simpler API | +| `describe`/`attributes` | Remove | `element` returns full map; users destructure | +| Namespace filtering | By symbol or regex | Flexible for different use cases | + +## Implementation Status + +See `implementation-plan.md` for detailed task breakdown. + +- [x] Phase 1: Internal helper function (`tag->element`) +- [x] Phase 2: New API functions +- [x] Phase 3: Remove old API functions +- [x] Phase 4: Testing diff --git a/specs/better-discoverability-api/implementation-plan.md b/specs/better-discoverability-api/implementation-plan.md new file mode 100644 index 0000000..ed3f156 --- /dev/null +++ b/specs/better-discoverability-api/implementation-plan.md @@ -0,0 +1,85 @@ +# Better Discoverability API - Implementation Plan + +## Overview + +Add API functions for element discovery using a hybrid approach: enumerate via Chassis multimethod, lookup via var metadata. **No new mutable state required** - leverages existing `defelem` infrastructure. + +## Prerequisites + +- [x] Understand current defelem macro structure (lines 119-228) +- [x] Confirm Chassis multimethod can be enumerated via `(keys (methods c/resolve-alias))` +- [x] Confirm defelem already attaches `::attributes`, `::children`, `:doc` to var metadata +- [x] Design hybrid API approach + +## Phase 1: Internal Helper Function + +Add helper to resolve keyword tag back to var metadata. + +- [x] Add `tag->element` internal function to `src/html/yeah.clj` (around line 105) + +## Phase 2: New API Functions + +Add public functions for element discovery. Replace existing `describe`/`attributes` (lines 105-117). + +- [x] Add `element` function - lookup by keyword +- [x] Add `element-tags` function - list all keywords +- [x] Add `elements` function - list with optional filtering +- [x] Add `search-elements` function - search by doc + +## Phase 3: Remove Old API Functions + +Remove `describe` and `attributes` functions - they're superseded by `element`. + +- [x] Delete `describe` function (lines 105-112) +- [x] Delete `attributes` function (lines 114-117) + +## Phase 4: Testing + +Add tests in `test/html/yeah_test.clj`. + +- [x] Test `element` lookup by keyword +- [x] Test `element` returns nil for unknown keyword +- [x] Test `element` returns nil for keyword without namespace +- [x] Test `element-tags` returns all defined elements +- [x] Test `elements` returns full metadata +- [x] Test `elements` with namespace filter (symbol) +- [x] Test `elements` with namespace filter (regex) +- [x] Test `search-elements` with string pattern +- [x] Test `search-elements` with regex pattern +- [x] Test `search-elements` returns empty for no matches +- [x] Update `::merge` property to use new API + +## Verification + +1. **Run tests**: `clojure -M:dev:test` +2. **REPL verification**: + ```clojure + (require '[html.yeah :as ya]) + (require 'html.yeah-test) ;; load test elements + + ;; Verify keyword lookup + (ya/element :html.yeah-test/simple-button) + (:attributes (ya/element :html.yeah-test/simple-button)) + + ;; Verify listing + (ya/element-tags) + (ya/elements {:ns 'html.yeah-test}) + + ;; Verify search + (ya/search-elements "button") + ``` + +3. **REPL reload test** (verify no stale entries): + ```clojure + ;; Define element, verify it appears + ;; Remove defelem from source, reload namespace + ;; Verify element no longer appears in element-tags + ``` + +## Rollback Plan + +If issues arise: +1. Remove new functions (element, element-tags, elements, search-elements, tag->element) +2. Restore describe/attributes functions from git history + +Note: No defelem changes to revert - this approach doesn't modify the macro. diff --git a/specs/better-discoverability-api/research.md b/specs/better-discoverability-api/research.md new file mode 100644 index 0000000..749f07b --- /dev/null +++ b/specs/better-discoverability-api/research.md @@ -0,0 +1,314 @@ +# Better Discoverability API - Research + +## Problem Statement + +html.yeah's `defelem` macro creates Chassis aliases with rich metadata (malli schemas, documentation), but this metadata is only accessible via Clojure symbols. Users working in hiccup use keywords like `:ui/alert`, but cannot look up metadata using that same keyword. + +This matters because: +1. **Tooling**: Documentation generators, component browsers, and IDE plugins need to enumerate and inspect components +2. **REPL exploration**: Developers want to quickly check a component's schema using the keyword they'd use in markup +3. **Multi-namespace composition**: When composing UI libraries, you work with keywords, not symbols from each namespace + +## Requirements + +### Functional Requirements + +1. Look up element metadata by keyword tag: `(element :ui/alert)` -> metadata map +2. List all registered element tags: `(element-tags)` -> sequence of keywords +3. List all elements with optional namespace filtering: `(elements {:ns 'ui})` +4. Search elements by documentation: `(search-elements "button")` + +### Non-Functional Requirements + +- **Performance**: O(1) keyword lookup; linear scan acceptable for listing/search (typically <1000 elements) +- **REPL-friendly**: Registry state inspectable, re-evaluation updates registry +- **Minimal API**: Single lookup function; users destructure what they need + +## Current Architecture + +### How defelem Works (src/html/yeah.clj:119-228) + +```clojure +(defelem alert + [:map {:doc "Alert component" :as attrs} + [:variant [:enum :info :warning :error]]] + [:div.alert {:class (:variant attrs)} (children)]) +``` + +Expands to: +1. **Render function**: `render-alert-html` with malli schema metadata +2. **Chassis method**: `(defmethod c/resolve-alias :ui/alert ...)` +3. **Var binding**: `(def alert :ui/alert)` +4. **Var metadata**: `{::attributes [...], ::children [...], :doc "..."}` + +### Current Introspection (src/html/yeah.clj:105-117) - TO BE REMOVED + +```clojure +(defn describe [symbol] + (some-> (resolve symbol) (meta) (select-keys [::attributes ::children ::render]))) + +(defn attributes [symbol] + (::attributes (describe symbol))) +``` + +**Problems**: +- Requires symbol, uses `resolve` to find var +- Cannot work with keywords (which is how elements are used in hiccup) +- Redundant if `element` returns full metadata map + +### Chassis Analysis (github.com/onionpancakes/chassis) + +- `c/resolve-alias` is a multimethod dispatching on keyword tag +- **Can enumerate via `(keys (methods c/resolve-alias))`** - gives all registered alias keywords +- Multimethod entries have no metadata storage - schema info must come from elsewhere +- Key insight: Chassis already tracks all aliases; we just need metadata lookup + +## Performance Considerations + +### Scale Analysis + +| Elements | `element` lookup | `elements` / `element-tags` | `search-elements` | Memory | +|----------|------------------|-----------------------------|--------------------|--------| +| 50 | O(1) ~1μs | O(n) ~10μs | O(n) ~50μs | ~25KB | +| 500 | O(1) ~1μs | O(n) ~100μs | O(n) ~500μs | ~250KB | +| 1000 | O(1) ~1μs | O(n) ~200μs | O(n) ~1ms | ~500KB | + +Conclusion: Performance is not a concern at any realistic scale. + +### REPL Reloadability + +**With atom approach (rejected):** +- Re-evaluating `defelem` updates the registry entry +- Deleting an element from source leaves stale entry + +**With hybrid approach (recommended):** +- Re-evaluating `defelem` updates both multimethod and var metadata +- Deleting a `defelem` removes the multimethod entry (via `remove-method`) +- **No stale entries** - state is derived from multimethod + var metadata + +## Storage Options + +### Option 1: Atom with Map (Originally Proposed) + +```clojure +(defonce ^:private *registry* (atom {})) +;; in defelem: +(swap! *registry* assoc ~tag {...}) +``` + +**Pros:** Simple, idiomatic, fast lookup +**Cons:** Mutable state, stale entries on reload + +### Option 2: Var Metadata Only (No Central Registry) + +Leverage existing var metadata that `defelem` already attaches: + +```clojure +;; defelem already does this: +(alter-meta! (resolve '~name) merge {::attributes schema' ::children children-schema}) + +;; Discovery via namespace scanning: +(defn elements-in-ns [ns-sym] + (->> (ns-publics ns-sym) + (vals) + (filter #(::attributes (meta %))) + (map (fn [v] {:tag @v :attributes (::attributes (meta v)) ...})))) +``` + +**Pros:** No new mutable state, truly static, already works +**Cons:** Requires knowing namespaces to scan, no global view without scanning all loaded namespaces + +### Option 3: Chassis Multimethod + Var Metadata (RECOMMENDED) + +Hybrid approach: enumerate via Chassis, lookup via var resolution. + +```clojure +;; Enumeration - Chassis already tracks all registered aliases +(defn element-tags [] + (keys (methods c/resolve-alias))) + +;; Lookup - resolve keyword back to var, extract existing metadata +(defn element [tag] + (when-let [v (ns-resolve (find-ns (symbol (namespace tag))) + (symbol (name tag)))] + (let [m (meta v)] + (when (::attributes m) ;; verify it's a defelem var + {:tag tag + :attributes (::attributes m) + :children (::children m) + :doc (:doc m) + :var v + :render-var (some-> (::render m) resolve) + :ns (symbol (namespace tag))})))) + +;; Listing - combine enumeration with lookup +(defn elements + ([] (keep element (element-tags))) + ([{:keys [ns]}] + (filter #(if ns (= ns (:ns %)) true) (elements)))) + +;; Search - filter elements by doc +(defn search-elements [pattern] + (filter #(when-let [doc (:doc %)] + (if (instance? java.util.regex.Pattern pattern) + (re-find pattern doc) + (.contains (.toLowerCase ^String doc) + (.toLowerCase ^String (str pattern))))) + (elements))) +``` + +**Pros:** +- Zero new mutable state +- No stale entries - removing `defelem` removes the method +- REPL-friendly - reloading works naturally +- Leverages what `defelem` already does + +**Cons:** +- `element` does namespace resolve instead of map lookup (still O(1), slightly more steps) +- `element-tags` may include non-html.yeah aliases (filtered by checking `::attributes`) + +### Option 4: Alter-var-root on Def + +```clojure +(def ^:private registry {}) +;; in defelem: +(alter-var-root #'registry assoc ~tag {...}) +``` + +**Pros:** Slightly more "static" feel +**Cons:** Functionally equivalent to atom, same staleness issue + +## Options Considered + +### Option A: Derive Keyword from Symbol at Runtime + +**Description:** Keep metadata on vars only. When given a keyword like `:ui/alert`, parse it to find the namespace and symbol, then resolve. + +```clojure +(defn element [tag] + (let [ns-sym (symbol (namespace tag)) + name-sym (symbol (name tag))] + (describe (symbol (str ns-sym "/" name-sym))))) +``` + +**Pros:** +- No new state to maintain +- Always consistent with var metadata + +**Cons:** +- Requires namespace to be loaded +- Cannot list all elements (no central registry) +- Cannot search by doc +- Fragile: keyword namespace might not match Clojure namespace exactly + +### Option B: Global Registry Atom + +**Description:** Maintain a global atom containing element metadata indexed by keyword tag. Populated automatically by `defelem`. + +```clojure +(defonce ^:private *registry* (atom {})) + +;; Structure: {:ui/alert {:tag :ui/alert, :attributes [...], :doc "...", :ns 'ui, ...}} +``` + +**Pros:** +- O(1) keyword lookup +- Can list all elements +- Can search/filter efficiently +- Decoupled from namespace loading +- Standard pattern in Clojure (spec, malli, integrant all use registries) + +**Cons:** +- Additional state to maintain +- Must keep in sync with defelem (but automatic registration solves this) +- Memory overhead (negligible for typical component counts) +- Breaking change to existing `describe`/`attributes` API (acceptable - cleaner result) + +### Option C: Attach Metadata to Chassis Multimethod + +**Description:** Store metadata in the multimethod's dispatch table or as method metadata. + +**Pros:** +- Leverages existing Chassis infrastructure + +**Cons:** +- Chassis doesn't support this pattern +- Would require forking/patching Chassis +- Multimethod dispatch table not designed for metadata storage + +## Recommendation + +**Option 3: Chassis Multimethod + Var Metadata (Hybrid)** + +This approach requires zero new mutable state by leveraging what already exists: + +1. **Enumeration**: `(keys (methods c/resolve-alias))` gives all registered alias keywords +2. **Lookup**: Resolve keyword → var, extract existing metadata that `defelem` already attaches +3. **No staleness**: Removing a `defelem` removes the multimethod entry automatically + +This is superior to the atom-based registry because: +- No additional state to manage or synchronize +- No stale entry problem on REPL reload +- Uses standard Clojure mechanisms (multimethods, var metadata) +- `defelem` already does the work - we just need to query it + +## API Design + +### Public Functions + +```clojure +;; Primary lookup - returns full metadata, users destructure what they need +(element :ui/alert) +;; => {:tag :ui/alert +;; :attributes [:map {:doc "Alert component"} ...] +;; :children [:* :any] +;; :doc "Alert component" +;; :var #'ui/alert +;; :render-var #'ui/render-alert-html +;; :ns 'ui} + +;; Common patterns +(:attributes (element :ui/alert)) ;; get just the schema +(:doc (element :ui/alert)) ;; get just the docstring + +;; List all tags +(element-tags) +;; => (:ui/alert :ui/button :dev/card ...) + +;; List with filtering +(elements) ;; all +(elements {:ns 'ui}) ;; by namespace symbol +(elements {:ns #"ui\..*"}) ;; by namespace regex + +;; Search by documentation +(search-elements "button") ;; case-insensitive substring +(search-elements #"[Bb]utton") ;; regex +``` + +### Removed Functions + +The existing `describe` and `attributes` functions are removed. They're superseded by `element`: + +```clojure +;; Old way (removed) +(describe 'alert) ;; symbol-based, returns subset +(attributes 'alert) ;; symbol-based, returns just schema + +;; New way +(element :ui/alert) ;; keyword-based, returns everything +(:attributes (element :ui/alert)) ;; destructure what you need +``` + +## Open Questions + +- [x] Is there value in maintaining the current symbol approach? **No - keywords match hiccup usage** +- [x] Does this require a registry pattern? **No - Chassis multimethod + var metadata is sufficient** +- [x] Should `describe`/`attributes` be kept? **No - `element` returns full map, users destructure** +- [x] Is an atom the best storage? **No - hybrid approach uses no new mutable state** +- [x] REPL reloadability concerns? **Solved - no stale entries with hybrid approach** + +## References + +- [Chassis](https://github.com/onionpancakes/chassis) - Underlying HTML compilation library +- [malli registry](https://github.com/metosin/malli#registry) - Similar pattern for schema registry +- [re-frame subscriptions](https://day8.github.io/re-frame/subscriptions/) - Uses keyword-based registry for subscription lookup diff --git a/src/html/yeah.clj b/src/html/yeah.clj index 5702d5f..24d806d 100644 --- a/src/html/yeah.clj +++ b/src/html/yeah.clj @@ -102,19 +102,70 @@ [] ') -(defn describe - "Given an element symbol, return schema metadata for the element's - attributes and children" - [symbol] - (some-> - (resolve symbol) - (meta) - (select-keys [::attributes ::children ::render]))) - -(defn attributes - "Returns the attribute schema for the given element symbol" - [symbol] - (::attributes (describe symbol))) +(defn- tag->element + "Resolve a keyword tag to element metadata map. + Returns nil if tag doesn't resolve to a defelem var." + [tag] + (when-let [tag-ns (namespace tag)] + (when-let [ns-obj (find-ns (symbol tag-ns))] + (when-let [v (ns-resolve ns-obj (symbol (name tag)))] + (let [m (meta v)] + (when (::attributes m) + {:tag tag + :attributes (::attributes m) + :children (::children m) + :doc (:doc m) + :var v + :render-var (some-> (::render m) resolve) + :ns (symbol tag-ns)})))))) + +(defn element + "Lookup element metadata by keyword tag. Returns nil if not found. + + Example: + (element :dev/simple-button) + ;; => {:tag :dev/simple-button + ;; :attributes [:map ...] + ;; :children [:* :any] + ;; :doc \"...\" + ;; :var #'dev/simple-button + ;; :ns 'dev}" + [tag] + (tag->element tag)) + +(defn element-tags + "List all registered element tags (keywords). + Returns all Chassis aliases that are html.yeah elements." + [] + (->> (keys (methods c/resolve-alias)) + (filter #(and (keyword? %) (namespace %))) + (filter tag->element))) + +(defn elements + "List all registered elements. Returns sequence of metadata maps. + + Options: + :ns - Filter by namespace (symbol or regex pattern)" + ([] + (keep tag->element (keys (methods c/resolve-alias)))) + ([{:keys [ns]}] + (let [matcher (cond + (nil? ns) (constantly true) + (instance? java.util.regex.Pattern ns) + #(re-matches ns (str (:ns %))) + :else #(= ns (:ns %)))] + (filter matcher (elements))))) + +(defn search-elements + "Search elements by documentation text. + Pattern can be string (case-insensitive) or regex." + [pattern] + (let [matcher (if (instance? java.util.regex.Pattern pattern) + #(when-let [doc (:doc %)] (re-find pattern doc)) + #(when-let [doc (:doc %)] + (.contains (.toLowerCase ^String doc) + (.toLowerCase ^String (str pattern)))))] + (filter matcher (elements)))) (defmacro defelem "Define a chassis alias in terms of an attribute schema. Schemas must be @@ -222,7 +273,7 @@ (defmethod c/resolve-alias ~tag [_# props# children#] (~render-sym props# children#)) - + (def ~name ~tag) (alter-meta! (resolve '~name) merge ~var-meta {:html.yeah/render '~render-sym}) (var ~name)))) diff --git a/test/html/yeah_test.clj b/test/html/yeah_test.clj index 9ee8443..20b576d 100644 --- a/test/html/yeah_test.clj +++ b/test/html/yeah_test.clj @@ -201,7 +201,6 @@ expected "

Child

"] (is (= expected result)))) - ;;; Attribute options can be used to enable multiple features for a specific use case. ;;; The below demonstrates an Alpine.js feature enabling Clojure maps for x-data, and ClojureScript ;;; expressions for x-init and @click @@ -249,7 +248,7 @@ (defmethod yeah/property ::merge [_ schema element-syms] (m/form - (->> (mapv yeah/attributes element-syms) + (->> (mapv #(:attributes (yeah/element @(resolve %))) element-syms) (reduce (fn [result target] (mu/merge target result)) schema)))) @@ -294,8 +293,64 @@ #":malli.core/invalid-input" (c/html [mutually-exclusive {:hair/style :bald :hair/color :blonde "@click" '(js/alert "Hello")}])))))) - -;;; Instrument schemas for tests +;;; Discoverability API Tests + +(deftest element-lookup-by-keyword + (testing "element returns metadata for valid defelem" + (let [result (yeah/element :html.yeah-test/simple-button)] + (is (map? result)) + (is (= :html.yeah-test/simple-button (:tag result))) + (is (vector? (:attributes result))) + (is (= :map (first (:attributes result)))) + (is (= [:* :any] (:children result))) + (is (= "A simple button with a schema for type" (:doc result))) + (is (var? (:var result))) + (is (= 'html.yeah-test (:ns result))))) + (testing "element returns nil for unknown keyword" + (is (nil? (yeah/element :nonexistent/element)))) + (testing "element returns nil for keyword without namespace" + (is (nil? (yeah/element :no-namespace))))) + +(deftest element-tags-lists-all-elements + (testing "element-tags returns a sequence of keywords" + (let [tags (yeah/element-tags)] + (is (seq tags)) + (is (every? keyword? tags)) + (is (every? namespace tags)))) + (testing "element-tags includes test elements" + (let [tags (set (yeah/element-tags))] + (is (contains? tags :html.yeah-test/simple-button)) + (is (contains? tags :html.yeah-test/let-button))))) + +(deftest elements-returns-metadata-maps + (testing "elements returns sequence of metadata maps" + (let [elems (yeah/elements)] + (is (seq elems)) + (is (every? map? elems)) + (is (every? :tag elems)) + (is (every? :attributes elems)))) + (testing "elements with namespace filter (symbol)" + (let [elems (yeah/elements {:ns 'html.yeah-test})] + (is (seq elems)) + (is (every? #(= 'html.yeah-test (:ns %)) elems)))) + (testing "elements with namespace filter (regex)" + (let [elems (yeah/elements {:ns #"html\.yeah.*"})] + (is (seq elems)) + (is (every? #(re-matches #"html\.yeah.*" (str (:ns %))) elems))))) + +(deftest search-elements-by-doc + (testing "search-elements with string pattern (case-insensitive)" + (let [results (yeah/search-elements "simple button")] + (is (seq results)) + (is (some #(= :html.yeah-test/simple-button (:tag %)) results)))) + (testing "search-elements with regex pattern" + (let [results (yeah/search-elements #"[Bb]utton")] + (is (seq results)) + (is (every? #(re-find #"[Bb]utton" (or (:doc %) "")) results)))) + (testing "search-elements returns empty for no matches" + (is (empty? (yeah/search-elements "xyzzy-nonexistent-pattern-12345"))))) + +;;; Instrument schemas for tests (mi/collect! {:ns *ns*})