orz-paged 0.1.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.
- package/README.md +146 -0
- package/assets/app.js +413 -0
- package/assets/templates/article-page.md +71 -0
- package/assets/templates/article-section.md +83 -0
- package/assets/templates/cover-letter.md +53 -0
- package/assets/templates/cv-elegant.md +93 -0
- package/assets/templates/cv-modern.md +86 -0
- package/assets/templates/cv.md +45 -0
- package/assets/templates/exam-page.md +76 -0
- package/assets/templates/exam-section.md +59 -0
- package/assets/templates/letter.md +47 -0
- package/assets/templates/note.md +28 -0
- package/assets/templates/report-page.md +72 -0
- package/assets/templates/report-section.md +80 -0
- package/assets/themes/base.css +538 -0
- package/assets/themes/beige-decent-1.css +103 -0
- package/assets/themes/beige-decent-2.css +92 -0
- package/assets/themes/light-academic-1.css +114 -0
- package/assets/themes/light-academic-2.css +99 -0
- package/assets/themes/light-neat-1.css +90 -0
- package/assets/themes/light-neat-2.css +101 -0
- package/assets/themes/light-neat-3.css +91 -0
- package/dist/browser-entry.js +302 -0
- package/dist/cli.js +167 -0
- package/dist/doc/dynamic.js +57 -0
- package/dist/doc/elements.js +813 -0
- package/dist/doc/fonts.js +83 -0
- package/dist/doc/nyml.js +140 -0
- package/dist/doc/page-css.js +230 -0
- package/dist/doc/settings.js +390 -0
- package/dist/doc/templates.js +147 -0
- package/dist/doc/theme-fonts.js +10 -0
- package/dist/orz-paged.browser.js +1524 -0
- package/dist/paged.js +31 -0
- package/dist/render-paged.js +123 -0
- package/dist/template.js +242 -0
- package/dist/types.js +11 -0
- package/orz-paged-skills/SKILL.md +547 -0
- package/package.json +51 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orz-paged — built-in document elements.
|
|
3
|
+
*
|
|
4
|
+
* `renderElement(spec, ctx)` turns one `{{nyml kind: element}}` block into
|
|
5
|
+
* semantic HTML + a small scoped stylesheet. Every element is wrapped in
|
|
6
|
+
* `<section class="orz-element orz-el-<kind>">…</section>`. Markdown-typed
|
|
7
|
+
* fields go through the injected `ctx.renderInline` / `ctx.renderBlock` so this
|
|
8
|
+
* module never depends on orz-markdown directly.
|
|
9
|
+
*
|
|
10
|
+
* The `css` returned per kind is identical on every call (the assembler dedupes
|
|
11
|
+
* by kind), uses `var(--accent)` for the accent color, and stays light-only.
|
|
12
|
+
*
|
|
13
|
+
* Spec: docs/document-model.md (Built-in Elements) — reimplemented cleanly.
|
|
14
|
+
*/
|
|
15
|
+
/* ─────────────────────────── helpers ─────────────────────────── */
|
|
16
|
+
/** Escape text destined for an HTML attribute value (and general text). */
|
|
17
|
+
function escapeHtml(s) {
|
|
18
|
+
return s
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
24
|
+
}
|
|
25
|
+
/** Read a field, trimmed; returns '' when absent. */
|
|
26
|
+
function field(spec, key) {
|
|
27
|
+
const v = spec.fields[key];
|
|
28
|
+
return v == null ? '' : v.trim();
|
|
29
|
+
}
|
|
30
|
+
/** True when a field is present and non-empty after trimming. */
|
|
31
|
+
function has(spec, key) {
|
|
32
|
+
return field(spec, key) !== '';
|
|
33
|
+
}
|
|
34
|
+
/** Read a boolean field; absent → fallback. `false`/`no`/`off`/`0` are false. */
|
|
35
|
+
function boolField(spec, key, fallback) {
|
|
36
|
+
const v = spec.fields[key];
|
|
37
|
+
if (v == null || v.trim() === '')
|
|
38
|
+
return fallback;
|
|
39
|
+
return !/^(false|no|off|0)$/i.test(v.trim());
|
|
40
|
+
}
|
|
41
|
+
/** Split a pipe-separated multi-line field into trimmed, non-empty parts. */
|
|
42
|
+
function pipeLines(value) {
|
|
43
|
+
return value
|
|
44
|
+
.split('|')
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter((s) => s !== '');
|
|
47
|
+
}
|
|
48
|
+
/** Split a multiline field into trimmed, non-empty lines. */
|
|
49
|
+
function textLines(value) {
|
|
50
|
+
return value
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter((s) => s !== '');
|
|
54
|
+
}
|
|
55
|
+
/** ORCID iD + email icons for author entries (compact, inline, ~1em). */
|
|
56
|
+
const ORCID_ICON = '<svg class="orz-orcid-icon" viewBox="0 0 256 256" aria-hidden="true"><path fill="#A6CE39" d="M256 128c0 70.7-57.3 128-128 128S0 198.7 0 128 57.3 0 128 0s128 57.3 128 128z"/><g fill="#fff"><path d="M86.3 186.2H70.9V79.1h15.4v107.1z"/><path d="M108.9 79.1h41.6c39.6 0 57 28.3 57 53.6 0 27.5-21.5 53.6-56.8 53.6h-41.8V79.1zm15.4 93.3h24.5c34.9 0 42.9-26.5 42.9-39.7 0-21.5-13.7-39.7-43.7-39.7h-23.7v79.4z"/><path d="M88.7 56.8a10.1 10.1 0 1 1-20.2 0 10.1 10.1 0 0 1 20.2 0z"/></g></svg>';
|
|
57
|
+
const EMAIL_ICON = '<svg class="orz-email-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>';
|
|
58
|
+
/** Parse one `authors:` line: `Name | marks | email | orcid`. Fields after the
|
|
59
|
+
* name are auto-detected (`@` → email, an ORCID id / orcid.org → orcid, else
|
|
60
|
+
* affiliation/note markers), so order is flexible and any may be omitted. */
|
|
61
|
+
function parseAuthor(line) {
|
|
62
|
+
const parts = line.split('|').map((s) => s.trim());
|
|
63
|
+
const out = { name: parts[0] || '', marks: '', email: '', orcid: '' };
|
|
64
|
+
for (const p of parts.slice(1)) {
|
|
65
|
+
if (!p)
|
|
66
|
+
continue;
|
|
67
|
+
if (p.includes('@'))
|
|
68
|
+
out.email = p;
|
|
69
|
+
else if (/orcid\.org/i.test(p) || /^\d{4}-\d{4}-\d{4}-\d{3}[\dXx]$/.test(p))
|
|
70
|
+
out.orcid = p;
|
|
71
|
+
else
|
|
72
|
+
out.marks = p;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** ORCID iD link for an author entry. */
|
|
77
|
+
function orcidLink(orcid) {
|
|
78
|
+
const m = orcid.match(/(\d{4}-\d{4}-\d{4}-\d{3}[\dXx])/);
|
|
79
|
+
const id = m ? m[1] : orcid;
|
|
80
|
+
return `<a class="orz-author-orcid" href="https://orcid.org/${escapeHtml(id)}" title="ORCID ${escapeHtml(id)}">${ORCID_ICON}</a>`;
|
|
81
|
+
}
|
|
82
|
+
/** Email link for an author entry. */
|
|
83
|
+
function emailLink(email) {
|
|
84
|
+
return `<a class="orz-author-email" href="mailto:${escapeHtml(email)}" title="${escapeHtml(email)}">${EMAIL_ICON}</a>`;
|
|
85
|
+
}
|
|
86
|
+
/** Render a `key: text` reference line (affiliations / notes) with a superscript key. */
|
|
87
|
+
function refLine(line, cls, ctx) {
|
|
88
|
+
const m = line.match(/^([^:]+):\s*(.*)$/);
|
|
89
|
+
const key = m ? m[1].trim() : '';
|
|
90
|
+
const text = m ? m[2].trim() : line;
|
|
91
|
+
return `<div class="${cls}">${key ? `<sup>${escapeHtml(key)}</sup> ` : ''}${ctx.renderInline(text)}</div>`;
|
|
92
|
+
}
|
|
93
|
+
/** Resolve the effective placement for a spec (`spec.placement` or fields). */
|
|
94
|
+
function resolvePlacement(spec) {
|
|
95
|
+
if (spec.placement === 'page')
|
|
96
|
+
return 'page';
|
|
97
|
+
if (field(spec, 'placement').toLowerCase() === 'page')
|
|
98
|
+
return 'page';
|
|
99
|
+
return 'section';
|
|
100
|
+
}
|
|
101
|
+
/** CSS that turns a section into a dedicated page (used for `placement: page`).
|
|
102
|
+
* `page: orz-front` names the page so page-css can target it (front_matter:
|
|
103
|
+
* clean strips its chrome via `@page orz-front`). Harmless when no such rule. */
|
|
104
|
+
const PAGE_BREAK_CSS = `
|
|
105
|
+
.orz-el-PLACEHOLDER.orz-place-page {
|
|
106
|
+
break-after: page;
|
|
107
|
+
page: orz-front;
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
min-height: 80vh;
|
|
112
|
+
}`;
|
|
113
|
+
/** Build the page-break rule scoped to a kind. */
|
|
114
|
+
function pageBreakCss(kind) {
|
|
115
|
+
return PAGE_BREAK_CSS.replace(/PLACEHOLDER/g, kind);
|
|
116
|
+
}
|
|
117
|
+
/** Wrap inner HTML in the element <section> with kind + placement classes. */
|
|
118
|
+
function wrap(kind, placement, inner) {
|
|
119
|
+
const placeClass = placement === 'page' ? ' orz-place-page' : '';
|
|
120
|
+
return `<section class="orz-element orz-el-${kind}${placeClass}">${inner}</section>`;
|
|
121
|
+
}
|
|
122
|
+
function renderTitle(kind, spec, ctx) {
|
|
123
|
+
const placement = resolvePlacement(spec);
|
|
124
|
+
const rows = [];
|
|
125
|
+
if (has(spec, 'title')) {
|
|
126
|
+
rows.push(`<h1 class="orz-title">${ctx.renderInline(field(spec, 'title'))}</h1>`);
|
|
127
|
+
}
|
|
128
|
+
if (has(spec, 'subtitle')) {
|
|
129
|
+
rows.push(`<p class="orz-subtitle">${ctx.renderInline(field(spec, 'subtitle'))}</p>`);
|
|
130
|
+
}
|
|
131
|
+
// Authors: rich `authors:` (multiple — each with affiliation marks / email /
|
|
132
|
+
// orcid) or the simple legacy `author:` single line. `affiliations:` and
|
|
133
|
+
// `notes:` are `key: text` lists keyed by the markers used in `authors:`.
|
|
134
|
+
const meta = [];
|
|
135
|
+
if (has(spec, 'authors')) {
|
|
136
|
+
const items = textLines(field(spec, 'authors')).map(parseAuthor).map((a) => {
|
|
137
|
+
let s = `<span class="orz-author-name">${ctx.renderInline(a.name)}</span>`;
|
|
138
|
+
if (a.marks)
|
|
139
|
+
s += `<sup class="orz-author-mark">${escapeHtml(a.marks)}</sup>`;
|
|
140
|
+
if (a.email)
|
|
141
|
+
s += emailLink(a.email);
|
|
142
|
+
if (a.orcid)
|
|
143
|
+
s += orcidLink(a.orcid);
|
|
144
|
+
return `<span class="orz-author">${s}</span>`;
|
|
145
|
+
});
|
|
146
|
+
if (items.length) {
|
|
147
|
+
meta.push(`<div class="orz-authors">${items.join('<span class="orz-author-sep">, </span>')}</div>`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (has(spec, 'author')) {
|
|
151
|
+
meta.push(`<div class="orz-author orz-authors">${ctx.renderInline(field(spec, 'author'))}</div>`);
|
|
152
|
+
}
|
|
153
|
+
if (has(spec, 'affiliations')) {
|
|
154
|
+
const lines = textLines(field(spec, 'affiliations')).map((l) => refLine(l, 'orz-affil', ctx));
|
|
155
|
+
if (lines.length)
|
|
156
|
+
meta.push(`<div class="orz-affiliations">${lines.join('')}</div>`);
|
|
157
|
+
}
|
|
158
|
+
if (has(spec, 'notes')) {
|
|
159
|
+
const lines = textLines(field(spec, 'notes')).map((l) => refLine(l, 'orz-note', ctx));
|
|
160
|
+
if (lines.length)
|
|
161
|
+
meta.push(`<div class="orz-notes">${lines.join('')}</div>`);
|
|
162
|
+
}
|
|
163
|
+
if (kind === 'exam-title') {
|
|
164
|
+
if (has(spec, 'course')) {
|
|
165
|
+
meta.push(`<div class="orz-course">${ctx.renderInline(field(spec, 'course'))}</div>`);
|
|
166
|
+
}
|
|
167
|
+
if (has(spec, 'instructor')) {
|
|
168
|
+
meta.push(`<div class="orz-instructor">${escapeHtml(field(spec, 'instructor'))}</div>`);
|
|
169
|
+
}
|
|
170
|
+
const examMeta = [];
|
|
171
|
+
if (has(spec, 'duration'))
|
|
172
|
+
examMeta.push(escapeHtml(field(spec, 'duration')));
|
|
173
|
+
if (has(spec, 'total_points'))
|
|
174
|
+
examMeta.push(escapeHtml(field(spec, 'total_points')));
|
|
175
|
+
if (examMeta.length) {
|
|
176
|
+
meta.push(`<div class="orz-exam-meta">${examMeta.join(' · ')}</div>`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (has(spec, 'date')) {
|
|
180
|
+
meta.push(`<div class="orz-date">${escapeHtml(field(spec, 'date'))}</div>`);
|
|
181
|
+
}
|
|
182
|
+
if (has(spec, 'doi')) {
|
|
183
|
+
meta.push(`<div class="orz-doi">${escapeHtml(field(spec, 'doi'))}</div>`);
|
|
184
|
+
}
|
|
185
|
+
if (meta.length) {
|
|
186
|
+
rows.push(`<div class="orz-title-meta">${meta.join('')}</div>`);
|
|
187
|
+
}
|
|
188
|
+
// Exam-only: student fill-in fields (Name / Student ID / Score …) and an
|
|
189
|
+
// optional instructions block, both on the title page/section — so identity
|
|
190
|
+
// fields live on page 1 instead of a running header on every page.
|
|
191
|
+
if (kind === 'exam-title') {
|
|
192
|
+
if (has(spec, 'student_fields')) {
|
|
193
|
+
// Each line is a ROW; `|` puts several fields on the same row. A trailing
|
|
194
|
+
// `/ <number>` (e.g. "Score / 100") becomes a suffix after the blank.
|
|
195
|
+
const fieldRows = textLines(field(spec, 'student_fields')).map((line) => {
|
|
196
|
+
const fields = line
|
|
197
|
+
.split('|')
|
|
198
|
+
.map((s) => s.trim())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.map((f) => {
|
|
201
|
+
const m = f.match(/^(.*?)\s*(\/\s*\d.*)$/);
|
|
202
|
+
const label = m ? m[1].trim() : f;
|
|
203
|
+
const suffix = m ? m[2].trim() : '';
|
|
204
|
+
return '<span class="orz-exam-field">'
|
|
205
|
+
+ `<span class="orz-field-label">${ctx.renderInline(label)}</span>`
|
|
206
|
+
+ '<span class="orz-field-blank"></span>'
|
|
207
|
+
+ (suffix ? `<span class="orz-field-suffix">${ctx.renderInline(suffix)}</span>` : '')
|
|
208
|
+
+ '</span>';
|
|
209
|
+
});
|
|
210
|
+
return fields.length ? `<div class="orz-exam-row">${fields.join('')}</div>` : '';
|
|
211
|
+
}).filter(Boolean);
|
|
212
|
+
if (fieldRows.length)
|
|
213
|
+
rows.push(`<div class="orz-exam-fields">${fieldRows.join('')}</div>`);
|
|
214
|
+
}
|
|
215
|
+
if (has(spec, 'instructions')) {
|
|
216
|
+
rows.push(`<div class="orz-exam-instructions">${ctx.renderBlock(field(spec, 'instructions'))}</div>`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const html = wrap(kind, placement, rows.join(''));
|
|
220
|
+
let css = `
|
|
221
|
+
.orz-el-${kind} {
|
|
222
|
+
text-align: center;
|
|
223
|
+
margin: 0 0 1.5rem;
|
|
224
|
+
}
|
|
225
|
+
.orz-el-${kind} .orz-title {
|
|
226
|
+
margin: 0 0 0.25em;
|
|
227
|
+
font-size: 1.9em;
|
|
228
|
+
line-height: 1.2;
|
|
229
|
+
color: var(--accent, #000);
|
|
230
|
+
}
|
|
231
|
+
/* The title is an <h1>; neutralize any theme h1 underline — the rule under a
|
|
232
|
+
title block is owned by the element wrapper, not the heading. */
|
|
233
|
+
.orz-doc.markdown-body .orz-el-${kind} .orz-title { border-bottom: 0; padding-bottom: 0; }
|
|
234
|
+
.orz-doc.markdown-body .orz-el-${kind} .orz-subtitle {
|
|
235
|
+
margin: 0 0 0.75em;
|
|
236
|
+
font-size: 1.2em;
|
|
237
|
+
font-style: italic;
|
|
238
|
+
opacity: 0.85;
|
|
239
|
+
/* outspecify a theme's justified-prose rule (.orz-doc.markdown-body p) so the
|
|
240
|
+
subtitle stays centered with the rest of the title block */
|
|
241
|
+
text-align: center;
|
|
242
|
+
}
|
|
243
|
+
.orz-el-${kind} .orz-title-meta {
|
|
244
|
+
font-size: 0.95em;
|
|
245
|
+
line-height: 1.5;
|
|
246
|
+
}
|
|
247
|
+
.orz-el-${kind} .orz-title-meta > div {
|
|
248
|
+
margin: 0.1em 0;
|
|
249
|
+
}
|
|
250
|
+
.orz-el-${kind} .orz-authors {
|
|
251
|
+
font-weight: 600;
|
|
252
|
+
}
|
|
253
|
+
.orz-el-${kind} .orz-author { white-space: nowrap; }
|
|
254
|
+
.orz-el-${kind} .orz-author-mark,
|
|
255
|
+
.orz-el-${kind} .orz-author-sep { font-weight: 400; }
|
|
256
|
+
.orz-el-${kind} .orz-author-email,
|
|
257
|
+
.orz-el-${kind} .orz-author-orcid { margin-left: 0.22em; text-decoration: none; }
|
|
258
|
+
.orz-el-${kind} .orz-author-email { color: var(--accent, #555); }
|
|
259
|
+
.orz-el-${kind} .orz-author-email svg,
|
|
260
|
+
.orz-el-${kind} .orz-author-orcid svg {
|
|
261
|
+
width: 0.92em; height: 0.92em; vertical-align: -0.13em;
|
|
262
|
+
}
|
|
263
|
+
.orz-el-${kind} .orz-affiliations {
|
|
264
|
+
font-weight: 400; font-size: 0.82em; opacity: 0.85;
|
|
265
|
+
margin-top: 0.4em; line-height: 1.5;
|
|
266
|
+
}
|
|
267
|
+
.orz-el-${kind} .orz-notes {
|
|
268
|
+
font-weight: 400; font-size: 0.78em; opacity: 0.8;
|
|
269
|
+
margin-top: 0.25em; line-height: 1.5;
|
|
270
|
+
}
|
|
271
|
+
.orz-el-${kind} .orz-affil sup,
|
|
272
|
+
.orz-el-${kind} .orz-note sup { margin-right: 0.12em; }`;
|
|
273
|
+
if (kind === 'exam-title') {
|
|
274
|
+
css += `
|
|
275
|
+
.orz-el-exam-title .orz-exam-fields {
|
|
276
|
+
text-align: left;
|
|
277
|
+
margin: 1.3em auto 0;
|
|
278
|
+
width: 100%; /* stretch to the page width on the cover (a flex column),
|
|
279
|
+
matching the title-section variant */
|
|
280
|
+
max-width: 40em;
|
|
281
|
+
}
|
|
282
|
+
.orz-el-exam-title .orz-exam-row {
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-wrap: wrap;
|
|
285
|
+
gap: 0.5em 1.3em;
|
|
286
|
+
margin: 0.75em 0;
|
|
287
|
+
}
|
|
288
|
+
.orz-el-exam-title .orz-exam-field {
|
|
289
|
+
flex: 1 1 0;
|
|
290
|
+
display: flex;
|
|
291
|
+
align-items: flex-end;
|
|
292
|
+
gap: 0.4em;
|
|
293
|
+
}
|
|
294
|
+
.orz-el-exam-title .orz-field-label { white-space: nowrap; font-weight: 600; }
|
|
295
|
+
.orz-el-exam-title .orz-field-blank {
|
|
296
|
+
flex: 1;
|
|
297
|
+
min-width: 2.5em;
|
|
298
|
+
height: 1.45em;
|
|
299
|
+
border-bottom: 1px solid var(--text-main, #333);
|
|
300
|
+
}
|
|
301
|
+
.orz-el-exam-title .orz-field-suffix { white-space: nowrap; color: var(--text-muted, #555); }
|
|
302
|
+
.orz-doc.markdown-body .orz-el-exam-title .orz-exam-instructions {
|
|
303
|
+
text-align: left;
|
|
304
|
+
margin: 1.4em auto 0;
|
|
305
|
+
max-width: 40em;
|
|
306
|
+
font-size: 0.94em;
|
|
307
|
+
}
|
|
308
|
+
.orz-doc.markdown-body .orz-el-exam-title .orz-exam-instructions p { text-align: left; margin: 0.35em 0; }`;
|
|
309
|
+
}
|
|
310
|
+
css += pageBreakCss(kind);
|
|
311
|
+
return { html, css, placement };
|
|
312
|
+
}
|
|
313
|
+
/* ─────────────────────────── abstract ─────────────────────────── */
|
|
314
|
+
function renderAbstract(spec, ctx) {
|
|
315
|
+
const placement = resolvePlacement(spec);
|
|
316
|
+
const parts = [];
|
|
317
|
+
parts.push('<h2 class="orz-abstract-heading">Abstract</h2>');
|
|
318
|
+
if (has(spec, 'text')) {
|
|
319
|
+
parts.push(`<div class="orz-abstract-body">${ctx.renderBlock(field(spec, 'text'))}</div>`);
|
|
320
|
+
}
|
|
321
|
+
if (has(spec, 'keywords')) {
|
|
322
|
+
parts.push(`<p class="orz-keywords"><span class="orz-keywords-label">Keywords:</span> ${ctx.renderInline(field(spec, 'keywords'))}</p>`);
|
|
323
|
+
}
|
|
324
|
+
const html = wrap('abstract', placement, parts.join(''));
|
|
325
|
+
let css = `
|
|
326
|
+
.orz-el-abstract {
|
|
327
|
+
margin: 0 auto 1.5rem;
|
|
328
|
+
padding: 0 2em;
|
|
329
|
+
font-size: 0.95em;
|
|
330
|
+
}
|
|
331
|
+
.orz-el-abstract .orz-abstract-heading {
|
|
332
|
+
text-align: center;
|
|
333
|
+
text-transform: uppercase;
|
|
334
|
+
letter-spacing: 0.08em;
|
|
335
|
+
font-size: 0.9em;
|
|
336
|
+
margin: 0 0 0.5em;
|
|
337
|
+
color: var(--accent, #000);
|
|
338
|
+
}
|
|
339
|
+
.orz-el-abstract .orz-abstract-body p:first-child {
|
|
340
|
+
margin-top: 0;
|
|
341
|
+
}
|
|
342
|
+
.orz-el-abstract .orz-keywords {
|
|
343
|
+
margin: 0.75em 0 0;
|
|
344
|
+
}
|
|
345
|
+
.orz-el-abstract .orz-keywords-label {
|
|
346
|
+
font-weight: 600;
|
|
347
|
+
}`;
|
|
348
|
+
css += pageBreakCss('abstract');
|
|
349
|
+
return { html, css, placement };
|
|
350
|
+
}
|
|
351
|
+
/* ─────────────────────────── letterhead ─────────────────────────── */
|
|
352
|
+
function renderLetterhead(spec, ctx) {
|
|
353
|
+
const placement = resolvePlacement(spec);
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (has(spec, 'organization')) {
|
|
356
|
+
parts.push(`<div class="orz-org">${ctx.renderInline(field(spec, 'organization'))}</div>`);
|
|
357
|
+
}
|
|
358
|
+
if (has(spec, 'tagline')) {
|
|
359
|
+
parts.push(`<div class="orz-tagline">${ctx.renderInline(field(spec, 'tagline'))}</div>`);
|
|
360
|
+
}
|
|
361
|
+
const contact = [];
|
|
362
|
+
if (has(spec, 'address')) {
|
|
363
|
+
for (const line of pipeLines(field(spec, 'address'))) {
|
|
364
|
+
contact.push(`<span class="orz-address-line">${escapeHtml(line)}</span>`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (has(spec, 'phone')) {
|
|
368
|
+
contact.push(`<span class="orz-phone">${escapeHtml(field(spec, 'phone'))}</span>`);
|
|
369
|
+
}
|
|
370
|
+
if (has(spec, 'email')) {
|
|
371
|
+
contact.push(`<span class="orz-email">${escapeHtml(field(spec, 'email'))}</span>`);
|
|
372
|
+
}
|
|
373
|
+
if (has(spec, 'website')) {
|
|
374
|
+
contact.push(`<span class="orz-website">${escapeHtml(field(spec, 'website'))}</span>`);
|
|
375
|
+
}
|
|
376
|
+
if (contact.length) {
|
|
377
|
+
parts.push(`<div class="orz-contact">${contact.join('')}</div>`);
|
|
378
|
+
}
|
|
379
|
+
const border = boolField(spec, 'border', true);
|
|
380
|
+
const inner = `<div class="orz-letterhead-inner${border ? ' orz-bordered' : ''}">${parts.join('')}</div>`;
|
|
381
|
+
const html = wrap('letterhead', placement, inner);
|
|
382
|
+
let css = `
|
|
383
|
+
.orz-el-letterhead {
|
|
384
|
+
margin: 0 0 1.5rem;
|
|
385
|
+
}
|
|
386
|
+
.orz-el-letterhead .orz-letterhead-inner {
|
|
387
|
+
padding-bottom: 0.75em;
|
|
388
|
+
}
|
|
389
|
+
.orz-el-letterhead .orz-letterhead-inner.orz-bordered {
|
|
390
|
+
border-bottom: 2px solid var(--accent, #000);
|
|
391
|
+
}
|
|
392
|
+
.orz-el-letterhead .orz-org {
|
|
393
|
+
font-size: 1.4em;
|
|
394
|
+
font-weight: 700;
|
|
395
|
+
color: var(--accent, #000);
|
|
396
|
+
}
|
|
397
|
+
.orz-el-letterhead .orz-tagline {
|
|
398
|
+
font-style: italic;
|
|
399
|
+
opacity: 0.8;
|
|
400
|
+
margin-top: 0.1em;
|
|
401
|
+
}
|
|
402
|
+
.orz-el-letterhead .orz-contact {
|
|
403
|
+
margin-top: 0.4em;
|
|
404
|
+
font-size: 0.85em;
|
|
405
|
+
display: flex;
|
|
406
|
+
flex-wrap: wrap;
|
|
407
|
+
gap: 0.25em 1em;
|
|
408
|
+
}
|
|
409
|
+
.orz-el-letterhead .orz-address-line {
|
|
410
|
+
display: block;
|
|
411
|
+
flex-basis: 100%;
|
|
412
|
+
}`;
|
|
413
|
+
css += pageBreakCss('letterhead');
|
|
414
|
+
return { html, css, placement };
|
|
415
|
+
}
|
|
416
|
+
/* ─────────────────────── letter-inside-address ─────────────────────── */
|
|
417
|
+
function renderInsideAddress(spec, ctx) {
|
|
418
|
+
const placement = resolvePlacement(spec);
|
|
419
|
+
const lines = [];
|
|
420
|
+
if (has(spec, 'to')) {
|
|
421
|
+
lines.push(`<div class="orz-to">${ctx.renderInline(field(spec, 'to'))}</div>`);
|
|
422
|
+
}
|
|
423
|
+
if (has(spec, 'title')) {
|
|
424
|
+
for (const line of pipeLines(field(spec, 'title'))) {
|
|
425
|
+
lines.push(`<div class="orz-recipient-title">${escapeHtml(line)}</div>`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (has(spec, 'organization')) {
|
|
429
|
+
for (const line of pipeLines(field(spec, 'organization'))) {
|
|
430
|
+
lines.push(`<div class="orz-recipient-org">${escapeHtml(line)}</div>`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (has(spec, 'address')) {
|
|
434
|
+
for (const line of pipeLines(field(spec, 'address'))) {
|
|
435
|
+
lines.push(`<div class="orz-recipient-address">${escapeHtml(line)}</div>`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const align = field(spec, 'align').toLowerCase() === 'right' ? ' orz-align-right' : '';
|
|
439
|
+
const html = wrap('letter-inside-address', placement, `<address class="orz-inside-address${align}">${lines.join('')}</address>`);
|
|
440
|
+
let css = `
|
|
441
|
+
.orz-el-letter-inside-address {
|
|
442
|
+
margin: 0 0 1.5rem;
|
|
443
|
+
}
|
|
444
|
+
.orz-el-letter-inside-address .orz-inside-address {
|
|
445
|
+
font-style: normal;
|
|
446
|
+
line-height: 1.4;
|
|
447
|
+
}
|
|
448
|
+
.orz-el-letter-inside-address .orz-inside-address.orz-align-right {
|
|
449
|
+
text-align: right;
|
|
450
|
+
}
|
|
451
|
+
.orz-el-letter-inside-address .orz-to {
|
|
452
|
+
font-weight: 600;
|
|
453
|
+
}`;
|
|
454
|
+
css += pageBreakCss('letter-inside-address');
|
|
455
|
+
return { html, css, placement };
|
|
456
|
+
}
|
|
457
|
+
/* ─────────────────────────── letter-signature ─────────────────────────── */
|
|
458
|
+
function renderSignature(spec, ctx) {
|
|
459
|
+
const placement = resolvePlacement(spec);
|
|
460
|
+
const closing = has(spec, 'closing') ? field(spec, 'closing') : 'Sincerely';
|
|
461
|
+
const space = has(spec, 'signature_space') ? field(spec, 'signature_space') : '1in';
|
|
462
|
+
const parts = [];
|
|
463
|
+
parts.push(`<div class="orz-closing">${ctx.renderInline(closing)},</div>`);
|
|
464
|
+
parts.push(`<div class="orz-sig-space" style="height:${escapeHtml(space)}" aria-hidden="true"></div>`);
|
|
465
|
+
if (has(spec, 'from')) {
|
|
466
|
+
parts.push(`<div class="orz-from">${ctx.renderInline(field(spec, 'from'))}</div>`);
|
|
467
|
+
}
|
|
468
|
+
if (has(spec, 'title')) {
|
|
469
|
+
for (const line of pipeLines(field(spec, 'title'))) {
|
|
470
|
+
parts.push(`<div class="orz-signer-title">${ctx.renderInline(line)}</div>`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (has(spec, 'organization')) {
|
|
474
|
+
for (const line of pipeLines(field(spec, 'organization'))) {
|
|
475
|
+
parts.push(`<div class="orz-signer-org">${ctx.renderInline(line)}</div>`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const html = wrap('letter-signature', placement, `<div class="orz-signature-block">${parts.join('')}</div>`);
|
|
479
|
+
let css = `
|
|
480
|
+
.orz-el-letter-signature {
|
|
481
|
+
margin: 2rem 0 0;
|
|
482
|
+
}
|
|
483
|
+
.orz-el-letter-signature .orz-signature-block {
|
|
484
|
+
margin-left: 40%;
|
|
485
|
+
line-height: 1.4;
|
|
486
|
+
}
|
|
487
|
+
.orz-el-letter-signature .orz-sig-space {
|
|
488
|
+
min-height: 0.75in;
|
|
489
|
+
}
|
|
490
|
+
.orz-el-letter-signature .orz-from {
|
|
491
|
+
font-weight: 600;
|
|
492
|
+
}`;
|
|
493
|
+
css += pageBreakCss('letter-signature');
|
|
494
|
+
return { html, css, placement };
|
|
495
|
+
}
|
|
496
|
+
/* ─────────────────────────── toc ─────────────────────────── */
|
|
497
|
+
function renderToc(spec) {
|
|
498
|
+
const placement = resolvePlacement(spec);
|
|
499
|
+
const title = has(spec, 'title') ? field(spec, 'title') : 'Table of Contents';
|
|
500
|
+
let maxLevel = parseInt(field(spec, 'max_level'), 10);
|
|
501
|
+
if (!Number.isFinite(maxLevel) || maxLevel < 1 || maxLevel > 6)
|
|
502
|
+
maxLevel = 3;
|
|
503
|
+
const inner = `<h2 class="orz-toc-heading">${escapeHtml(title)}</h2>` +
|
|
504
|
+
`<nav class="orz-toc" data-max-level="${maxLevel}"></nav>`;
|
|
505
|
+
const html = wrap('toc', placement, inner);
|
|
506
|
+
let css = `
|
|
507
|
+
.orz-el-toc {
|
|
508
|
+
margin: 0 0 1.5rem;
|
|
509
|
+
}
|
|
510
|
+
.orz-el-toc .orz-toc-heading {
|
|
511
|
+
color: var(--accent, #000);
|
|
512
|
+
margin: 0 0 0.75em;
|
|
513
|
+
}
|
|
514
|
+
.orz-el-toc .orz-toc {
|
|
515
|
+
display: block;
|
|
516
|
+
}`;
|
|
517
|
+
css += pageBreakCss('toc');
|
|
518
|
+
return { html, css, placement };
|
|
519
|
+
}
|
|
520
|
+
/* ─────────────────────────── cv-header ─────────────────────────── */
|
|
521
|
+
function renderCvHeader(spec, ctx) {
|
|
522
|
+
const placement = resolvePlacement(spec);
|
|
523
|
+
const nameBlock = [];
|
|
524
|
+
if (has(spec, 'full_name')) {
|
|
525
|
+
nameBlock.push(`<h1 class="orz-cv-name">${ctx.renderInline(field(spec, 'full_name'))}</h1>`);
|
|
526
|
+
}
|
|
527
|
+
if (has(spec, 'title')) {
|
|
528
|
+
nameBlock.push(`<div class="orz-cv-title">${ctx.renderInline(field(spec, 'title'))}</div>`);
|
|
529
|
+
}
|
|
530
|
+
let photo = '';
|
|
531
|
+
if (has(spec, 'photo')) {
|
|
532
|
+
photo = `<div class="orz-cv-photo">${ctx.renderInline(field(spec, 'photo'))}</div>`;
|
|
533
|
+
}
|
|
534
|
+
let contacts = '';
|
|
535
|
+
if (has(spec, 'contacts')) {
|
|
536
|
+
const items = pipeLines(field(spec, 'contacts'))
|
|
537
|
+
.map((c) => `<span class="orz-cv-contact">${ctx.renderInline(c)}</span>`)
|
|
538
|
+
.join('');
|
|
539
|
+
contacts = `<div class="orz-cv-contacts">${items}</div>`;
|
|
540
|
+
}
|
|
541
|
+
const border = boolField(spec, 'border', true);
|
|
542
|
+
const top = `<div class="orz-cv-top">${`<div class="orz-cv-identity">${nameBlock.join('')}</div>`}${photo}</div>`;
|
|
543
|
+
let summary = '';
|
|
544
|
+
if (has(spec, 'summary')) {
|
|
545
|
+
summary = `<div class="orz-cv-summary">${ctx.renderBlock(field(spec, 'summary'))}</div>`;
|
|
546
|
+
}
|
|
547
|
+
const inner = `<div class="orz-cv-header-inner${border ? ' orz-bordered' : ''}">${top}${contacts}${summary}</div>`;
|
|
548
|
+
const html = wrap('cv-header', placement, inner);
|
|
549
|
+
let css = `
|
|
550
|
+
.orz-el-cv-header {
|
|
551
|
+
margin: 0 0 1.5rem;
|
|
552
|
+
}
|
|
553
|
+
.orz-el-cv-header .orz-cv-header-inner.orz-bordered {
|
|
554
|
+
border-bottom: 2px solid var(--accent, #000);
|
|
555
|
+
padding-bottom: 0.75em;
|
|
556
|
+
}
|
|
557
|
+
.orz-el-cv-header .orz-cv-top {
|
|
558
|
+
display: flex;
|
|
559
|
+
justify-content: space-between;
|
|
560
|
+
align-items: flex-start;
|
|
561
|
+
gap: 1em;
|
|
562
|
+
}
|
|
563
|
+
.orz-el-cv-header .orz-cv-name {
|
|
564
|
+
margin: 0;
|
|
565
|
+
font-size: 1.8em;
|
|
566
|
+
color: var(--accent, #000);
|
|
567
|
+
}
|
|
568
|
+
/* The name is an <h1>; neutralize any theme h1 underline — the CV header's rule
|
|
569
|
+
is owned by .orz-cv-header-inner.orz-bordered, not the name. */
|
|
570
|
+
.orz-doc.markdown-body .orz-el-cv-header .orz-cv-name { border-bottom: 0; padding-bottom: 0; }
|
|
571
|
+
.orz-el-cv-header .orz-cv-title {
|
|
572
|
+
font-size: 1.1em;
|
|
573
|
+
opacity: 0.85;
|
|
574
|
+
margin-top: 0.15em;
|
|
575
|
+
}
|
|
576
|
+
.orz-el-cv-header .orz-cv-photo {
|
|
577
|
+
flex: 0 0 auto;
|
|
578
|
+
}
|
|
579
|
+
.orz-el-cv-header .orz-cv-photo img {
|
|
580
|
+
width: 90px;
|
|
581
|
+
height: 90px;
|
|
582
|
+
object-fit: cover;
|
|
583
|
+
border-radius: 50%;
|
|
584
|
+
}
|
|
585
|
+
.orz-el-cv-header .orz-cv-contacts {
|
|
586
|
+
margin-top: 0.5em;
|
|
587
|
+
display: flex;
|
|
588
|
+
flex-wrap: wrap;
|
|
589
|
+
gap: 0.25em 1.25em;
|
|
590
|
+
font-size: 0.9em;
|
|
591
|
+
}
|
|
592
|
+
.orz-el-cv-header .orz-cv-summary {
|
|
593
|
+
margin-top: 0.5em;
|
|
594
|
+
font-size: 0.95em;
|
|
595
|
+
}`;
|
|
596
|
+
css += pageBreakCss('cv-header');
|
|
597
|
+
return { html, css, placement };
|
|
598
|
+
}
|
|
599
|
+
/* ─────────────────────────── question-mc ─────────────────────────── */
|
|
600
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
601
|
+
function questionHeader(spec) {
|
|
602
|
+
const n = field(spec, 'n');
|
|
603
|
+
const pts = field(spec, 'pts');
|
|
604
|
+
const parts = [];
|
|
605
|
+
parts.push(`<span class="orz-q-num">${escapeHtml(n || '')}${n ? '.' : ''}</span>`);
|
|
606
|
+
if (pts) {
|
|
607
|
+
parts.push(`<span class="orz-q-pts">(${escapeHtml(pts)})</span>`);
|
|
608
|
+
}
|
|
609
|
+
return `<div class="orz-q-header">${parts.join(' ')}</div>`;
|
|
610
|
+
}
|
|
611
|
+
function renderQuestionMc(spec, ctx) {
|
|
612
|
+
const placement = resolvePlacement(spec);
|
|
613
|
+
// `answer` accepts one or several correct options (select-all-that-apply):
|
|
614
|
+
// "B", "B, D", "B D", or contiguous "BD". A single all-letters token is split
|
|
615
|
+
// into characters; otherwise only single-letter tokens count (so "B and D"
|
|
616
|
+
// yields B, D — the stray word is ignored).
|
|
617
|
+
const rawAnswer = field(spec, 'answer').toUpperCase().trim();
|
|
618
|
+
const answerParts = rawAnswer.split(/[\s,;/]+/).filter(Boolean);
|
|
619
|
+
const answerSet = new Set(answerParts.length === 1 && /^[A-Z]{2,}$/.test(answerParts[0])
|
|
620
|
+
? answerParts[0].split('')
|
|
621
|
+
: answerParts.filter((p) => /^[A-Z]$/.test(p)));
|
|
622
|
+
const header = questionHeader(spec);
|
|
623
|
+
const body = has(spec, 'body')
|
|
624
|
+
? `<div class="orz-q-body">${ctx.renderBlock(field(spec, 'body'))}</div>`
|
|
625
|
+
: '';
|
|
626
|
+
const rawOptions = textLines(field(spec, 'options'));
|
|
627
|
+
let longest = 0;
|
|
628
|
+
const optionsHtml = rawOptions
|
|
629
|
+
.map((raw, i) => {
|
|
630
|
+
// Honor an explicit label like "B. text"; otherwise auto-letter.
|
|
631
|
+
const m = raw.match(/^([A-Za-z])[.)]\s+(.*)$/);
|
|
632
|
+
const label = (m ? m[1] : LETTERS[i] || '?').toUpperCase();
|
|
633
|
+
const text = m ? m[2] : raw;
|
|
634
|
+
longest = Math.max(longest, text.length);
|
|
635
|
+
const isAnswer = answerSet.has(label);
|
|
636
|
+
const answerAttr = isAnswer ? ' data-answer="true"' : '';
|
|
637
|
+
// The ✓ reveal is gated by the dynamic switch: it survives only when
|
|
638
|
+
// answer-key=show (instructor key), and is removed for the student copy.
|
|
639
|
+
const mark = isAnswer
|
|
640
|
+
? ' <span class="orz-answer-mark" data-show-when="answer-key=show">✓</span>'
|
|
641
|
+
: '';
|
|
642
|
+
return (`<li class="orz-option"${answerAttr}>` +
|
|
643
|
+
`<span class="orz-option-label">${escapeHtml(label)}.</span> ` +
|
|
644
|
+
`<span class="orz-option-text">${ctx.renderInline(text)}</span>` +
|
|
645
|
+
mark +
|
|
646
|
+
`</li>`);
|
|
647
|
+
})
|
|
648
|
+
.join('');
|
|
649
|
+
// Collapse to a single column when any option is long.
|
|
650
|
+
const singleCol = longest > 40;
|
|
651
|
+
const gridClass = singleCol ? ' orz-single-col' : '';
|
|
652
|
+
const options = `<ol class="orz-options${gridClass}">${optionsHtml}</ol>`;
|
|
653
|
+
const html = wrap('question-mc', placement, header + body + options);
|
|
654
|
+
let css = `
|
|
655
|
+
.orz-el-question-mc {
|
|
656
|
+
margin: 0 0 1em;
|
|
657
|
+
break-inside: avoid;
|
|
658
|
+
}
|
|
659
|
+
.orz-el-question-mc .orz-q-header {
|
|
660
|
+
font-weight: 600;
|
|
661
|
+
}
|
|
662
|
+
.orz-el-question-mc .orz-q-pts {
|
|
663
|
+
font-weight: 400;
|
|
664
|
+
opacity: 0.7;
|
|
665
|
+
font-size: 0.9em;
|
|
666
|
+
}
|
|
667
|
+
.orz-el-question-mc .orz-q-body {
|
|
668
|
+
margin: 0.25em 0 0.5em;
|
|
669
|
+
}
|
|
670
|
+
.orz-el-question-mc .orz-q-body > p:first-child {
|
|
671
|
+
margin-top: 0;
|
|
672
|
+
}
|
|
673
|
+
.orz-el-question-mc .orz-options {
|
|
674
|
+
list-style: none;
|
|
675
|
+
margin: 0;
|
|
676
|
+
padding: 0;
|
|
677
|
+
display: grid;
|
|
678
|
+
grid-template-columns: 1fr 1fr;
|
|
679
|
+
gap: 0.25em 1.5em;
|
|
680
|
+
}
|
|
681
|
+
.orz-el-question-mc .orz-options.orz-single-col {
|
|
682
|
+
grid-template-columns: 1fr;
|
|
683
|
+
}
|
|
684
|
+
.orz-el-question-mc .orz-option-label {
|
|
685
|
+
font-weight: 600;
|
|
686
|
+
}
|
|
687
|
+
/* The correct option is visually neutral on the student copy (the ✓ is removed
|
|
688
|
+
by the dynamic switch). When answer-key=show the ✓ survives and tints it. */
|
|
689
|
+
.orz-el-question-mc .orz-answer-mark {
|
|
690
|
+
color: var(--success-text, #1a7f37);
|
|
691
|
+
font-weight: 700;
|
|
692
|
+
margin-left: 0.3em;
|
|
693
|
+
}
|
|
694
|
+
.orz-el-question-mc .orz-option:has(.orz-answer-mark) {
|
|
695
|
+
color: var(--success-text, #1a7f37);
|
|
696
|
+
font-weight: 600;
|
|
697
|
+
}`;
|
|
698
|
+
css += pageBreakCss('question-mc');
|
|
699
|
+
return { html, css, placement };
|
|
700
|
+
}
|
|
701
|
+
/* ─────────────────────────── question-open ─────────────────────────── */
|
|
702
|
+
function renderQuestionOpen(spec, ctx) {
|
|
703
|
+
const placement = resolvePlacement(spec);
|
|
704
|
+
const space = has(spec, 'space') ? field(spec, 'space') : '3cm';
|
|
705
|
+
const header = questionHeader(spec);
|
|
706
|
+
const body = has(spec, 'body')
|
|
707
|
+
? `<div class="orz-q-body">${ctx.renderBlock(field(spec, 'body'))}</div>`
|
|
708
|
+
: '';
|
|
709
|
+
const writing = `<div class="orz-q-space" style="height:${escapeHtml(space)}" aria-hidden="true"></div>`;
|
|
710
|
+
// Model answer — shown only on the instructor key (answer-key=show).
|
|
711
|
+
const answerKey = has(spec, 'answer')
|
|
712
|
+
? `<div class="orz-answer-key" data-show-when="answer-key=show">`
|
|
713
|
+
+ `<div class="orz-answer-label">Answer</div>`
|
|
714
|
+
+ `${ctx.renderBlock(field(spec, 'answer'))}</div>`
|
|
715
|
+
: '';
|
|
716
|
+
const html = wrap('question-open', placement, header + body + writing + answerKey);
|
|
717
|
+
let css = `
|
|
718
|
+
.orz-el-question-open {
|
|
719
|
+
margin: 0 0 1em;
|
|
720
|
+
break-inside: avoid;
|
|
721
|
+
}
|
|
722
|
+
.orz-el-question-open .orz-q-header {
|
|
723
|
+
font-weight: 600;
|
|
724
|
+
}
|
|
725
|
+
.orz-el-question-open .orz-q-pts {
|
|
726
|
+
font-weight: 400;
|
|
727
|
+
opacity: 0.7;
|
|
728
|
+
font-size: 0.9em;
|
|
729
|
+
}
|
|
730
|
+
.orz-el-question-open .orz-q-body {
|
|
731
|
+
margin: 0.25em 0 0.5em;
|
|
732
|
+
}
|
|
733
|
+
.orz-el-question-open .orz-q-body > p:first-child {
|
|
734
|
+
margin-top: 0;
|
|
735
|
+
}
|
|
736
|
+
.orz-el-question-open .orz-q-space {
|
|
737
|
+
min-height: 1cm;
|
|
738
|
+
border-bottom: 1px solid #ccc;
|
|
739
|
+
}
|
|
740
|
+
.orz-el-question-open .orz-answer-key {
|
|
741
|
+
margin-top: 0.5em;
|
|
742
|
+
padding: 0.5em 0.8em;
|
|
743
|
+
background: var(--success-bg, #eef7f0);
|
|
744
|
+
border-left: 3px solid var(--success-text, #1a7f37);
|
|
745
|
+
border-radius: 3px;
|
|
746
|
+
font-size: 0.95em;
|
|
747
|
+
}
|
|
748
|
+
.orz-el-question-open .orz-answer-label {
|
|
749
|
+
font-weight: 700;
|
|
750
|
+
color: var(--success-text, #1a7f37);
|
|
751
|
+
font-size: 0.8em;
|
|
752
|
+
text-transform: uppercase;
|
|
753
|
+
letter-spacing: 0.05em;
|
|
754
|
+
margin-bottom: 0.2em;
|
|
755
|
+
}
|
|
756
|
+
.orz-el-question-open .orz-answer-key > p:last-child { margin-bottom: 0; }`;
|
|
757
|
+
css += pageBreakCss('question-open');
|
|
758
|
+
return { html, css, placement };
|
|
759
|
+
}
|
|
760
|
+
/* ─────────────────────────── timestamp ─────────────────────────── */
|
|
761
|
+
function renderTimestamp(spec) {
|
|
762
|
+
const placement = resolvePlacement(spec);
|
|
763
|
+
const label = has(spec, 'label') ? field(spec, 'label') : 'Last updated';
|
|
764
|
+
let date = field(spec, 'date');
|
|
765
|
+
if (date === '' || date.toLowerCase() === 'auto') {
|
|
766
|
+
date = new Date().toISOString().slice(0, 10);
|
|
767
|
+
}
|
|
768
|
+
const html = wrap('timestamp', placement, `<p class="orz-timestamp-line"><span class="orz-timestamp-label">${escapeHtml(label)}:</span> <time class="orz-timestamp-date">${escapeHtml(date)}</time></p>`);
|
|
769
|
+
let css = `
|
|
770
|
+
.orz-el-timestamp {
|
|
771
|
+
margin: 0.5rem 0;
|
|
772
|
+
}
|
|
773
|
+
.orz-el-timestamp .orz-timestamp-line {
|
|
774
|
+
text-align: right;
|
|
775
|
+
font-size: 0.85em;
|
|
776
|
+
opacity: 0.75;
|
|
777
|
+
margin: 0;
|
|
778
|
+
}
|
|
779
|
+
.orz-el-timestamp .orz-timestamp-label {
|
|
780
|
+
font-weight: 600;
|
|
781
|
+
}`;
|
|
782
|
+
css += pageBreakCss('timestamp');
|
|
783
|
+
return { html, css, placement };
|
|
784
|
+
}
|
|
785
|
+
/* ─────────────────────────── dispatcher ─────────────────────────── */
|
|
786
|
+
export function renderElement(spec, ctx) {
|
|
787
|
+
switch (spec.kind) {
|
|
788
|
+
case 'article-title':
|
|
789
|
+
case 'report-title':
|
|
790
|
+
case 'exam-title':
|
|
791
|
+
return renderTitle(spec.kind, spec, ctx);
|
|
792
|
+
case 'abstract':
|
|
793
|
+
return renderAbstract(spec, ctx);
|
|
794
|
+
case 'letterhead':
|
|
795
|
+
return renderLetterhead(spec, ctx);
|
|
796
|
+
case 'letter-inside-address':
|
|
797
|
+
return renderInsideAddress(spec, ctx);
|
|
798
|
+
case 'letter-signature':
|
|
799
|
+
return renderSignature(spec, ctx);
|
|
800
|
+
case 'toc':
|
|
801
|
+
return renderToc(spec);
|
|
802
|
+
case 'cv-header':
|
|
803
|
+
return renderCvHeader(spec, ctx);
|
|
804
|
+
case 'question-mc':
|
|
805
|
+
return renderQuestionMc(spec, ctx);
|
|
806
|
+
case 'question-open':
|
|
807
|
+
return renderQuestionOpen(spec, ctx);
|
|
808
|
+
case 'timestamp':
|
|
809
|
+
return renderTimestamp(spec);
|
|
810
|
+
default:
|
|
811
|
+
return { html: '', css: '', placement: 'section' };
|
|
812
|
+
}
|
|
813
|
+
}
|