@airmark/quiver 0.33.0 → 0.35.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.
Files changed (54) hide show
  1. package/README.md +18 -12
  2. package/package.json +7 -7
  3. package/quills/af4141/0.1.0/Quill.yaml +3 -12
  4. package/quills/af4141/0.1.0/example.md +53 -0
  5. package/quills/afmc_moa/0.0.1/Quill.yaml +29 -45
  6. package/quills/afmc_moa/0.0.1/example.md +114 -0
  7. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +1 -1
  8. package/quills/classic_resume/{0.0.1 → 0.1.0}/Quill.yaml +88 -168
  9. package/quills/classic_resume/0.1.0/example.md +111 -0
  10. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/components.typ +77 -35
  11. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/config.typ +23 -14
  12. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/layout.typ +37 -7
  13. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/lib.typ +1 -0
  14. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/util.typ +50 -0
  15. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/typst.toml +0 -5
  16. package/quills/classic_resume/{0.0.1 → 0.1.0}/plate.typ +63 -56
  17. package/quills/cyber_career_plan/0.1.0/Quill.yaml +328 -0
  18. package/quills/cyber_career_plan/0.1.0/example.md +55 -0
  19. package/quills/cyber_career_plan/0.1.0/plate.typ +681 -0
  20. package/quills/daf1206/0.1.0/Quill.yaml +14 -16
  21. package/quills/daf1206/0.1.0/example.md +55 -0
  22. package/quills/daf4392/0.1.0/Quill.yaml +1 -6
  23. package/quills/daf4392/0.1.0/example.md +51 -0
  24. package/quills/usaf_letter/0.1.0/Quill.yaml +7 -42
  25. package/quills/usaf_letter/0.1.0/example.md +35 -0
  26. package/quills/{usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE → usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/OFL.txt} +2 -2
  27. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  28. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  29. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +1 -1
  30. package/quills/usaf_letter/0.1.0/plate.typ +1 -4
  31. package/quills/usaf_memo/0.2.0/Quill.yaml +7 -40
  32. package/quills/usaf_memo/0.2.0/example.md +45 -0
  33. package/quills/usaf_memo/0.3.0/Quill.yaml +13 -50
  34. package/quills/usaf_memo/0.3.0/example.md +48 -0
  35. package/quills/{usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/LICENSE → usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt} +2 -2
  36. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  37. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  38. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +1 -1
  39. package/quills/usaf_memo/0.3.0/plate.typ +1 -4
  40. package/templates/deros_extension.md +40 -0
  41. package/templates/templates.json +7 -0
  42. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +0 -32
  43. package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +0 -320
  44. package/quills/cyber_ribbon_chart/0.0.1/plate.typ +0 -428
  45. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  46. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  47. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  48. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  49. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/LICENSE +0 -0
  50. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
  51. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
  52. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
  53. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
  54. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +0 -0
@@ -22,24 +22,63 @@
22
22
  }
23
23
 
24
24
  // Shared skeleton of `entry`: a grid of left/right aligned header cells
25
- // followed by an optional bulleted body, kept together on one page.
25
+ // followed by an optional bulleted body, kept together on one page. The gutter
26
+ // keeps a left cell that wraps off the right one. An entry taller than a page
27
+ // cannot be kept together, so it breaks where it must rather than running off
28
+ // the foot of one.
26
29
  #let _entry(cfg, cells, body) = {
27
- v(cfg.entry-spacing)
28
- block(breakable: false, {
29
- grid(columns: (1fr, auto), row-gutter: cfg.leading, ..cells)
30
+ let inner = {
31
+ grid(columns: (1fr, auto), column-gutter: 1em, row-gutter: cfg.leading, ..cells)
30
32
  if body != none {
31
33
  _bulleted(cfg, body)
32
34
  }
33
- })
35
+ }
36
+ block(
37
+ above: cfg.leading + cfg.rule-spacing,
38
+ below: cfg.leading + cfg.entry-spacing,
39
+ layout(region => block(
40
+ breakable: measure(block(width: region.width, inner)).height > region.height,
41
+ inner,
42
+ )),
43
+ )
34
44
  }
