Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19

Merged
Tyler merged 16 commits from export_changes into main 2026-06-15 22:10:32 +00:00
3 changed files with 35 additions and 4 deletions
Showing only changes of commit 722c5d2eee - Show all commits
+11 -2
View File
@@ -640,14 +640,19 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
} }
/** /**
* Optional document metadata (author, co-authors, organization) shown at the * Optional document metadata (author, co-authors, organization, description)
* top of the guide, below the title, and surfaced on the PDF cover page. * shown at the top of the guide, below the title, and surfaced on the PDF
* cover page and the top of other export formats.
*/ */
function showGuideInfoDialog({ values = {}, onSave } = {}) { function showGuideInfoDialog({ values = {}, onSave } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' }); const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' }); const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' }); const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
const descriptionInput = el('textarea', {
rows: 4,
placeholder: 'A short summary of this guide.',
}, values.description || '');
const { close } = openModal({ const { close } = openModal({
title: 'Guide information', title: 'Guide information',
@@ -657,6 +662,9 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) {
labeledRow('Author', authorInput), labeledRow('Author', authorInput),
labeledRow('Co-authors', coAuthorsInput), labeledRow('Co-authors', coAuthorsInput),
labeledRow('Organization', organizationInput), labeledRow('Organization', organizationInput),
labeledRow('Description', descriptionInput, { stacked: true }),
el('div.muted', { style: { marginTop: '-4px' } },
'Shown on the first page of the PDF and at the top of other export formats.'),
), ),
footer: [ footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
@@ -666,6 +674,7 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) {
author: authorInput.value.trim(), author: authorInput.value.trim(),
coAuthors: coAuthorsInput.value.trim(), coAuthors: coAuthorsInput.value.trim(),
organization: organizationInput.value.trim(), organization: organizationInput.value.trim(),
description: descriptionInput.value.trim(),
}); });
close(); close();
resolve(true); resolve(true);
+6 -2
View File
@@ -1574,9 +1574,13 @@ class GuideEditor {
async openGuideInfo() { async openGuideInfo() {
if (!this.guide) return; if (!this.guide) return;
await dialogs.showGuideInfoDialog({ await dialogs.showGuideInfoDialog({
values: this.guide.metadata || {}, values: {
onSave: async (metadata) => { ...this.guide.metadata,
description: htmlToPlainText(this.guide.descriptionHtml || ''),
},
onSave: async ({ description, ...metadata }) => {
this.guide.metadata = metadata; this.guide.metadata = metadata;
this.guide.descriptionHtml = textToHtml(description);
await api.guide.save({ guide: this.guide }); await api.guide.save({ guide: this.guide });
this.onToast('Guide information saved.'); this.onToast('Guide information saved.');
}, },
+18
View File
@@ -187,3 +187,21 @@ function fmtDate(iso) {
const escapeHtml = (s) => String(s ?? '') const escapeHtml = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */
function htmlToPlainText(html) {
return String(html || '')
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, '\'').replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
}
/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */
function textToHtml(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return '';
return trimmed.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}