@airmark/quiver 0.32.2 → 0.34.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 (90) hide show
  1. package/README.md +63 -21
  2. package/package.json +9 -7
  3. package/quills/af4141/0.1.0/Quill.yaml +4 -13
  4. package/quills/af4141/0.1.0/example.md +53 -0
  5. package/quills/afmc_moa/0.0.1/Quill.yaml +39 -62
  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/Quill.yaml +281 -0
  9. package/quills/classic_resume/0.0.1/example.md +111 -0
  10. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/LICENSE +21 -0
  11. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
  12. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
  13. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
  14. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
  15. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +94 -0
  16. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +214 -0
  17. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +61 -0
  18. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +86 -0
  19. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +7 -0
  20. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +37 -0
  21. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/typst.toml +26 -0
  22. package/quills/classic_resume/0.0.1/plate.typ +143 -0
  23. package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +327 -0
  24. package/quills/cyber_ribbon_chart/0.0.1/example.md +55 -0
  25. package/quills/cyber_ribbon_chart/0.0.1/plate.typ +609 -0
  26. package/quills/daf1206/0.1.0/Quill.yaml +15 -18
  27. package/quills/daf1206/0.1.0/example.md +55 -0
  28. package/quills/daf4392/0.1.0/Quill.yaml +16 -7
  29. package/quills/daf4392/0.1.0/example.md +51 -0
  30. package/quills/daf4392/0.1.0/plate.typ +2 -2
  31. package/quills/usaf_letter/0.1.0/Quill.yaml +179 -0
  32. package/quills/usaf_letter/0.1.0/assets/dod_seal.png +0 -0
  33. package/quills/usaf_letter/0.1.0/assets/dow_seal.png +0 -0
  34. package/quills/usaf_letter/0.1.0/example.md +35 -0
  35. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/.gitignore +7 -0
  36. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/LICENSE +201 -0
  37. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
  38. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/LICENSE.md +79 -0
  39. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LICENSE +102 -0
  40. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
  41. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  42. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  43. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  44. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  45. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  46. 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
  47. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  48. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  49. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/backmatter.typ +32 -0
  50. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/config.typ +45 -0
  51. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +226 -0
  52. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/lib.typ +35 -0
  53. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/mainmatter.typ +140 -0
  54. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/primitives.typ +544 -0
  55. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/utils.typ +334 -0
  56. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/typst.toml +22 -0
  57. package/quills/usaf_letter/0.1.0/plate.typ +121 -0
  58. package/quills/usaf_memo/0.2.0/Quill.yaml +8 -41
  59. package/quills/usaf_memo/0.2.0/example.md +45 -0
  60. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  61. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +1 -1
  62. package/quills/usaf_memo/0.3.0/Quill.yaml +51 -69
  63. package/quills/usaf_memo/0.3.0/example.md +48 -0
  64. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  65. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt +93 -0
  66. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  67. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  68. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -17
  69. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +97 -126
  70. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +12 -45
  71. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +81 -87
  72. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +58 -66
  73. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +14 -36
  74. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +47 -19
  75. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +158 -111
  76. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +41 -141
  77. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +3 -8
  78. package/quills/usaf_memo/0.3.0/plate.typ +78 -85
  79. package/templates/af4141.md +23 -0
  80. package/templates/appointment_letter.md +58 -0
  81. package/templates/daf1206.md +23 -0
  82. package/templates/daf4392.md +34 -0
  83. package/templates/deros_extension.md +40 -0
  84. package/templates/pass_request.md +48 -0
  85. package/templates/rebuttal.md +63 -0
  86. package/templates/templates.json +65 -0
  87. package/templates/usaf_template.md +30 -0
  88. package/templates/ussf_template.md +30 -0
  89. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
  90. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