35
45
 
46
+ // Sets the contacts on as few lines as hold them, a separator between two on
47
+ // one line. A line therefore breaks only between contacts, and never starts or
48
+ // ends on a separator. The spaces around the separator are real ones, so text
49
+ // copied out of the PDF keeps the contacts apart.
50
+ #let _contact-lines(cfg, contacts) = layout(region => {
51
+ // The separator's ink is centred on the x-height, where a middle dot sits,
52
+ // whatever glyph and size it is set in.
53
+ let glyph = text(size: cfg.contact-separator-size, cfg.contact-separator)
54
+ let extent(top, bottom, body) = measure(text(top-edge: top, bottom-edge: bottom, body)).height
55
+ let centre = extent("bounds", "baseline", glyph) - extent("bounds", "bounds", glyph) / 2
56
+ let separator = [ #box(
57
+ baseline: centre - extent("x-height", "baseline", [x]) / 2,
58
+ inset: (x: cfg.contact-separator-padding),
59
+ glyph,
60
+ ) ]
61
+ let lines = ()
62
+ let current = ()
63
+ for contact in contacts {
64
+ if current.len() > 0 and measure((..current, contact).join(separator)).width > region.width {
65
+ lines.push(current)
66
+ current = ()
67
+ }
68
+ current.push(contact)
69
+ }
70
+ lines.push(current)
71
+ lines.map(it => it.join(separator)).join(linebreak())
72
+ })
73
+
36
74
  /// Name and contact line at the top of the resume.
37
75
  ///
38
76
  /// The name becomes the level 1 heading of the document, and — unless `title`
39
- /// and `author` say otherwise — the title and author of the exported PDF.
40
- /// Contacts are separated by a diamond and, with `link-contacts` left on, email
41
- /// addresses, web addresses and phone numbers among them become links. Pass
42
- /// ready-made content instead of a string to opt a single contact out.
77
+ /// and `author` say otherwise — the title and author of the exported PDF. A
78
+ /// `none` or empty name prints no heading and leaves both unset. With
79
+ /// `link-contacts` left on, email addresses, web addresses and phone numbers
80
+ /// among the contacts become links. Pass ready-made content instead of a
81
+ /// string to opt a single contact out.
43
82
  #let resume-header(
44
83
  name: "",
45
84
  contacts: (),
@@ -48,28 +87,28 @@
48
87
  author: auto,
