@typfolio/core 0.1.0 → 0.2.0

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.
@@ -7,10 +7,20 @@
7
7
  // `sample-from-schema(schema, mode: "full")` builds a value from a JSON Schema (a dictionary):
8
8
  // "full" shape-complete, for editor completion: every property, one array item, non-null shapes.
9
9
  // "minimal" the sparsest value the schema allows, to expose templates that assume optional data.
10
+ // In both, an image field (`contentMediaType: "image/..."`) is a placeholder image's bytes.
10
11
  // The sample is not validated against the schema.
11
12
 
12
13
  #let _is-empty(v) = (type(v) == array or type(v) == dictionary) and v.len() == 0
13
14
 
15
+ // A field that holds the path of an image file says so with `contentMediaType: "image/..."`. In the
16
+ // editor and in template checks there is no such file to read, so the sample is the bytes of a grey
17
+ // placeholder image instead, which `image()` draws: write `image(data.logo)`, not `image("/" + ...)`.
18
+ #let _placeholder = bytes("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"200\" viewBox=\"0 0 400 200\"><rect width=\"400\" height=\"200\" fill=\"#e6e8eb\"/><path d=\"M0 0L400 200M400 0L0 200\" stroke=\"#c4c8cd\" stroke-width=\"2\"/></svg>")
19
+ #let _is-image(node) = {
20
+ let media = node.at("contentMediaType", default: none)
21
+ type(media) == str and media.starts-with("image/")
22
+ }
23
+
14
24
  #let _formats = (
15
25
  "date": "2024-01-15",
16
26
  "date-time": "2024-01-15T09:30:00Z",
@@ -19,8 +29,9 @@
19
29
  "uuid": "123e4567-e89b-12d3-a456-426614174000",
20
30
  )
21
31
 
22
- // Resolves a local "#/..." reference against the root schema.
32
+ // Resolves a local "#" or "#/..." reference against the root schema.
23
33
  #let _resolve(root, ref) = {
34
+ if ref == "#" { return root }
24
35
  let node = root
25
36
  for part in ref.slice(2).split("/") {
26
37
  node = node.at(part.replace("~1", "/").replace("~0", "~"))
@@ -28,10 +39,31 @@
28
39
  node
29
40
  }
30
41
 
31
- // `seen` holds the $ref values being expanded on the current path, so a recursive schema ends at
32
- // `none` instead of recursing; `depth` bounds anything else.
42
+ // `seen` holds the $ref values being expanded on the current path. A recursive schema is expanded
43
+ // one level (a tree gets one child) and then cut: the cut value is `_cut`, which an array turns into
44
+ // no items and an object into a missing optional property, so a recursive list ends empty instead
45
+ // of holding `none`. `depth` bounds anything else.
46
+ #let _cut = "\u{E000}typfolio-recursion-cut"
47
+ #let _recursing(seen, ref) = seen.filter(r => r == ref).len() >= 2
48
+
49
+ // An object's properties from their samples: a cut optional property is left out, a cut required
50
+ // one is `none` (the schema demands an endless value there).
51
+ #let _object(node, sampled) = {
52
+ let required = node.at("required", default: ())
53
+ let out = (:)
54
+ for (key, v) in sampled {
55
+ if v == _cut {
56
+ if key in required { out.insert(key, none) }
57
+ } else {
58
+ out.insert(key, v)
59
+ }
60
+ }
61
+ out
62
+ }
63
+
33
64
  #let _sample(root, node, depth, seen) = {
34
65
  if depth > 16 or type(node) != dictionary { return none }
66
+ if _is-image(node) { return _placeholder }
35
67
  let candidates = ()
36
68
  if "examples" in node { candidates += node.at("examples") }
37
69
  if "default" in node { candidates.push(node.at("default")) }
@@ -45,14 +77,14 @@
45
77
  }
46
78
  if "$ref" in node {
47
79
  let ref = node.at("$ref")
48
- if ref in seen { return none }
80
+ if _recursing(seen, ref) { return _cut }
49
81
  return _sample(root, _resolve(root, ref), depth + 1, seen + (ref,))
50
82
  }
51
83
  for key in ("anyOf", "oneOf") {
52
84
  if key in node {
53
85
  for branch in node.at(key) {
54
86
  let v = _sample(root, branch, depth + 1, seen)
55
- if v != none { return v }
87
+ if v != none and v != _cut { return v }
56
88
  }
57
89
  }
58
90
  }
@@ -70,10 +102,11 @@
70
102
  if "properties" in node { t = "object" } else if "items" in node { t = "array" }
71
103
  }
