-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ogar-render-askama): T2 — TsInterface askama template + emitter #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
181 changes: 181 additions & 0 deletions
181
crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| //! `TsInterface` emitter — T2 from the Northstar plan §3. | ||
| //! | ||
| //! Lifts an [`ogar_vocab::Class`] into a TypeScript `interface` declaration | ||
| //! with a `CLASS_ID` const (the OGAR codebook id, `as const` so consumers | ||
| //! get the literal type). Mirrors the shape of | ||
| //! [`crate::artifact_kinds::rust_struct`] — different template, different | ||
| //! binding-struct field names, same canonical input. | ||
| //! | ||
| //! Per Northstar §1.6 (mass-mail templates): the template names variables, | ||
| //! the binding struct supplies them. The Rust ↔ TS difference lives in the | ||
| //! type mapping (`rails_to_ts_type`) and the identifier escape rules | ||
| //! (TypeScript allows almost anything as a property name, but a small set | ||
| //! of JS keywords is safest quoted) — the rest is the same shape. | ||
|
|
||
| use askama::Template; | ||
|
|
||
| use super::ArtifactEmitter; | ||
| use crate::spec::ArtifactSpec; | ||
| use ogar_vocab::{canonical_concept_id, AssociationKind}; | ||
|
|
||
| #[derive(Template)] | ||
| #[template(path = "dispatch/ts_interface.askama", escape = "none")] | ||
| struct TsInterfaceCtx { | ||
| name: String, | ||
| concept_fn: String, | ||
| canonical_concept: String, | ||
| class_id_hex: String, | ||
| attributes: Vec<TsAttr>, | ||
| associations: Vec<TsEdge>, | ||
| } | ||
|
|
||
| struct TsAttr { | ||
| name: String, | ||
| /// The property name as it appears in the emitted interface. Either | ||
| /// a bare identifier or a quoted string for safety on JS reserved | ||
| /// words. See [`escape_ts_property`]. | ||
| ts_name: String, | ||
| ts_type: String, | ||
| type_name: String, | ||
| } | ||
|
|
||
| struct TsEdge { | ||
| name: String, | ||
| ts_name: String, | ||
| ts_type: String, | ||
| kind_label: String, | ||
| target: String, | ||
| } | ||
|
|
||
| /// The concrete emitter for | ||
| /// [`ArtifactKind::TsInterface`](crate::ArtifactKind::TsInterface). | ||
| pub struct TsInterfaceEmitter; | ||
|
|
||
| impl ArtifactEmitter for TsInterfaceEmitter { | ||
| fn emit(&self, spec: &ArtifactSpec<'_>) -> Result<String, askama::Error> { | ||
| let class = spec.class; | ||
| let concept = class.canonical_concept.as_deref().unwrap_or(""); | ||
| let class_id_hex = canonical_concept_id(concept) | ||
| .map(|id| format!("0x{id:04X}")) | ||
| .unwrap_or_default(); | ||
|
|
||
| let attributes = class | ||
| .attributes | ||
| .iter() | ||
| .map(|a| TsAttr { | ||
| name: a.name.clone(), | ||
| ts_name: escape_ts_property(&a.name), | ||
| ts_type: rails_to_ts_type(a.type_name.as_deref()), | ||
| type_name: a.type_name.clone().unwrap_or_default(), | ||
| }) | ||
| .collect(); | ||
|
|
||
| let associations = class | ||
| .associations | ||
| .iter() | ||
| .map(|a| TsEdge { | ||
| name: a.name.clone(), | ||
| ts_name: escape_ts_property(&a.name), | ||
| ts_type: edge_ts_type(a), | ||
| kind_label: assoc_label(a.kind), | ||
| target: a.class_name.clone().unwrap_or_default(), | ||
| }) | ||
| .collect(); | ||
|
|
||
| TsInterfaceCtx { | ||
| name: class.name.clone(), | ||
| concept_fn: concept.to_string(), | ||
| canonical_concept: concept.to_string(), | ||
| class_id_hex, | ||
| attributes, | ||
| associations, | ||
| } | ||
| .render() | ||
| } | ||
| } | ||
|
|
||
| /// Quote JS reserved words / non-identifier names so the emitted TS | ||
| /// compiles. TypeScript accepts any string-literal property name in an | ||
| /// interface body (`"type": string;`), so escaping by quoting is the | ||
| /// cleanest safe move. Identifiers that are unambiguously bare (snake_case | ||
| /// without reserved-word collisions, no embedded dots / spaces) pass | ||
| /// through unchanged. | ||
| /// | ||
| /// Conservative list: the union of strict JS reserved words plus a few | ||
| /// contextual ones that consumers sometimes trip over (`type` in TS | ||
| /// declaration contexts; safe here because we're in an interface body, | ||
| /// but quoting costs nothing). | ||
| fn escape_ts_property(name: &str) -> String { | ||
| // Property names containing characters that can't appear in a bare | ||
| // identifier must be quoted (e.g. Odoo-style `"account.move.line"`). | ||
| let ident_safe = !name.is_empty() | ||
| && name | ||
| .chars() | ||
| .enumerate() | ||
| .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())); | ||
| const QUOTED: &[&str] = &[ | ||
| // Strict JS reserved words (subset that field names sometimes hit). | ||
| "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", | ||
| // ES strict-mode reserved. | ||
| "let", "static", "implements", "interface", "package", "private", | ||
| "protected", "public", | ||
| // TypeScript contextual keywords — bare in property position is | ||
| // technically accepted, but consumers' linters / downstream | ||
| // codegen often choke. Conservative quote: zero downside, removes | ||
| // a class of "compiles here, breaks at consumer" footguns. This | ||
| // is the TS-side companion of Rust's `r#type` escape (codex P1 | ||
| // on #78). | ||
| "type", "async", "await", "as", "from", "of", "is", "infer", | ||
| "keyof", "namespace", "satisfies", "readonly", | ||
| ]; | ||
| if !ident_safe || QUOTED.contains(&name) { | ||
| format!("\"{name}\"") | ||
| } else { | ||
| name.to_string() | ||
| } | ||
| } | ||
|
|
||
| /// Map a producer-side Rails type name onto a TypeScript type. Coarse — | ||
| /// downstream consumers can specialise (e.g. `Decimal` strings vs | ||
| /// `number`); the canonical contract round-trips on the structural shape. | ||
| fn rails_to_ts_type(t: Option<&str>) -> String { | ||
| match t { | ||
| Some("string") | Some("text") => "string".into(), | ||
| Some("integer") | Some("big_integer") | Some("bigint") => "number".into(), | ||
| Some("float") | Some("double") => "number".into(), | ||
| Some("decimal") | Some("monetary") => "number".into(), | ||
| Some("boolean") | Some("bool") => "boolean".into(), | ||
| // ISO-8601 string at the canonical layer; consumers may parse to | ||
| // Date downstream. | ||
| Some("date") | Some("datetime") | Some("timestamp") => "string".into(), | ||
| Some("json") | Some("jsonb") => "unknown".into(), | ||
| Some(_) | None => "string".into(), | ||
| } | ||
| } | ||
|
|
||
| fn edge_ts_type(a: &ogar_vocab::Association) -> String { | ||
| // Same coarse shape as the Rust emitter: belongs_to / has_one → fk id | ||
| // (nullable); has_many / habtm → array of fk ids. Concrete consumers | ||
| // can swap for typed object references downstream. | ||
| match a.kind { | ||
| AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => { | ||
| "ReadonlyArray<number>".into() | ||
| } | ||
| _ => "number | null".into(), | ||
| } | ||
| } | ||
|
|
||
| fn assoc_label(k: AssociationKind) -> String { | ||
| match k { | ||
| AssociationKind::BelongsTo => "belongs_to".into(), | ||
| AssociationKind::HasOne => "has_one".into(), | ||
| AssociationKind::HasMany => "has_many".into(), | ||
| AssociationKind::HasAndBelongsToMany => "has_and_belongs_to_many".into(), | ||
| _ => format!("{k:?}").to_ascii_lowercase(), | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
31 changes: 31 additions & 0 deletions
31
crates/ogar-render-askama/templates/dispatch/ts_interface.askama
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| {# Render one canonical concept as a TypeScript interface + class-id constant. #} | ||
| {# Mass-mail simple: bag-of-variables substitution. Per the Northstar §1.6, #} | ||
| {# the template just names what it wants; the binding struct supplies it. #} | ||
| // | ||
| // {{ name }} — canonical class generated from `ogar_vocab::{{ concept_fn }}()`. | ||
| // DO NOT EDIT BY HAND. Re-render via `ogar-render-askama`. | ||
| // | ||
|
|
||
| /** Canonical OGAR codebook id for [`{{ name }}`]. */ | ||
| {% if class_id_hex.len() > 0 -%} | ||
| export const CLASS_ID = {{ class_id_hex }} as const; | ||
| {% else -%} | ||
| // (no codebook id — concept not yet promoted) | ||
| {% endif %} | ||
|
|
||
| /** Canonical concept name as in the OGAR codebook. */ | ||
| export const CANONICAL_CONCEPT = "{{ canonical_concept }}" as const; | ||
|
|
||
| export interface {{ name }} { | ||
| {%- for attr in attributes %} | ||
| /** `{{ attr.name }}`{% if attr.type_name.len() > 0 %} (curator type: `{{ attr.type_name }}`){% endif %} */ | ||
| {{ attr.ts_name }}: {{ attr.ts_type }}; | ||
| {%- endfor %} | ||
| {%- if associations.len() > 0 %} | ||
| // Family edges (canonical relations): | ||
| {%- for edge in associations %} | ||
| /** {{ edge.kind_label }} `{{ edge.name }}`{% if edge.target.len() > 0 %} → `{{ edge.target }}`{% endif %} */ | ||
| {{ edge.ts_name }}: {{ edge.ts_type }}; | ||
| {%- endfor %} | ||
| {%- endif %} | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When rendering a supported
Classwhosenameis not a TypeScript identifier, this splices the raw source name into the declaration and produces invalid TS such asexport interface account.move {(theClass::namedocs explicitly preserve dotted ORM names incrates/ogar-vocab/src/lib.rs:89-91, and aliases likeaccount.analytic.lineare part of the codebook flow). Since this emitter accepts arbitraryogar_vocab::Classvalues, derive or sanitize a valid TS interface identifier instead of using the source name directly.Useful? React with 👍 / 👎.