49
88
  ) = {
50
89
  // Only the metadata that resolves to something is set, so that passing
51
- // `none` leaves Typst's own default in place.
90
+ // `none` leaves Typst's own default in place. An author is text only.
52
91
  let metadata = (:)
53
92
  let title = if title == auto { name } else { title }
54
93
  let author = if author == auto { name } else { author }
55
94
  if title not in (none, "") { metadata.title = title }
56
- if author not in (none, "") { metadata.author = author }
95
+ if type(author) == str and author != "" { metadata.author = author }
57
96
  set document(..metadata)
58
97
 
59
- with-config(cfg => {
60
- heading(level: 1, name)
61
- v(cfg.entry-spacing)
62
-
63
- // A box rather than spaces, so that the padding is a real length and the
64
- // separator still cannot be split from the contacts on either side.
65
- let separator = box(
66
- inset: (x: cfg.contact-separator-padding),
67
- text(size: cfg.contact-separator-size, cfg.contact-separator),
68
- )
69
- contacts
70
- .map(contact => if link-contacts { auto-link(contact) } else { contact })
71
- .join(separator)
72
- })
98
+ let named = name not in (none, "")
99
+ if named or contacts.len() > 0 {
100
+ with-config(cfg => block(below: cfg.leading + cfg.header-spacing, {
101
+ if named {
102
+ [#heading(level: 1, name) <ttq-classic-resume-name>]
103
+ }
104
+ if contacts.len() > 0 {
105
+ block(
106
+ above: cfg.leading + cfg.entry-spacing,
107
+ _contact-lines(cfg, contacts.map(contact => if link-contacts { auto-link(contact) } else { contact })),
108
+ )
109
+ }
110
+ }))
111
+ }
73
112
  }
74
113
 
75
114
  /// Section title with a rule underneath it, such as `Work Experience`.
@@ -85,8 +124,9 @@
85
124
  ///
86
125
  /// `dated` puts `dates` opposite `heading` and an optional italic second line
87
126
  /// (`subtitle` / `location`). `linked` puts `url` opposite `heading`, small
88
- /// and italic, and drops the second line. `body` is ordinary Typst markup —
89
- /// most often a `- item` list, which picks up the square bullet of the template.
127
+ /// and italic, and drops the second line; a `url` given as a string becomes a
128
+ /// link where a contact would. `body` is ordinary Typst markup — most often a
129
+ /// `- item` list, which picks up the square bullet of the template.
90
130
  #let entry(
91
131
  heading: "",
92
132
  form: "dated",
@@ -109,7 +149,7 @@
109
149
  style: "italic",
110
150
  url,
111
151
  )
112
- if type(url) == str and url.starts-with("http") { link(url, label) } else { label }
152
+ auto-link(url, body: label)
113
153
  }
114
154
  (align(left, text(weight: "bold", heading)), align(right, annotation))
115
155
  } else {
@@ -131,7 +171,7 @@
131
171
  ///
132
172
  /// Two shapes are accepted and told apart by the first item: a flat array of
133
173
  /// content, or an array of `(label: .., text: ..)` dictionaries, which puts
134
- /// the label in bold above its text.
174
+ /// the label in bold above its text. A `none` or empty label prints no line.
135
175
  #let item-grid(items: (), columns: 2) = {
136
176
  if items.len() == 0 {
137
177
  return
@@ -150,8 +190,10 @@
150
190
  + "when the first one is, found " + repr(item),
151
191
  )
152
192
  block({
153
- text(weight: "bold", item.label)
154
- linebreak()
193
+ if item.label not in (none, "", []) {
194
+ text(weight: "bold", item.label)
195
+ linebreak()
196
+ }
155
197
  item.text
156
198
  })
157
199
  } else {
@@ -163,8 +205,8 @@
163
205
  item
164
206
  }
165
207
 
166
- with-config(cfg => {
167
- v(cfg.entry-spacing)
208
+ with-config(cfg => block(
209
+ above: cfg.leading + cfg.rule-spacing,
168
210
  pad(
169
211
  // Line the items up with the bullets of the entries around them.
170
212
  left: cfg.marker-size + cfg.marker-indent,
@@ -176,6 +218,6 @@
176
218
  column-gutter: 1em,
177
219
  ..items.map(cell),
178
220
  ),
179
- )
180
- })
221
+ ),
222
+ ))
181
223
  }
@@ -15,35 +15,44 @@
15
15
  margin: 0.5in,
16
16
 
17
17
  // Vertical rhythm. `leading` is used for the leading inside a paragraph and
18
- // for the gap between paragraphs, blocks and list items; the two `*-spacing`
19
- // values are the extra breathing room above a section header and above an
20
- // entry.
18
+ // for the gap between paragraphs, blocks and list items; each `*-spacing`
19
+ // value is added to it for one wider gap: above a section header, under its
20
+ // rule, between two entries, and under the name and contacts. The gap under
21
+ // the rule is the same whether an entry, a list of items or prose follows it,
22
+ // and an entry or a list of items keeps it from a paragraph above it too.
21
23
  leading: 0.5em,
22
24
  section-spacing: 5pt,