72
104
  if t == "object" {
73
- let out = (:)
105
+ let sampled = (:)
74
106
  for (key, sub) in node.at("properties", default: (:)) {
75
- out.insert(key, _sample(root, sub, depth + 1, seen))
107
+ sampled.insert(key, _sample(root, sub, depth + 1, seen))
76
108
  }
109
+ let out = _object(node, sampled)
77
110
  // A partial object default fills in the generated shape rather than replacing it.
78
111
  if filled.len() > 0 { out += filled.first() }
79
112
  return out
@@ -81,7 +114,9 @@
81
114
  if t == "array" {
82
115
  let items = node.at("items", default: none)
83
116
  if items == none { return () }
84
- return range(calc.max(1, node.at("minItems", default: 1))).map(_ => _sample(root, items, depth + 1, seen))
117
+ let item = _sample(root, items, depth + 1, seen)
118
+ if item == _cut { return () }
119
+ return range(calc.max(1, node.at("minItems", default: 1))).map(_ => item)
85
120
  }
86
121
  if t == "string" { return _formats.at(node.at("format", default: ""), default: "Sample text") }
87
122
  if t == "integer" or t == "number" { return node.at("minimum", default: 1) }
@@ -89,9 +124,6 @@
89
124
  none
90
125
  }
91
126
 
92
- /// Builds a shape-complete sample value from a JSON Schema (a dictionary).
93
- #let sample-from-schema(schema) = _sample(schema, schema, 0, ())
94
-
95
127
  // Minimal mode. Only `required` properties, nullable values are `none`, arrays are empty unless
96
128
  // `minItems` says otherwise, `examples` and `default` are ignored. `$ref` and `allOf` follow full mode.
97
129
  #let _is-nullable(node) = {
@@ -108,16 +140,18 @@
108
140
  #let _minimal(root, node, depth, seen) = {
109
141
  if depth > 16 or type(node) != dictionary { return none }
110
142
  if _is-nullable(node) { return none }
143
+ if _is-image(node) { return _placeholder }
111
144
  if "const" in node { return node.at("const") }
112
145
  if "enum" in node and node.at("enum").len() > 0 { return node.at("enum").first() }
113
146
  if "$ref" in node {
114
147
  let ref = node.at("$ref")
115
- if ref in seen { return none }
148
+ if _recursing(seen, ref) { return _cut }
116
149
  return _minimal(root, _resolve(root, ref), depth + 1, seen + (ref,))
117
150
  }
118
151
  for key in ("anyOf", "oneOf") {
119
152
  if key in node and node.at(key).len() > 0 {
120
- return _minimal(root, node.at(key).first(), depth + 1, seen)
153
+ let v = _minimal(root, node.at(key).first(), depth + 1, seen)
154
+ return if v == _cut { none } else { v }
121
155
  }
122
156
  }
123
157
  if "allOf" in node {
@@ -134,18 +168,20 @@
134
168
  if "properties" in node { t = "object" } else if "items" in node { t = "array" }
135
169
  }
136
170
  if t == "object" {
137
- let out = (:)
171
+ let sampled = (:)
138
172
  let properties = node.at("properties", default: (:))
139
173
  for key in node.at("required", default: ()) {
140
- out.insert(key, _minimal(root, properties.at(key, default: none), depth + 1, seen))
174
+ sampled.insert(key, _minimal(root, properties.at(key, default: none), depth + 1, seen))
141
175
  }
142
- return out
176
+ return _object(node, sampled)
143
177
  }
144
178
  if t == "array" {
145
179
  let items = node.at("items", default: none)
146
180
  let count = node.at("minItems", default: 0)
147
181
  if items == none or count <= 0 { return () }
148
- return range(count).map(_ => _minimal(root, items, depth + 1, seen))
182
+ let item = _minimal(root, items, depth + 1, seen)
183
+ if item == _cut { return () }
184
+ return range(count).map(_ => item)
149
185
  }
150
186
  if t == "string" { return "a" * node.at("minLength", default: 0) }
151
187
  if t == "integer" or t == "number" {
@@ -160,7 +196,9 @@
160
196
 
161
197
  /// Builds a sample value from a JSON Schema (a dictionary). `mode` is "full" or "minimal".
162
198
  #let sample-from-schema(schema, mode: "full") = {
163
- if mode == "full" { _sample(schema, schema, 0, ()) }
164
- else if mode == "minimal" { _minimal(schema, schema, 0, ()) }
165
- else { panic("@local/typfolio: sample mode must be \"full\" or \"minimal\", got " + repr(mode)) }
199
+ // The root is itself an expansion of "#", so a schema that refers to itself recurses one level too.
200
+ let value = if mode == "full" { _sample(schema, schema, 0, ("#",)) }
201
+ else if mode == "minimal" { _minimal(schema, schema, 0, ("#",)) }
202
+ else { panic("@local/typfolio: sample mode must be \"full\" or \"minimal\", got " + repr(mode)) }
203
+ if value == _cut { none } else { value }
166
204
  }