diff --git a/.gitignore b/.gitignore index dd21cc3..8e2fd42 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ npm-debug.log* # wizard scratch (generated manifests, funnel log, test CSVs) /.wizard + +# rendered forms (regenerate with npm run render:form) +/out diff --git a/CLAUDE.md b/CLAUDE.md index 25042f7..644e0b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,3 +227,10 @@ Building locally on macOS can trip the "access data from other apps" prompt; (`modals.ts`). There is no middleware doing this centrally — anyone who can see the alert channel can click a button, so a new handler that forgets the check is open to the workspace. Findings name students. +- **Files are recorded as metadata, never fetched.** `record.ts` stores + `{id, name, mimetype, size}` and the bytes stay in Slack. That an image was + shared is the policy-relevant fact; the image itself is a minor's photograph, + and downloading it would put content in the team's custody that the + content-blind rules never need. Slack retention is set to keep everything, so + an investigation that genuinely needs the file gets it from Slack. Do not add + file download. diff --git a/docs/consent-form.md b/docs/consent-form.md new file mode 100644 index 0000000..a0601ae --- /dev/null +++ b/docs/consent-form.md @@ -0,0 +1,195 @@ +# Parental Consent — Team Communication on Slack + +**Form version 2027.1 · 2027 season** +Questions, records requests, and withdrawals: **contact@redhawkrobotics.org** + + + +--- + +## What this is + +Melrose Red Hawk Robotics Educational Foundation Inc. ("the team") uses +**Slack** for team communication. Slack is the platform where team business +happens: announcements, build season planning, subteam coordination, and +scheduling. This form tells you how it works, what we record, and who can see it +— and asks your permission for your student to take part. + +Please read the section titled **How direct messages are handled**. It describes +something most communication tools don't do, and we would rather you learn it +here than discover it later. + +This form covers Slack only. Photo and media permissions are handled on a +separate form, and you can decline those without affecting your student's +participation. + +## What we are asking you to permit + +1. **Creating a Slack account for your student**, which shares their name and + email address with Slack, the company that operates the service. +2. **Recording and keeping direct messages between your student and adult + mentors**, as described below. + +Both are conditions of taking part in team communication. If you would rather +not, tell us and we will arrange another way to keep your student informed. + +## How direct messages are handled + +_FIRST_ Youth Protection Policy prohibits one-to-one private communication +between an adult and a student. Slack does not let us switch direct messages off +at the service level we can afford, so instead of blocking them, we record them. + +**What is recorded.** Every direct message between your student and an adult +mentor — in both directions — is saved to a database the team operates, +including the message text, who sent it, and when. + +**What is not recorded.** Direct messages between your student and other +students are never recorded. We do not record messages in team channels beyond +what Slack itself keeps and what every member of that channel can already see. + +**Who can read the recorded messages.** Two screened adults who administer the +team's Slack workspace. Nobody else — not other mentors, not the school +administrator who holds a seat in our workspace, not other parents or students. + +**When they read them.** Only when there is a reason to. The system flags a +conversation automatically based on **who was in it** — for example, an adult +and a student messaging with no second adult present — and never based on +anything a message says. Nothing scans messages for content, and no +administrator browses the archive out of curiosity. A person reads a +conversation when it has been flagged, or when a specific concern has been +raised. + +**What we cannot keep private.** Team mentors are mandated reporters under +Massachusetts law. If something indicates a child may be at risk of abuse or +neglect, we are required to report it to the Department of Children and +Families, regardless of this agreement. + +## What is collected, and who can see it + +**Slack keeps everything, for as long as our workspace exists.** That is how +Slack works, and it is true of every workspace, including all the parts nobody +on the team will ever look at. Slack stores your student's name, email address, +display name and profile photo if set; every message, file, and reaction they +post, in channels and in direct messages alike; and technical information such +as IP address, device, and timestamps. We have deliberately set our workspace to +keep edited and deleted messages as well, so that the record cannot be quietly +changed after the fact. + +**On top of that, the team keeps a much smaller copy of its own.** Only these +things: + +- The text and details of direct messages between your student and adult + mentors. +- A note that a file was shared in one of those conversations — its name, type, + and size. We do not keep the file. If your student sends a photo, we record + that a photo was sent, not the photo itself. +- Your student's roster information, and this consent. + +That is the entire list. The team's own records contain no channel messages, no +student-to-student messages, and no files. + +**Where the team's copy is stored.** On a server the team controls, hosted with +a commercial cloud provider in the United States (currently Linode), and in a +monthly archive kept in the team's own Google Drive. The same two screened +administrators control access to both. + +**Who else may receive it, and when:** + +- **You**, on request, for your own student. +- **_FIRST_**, in a Youth Protection investigation. +- **Melrose Public Schools**, if a concern involves a district employee or a + student-safety matter. The school administrator seated in our Slack workspace + can see the channels they join and **cannot** read recorded direct messages. +- **The Department of Children and Families or law enforcement**, as described + above. +- **A court**, if we are legally compelled. + +**What we never do:** we do not use any of this for advertising, we never sell +it, we do not share it with anyone for marketing, and we do not pass team +records to the school for routine academic or disciplinary purposes. + +## How long we keep it + +**Slack's copy lasts as long as our workspace does.** Slack gives us no way to +remove one student's messages from it, so we cannot promise you that anything +posted on Slack is ever fully deleted. This is true of Slack generally, not of +anything particular to our team. + +**The team's own copy is deleted two years after your student's last day on the +team** — both the database and the monthly archive. + +## Seeing, correcting, and withdrawing + +**Seeing what we have.** You may ask to see the recorded messages involving your +student at any time, by writing to contact@redhawkrobotics.org. We will respond within ten +business days. Where a conversation included other students, we will remove +their messages before sharing it — those belong to their families. + +**Correcting our records.** Tell us and we will fix any error in your student's +roster information or your contact details. We cannot edit the message log +itself; it is kept unchangeable on purpose, because a record that can be edited +is not a record. If you disagree with something in it, you may give us a written +statement, which we will keep with the record and share whenever the record is +shared. + +**Withdrawing consent.** You may withdraw this consent at any time in writing. +Your student's Slack account will be closed and they will no longer take part in +team communication on Slack. Messages already recorded will not be deleted — +they are kept on the schedule above and deleted with everything else. We keep +them because a record that could be erased on request could not do the job it +exists to do. + +**We will ask again next year.** This consent lasts one year. We collect it +again each season rather than letting an old signature stand indefinitely. + +--- + +## Parent or guardian + +I have read this form. I understand that direct messages between my student and +adult mentors are recorded and kept, and who can read them. + +I confirm that: + +- I am the parent or legal guardian of the student named below. +- My student is **thirteen years of age or older**. + +I permit the team to create a Slack account for my student, to share their name +and email address with Slack for that purpose, and to record and keep their +direct messages with adult mentors as described above. + +| | | +| ----------------------- | --------------------------------------------- | +| Student's full name | ............................................. | +| Student's email address | ............................................. | +| Parent/guardian name | ............................................. | +| Parent/guardian email | ............................................. | +| Signature | ............................................. | +| Date | ............................................. | + +## Student + +I know that my direct messages with adult mentors on the team's Slack are +recorded, and that a screened adult may read them if a conversation is flagged +or if there is a concern. + +| | | +| ----------------- | --------------------------------------------- | +| Student signature | ............................................. | +| Date | ............................................. | + +--- + + + +**Team use only.** Filed at: ................................ · +Recorded by: ................................ · +Form version 2027.1 + + diff --git a/docs/moving-team-communication-to-slack.md b/docs/moving-team-communication-to-slack.md index c7d6d1d..8bc8b46 100644 --- a/docs/moving-team-communication-to-slack.md +++ b/docs/moving-team-communication-to-slack.md @@ -6,16 +6,16 @@ Slack, and the policy analysis behind it. hawk-mod implements §4 and §6. carries it, records which ones are deliberately manual, and notes two places where later reading of the FIRST rules corrected what §3 and §7 say here. -Two caveats the original carries in a note to the board rather than in the text: -the nonprofit-pricing/educator-status tension in §5 is unresolved and worth -re-checking at renewal, and clause 1 quoted in §8 is typical language for that -policy family, **not confirmed Melrose text** — the district PDF has not been -read cleanly. If clause 1 turns out not to be in IJNDD, that whole conditional -goes away. +One caveat the original carries in a note to the board rather than in the text +still stands: the nonprofit-pricing/educator-status tension in §5 is unresolved +and worth re-checking at renewal. The other has been settled — the IJNDD clauses +§8 quoted were typical language for that policy family rather than confirmed +Melrose text, and reading the adopted policy showed they are not in it. §8 now +records what Melrose actually says. --- -**Summary:** Slack is workable for our team, legally and under FIRST policy, but only with a specific setup. This post lays out what's required, what it costs, and the one open question we need the district to answer. +**Summary:** Slack is workable for our team, legally and under FIRST policy, but only with a specific setup. This post lays out what's required, what it costs, and how the one district-policy question resolved. --- @@ -23,7 +23,7 @@ goes away. - Our **501(c)(3) will own the workspace**, not the school. This is what lets non-employee mentors participate — the reason we can't just use Google Classroom. - We need the **Business+ plan** (~$2.25/user/month at the nonprofit discount) so that DMs are auditable. - **Direct messages between mentors and students cannot be technically blocked.** We manage this with policy and real audits, not software. -- **One open item:** one of our mentors is an MPS employee and is bound by district policy IJNDD. We need written approval from the principal before launch. +- **Settled:** one of our mentors is an MPS employee and is bound by district policy IJNDD, but the text Melrose actually adopted does not bar them from an outside platform (§8). We still want written approval from the principal before launch. --- @@ -106,21 +106,70 @@ Independent of everything above, and required regardless of platform: Keep training completions filed with the consent forms. -## 8. Open item: our MPS-employee mentor - -Melrose School Committee policy **IJNDD — Policy on Use of Social Media Sites** (Section I of the district policy manual) binds any MPS employee personally, as a condition of employment. It prohibits "improper fraternization with students using any social media... chat rooms, texts... or other digital means." - -Policies in this family typically also contain: - -1. _"All electronic contacts with students should be through the district's e-mail, computer and telephone systems, except in emergency situations."_ — **If Melrose's version contains this, our employee-mentor cannot use Slack with students at all**, no matter who owns the workspace. This clause decides the question. -2. _"Team, class, or student organization pages, accounts, or groups will be created only in conjunction with the teacher, coach or faculty advisor. All groups must include the appropriate administrator as a member."_ — This is our remedy. - -**Proposed actions:** - -- Read IJNDD in full and check for clause 1 -- Get **written approval** from the principal or superintendent before launch — email is fine, but in writing, filed with the consents -- **Add an MPS administrator to the workspace as a member.** This satisfies clause 2 on its own terms and turns our Slack from "an outside platform an employee uses with students" into "a channel the district can see." One seat, ~$27/yr — the cheapest risk reduction in this entire plan. -- If clause 1 applies and no exception is granted, that mentor stays in mentors-only channels +## 8. Resolved: our MPS-employee mentor + +Melrose School Committee policy **IJNDD — Electronic Communication/Social +Media** (Section I of the district policy manual, adopted June 12, 2018) binds +any MPS employee personally, as a condition of employment, and it does reach +Slack. Its definition of social media covers "chat, text message features of +cell phones... and other electronic or technologically based communication +systems," and clause (i) of _Professional Use_ provides that employees +"including coaches/advisors" who engage with team social media "do so as an +employee of the District." Booster club ownership of the workspace does not put +our mentor outside it. + +**The clause that would have decided this against us is not in Melrose's +version.** This section originally quoted two provisions as typical of the +policy family, because the district text had not been read. Both are absent, as +is the "improper fraternization" language the section attributed to IJNDD. +Melrose adopted a locally drafted policy instead, and its operative provision on +individual contact reads: + +> Educators who wish to communicate with students or families on an individual +> basis **should**: (a) use their district e-mail account or web portal accounts +> rather than alternative media, and (b) inform families which social media are +> to be used for school business. + +"Should" — in a policy that says "shall not" and "must" in the clauses on either +side of it. And half (b) presupposes that non-district platforms do carry school +business, or there would be nothing to tell families about. Nothing prohibits +using an outside platform with students. **Our employee-mentor is not barred +from Slack.** + +The remedy this section proposed rested on the second quoted clause — that +student-organization groups must include the appropriate administrator as a +member — which is likewise absent. Adding an MPS administrator is still worth +the $27/yr for district visibility, but as goodwill rather than as compliance +with anything written. + +**What Melrose does ask of that mentor:** + +- Clause (k): _"All contact and messages by coaches with team members will be + sent to all team members, except for messages concerning medical or academic + privacy matters, in which case the messages will be copied to the athletic + director and the principal."_ Team channels satisfy this. Group DMs do not — + so that mentor works in channels and does not DM students at all. That is + stricter than §4.1, and it is enforced by their obligations under the employee + manual, not by hawk-mod. +- Clauses (e)(b) and (l): parents must be told which platforms carry school + business. Fold this into the consent form, which we are writing anyway. +- **Public records.** Under **M.G.L. c. 66 §10**, IJNDD puts the retention + burden personally on the educator when school business runs through + non-district accounts, and asks them to forward such communications to their + school e-mail so it can be archived. hawk-mod covers the substance — the + messages are retained and producible via `export-conversation` — but the + forwarding expectation should be put to the principal explicitly rather than + assumed satisfied. + +**Remaining actions:** + +- Get **written approval** from the principal or superintendent before launch — + email is fine, but in writing, filed with the consents. The ask is now + documentation of how we comply, not a request for an exception. +- Settle how the public-records expectation is met for that mentor. +- **Add an MPS administrator to the workspace as a member.** One seat, ~$27/yr, + and it turns our Slack from "an outside platform an employee uses with + students" into "a channel the district can see." This binds one person, not the booster club or our other mentors. It's a one-person problem with a one-person solution and doesn't threaten the plan. @@ -129,7 +178,8 @@ This binds one person, not the booster club or our other mentors. It's a one-per ## Launch checklist - [ ] Board votes to approve, records the DM-risk tradeoff in the minutes -- [ ] Read IJNDD; send written approval request to the principal +- [ ] Send written approval request to the principal (IJNDD read — see §8) +- [ ] Settle the M.G.L. c. 66 §10 forwarding expectation for our MPS-employee mentor - [ ] Apply for Slack for Nonprofits (booster club, not the school) - [ ] Upgrade to Business+; apply for Corporate Export - [ ] Configure workspace per section 6 diff --git a/docs/policy-mapping.md b/docs/policy-mapping.md index 1b1dbfb..10ec6cb 100644 --- a/docs/policy-mapping.md +++ b/docs/policy-mapping.md @@ -5,29 +5,33 @@ Each control from and what carries it. "Manual" means hawk-mod cannot do it and does not pretend to. -| § | Control | Carried by | -| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2 | Parental consent on file before a student account exists | `consent.ts`; `team_join` event raises `unconsented_account` the moment an account appears; nightly sweep re-checks | -| 2 | Consent re-collected annually | `CONSENT_VALID_YEARS = 1`; consents expire rather than linger | -| 2 | Consents kept on file and producible | `consents` table records `document_ref`; the signed copies themselves live wherever the team files them — **manual** | -| 3 | Two YPP-screened Lead Coaches | `workspace_config` `screened_admins`: two of the workspace's Owners/Admins must be screened adults on the roster, plus `screening_lapsed` findings | -| 3 | Written communications copied to a second adult | `dmPolicy` — two screened adults required in any student conversation | -| 4.1 | No 1:1 adult–student DMs, ever | `dmPolicy` `one_to_one_adult_student`, raised on each new message and on backfill; a message after a finding is closed raises it again (`recurrence.ts`) | -| 4.2 | Two screened adults in every channel students are in | `twoAdults.ts`; re-evaluated on every join/leave, plus nightly | -| 4.3 | Students and parents told DMs are subject to audit | consent form wording — **manual**, and a precondition of deploying this | -| 4.4 | Quarterly export actually run and spot-checked | continuous instead: DMs are recorded as they happen. The quarterly reminder and runbook keep the human review honest | -| 5 | Business+ / Corporate Export | procurement — **manual**. Note hawk-mod does not depend on Corporate Export; it is the backstop for adults who never enroll | -| 6 | Retention "keep everything" | this log is append-only: deletions are tombstoned, edits keep prior text | -| 6 | Slack Connect external DMs disabled | workspace setting, not API-readable — **manual** | -| 6 | Invites restricted to Owners/Admins | workspace setting — **manual**; `unknown_account` catches the consequence | -| 6 | Two Workspace Owners minimum, never a student | `workspace_config` findings | -| 6 | User group editing restricted to Owners/Admins | workspace setting — **manual**. Only matters when `STUDENT_USERGROUP`/`ADULT_USERGROUP` are set, since group membership then declares who is monitored | -| 6 | Real names enforced | workspace setting — **manual** | -| 6 | Huddles off | no API to observe huddles — **manual**, and the reason it matters is in the README's gap list | -| 7 | Youth Protection Training annually (the part FIRST requires for clearance) | `screening.ts`, `YPT_VALID_YEARS = 1` | -| 7 | CORI + national fingerprints every 3 years (M.G.L. c. 71 §38R, 603 CMR 51.00) | `screening.ts`, `CORI_VALID_YEARS = 3` | -| 8 | MPS administrator added to the workspace | roster role `district_observer`; counts as an adult only with screening dates recorded | -| 8 | Written approval from the principal before launch | **manual**, and blocking | +| § | Control | Carried by | +| --- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2 | Parental consent on file before a student account exists | `consent.ts`; `team_join` event raises `unconsented_account` the moment an account appears; nightly sweep re-checks | +| 2 | Consent re-collected annually | `CONSENT_VALID_YEARS = 1`; consents expire rather than linger | +| 2 | Consents kept on file and producible | `consents` table records `document_ref`; the signed copies themselves live wherever the team files them — **manual** | +| 2 | Parents notified of the PII collected and shared (Slack CSS §IV) | [`consent-form.md`](consent-form.md) — **manual**. The form is the artifact; hawk-mod records only that a version of it was signed (`form_version`) | +| 2 | Consent withdrawable on request | `revokeConsent()` exists in `repo.ts` but **has no caller** — no CLI, no modal. The form promises this; the tool cannot yet do it | +| 3 | Two YPP-screened Lead Coaches | `workspace_config` `screened_admins`: two of the workspace's Owners/Admins must be screened adults on the roster, plus `screening_lapsed` findings | +| 3 | Written communications copied to a second adult | `dmPolicy` — two screened adults required in any student conversation | +| 4.1 | No 1:1 adult–student DMs, ever | `dmPolicy` `one_to_one_adult_student`, raised on each new message and on backfill; a message after a finding is closed raises it again (`recurrence.ts`) | +| 4.2 | Two screened adults in every channel students are in | `twoAdults.ts`; re-evaluated on every join/leave, plus nightly | +| 4.3 | Students and parents told DMs are subject to audit | [`consent-form.md`](consent-form.md) — **manual**, and a precondition of deploying this. Both halves: the guardian signature block and the student acknowledgment below it | +| 4.4 | Quarterly export actually run and spot-checked | continuous instead: DMs are recorded as they happen. The quarterly reminder and runbook keep the human review honest | +| 5 | Business+ / Corporate Export | procurement — **manual**. Note hawk-mod does not depend on Corporate Export; it is the backstop for adults who never enroll | +| 6 | Retention | append-only while retained: deletions are tombstoned, edits keep prior text. No longer "keep everything" — `consent-form.md` promises deletion two years after a student's last day, and **nothing purges yet** | +| 6 | Slack Connect external DMs disabled | workspace setting, not API-readable — **manual** | +| 6 | Invites restricted to Owners/Admins | workspace setting — **manual**; `unknown_account` catches the consequence | +| 6 | Two Workspace Owners minimum, never a student | `workspace_config` findings | +| 6 | User group editing restricted to Owners/Admins | workspace setting — **manual**. Only matters when `STUDENT_USERGROUP`/`ADULT_USERGROUP` are set, since group membership then declares who is monitored | +| 6 | Real names enforced | workspace setting — **manual** | +| 6 | Huddles off | no API to observe huddles — **manual**, and the reason it matters is in the README's gap list | +| 7 | Youth Protection Training annually (the part FIRST requires for clearance) | `screening.ts`, `YPT_VALID_YEARS = 1` | +| 7 | CORI + national fingerprints every 3 years (M.G.L. c. 71 §38R, 603 CMR 51.00) | `screening.ts`, `CORI_VALID_YEARS = 3` | +| 8 | MPS administrator added to the workspace | roster role `district_observer`; counts as an adult only with screening dates recorded. Voluntary — IJNDD does not require it (see the correction below) | +| 8 | Employee-mentor keeps to channels, no student DMs (IJNDD clause k) | **manual**, and deliberately so: the employee manual binds that one person more tightly than §4.1 does, and hawk-mod is not the enforcer of it | +| 8 | Public-records retention for the employee-mentor (M.G.L. c. 66 §10) | substantively covered — messages are retained and producible via `export-conversation`; IJNDD's forward-to-school-e-mail expectation is **manual** | +| 8 | Written approval from the principal before launch | **manual**, and blocking | ## Corrections worth keeping straight @@ -38,6 +42,18 @@ training inside it is required. hawk-mod tracks it and reports it as outstanding, but it never blocks screened-adult status. Requiring it would have excluded adults who had done everything actually asked of them. +**IJNDD says less than §8 assumed.** The source document quoted two clauses as +typical of the policy family, flagged as unconfirmed, and hung the +employee-mentor question on the first of them. Melrose's adopted text +(_Electronic Communication/Social Media_, June 12, 2018) contains neither, nor +the "improper fraternization" phrasing §8 attributed to it. Its individual-contact +clause is advisory ("should") and expressly contemplates non-district platforms +carrying school business once families are told. Two consequences for this table: +the `district_observer` seat is goodwill rather than a required control, and the +real constraint on that mentor is clause (k) — coach messages go to all team +members — which is stricter than §4.1 and belongs to the employee manual, not to +hawk-mod. + **"Lead Coach" is not a role hawk-mod stores.** It used to be: a roster role that granted every administrative action in the app. That put a youth-protection permission behind a label anyone with CLI access could type, diff --git a/package-lock.json b/package-lock.json index 55545d2..1e66f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,11 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/markdown-it": "^14.2.0", "@types/node": "^22.10.5", + "markdown-it": "^15.0.0", "prettier": "^3.4.2", + "puppeteer": "^25.8.0", "tsx": "^4.19.2", "typescript": "^5.7.3" }, @@ -466,6 +469,35 @@ "node": ">=18" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.1.tgz", + "integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.0", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@slack/bolt": { "version": "4.7.3", "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.7.3.tgz", @@ -646,6 +678,31 @@ "@types/node": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -736,6 +793,49 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -847,6 +947,56 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -949,6 +1099,13 @@ "node": ">= 0.8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -978,6 +1135,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -987,6 +1151,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1074,6 +1251,16 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1258,6 +1445,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1477,6 +1687,39 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1519,6 +1762,34 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/markdown-it": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz", + "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1528,6 +1799,13 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -1578,6 +1856,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", + "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1748,6 +2043,56 @@ "node": ">=10" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.8.0.tgz", + "integrity": "sha512-3gcUJ+Jfodb5zNa/lWLZukBUwYiRIwAc8WRICoqfi+ZYmNWqpsPFyanTU3Gw/lhgII9aotVbAmhT6/NKHWgyUA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.1", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.8.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.8.0.tgz", + "integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.1", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1987,6 +2332,39 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2055,6 +2433,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2069,6 +2454,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2093,6 +2485,49 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2120,6 +2555,44 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 94a236d..4c3eae9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "typecheck": "tsc --noEmit", "test": "tsx --test test/*.test.ts", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "render:form": "tsx scripts/render-form.ts" }, "dependencies": { "@slack/bolt": "^4.2.1", @@ -25,8 +26,11 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/markdown-it": "^14.2.0", "@types/node": "^22.10.5", + "markdown-it": "^15.0.0", "prettier": "^3.4.2", + "puppeteer": "^25.8.0", "tsx": "^4.19.2", "typescript": "^5.7.3" } diff --git a/scripts/render-form.ts b/scripts/render-form.ts new file mode 100644 index 0000000..f500c71 --- /dev/null +++ b/scripts/render-form.ts @@ -0,0 +1,132 @@ +/** + * Renders a Markdown form to a print-ready HTML page and a PDF. + * + * One source, two outputs: parents read the HTML on a phone and sign the PDF, + * so the two can never drift. Nothing here knows about Slack or the database — + * it is a document build, and it must keep running with no credentials present. + * + * npm run render:form # docs/consent-form.md + * npm run render:form -- docs/other.md # anything else + * + * Chromium is not installed by `npm ci --ignore-scripts`. If this fails to + * launch a browser, run: npx puppeteer browsers install chrome + */ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import MarkdownIt from "markdown-it"; +import puppeteer from "puppeteer"; + +const OUT_DIR = "out/forms"; + +/** + * The form version is the identity of what a family signed — it is recorded on + * every consent row — so it belongs in the filename and the page footer rather + * than only in the prose. Parsed from the document so there is one place to + * change it. + */ +function formVersion(markdown: string): string | null { + return markdown.match(/\*\*Form version ([^\s·*]+)/)?.[1] ?? null; +} + +function title(markdown: string): string { + return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? "Form"; +} + +/** + * Signature lines are dot leaders in the source, so the Markdown stays readable + * on its own. On the page they should be rules: keep the dots for width, make + * them invisible, and underline the cell. + */ +function ruleSignatureLines(html: string): string { + return html.replace(/\.{5,}/g, '$&'); +} + +const CSS = ` + @page { size: letter; margin: 0.85in 0.9in 0.95in; } + :root { color-scheme: light; } + html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + body { + font: 11.5pt/1.55 Georgia, "Times New Roman", serif; + color: #14171a; background: #fff; + max-width: 7in; margin: 2rem auto; padding: 0 1rem; + } + h1 { font-size: 20pt; line-height: 1.2; margin: 0 0 .35rem; } + h2 { + font-size: 13pt; margin: 1.9rem 0 .5rem; + padding-bottom: .25rem; border-bottom: 1px solid #d8dce0; + break-after: avoid; page-break-after: avoid; + } + p, li { orphans: 3; widows: 3; } + strong { color: #000; } + hr { border: 0; border-top: 1px solid #d8dce0; margin: 1.75rem 0; } + ul, ol { padding-left: 1.35rem; } + li { margin: .3rem 0; } + small { font-size: 8.5pt; color: #5b6470; } + + table { width: 100%; border-collapse: collapse; margin: 1rem 0; } + td { padding: .55rem .5rem .55rem 0; vertical-align: bottom; } + td:first-child { width: 12em; white-space: nowrap; color: #3d454e; } + .rule { + display: inline-block; width: 100%; + color: transparent; border-bottom: 1px solid #40474f; + } + + /* A signature must never be stranded from what it signs. */ + h2, h2 + p, table { break-inside: avoid; page-break-inside: avoid; } + + @media screen and (max-width: 34em) { + body { font-size: 12.5pt; padding: 0 1.1rem; } + td { display: block; padding: .1rem 0; } + td:first-child { width: auto; padding-top: .7rem; } + } +`; + +function page(bodyHtml: string, docTitle: string): string { + return ` +
+ + +