25
+ rule-spacing: 3.5pt,
23
26
  entry-spacing: 5pt,
27
+ header-spacing: 10pt,
24
28
 
25
- // Resume header. The separator keeps its own padding so that the space
26
- // around it does not change when its size does.
27
- name-size: 18pt,
29
+ // Resume header. The separator stands between two contacts on one line,
30
+ // never at either end of one, with a word space and this padding on each
31
+ // side of it. Sizes in `em` follow the body size: at 12pt, the name is set
32
+ // at 18pt and the separator at 7pt.
33
+ name-size: 1.5em,
28
34
  contact-separator: "❖",
29
- contact-separator-size: 7pt,
30
- contact-separator-padding: 0.5em,
35
+ contact-separator-size: 7em / 12,
36
+ contact-separator-padding: 0.25em,
31
37
 
32
- // Rule drawn underneath a section header. The gap between the rule and the
33
- // first entry of the section comes from that entry's `entry-spacing`.
38
+ // Rule drawn underneath a section header.
34
39
  rule-stroke: 0.75pt,
35
40
 
36
41
  // Square bullet used by lists inside an entry. The negative baseline lifts
37
42
  // the square off the baseline so that it lines up with the middle of the
38
- // x-height instead of hanging below the line.
39
- marker-size: 3.5pt,
43
+ // x-height instead of hanging below the line. At 12pt the square is 3.5pt.
44
+ marker-size: 3.5em / 12,
40
45
  marker-baseline: -0.07em,
41
46
  marker-indent: 0.8em,
42
47
 
43
48
  // Right-aligned annotation of a linked entry, normally a URL. A font of
44
- // `auto` keeps the body font.
49
+ // `auto` keeps the body font; an `em` size follows the body size.
45
50
  annotation-font: auto,
46
- annotation-size: 8pt,
51
+ annotation-size: 0.85em,
52
+
53
+ // Name and page number over every page after the first; `false` leaves
54
+ // those pages bare.
55
+ continuation-header: true,
47
56
  )
48
57
 
49
58
  #let config-state = state("ttq-classic-resume:config", default-config)
@@ -3,6 +3,22 @@
3
3
 
4
4
  #import "config.typ": config-state, default-config
5
5
 
6
+ // The name at the left and the page count at the right, over every page after
7
+ // the first, so a page read apart from the first still says whose it is. The
8
+ // name is read back off the heading `resume-header` labels, so a level 1
9
+ // heading written in the body never stands in for it. Small and italic, it
10
+ // reads as a running head: a 0.5in margin leaves no room to set it further off
11
+ // the page.
12
+ #let _continuation-header(cfg) = context {
13
+ if counter(page).get().first() > 1 {
14
+ set text(size: cfg.annotation-size, style: "italic")
15
+ let names = query(<ttq-classic-resume-name>)
16
+ if names.len() > 0 { names.first().body }
17
+ h(1fr)
18
+ counter(page).display("1 of 1", both: true)
19
+ }
20
+ }
21
+
6
22
  /// Applies the resume styling to the whole document.
7
23
  ///
8
24
  /// Use `#show: resume` for the defaults, or `#show: resume.with(size: 11pt)`
@@ -22,11 +38,18 @@
22
38
 
23
39
  let cfg = default-config + options.named()
24
40
 
25
- set page(paper: cfg.paper, margin: cfg.margin)
41
+ set page(
42
+ paper: cfg.paper,
43
+ margin: cfg.margin,
44
+ header: if cfg.continuation-header { _continuation-header(cfg) },
45
+ )
26
46
  set text(font: cfg.font, size: cfg.size)
27
47
  set par(leading: cfg.leading, spacing: cfg.leading, justify: false)
28
48
 