@@ -0,0 +1,214 @@
1
+ // components.typ
2
+ // The building blocks a resume is written with.
3
+
4
+ #import "config.typ": with-config
5
+ #import "util.typ": auto-link
6
+
7
+ // Restyles native `- item` lists to the square bullet of the template. It is a
8
+ // show rule rather than a custom list component so that the body of an entry
9
+ // stays ordinary Typst markup.
10
+ #let _bulleted(cfg, body) = {
11
+ show list: set list(
12
+ marker: box(
13
+ fill: black,
14
+ width: cfg.marker-size,
15
+ height: cfg.marker-size,
16
+ baseline: cfg.marker-baseline,
17
+ ),
18
+ body-indent: cfg.marker-indent,
19
+ indent: 0em,
20
+ )
21
+ body
22
+ }
23
+
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. The gutter
26
+ // keeps a left cell that wraps off the right one.
27
+ #let _entry(cfg, cells, body) = block(
28
+ above: cfg.leading + cfg.rule-spacing,
29
+ below: cfg.leading + cfg.entry-spacing,
30
+ breakable: false,
31
+ {
32
+ grid(columns: (1fr, auto), column-gutter: 1em, row-gutter: cfg.leading, ..cells)
33
+ if body != none {
34
+ _bulleted(cfg, body)
35
+ }
36
+ },
37
+ )
38
+
39
+ // Sets the contacts on as few lines as hold them, a separator between two on
40
+ // one line. A line therefore breaks only between contacts, and never starts or
41
+ // ends on a separator. The spaces around the separator are real ones, so text
42
+ // copied out of the PDF keeps the contacts apart.
43
+ #let _contact-lines(cfg, contacts) = layout(region => {
44
+ // The separator's ink is centred on the x-height, where a middle dot sits,
45
+ // whatever glyph and size it is set in.
46
+ let glyph = text(size: cfg.contact-separator-size, cfg.contact-separator)
47
+ let extent(top, bottom, body) = measure(text(top-edge: top, bottom-edge: bottom, body)).height
48
+ let centre = extent("bounds", "baseline", glyph) - extent("bounds", "bounds", glyph) / 2
49
+ let separator = [ #box(
50
+ baseline: centre - extent("x-height", "baseline", [x]) / 2,
51
+ inset: (x: cfg.contact-separator-padding),
52
+ glyph,
53
+ ) ]
54
+ let lines = ()
55
+ let current = ()
56
+ for contact in contacts {
57
+ if current.len() > 0 and measure((..current, contact).join(separator)).width > region.width {
58
+ lines.push(current)
59
+ current = ()
60
+ }
61
+ current.push(contact)
62
+ }
63
+ lines.push(current)
64
+ lines.map(it => it.join(separator)).join(linebreak())
65
+ })
66
+
67
+ /// Name and contact line at the top of the resume.
68
+ ///
69
+ /// The name becomes the level 1 heading of the document, and — unless `title`
70
+ /// and `author` say otherwise — the title and author of the exported PDF. A
71
+ /// `none` or empty name prints no heading and leaves both unset. With
72
+ /// `link-contacts` left on, email addresses, web addresses and phone numbers
73
+ /// among the contacts become links. Pass ready-made content instead of a
74
+ /// string to opt a single contact out.
75
+ #let resume-header(
76
+ name: "",
77
+ contacts: (),
78
+ link-contacts: true,
79
+ title: auto,
80
+ author: auto,
81
+ ) = {
82
+ // Only the metadata that resolves to something is set, so that passing
83
+ // `none` leaves Typst's own default in place. An author is text only.
84
+ let metadata = (:)
85
+ let title = if title == auto { name } else { title }
86
+ let author = if author == auto { name } else { author }
87
+ if title not in (none, "") { metadata.title = title }
88
+ if type(author) == str and author != "" { metadata.author = author }
89
+ set document(..metadata)
90
+
91
+ let named = name not in (none, "")
92
+ if named or contacts.len() > 0 {
93
+ with-config(cfg => block(below: cfg.leading + cfg.header-spacing, {
94
+ if named {
95
+ heading(level: 1, name)
96
+ }
97
+ if contacts.len() > 0 {
98
+ block(
99
+ above: cfg.leading + cfg.entry-spacing,
100
+ _contact-lines(cfg, contacts.map(contact => if link-contacts { auto-link(contact) } else { contact })),
101
+ )
102
+ }
103
+ }))
104
+ }
105
+ }
106
+
107
+ /// Section title with a rule underneath it, such as `Work Experience`.
108
+ ///
109
+ /// `extra` is appended to the title in the same weight, for a qualifier like a
110
+ /// date range. The section becomes a level 2 heading, so `== Work Experience`
111
+ /// renders identically.
112
+ #let section-header(title, extra: none) = {
113
+ heading(level: 2, if extra == none { title } else { [#title #extra] })
114
+ }
115
+
116
+ /// A resume row: dated (a job, a degree, an award) or linked (a project).
117
+ ///
118
+ /// `dated` puts `dates` opposite `heading` and an optional italic second line
119
+ /// (`subtitle` / `location`). `linked` puts `url` opposite `heading`, small
120
+ /// and italic, and drops the second line; a `url` given as a string becomes a
121
+ /// link where a contact would. `body` is ordinary Typst markup — most often a
122
+ /// `- item` list, which picks up the square bullet of the template.
123
+ #let entry(
124
+ heading: "",
125
+ form: "dated",
126
+ dates: none,
127
+ subtitle: none,
128
+ location: none,
129
+ url: none,
130
+ body: none,
131
+ ) = with-config(cfg => {
132
+ assert(
133
+ form in ("dated", "linked"),
134
+ message: "entry: `form` must be \"dated\" or \"linked\", found " + repr(form),
135
+ )
136
+
137
+ let cells = if form == "linked" {
138
+ let annotation = if url != none {
139
+ let label = text(
140
+ size: cfg.annotation-size,
141
+ font: if cfg.annotation-font == auto { cfg.font } else { cfg.annotation-font },
142
+ style: "italic",
143
+ url,
144
+ )
145
+ auto-link(url, body: label)
146
+ }
147
+ (align(left, text(weight: "bold", heading)), align(right, annotation))
148
+ } else {
149
+ let cells = (
150
+ align(left, text(weight: "bold", heading)),
151
+ align(right, text(weight: "bold", dates)),
152
+ )
153
+ if subtitle != none or location != none {
154
+ cells.push(align(left, text(style: "italic", subtitle)))
155
+ cells.push(align(right, text(style: "italic", location)))
156
+ }
157
+ cells
158
+ }
159
+
160
+ _entry(cfg, cells, body)
161
+ })
162
+
163
+ /// Multi-column list of short items, for certifications, skills or awards.
164
+ ///
165
+ /// Two shapes are accepted and told apart by the first item: a flat array of
166
+ /// content, or an array of `(label: .., text: ..)` dictionaries, which puts
167
+ /// the label in bold above its text.
168
+ #let item-grid(items: (), columns: 2) = {
169
+ if items.len() == 0 {
170
+ return
171
+ }
172
+ assert(
173
+ type(columns) == int and columns >= 1,
174
+ message: "item-grid: `columns` must be a positive integer, found " + repr(columns),
175
+ )
176
+
177
+ let labeled = type(items.at(0)) == dictionary and "label" in items.at(0)
178
+
179
+ let cell(item) = if labeled {
180
+ assert(
181
+ type(item) == dictionary and "label" in item and "text" in item,
182
+ message: "item-grid: every item must be a `(label: .., text: ..)` dictionary "
183
+ + "when the first one is, found " + repr(item),
184
+ )
185
+ block({
186
+ text(weight: "bold", item.label)
187
+ linebreak()
188
+ item.text
189
+ })
190
+ } else {
191
+ assert(
192
+ type(item) != dictionary,
193
+ message: "item-grid: a `(label: .., text: ..)` dictionary cannot be mixed with "
194
+ + "plain items, found " + repr(item),
195
+ )
196
+ item
197
+ }
198
+
199
+ with-config(cfg => block(
200
+ above: cfg.leading + cfg.rule-spacing,
201
+ pad(
202
+ // Line the items up with the bullets of the entries around them.
203
+ left: cfg.marker-size + cfg.marker-indent,
204
+ grid(
205
+ columns: (1fr,) * columns,
206
+ // Labeled items are two lines tall, so they need the extra gap to
207
+ // stay visually separated.
208
+ row-gutter: if labeled { cfg.leading + cfg.entry-spacing } else { cfg.leading },
209
+ column-gutter: 1em,
210
+ ..items.map(cell),
211
+ ),
212
+ ),
213
+ ))
214
+ }
@@ -0,0 +1,61 @@
1
+ // config.typ
2
+ // Defaults for the template, plus the state that carries them to components.
3
+ //
4
+ // `resume` stores the effective configuration in `config-state`; components
5
+ // read it back through `with-config`. That way a `#show: resume.with(..)`
6
+ // override reaches every component without threading arguments through each
7
+ // individual call.
8
+
9
+ #let default-config = (
10
+ // Body font. The first installed family wins, so the bundled EB Garamond is
11
+ // preferred while Typst's built-in serif faces act as a fallback.
12
+ font: ("EB Garamond", "Libertinus Serif", "New Computer Modern"),
13
+ size: 12pt,
14
+ paper: "us-letter",
15
+ margin: 0.5in,
16
+
17
+ // Vertical rhythm. `leading` is used for the leading inside a paragraph and
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.
23
+ leading: 0.5em,
24
+ section-spacing: 5pt,
25
+ rule-spacing: 3.5pt,
26
+ entry-spacing: 5pt,
27
+ header-spacing: 10pt,
28
+
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.
32
+ name-size: 18pt,
33
+ contact-separator: "❖",
34
+ contact-separator-size: 7pt,
35
+ contact-separator-padding: 0.25em,
36
+
37
+ // Rule drawn underneath a section header.
38
+ rule-stroke: 0.75pt,
39
+
40
+ // Square bullet used by lists inside an entry. The negative baseline lifts
41
+ // the square off the baseline so that it lines up with the middle of the
42
+ // x-height instead of hanging below the line.
43
+ marker-size: 3.5pt,
44
+ marker-baseline: -0.07em,
45
+ marker-indent: 0.8em,
46
+
47
+ // Right-aligned annotation of a linked entry, normally a URL. A font of
48
+ // `auto` keeps the body font; an `em` size follows the body size.
49
+ annotation-font: auto,
50
+ annotation-size: 0.85em,
51
+
52
+ // Name and page number over every page after the first; `false` leaves
53
+ // those pages bare.
54
+ continuation-header: true,
55
+ )
56
+
57
+ #let config-state = state("ttq-classic-resume:config", default-config)
58
+
59
+ // Runs `fn` with the active configuration. Components use this instead of
60
+ // reading `config-state` directly so that the `context` stays out of the way.
61
+ #let with-config(fn) = context fn(config-state.get())
@@ -0,0 +1,86 @@
1
+ // layout.typ
2
+ // The document-wide show rule.
3
+
4
+ #import "config.typ": config-state, default-config
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 document's level 1 heading. Small and italic, it
9
+ // reads as a running head: a 0.5in margin leaves no room to set it further off
10
+ // the page.
11
+ #let _continuation-header(cfg) = context {
12
+ if counter(page).get().first() > 1 {
13
+ set text(size: cfg.annotation-size, style: "italic")
14
+ let names = query(heading.where(level: 1))
15
+ if names.len() > 0 { names.first().body }
16
+ h(1fr)
17
+ counter(page).display("1 of 1", both: true)
18
+ }
19
+ }
20
+
21
+ /// Applies the resume styling to the whole document.
22
+ ///
23
+ /// Use `#show: resume` for the defaults, or `#show: resume.with(size: 11pt)`
24
+ /// to override any key of `default-config`.
25
+ #let resume(..options, body) = {
26
+ assert(
27
+ options.pos().len() == 0,
28
+ message: "resume: expected named options only, found "
29
+ + str(options.pos().len()) + " positional argument(s)",
30
+ )
31
+ let unknown = options.named().keys().filter(key => key not in default-config)
32
+ assert(
33
+ unknown.len() == 0,
34
+ message: "resume: unknown option(s) " + unknown.map(repr).join(", ")
35
+ + "; expected one of " + default-config.keys().map(repr).join(", "),
36
+ )
37
+
38
+ let cfg = default-config + options.named()
39
+
40
+ set page(
41
+ paper: cfg.paper,
42
+ margin: cfg.margin,
43
+ header: if cfg.continuation-header { _continuation-header(cfg) },
44
+ )
45
+ set text(font: cfg.font, size: cfg.size)
46
+ set par(leading: cfg.leading, spacing: cfg.leading, justify: false)
47
+
48
+ // One vertical rhythm for every kind of block-level content. Block spacing
49
+ // collapses to the larger of two neighbours' gaps, so a gap wider than
50
+ // `leading` is set as a block's `above` or `below` rather than with `v`,
51
+ // which would add to it.
52
+ set block(above: cfg.leading, below: cfg.leading)
53
+ set list(spacing: cfg.leading)
54
+
55
+ // Hyperlinks are styled like the surrounding text: on paper the colour is
56
+ // noise, and the link is still live in the PDF.
57
+ show link: set text(fill: black)
58
+
59
+ // Headings carry the document structure into the tagged PDF. The resume look
60
+ // is applied here rather than inside the components, so that plain `=` and
61
+ // `==` markup renders the same way: the name is the level 1 heading, a
62
+ // section title the level 2 one. Both are set apart by weight and case
63
+ // rather than by size.
64
+ set heading(numbering: none)
65
+ show heading: set text(size: cfg.size, weight: "bold")
66
+ show heading: set block(above: cfg.leading, below: cfg.leading)
67
+ // The name is enlarged inside the block, not on the heading itself, so that
68
+ // the surrounding `em` spacing keeps resolving against the body size.
69
+ show heading.where(level: 1): it => block(text(size: cfg.name-size, it.body))
70
+ // A heading keeps with what follows it, so the section's first line is on
71
+ // the page its rule is. Capitals set solid crowd each other, so the title is
72
+ // tracked out a little.
73
+ show heading.where(level: 2): it => block(
74
+ above: cfg.leading + cfg.section-spacing,
75
+ below: cfg.leading + cfg.rule-spacing,
76
+ sticky: true,
77
+ {
78
+ text(tracking: 0.04em, upper(it.body))
79
+ block(above: cfg.leading, line(length: 100%, stroke: cfg.rule-stroke))
80
+ },
81
+ )
82
+
83
+ config-state.update(cfg)
84
+
85
+ body
86
+ }
@@ -0,0 +1,7 @@
1
+ // lib.typ
2
+ // Public API of the classic resume template.
3
+
4
+ #import "config.typ": default-config
5
+ #import "layout.typ": resume
6
+ #import "components.typ": entry, item-grid, resume-header, section-header
7
+ #import "util.typ": auto-link
@@ -0,0 +1,37 @@
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 _email = regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
8
+ #let _scheme = regex("^(https?://|www\\.)\\S+$")
9
+ #let _domain = regex("^[\\w-]+(\\.[\\w-]+)*\\.[A-Za-z]{2,24}(/\\S*)?$")
10
+ #let _phone = regex("^[+(\\d][\\d\\s()./+-]*\\d$")
11
+ #let _digit = regex("\\d")
12
+
13
+ /// Turns a contact into a link when it looks like an email address, a web
14
+ /// address or a phone number, and returns it unchanged otherwise. A city or a
15
+ /// clearance level is therefore left alone, and so is anything that is already
16
+ /// content, so callers can always pass their own `link(..)` instead.
17
+ ///
18
+ /// `body` is what prints, the contact itself unless given: a caller holding the
19
+ /// contact as text and as content of its own reads the one and prints the
20
+ /// other.
21
+ #let auto-link(contact, body: auto) = {
22
+ let body = if body == auto { contact } else { body }
23
+ if type(contact) != str {
24
+ body
25
+ } else if contact.match(_email) != none {
26
+ link("mailto:" + contact, body)
27
+ } else if contact.match(_scheme) != none {
28
+ let href = if contact.starts-with("www.") { "https://" + contact } else { contact }
29
+ link(href, body)
30
+ } else if contact.match(_domain) != none {
31
+ link("https://" + contact, body)
32
+ } else if contact.match(_phone) != none and contact.matches(_digit).len() >= 7 {
33
+ link("tel:" + contact.replace(regex("[^\\d+]"), ""), body)
34
+ } else {
35
+ body
36
+ }
37
+ }
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "ttq-classic-resume"
3
+ version = "0.1.0"
4
+ compiler = "0.14.0"
5
+ entrypoint = "src/lib.typ"
6
+ repository = "https://github.com/nibsbin/ttq-classic-resume"
7
+ authors = ["Nibs"]
8
+ license = "MIT"
9
+ description = "A clean and modern resume."
10
+
11
+ keywords = [
12
+ "resume",
13
+ "cv",
14
+ "curriculum vitae",
15
+ "job",
16
+ "career",
17
+ "professional",
18
+ "template",
19
+ ]
20
+ categories = ["cv"]
21
+ disciplines = ["business", "computer-science", "engineering", "communication"]
22
+
23
+ [template]
24
+ path = "template"
25
+ entrypoint = "resume.typ"
26
+ thumbnail = "thumbnail.png"
@@ -0,0 +1,143 @@
1
+ #import "@local/quillmark-helper:0.1.0": data, ink
2
+ #import "@local/ttq-classic-resume:0.1.0": (
3
+ auto-link, default-config, entry, item-grid, resume, resume-header, section-header,
4
+ )
5
+
6
+ // `plaintext`/`richtext` fields lower to content carrying a space element on
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
+ #let trim-inline(v) = {
10
+ if type(v) != content { return v }
11
+ let kids = v.at("children", default: none)
12
+ if kids == none { return v }
13
+ let padding(c) = c == [ ] or c.func() == parbreak
14
+ while kids.len() > 0 and padding(kids.first()) { kids = kids.slice(1) }
15
+ while kids.len() > 0 and padding(kids.last()) { kids = kids.slice(0, -1) }
16
+ kids.sum(default: [])
17
+ }
18
+
19
+ // `none` for a field the author left blank, so the components can drop what it
20
+ // would have occupied: a dated `entry` omits its whole second line when both
21
+ // halves are none, a linked one its annotation, an entry what is under it, and
22
+ // a section its heading.
23
+ #let or-none(v) = {
24
+ let trimmed = trim-inline(v)
25
+ if trimmed == [] or trimmed == "" { none } else { trimmed }
26
+ }
27
+
28
+ // The quillmark helper leaves an unset or whitespace-only markdown body as the
29
+ // empty string; only a non-empty body is eval'd into content.
30
+ #let body-of(card) = {
31
+ let body = card.at("$body", default: "")
32
+ if type(body) == str { none } else { body }
33
+ }
34
+
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
+ if value.trim() == "" { none } else { auto-link(value, body: printed) }
41
+ }
42
+
43
+ #show: resume.with(
44
+ // An enum's blank is authorable even where the schema declares a default.
45
+ paper: if data.paper != "" { data.paper } else { "us-letter" },
46
+ size: data.font_size * 1pt,
47
+ margin: data.margin * 1in,
48
+
49
+ // The package's stack names two families to fall back to; only the one this
50
+ // quill bundles is in the render's font book, and naming the absent two
51
+ // warns once per render.
52
+ font: "EB Garamond",
53
+ // EB Garamond has no ❖ (U+2756), the package's default separator: it prints
54
+ // only where a second family is installed to lend the glyph, and draws from
55
+ // that family rather than this one. ◆ is the nearest diamond EB Garamond
56
+ // carries itself, and wants a point less to weigh the same.
57
+ contact-separator: "◆",
58
+ contact-separator-size: 6pt,
59
+ )
60
+
61
+ // The package restyles lists to its square bullet inside an entry only. A
62
+ // section body is the other place a resume holds one, and a list that changed
63
+ // marker with its surroundings would read as two templates.
64
+ #show list: set list(
65
+ marker: box(
66
+ fill: black,
67
+ width: default-config.marker-size,
68
+ height: default-config.marker-size,
69
+ baseline: default-config.marker-baseline,
70
+ ),
71
+ body-indent: default-config.marker-indent,
72
+ indent: 0em,
73
+ )
74
+
75
+ #resume-header(
76
+ name: if data.name.trim() != "" { ink(data).name },
77
+ title: data.name.trim(),
78
+ author: data.name.trim(),
79
+ contacts: data.contacts.zip(ink(data).contacts)
80
+ .map(((contact, printed)) => linked(contact, printed))
81
+ .filter(contact => contact != none),
82
+ )
83
+
84
+ // The section titles are the PDF's outline, and a `details` cell cannot decline a
85
+ // heading as a body does, so one written there sets as a bold line outside it.
86
+ #let under-entry(details) = {
87
+ let body = or-none(details)
88
+ if body == none { return none }
89
+ set heading(outlined: false, bookmarked: false)
90
+ show heading: it => block(strong(it.body))
91
+ body
92
+ }
93
+
94
+ #let dated(heading, dates, subtitle, location, details) = entry(
95
+ heading: trim-inline(heading),
96
+ form: "dated",
97
+ dates: trim-inline(dates),
98
+ subtitle: or-none(subtitle),
99
+ location: or-none(location),
100
+ body: under-entry(details),
101
+ )
102
+
103
+ // What each declared kind sets under its heading and body. A card of a kind
104
+ // the quill does not declare is warned on and left off the page: its fields
105
+ // are not this quill's to read.
106
+ #let rows = (
107
+ summary: card => none,
108
+ experience: card => for job in card.jobs {
109
+ dated(job.company, job.dates, job.role, job.location, job.details)
110
+ },
111
+ education: card => for school in card.schools {
112
+ dated(school.school, school.dates, school.degree, school.location, school.details)
113
+ },
114
+ skills: card => item-grid(
115
+ items: card.skills.map(row => (label: trim-inline(row.label), text: trim-inline(row.items))),
116
+ columns: calc.max(1, card.columns),
117
+ ),
118
+ projects: card => for project in card.projects {
119
+ entry(
120
+ heading: trim-inline(project.name),
121
+ form: "linked",
122
+ url: linked(project.link, ink(project).link),
123
+ body: under-entry(project.details),
124
+ )
125
+ },
126
+ certifications: card => item-grid(
127
+ items: card.items.map(trim-inline),
128
+ columns: calc.max(1, card.columns),
129
+ ),
130
+ other: card => for row in card.entries {
131
+ dated(row.heading, row.dates, row.subtitle, row.location, row.details)
132
+ },
133
+ )
134
+
135
+ #for card in data.at("$cards") {
136
+ let kind = card.at("$kind", default: none)
137
+ if type(kind) == str and kind in rows {
138
+ let title = or-none(card.title)
139
+ if title != none { section-header(title) }
140
+ body-of(card)
141
+ rows.at(kind)(card)
142
+ }
143
+ }