29
- // One vertical rhythm for every kind of block-level content.
49
+ // One vertical rhythm for every kind of block-level content. Block spacing
50
+ // collapses to the larger of two neighbours' gaps, so a gap wider than
51
+ // `leading` is set as a block's `above` or `below` rather than with `v`,
52
+ // which would add to it.
30
53
  set block(above: cfg.leading, below: cfg.leading)
31
54
  set list(spacing: cfg.leading)
32
55
 
@@ -45,11 +68,18 @@
45
68
  // The name is enlarged inside the block, not on the heading itself, so that
46
69
  // the surrounding `em` spacing keeps resolving against the body size.
47
70
  show heading.where(level: 1): it => block(text(size: cfg.name-size, it.body))
48
- show heading.where(level: 2): it => {
49
- v(cfg.section-spacing)
50
- block(upper(it.body))
51
- block(above: cfg.leading, line(length: 100%, stroke: cfg.rule-stroke))
52
- }
71
+ // A heading keeps with what follows it, so the section's first line is on
72
+ // the page its rule is. Capitals set solid crowd each other, so the title is
73
+ // tracked out a little.
74
+ show heading.where(level: 2): it => block(
75
+ above: cfg.leading + cfg.section-spacing,
76
+ below: cfg.leading + cfg.rule-spacing,
77
+ sticky: true,
78
+ {
79
+ text(tracking: 0.04em, upper(it.body))
80
+ block(above: cfg.leading, line(length: 100%, stroke: cfg.rule-stroke))
81
+ },
82
+ )
53
83
 
54
84
  config-state.update(cfg)
55
85
 
@@ -4,3 +4,4 @@
4
4
  #import "config.typ": default-config
5
5
  #import "layout.typ": resume
6
6
  #import "components.typ": entry, item-grid, resume-header, section-header
7
+ #import "util.typ": auto-link
@@ -0,0 +1,50 @@
1
+ // util.typ
2
+ // Helpers shared by the components.
3
+
4
+ // A contact line usually holds an email address, a profile link, a phone
5
+ // number and a city. Only the first three are worth turning into a link, and
6
+ // they are distinguishable by shape.
7
+ #let _mailto = regex("^(?i:mailto):\\S+$")
8
+ #let _tel = regex("^(?i:tel):[+(\\d][\\d\\s()./+-]*\\d$")
9
+ #let _email = regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
10
+ #let _scheme = regex("^(?i:https?://|www\\.)\\S+$")
11
+ #let _domain = regex("^[\\w-]+(\\.[\\w-]+)*\\.[A-Za-z]{2,24}(/\\S*)?$")
12
+ #let _phone = regex("^[+(\\d][\\d\\s()./+-]*\\d$")
13
+ #let _digit = regex("\\d")
14
+
15
+ /// Turns a contact into a link when it looks like an email address, a web
16
+ /// address or a phone number, and returns it unchanged otherwise. A city or a
17
+ /// clearance level is therefore left alone, and so is anything that is already
18
+ /// content, so callers can always pass their own `link(..)` instead. One
19
+ /// already written as a `mailto:`, `tel:` or `http(s)://` link keeps its
20
+ /// target, whatever case its scheme is written in.
21
+ ///
22
+ /// `body` is what prints, the contact itself unless given: a caller holding the
23
+ /// contact as text and as content of its own reads the one and prints the
24
+ /// other.
25
+ #let auto-link(contact, body: auto) = {
26
+ let body = if body == auto { contact } else { body }
27
+ if type(contact) != str {
28
+ body
29
+ } else if contact.match(_mailto) != none {
30
+ link("mailto:" + contact.slice(7), body)
31
+ } else if contact.match(_tel) != none {
32
+ link("tel:" + contact.slice(4).replace(regex("[^\\d+]"), ""), body)
33
+ } else if contact.match(_email) != none {
34
+ link("mailto:" + contact, body)
35
+ } else if contact.match(_scheme) != none {
36
+ let (scheme, rest) = if lower(contact).starts-with("www.") {
37
+ ("https://", contact)
38
+ } else {
39
+ let split = contact.position("://") + 3
40
+ (lower(contact.slice(0, split)), contact.slice(split))
41
+ }
42
+ link(scheme + rest, body)
43
+ } else if contact.match(_domain) != none {
44
+ link("https://" + contact, body)
45
+ } else if contact.match(_phone) != none and contact.matches(_digit).len() >= 7 {
46
+ link("tel:" + contact.replace(regex("[^\\d+]"), ""), body)
47
+ } else {
48
+ body
49
+ }
50
+ }
@@ -19,8 +19,3 @@ keywords = [
19
19
  ]
20
20
  categories = ["cv"]
21
21
  disciplines = ["business", "computer-science", "engineering", "communication"]
22
-
23
- [template]
24
- path = "template"
25
- entrypoint = "resume.typ"
26
- thumbnail = "thumbnail.png"
@@ -1,11 +1,11 @@
1
- #import "@local/quillmark-helper:0.1.0": data
1
+ #import "@local/quillmark-helper:0.1.0": data, ink
2
2
  #import "@local/ttq-classic-resume:0.1.0": (
3
- default-config, entry, item-grid, resume, resume-header, section-header,
3
+ auto-link, default-config, entry, item-grid, resume, resume-header, section-header,
4
4
  )
5
5
 
6
6
  // `plaintext`/`richtext` fields lower to content carrying a space element on
7
- // each side, so a blank one is not `[]` and a filled one would print its
8
- // padding inside a bold run or an italic one.
7
+ // each side, so a filled one would print its padding inside a bold run or an
8
+ // italic one. A blank one lowers to "".
9
9
  #let trim-inline(v) = {
10
10
  if type(v) != content { return v }
11
11
  let kids = v.at("children", default: none)
@@ -32,14 +32,14 @@
32
32
  if type(body) == str { none } else { body }
33
33
  }
34
34
 
35
- #let stock-title = (
36
- summary: "Summary",
37
- experience: "Work Experience",
38
- education: "Education",
39
- projects: "Projects",
40
- skills: "Skills",
41
- certifications: "Certifications",
42
- )
35
+ // A `string` field is text to compute with: a contact or a project link is read
36
+ // to find its target, and the name reaches `set document(author:)`, which takes
37
+ // no content. What prints is the field's ink twin, `printed`, which keeps the
38
+ // click target a value computed with loses.
39
+ #let linked(value, printed) = {
40
+ let value = value.trim()
41
+ if value == "" { none } else { auto-link(value, body: printed) }
42
+ }
43
43
 
44
44
  #show: resume.with(
45
45
  // An enum's blank is authorable even where the schema declares a default.
@@ -54,9 +54,9 @@
54
54
  // EB Garamond has no ❖ (U+2756), the package's default separator: it prints
55
55
  // only where a second family is installed to lend the glyph, and draws from
56
56
  // that family rather than this one. ◆ is the nearest diamond EB Garamond
57
- // carries itself, and wants a point less to weigh the same.
57
+ // carries itself, and wants a point less to weigh the same: 6pt at 12pt.
58
58
  contact-separator: "◆",
59
- contact-separator-size: 6pt,
59
+ contact-separator-size: 0.5em,
60
60
  )
61
61
 
62
62
  // The package restyles lists to its square bullet inside an entry only. A
@@ -73,13 +73,13 @@
73
73
  indent: 0em,
74
74
  )
75
75
 
76
- // `name` and `contacts` are read as `string` rather than `plaintext`, which
77
- // lowers to content: the name reaches `set document(author:)`, which takes no
78
- // content, and `link-contacts` linkifies a str while passing content through.
79
76
  #resume-header(
80
- name: data.name,
81
- contacts: data.contacts,
82
- link-contacts: data.link_contacts,
77
+ name: if data.name.trim() != "" { ink(data).name },
78
+ title: data.name.trim(),
79
+ author: data.name.trim(),
80
+ contacts: data.contacts.zip(ink(data).contacts)
81
+ .map(((contact, printed)) => linked(contact, printed))
82
+ .filter(contact => contact != none),
83
83
  )
84
84
 
85
85
  // The section titles are the PDF's outline, and a `details` cell cannot decline a
@@ -101,43 +101,50 @@
101
101
  body: under-entry(details),
102
102
  )
103
103
 
104
- #for card in data.at("$cards") {
105
- let kind = card.at("$kind", default: none)
106
- let title = or-none(card.title)
107
- if title == none { title = stock-title.at(kind, default: none) }
108
- let extra = or-none(card.extra)
109
- if title != none or extra != none {
110
- section-header(if title != none { title } else { [] }, extra: extra)
111
- }
112
- body-of(card)
104
+ // The rows an author filled in. A row left wholly blank, as a new one is, would
105
+ // print as an empty gap.
106
+ #let filled(rows) = rows.filter(row => row.values().any(value => {
107
+ or-none(if type(value) == str { value.trim() } else { value }) != none
108
+ }))
113
109
 
114
- if kind == "experience" {
115
- for job in card.jobs {
116
- dated(job.company, job.dates, job.role, job.location, job.details)
117
- }
118
- } else if kind == "education" {
119
- for school in card.schools {
120
- dated(school.school, school.dates, school.degree, school.location, school.details)
121
- }
122
- } else if kind == "other" {
123
- for row in card.entries {
124
- dated(row.heading, row.dates, row.subtitle, row.location, row.details)
125
- }
126
- } else if kind == "projects" {
127
- for project in card.projects {
128
- entry(
129
- heading: trim-inline(project.name),
130
- form: "linked",
131
- url: if project.url != "" { project.url } else { none },
132
- body: under-entry(project.details),
133
- )
134
- }
135
- } else if kind == "skills" {
136
- item-grid(
137
- items: card.skills.map(row => (label: trim-inline(row.label), text: trim-inline(row.text))),
138
- columns: calc.max(1, card.columns),
110
+ // What each declared kind sets under its heading and body. A card of a kind
111
+ // the quill does not declare is warned on and left off the page: its fields
112
+ // are not this quill's to read.
113
+ #let rows = (
114
+ summary: card => none,
115
+ experience: card => for job in filled(card.jobs) {
116
+ dated(job.company, job.dates, job.role, job.location, job.details)
117
+ },
118
+ education: card => for school in filled(card.schools) {
119
+ dated(school.school, school.dates, school.degree, school.location, school.details)
120
+ },
121
+ skills: card => item-grid(
122
+ items: filled(card.skills).map(row => (label: or-none(row.label), text: trim-inline(row.items))),
123
+ columns: calc.max(1, card.columns),
124
+ ),
125
+ projects: card => for project in filled(card.projects) {
126
+ entry(
127
+ heading: trim-inline(project.name),
128
+ form: "linked",
129
+ url: linked(project.link, ink(project).link),
130
+ body: under-entry(project.details),
139
131
  )
140
- } else if kind == "certifications" {
141
- item-grid(items: card.items.map(trim-inline), columns: calc.max(1, card.columns))
132
+ },
133
+ certifications: card => item-grid(
134
+ items: card.items.map(or-none).filter(item => item != none),
135
+ columns: calc.max(1, card.columns),
136
+ ),
137
+ other: card => for row in filled(card.entries) {
138
+ dated(row.heading, row.dates, row.subtitle, row.location, row.details)
139
+ },
140
+ )
141
+
142
+ #for card in data.at("$cards") {
143
+ let kind = card.at("$kind", default: none)
144
+ if type(kind) == str and kind in rows {
145
+ let title = or-none(card.title)
146
+ if title != none { section-header(title) }
147
+ body-of(card)
148
+ rows.at(kind)(card)
142
149
  }
143
150
  }