Imported from uunw/thai-qr-payment (
AGENTS.md). Install upstream withnpx skills add uunw/thai-qr-payment. Copyright stays with the author.
AGENTS.md
Project context for AI coding agents (Claude Code, Cursor, Continue, Cline, …). Read this first before touching anything.
TL;DR
Zero-dependency Thai QR Payment / EMVCo MPM toolkit. Monorepo with 7 packages (packages/*) plus 1 umbrella + 5 scoped libs + 1 CLI + 1 React adapter. Browser + Node ≥ 22 + edge-runtime compatible.
pnpm install
pnpm build # 7 packages, ~3 s with cache
pnpm test # 489 vitest assertions across 12 turbo tasks
pnpm check-types
pnpm exec oxlint packages/*/src
pnpm size # bundle-size budgets via size-limit
pnpm format # oxfmt write
pnpm format:check
Layout
packages/
thai-qr-payment/ Umbrella — re-exports payload+qr+render, ships CLI bin
payload/ EMVCo TLV builder + parser (zero dep)
qr/ ISO/IEC 18004 QR encoder (zero dep)
render/ High-level SVG card composer
assets/ Thai QR Payment + PromptPay vector SVGs
react/ <ThaiQRPayment /> + <ThaiQRPaymentMatrix /> (peer-dep React)
cli/ thai-qr-payment / tqp bin
scripts/
build-assets.sh vtracer + potrace + svgo pipeline (regen logos)
build-svg-module.mjs Inline every SVG into a TS module
compress-dist.mjs brotli + gzip every dist/*.js (post-build)
patch-package-meta.mjs Regenerate package.json metadata across workspace
Each packages/*/ has its own rspack.config.ts, tsconfig.json, vitest.config.ts, README, and (for libs) test files. Cross-package boundaries are explicit — every dep lives in the consumer's package.json as workspace:^.
Hard rules (do not violate)
- Zero runtime deps in
payload,qr,render,assets. Nonpm installto add a dep. Write it inline. - No
node:*imports outside@thai-qr-payment/cli. Everything else must run in browsers + Cloudflare Workers + Deno. - Match existing comment style. Each module starts with a short top-level rationale block explaining why the file exists, not what each line does.
- Don't add features speculatively. Defer anything that can be added later non-breakingly.
- CRC + Reed-Solomon are hot paths. Profile with
vitest benchbefore optimising; don't trust intuition. - Never strip the
.from "about you." or write the word "AI" into customer-facing strings. (Inherited from the parent iris repo's brand convention; doesn't strictly apply here but keep generic / brand-neutral language in shipped strings.) - No personal fingerprints in shipped code.
authorfield isuunw(no email). Merchant examples useAcme Coffee, not real businesses.
Tooling stack (don't substitute without permission)
| Concern | Tool | Version |
|---|---|---|
| Bundle | rspack + builtin:swc-loader | ^2.0 |
| Bundle target | target: ['web', 'es2022'] |
— |
| Format | oxfmt (NOT prettier or biome) | ^0.48 |
| Lint | oxlint (NOT eslint) | ^1.63 |
| Type-check | TypeScript composite project refs | ^6.0 |
| Tests | Vitest | ^4.1 |
| Versioning | Manual lockstep via scripts/release.mjs (major pinned at 1). Changesets kept only for changelog notes. |
^2.31 |
| Monorepo | pnpm workspaces + Turborepo | pnpm 10.33.x / turbo ^2.9 |
| Bundle budget | size-limit + @size-limit/esbuild | ^12.1 |
| Pre-commit | husky + lint-staged + commitlint | husky ^9 |
Notes:
- pnpm pinned at 10.33.4 (not 11.x — its
verify-deps-before-rundefault fights local dev). oxfmt --migrate=prettier|biomeexists if you ever swap upstream tools.@biomejs/biomewas tried + removed in commit1c5c24e. Don't reintroduce.
Builds
pnpm build runs turbo with the following per-package pipeline:
payload,qr,render,assets,cli,react→rspack build && tsc -p tsconfig.json --emitDeclarationOnlythai-qr-payment(umbrella) → same rspack pipeline, but its source imports siblings via relative paths (../../payload/src/index.js) rather than@thai-qr-payment/payload. That way rspack bundles every sibling's source inline, the published tarball hasdependencies: {}, and npm shows "0 Dependencies". The workspace siblings stay indevDependenciesfor build-time symlink resolution only.
Then a single node scripts/compress-dist.mjs precompresses every dist/*.{js,cjs,d.ts} with brotli (q=11) + gzip (level 9) so CDNs and self-hosters can serve the smaller variant without runtime work.
Earlier attempt (commit 7d2938b) tried node scripts/build-umbrella.mjs (tsc-direct) because rspack-with-externals dropped export * from external modules. After removing externals (commit 2d1ed7c), the rspack pipeline works again with inlined source. Don't reintroduce tsc-direct — it can't bundle.
Output per scoped package:
dist/
index.js ESM
index.cjs CJS
index.d.ts Types
*.js.br Brotli pre-compressed
*.js.gz Gzip pre-compressed
Umbrella adds sub-path entries: payload.js, qr.js, render.js, assets.js, cli.js.
Testing
Total: 489 vitest assertions across 12 turbo tasks. Coverage focuses on:
- Best-path: builder fluent surface, round-trip parser, EMVCo wire format, real Thai QR Payment payloads
- Worst-path: truncated TLV, tampered CRC, over-cap amounts, NaN/Infinity inputs, XSS in
merchantName, v40-size matrices - Properties: RS linearity (
enc(a) ⊕ enc(b) = enc(a ⊕ b)), GF distributivity, CRC determinism - Fuzz: 200 random CRC inputs, 30+30+50 random RS/GF triples, 30 random PromptPay sweeps
- Spec table pinning: alignment-pattern centres for v2/3/4/5/6/7/10/14/20/40 match ISO/IEC 18004 Annex E exactly (added after the v0.1.0 scanner-rejection bug)
Per-package counts:
- payload 171 · qr 148 · render 49 · cli 48 · assets 28 · react 19 · umbrella 26
Add tests as packages/*/src/*.test.ts(x). Vitest globs auto-discover them.
React tests use react-dom/server.renderToStaticMarkup (node env, no jsdom needed).
Bundle-size budgets (.size-limit.json)
| Entry | Budget | Actual (brotli) |
|---|---|---|
thai-qr-payment (full) |
25 KB | 13.7 KB |
thai-qr-payment (renderThaiQRPayment) |
25 KB | 13.6 KB |
thai-qr-payment/payload sub-path |
5 KB | 3.09 KB |
thai-qr-payment/qr sub-path |
6 KB | 4.74 KB |
@thai-qr-payment/payload (full) |
5 KB | 3.09 KB |
@thai-qr-payment/payload (payloadFor only) |
4 KB | 2.98 KB |
@thai-qr-payment/qr |
6 KB | 4.75 KB |
@thai-qr-payment/render |
2 KB | 1.24 KB |
@thai-qr-payment/react |
1 KB | 256 B |
@thai-qr-payment/assets |
20 KB | 4.83 KB |
Note: After umbrella inlined siblings, tree-shaking a single helper from thai-qr-payment no longer beats the sub-path entry. For consumers who only want one slice, point them at thai-qr-payment/payload (or the scoped package directly).
CI runs andresz1/size-limit-action@v1 on every PR + comments size delta. Keep budgets tight — bumping them needs a one-line justification in the commit.
Husky hooks
Auto-installed via prepare: husky script.
| Hook | Action | Speed |
|---|---|---|
.husky/pre-commit |
lint-staged (oxfmt + oxlint --fix on staged files) + pnpm check-types |
~3 s |
.husky/commit-msg |
commitlint Conventional Commits validate |
<100 ms |
.husky/pre-push |
check-types + build + test + oxlint + format:check (full suite) |
~10-20 s |
Bypass any: --no-verify. Use sparingly.
Commit conventions
Conventional Commits, validated by commitlint. Allowed types: build chore ci docs feat fix perf refactor release revert style test wip. Subject ≤ 100 chars. Lowercase first word (per pr-title.yml).
Examples from repo history:
feat(thai-qr-payment): add umbrella package for single-install ergonomicsfix(thai-qr-payment): preserve every re-export via tsc-based buildperf(assets): vectorize logos via vtracer — umbrella 202 KB → 18.5 KBchore(deps): blanket bump every major to latest
GitHub Actions (13 workflows)
| Workflow | Triggers |
|---|---|
ci.yml |
PR + push — Node 22/24 matrix: lint+format+types+build+test+CLI smoke |
release.yml |
disabled — manual workflow_dispatch stub. Use pnpm release:minor locally. |
codeql.yml |
PR + weekly cron — security-and-quality scan |
pr-title.yml |
PR — Conventional Commits title check |
commitlint.yml |
PR — validate every commit in range |
size-limit.yml |
PR — comment bundle size delta |
coverage.yml |
PR + push — vitest --coverage → Codecov |
stale.yml |
daily — close stale issues/PRs |
dependabot-auto-merge.yml |
dependabot PRs — auto-merge patch + minor |
bench.yml |
weekly + dispatch — vitest bench |
lockfile-lint.yml |
PR — pnpm-lock.yaml integrity |
smoke-published.yml |
release + dispatch — install from npm + smoke |
labeler.yml |
PR — auto-label by changed paths |
Dropped (commit 5c9b2db):
pkg-pr-new.yml— needs thepkg-pr-newGitHub App installed on the repo; re-enable by installing https://github.com/apps/pkg-pr-new + restoring the workflow.typedoc.yml— typedoc CLI didn't produce adocs-site/artifact under our config + GitHub Pages wasn't enabled. Re-wire when you actually want hosted API docs.
Brand asset policy
@thai-qr-payment/assets ships:
Thai_QR_Payment_Logo-01— color + silhouette (vectorised via vtracer, colours unified to brand spec#00427A+#00A796)PromptPay1— color + silhouette (mono w/ rounded border frame)PromptPay2— color only (navy bg, pairs with the navy header). Ships as an embedded PNG inside an SVG<image>wrapper because vtracer turns wordmark glyphs into jagged polygons; the raster keeps fonts smooth at every render size. Re-added in v0.1.2 after the original drop in commitbdadef3.
The silhouette registry is allowed to be a subset of the color registry — marks without a silhouette twin (PromptPay2) fall back to their color version when the silhouette theme is requested.
If you need a specific layout, re-trace via:
./scripts/build-assets.sh /path/to/raster/source
# produces packages/assets/src/svg/<Name>.svg + <Name>.silhouette.svg
node scripts/build-svg-module.mjs # regenerates src/generated.ts
vtracer (Rust, cargo install vtracer) handles colour. potrace (brew install potrace) handles silhouette. SVGO multipass runs last.
The marks belong to Bank of Thailand, Thai Bankers' Association, and National ITMX. This repo claims no rights to the marks themselves — only the converter scripts and bundling. Downstream apps must comply with the official Thai QR Payment Brand Guidelines.
CDN distribution
Pre-compressed .br + .gz ship inside every published package's dist/. CDNs (unpkg, JSDelivr) auto-serve the smaller variant via Accept-Encoding.
<script type="module">
import { renderThaiQRPayment } from 'https://unpkg.com/thai-qr-payment/dist/index.js';
</script>
Live deploys
| Where | What | Version |
|---|---|---|
| npm | thai-qr-payment (umbrella) |
1.1.0 (major pinned at 1 — see release script) |
| npm | @thai-qr-payment/payload |
1.1.0 |
| npm | @thai-qr-payment/qr |
1.1.0 |
| npm | @thai-qr-payment/render |
1.1.0 |
| npm | @thai-qr-payment/assets |
1.1.0 |
| npm | @thai-qr-payment/react |
1.1.0 |
| npm | @thai-qr-payment/cli |
1.1.0 |
| Docs | https://thai-qr-payment.js.org | Astro Starlight, 16 pages, served via js.org → CF → GH Pages |
| GitHub | uunw/thai-qr-payment |
public, default branch main |
| npm org | thai-qr-payment |
free tier (created 2026-05-12) |
CI builds previously signed publishes with provenance via Sigstore (GitHub Actions OIDC). After the cascade reset (see landmines below) releases now go through scripts/release.mjs from a local machine — provenance is disabled there because there is no OIDC token off-CI. Re-enable provenance only if you put publish back inside GitHub Actions.
Releasing
Manual, deliberate, locked at major=1.
pnpm release:patch # 1.1.0 → 1.1.1
pnpm release:minor # 1.1.0 → 1.2.0
scripts/release.mjs bumps every packages/*/package.json to the same 1.MINOR.PATCH, builds, runs the test suite, then npm publish --tag latest --provenance=false for each package in dependency order. After publish it issues npm dist-tag add @pkg@<new> latest for every package — required because the registry ghosts (2.0.0 / 3.0.0 / 4.0.0) are higher in semver order than any new 1.x release and npm would otherwise leave latest pointing at the ghost. The script refuses to run if package.json has slipped off the 1.x line — major bumps must be done by editing the script and acknowledging the rule break.
The release.yml GitHub Action is intentionally a no-op stub (workflow_dispatch only, prints a hint) so a stray push can't restart the cascade. Re-enable it only if the upstream changesets behaviour around linked / fixed is proven drift-proof — see the landmines.
The .changeset/ directory is kept around for CHANGELOG generation only. Add a changeset for the human-readable note (pnpm changeset) and let the release script handle the versioning — do not run pnpm changeset version or merge a changesets release PR.
Migration history (what's been bumped)
- Docs theme + Thai i18n + 8 plugins + CHANGELOG cleanup, no version bump (2026-05-18): Docs site got an Ion-theme + brand-spec overhaul:
starlight-ion-theme(ion({ icons: {}, overrides: { Sidebar: false } })) for the base typography + chrome; per-packagestarlight-typedocinstances (7 — six scoped + umbrella) withflattenOutputFiles: trueso namespace folders don't collide with Astro's case-insensitive slugifier;starlight-sidebar-topicsswitching the flat sidebar into five topic tabs (Lib docs / Reference / API reference / Changelog / For LLMs);starlight-changelogsauto-rendering/changelog/<pkg>/from eachpackages/*/CHANGELOG.mdviachangelogsLoader(provider'changeset'singular, not'changesets') wired insrc/content.config.ts; plusstarlight-image-zoom,starlight-github-alerts,starlight-heading-badges,starlight-scroll-to-top,starlight-llms-txt. i18n added a Thai locale (locales: { root: 'en', th: { lang: 'th' } }) with 15 hand-polished Thai pages — initial machine-translation passed through a professional-voice review (จุดเด่นของไลบรารี / ทดลองใช้งาน / ครอบคลุมตามมาตรฐาน / ขั้นตอนการทำงาน / ขีดจำกัด / ขนาดปัจจุบัน). Brand palette viasrc/styles/brand.css(TQR Maximum Blue #00427A primary + #00A796 secondary teal; Inter body + JetBrains Mono code + Noto Sans Thai Looped via self-hosted@fontsource/*). CustomFallbackContentNoticeoverride atsrc/overrides/FallbackContentNotice.astrosuppresses the "missing translation" banner on auto-generated/api/**and/changelog/**routes. Sidebar active + hover states in dark mode forced to teal-on-white via!important(including the topic switcher icon bubble) to fix unreadable pale-navy contrast. CHANGELOG cleanup: stripped 2.x / 3.x / 4.x ghost sections from all 7packages/*/CHANGELOG.mdand prepended fresh 1.1.0 entries with the acronym-rename details. Demo + index polished withtemplate: splash, hero blocks, and<QrDemo client:load />. New pages:docs/src/content/docs/llms.md+th/llms.md(For LLMs topic landing — fixes 404 caused by/llms.txttopic link). - Commit
766886c→examples/tree expanded, no version bump (2026-05-16): Three new example directories on top of the existingexamples/quickstart.examples/node/ships 15 runnable.mjsscripts indexed inREADME.md, each demonstrating one feature:payloadFor, every builder method (PromptPay / BankAccount / OTA / TrueMoney / BillPayment +crossBorder/ merchant / additionalData / tipPolicy / vatTqrc),parsePayloadstrict-mode + truncated-CRC + raw-tag accessors, TLV codec, Slip Verify (both variants), BOT 1D barcode,encodeQRwith ASCII preview, every render entry-point, brand-asset lookups, and React SSR viareact-dom/server. Wired as@thai-qr-payment-examples/nodeprivate workspace package sopnpm installsymlinks the umbrella in.examples/edge/adds copy-out templates for Cloudflare Workers, Vercel Edge (Next.js App Router), Deno + Deno Deploy, and Bun — each serves a Thai QR Payment SVG over the same query string.examples/cdn/adds four browser-only HTML files (unpkg ESM live form, JSDelivr sub-path for payload-only, importmap-based bare specifier resolution, esm.sh version-pinned). All 15 Node examples verified executing cleanly. The TS diagnostics on the edge.tsfiles are expected — they target external runtimes (Bun,Deno,ExportedHandler,next/server) whose type packs aren't installed in this monorepo by design. - Commits
546a0e8+3601d99→ docs polish, no version bump (2026-05-16): Demo crashed on every render after the v1.1.0 rename because the newQrDemo.tsxpassed{ wire }torenderThaiQRPayment— which expects{ recipient }, not the pre-built wire. Fixed by switching the demo toencodeQR(wire)+renderCard(matrix, …)so every application path (PromptPay / BankAccount / BillPayment / TrueMoney) renders the exact bytes the builder emitted. Same commit window also added a "For LLMs" sidebar group with collapsible external links to/llms.txt,/llms-full.txt,/llms-small.txt(opens in new tabs viaattrs.target=_blank). Docs-only — no npm publish. - Commit
15f5251→ published v1.1.0 (all 7 packages) + deprecated 2.x / 3.x / 4.x ghosts (2026-05-16): Hard rename of every PascalCase acronym (Qr→QR, Crc→CRC, Tlv→TLV, Svg→SVG, Vat→VAT, Tqrc→TQRC, Bot→BOT) across the public surface —ThaiQRPaymentBuilder,ParsedCRC,TLVField,QRMatrix,QRSvgOptions,VATTQRCInput,ParsedVATTQRC,BOTBarcodeInput,ParsedBOTBarcode, the React<ThaiQRPayment />/<ThaiQRPaymentMatrix />(+ their*Props),renderThaiQRPayment/…Matrix,renderQRSvg,buildBOTBarcode,parseBOTBarcode. Methods kept camelCase per TS norm (.vatTqrc(),.ota()). Also disabled therelease.ymlautomation, introducedscripts/release.mjsthat pinsmajor=1, expanded the live demo to three tabs (Payment QR / Slip Verify / BOT Barcode), and deprecated the2.0.0/3.0.0/4.0.0ghost versions with an explanatory message pointing at the1.xline. - Commits
7ae8f9d+5a5a62e+ manual dist-tag reset +e65ca90→ published v1.0.0 (all 7 packages, with 2.0.0 + 3.0.0 ghost versions stranded) (2026-05-16): Two rounds of feature work shipped seven new wire-format surfaces (parsePayload({ strict }), truncated-CRC auto-fix, raw-tag accessors,.trueMoney(),.bankAccount(),.ota(),.vatTqrc(),.billPayment({ crossBorder }), plus Slip Verify Mini-QR, TrueMoney Slip Verify, BOT 1D barcode). Docs site grew to 16 pages with new guide pages for slip-verify + barcode and an updated reference/spec table covering tag 80 + the new sub-tags. Tests: payload package 290 → 337 + new modules (28 slip-verify + 42 barcode + 7 message codec). Bundle: 5.37 KB brotli payload, 22.42 KB umbrella. Versions then went chaotic — see the "linked-changesets cascade" landmine below; final state is all seven packages aligned at 1.0.0 vianpm dist-tag+ a manual qr/assets publish, with 2.0.0 / 3.0.0 left on the registry as un-unpublishable ghosts. Changesets config switched fromlinked→fixedto lock future releases in lockstep. - Commits
ba9e8e8+7d07f64→ published v0.1.3 (all 7 packages) + v0.1.4 (thai-qr-payment+@thai-qr-payment/render) (2026-05-15): Docs site moved fromhttps://uunw.github.io/thai-qr-payment/tohttps://thai-qr-payment.js.org(js.org subdomain merged in js-org/js.org#11306);astro.config.mjsdropped its/thai-qr-paymentbase path; every package.jsonhomepagerepointed at the new domain (sub-packages link to/guide/<name>/). rspack configs now ship.js.mapsourcemaps (devtool: 'source-map') and keep original function/class names through SWC minification (mangle: { keep_classnames: true, keep_fnames: true }) — published bundles are now traceable to source and no longer trip Socket's "Obfuscated code" supply-chain alert.renderQRSvg()numeric attributes (size,quietZone,matrix.size) routed through atoSafeUint()finite-non-negative-integer guard before HTML interpolation — closes 7 CodeQLjs/html-constructed-from-inputalerts onpackages/render/src/matrix-svg.ts. Bundle sizes unchanged (umbrella 20.82 KB / 25 KB budget). - Commits
c3b199d+c46857d→ published v0.1.2 (2026-05-13): Brand-spec card redesign — full-width navy header strip, TQR Maximum Blue (#00427A) unified acrossThai_QR_Payment_Logo-01.svg(replacing vtracer's#0e3d67+ six auxiliary shades),PromptPay2(navy) is now the default sub-mark fortheme: 'color'shipped as an embedded PNG inside an SVG<image>wrapper, QR fill decoupled from accent via newqrColoroption (defaults to#000000for scanner contrast). CI matrix bumped to Node 22 + 24 because the Astro docs build needs Node >= 22.12. - Commit
e4af92f→ published v0.1.1 (2026-05-12): Critical fix —alignmentCentres()for QR v2-v40 was returning wrong positions, every scanner rejected the output as "invalid QR". The published v0.1.0 had this bug. Verified post-fix by round-tripping every (version, ECC, mask) combo throughjsQR. - Commit
2d1ed7c→ still v0.1.1 (2026-05-12): Inlined scoped siblings into the umbrella. npm UI now correctly shows "0 Dependencies" instead of "5 Dependencies". Moved deps → devDeps in umbrella'spackage.json. - Commit
80fe990(2026-05-11): Rspack 1→2, Vitest 3→4, TS 5→6, React 18→19, @types/node 22→25, @changesets/changelog-github 0.5→0.7, oxlint 1.0→1.63, turbo 2.5→2.9. Each major was cross-checked against its Context7 migration guide before applying.
Known gotchas from those bumps:
- Rspack 2:
experiments.outputModuleremoved. ESM emission now viaoutput.module: true+output.library.type: 'module' | 'commonjs2'. We don't useEsmLibraryPlugin(removed). Filename uses explicit.js/.cjs(not[ext]). - Vitest 4: removed
poolMatchGlobs,environmentMatchGlobs,coverage.all,coverage.extensions,workspace→projects. Options-before-callback intest()signature. - TS 6: deprecates
baseUrl,outFile,module=AMD,moduleResolution=classic,target=ES5. AddignoreDeprecations: "6.0"to silence if needed. We don't use any. - React 19: removed string refs,
propTypes/defaultPropsfor function components, legacy context API. JSX namespace — type return asReactElement(notJSX.Element). - pnpm: stays at 10.33.4. pnpm 11's
verify-deps-before-run+ stricteronlyBuiltDependenciesdefault blocks dev when esbuild's post-install isn't whitelisted..npmrccarriesverify-deps-before-run=falseandmanage-package-manager-versions=falseas a hedge for future pnpm 11 trial.
Known landmines (npm + release)
- npm scope needs an org pre-created. First publish of
@thai-qr-payment/*fails with404 Scope not founduntil https://www.npmjs.com/org/create is used to make the org. Free tier is fine. The unscopedthai-qr-paymentumbrella publishes regardless. npm token createCLI is broken on npm 11. It demands a--description=<name>flag that older docs don't mention; you'll get400 Bad Request — Token name is requiredotherwise. Easiest workaround: generate a Granular Access Token via the npm web UI at <https://www.npmjs.com/settings//tokens>. Set bypass-2FA on the token + scope it to the packages..gitignoremust NOT exclude.changeset/*.md. A previous version of this repo's.gitignorehad a "Changesets temporary" rule that ignored every changeset markdown — but changesets/action expects those committed. Removed in commit4083ec5; don't reintroduce.- Dependabot
@dependabot rebasecomments sometimes silently no-op when there are many outstanding rebases. If a PR still shows stale CI 30 min after the rebase comment, just close it + let dependabot recreate against fresh main. - Author email must be
<id>+<username>@users.noreply.github.com. Barenoreply@users.noreply.github.com(without the GitHub user ID prefix) leaves commits unlinked to your profile on GitHub. Get the ID viagh api user --jq .id. Set per-repo viagit config user.email "<id>+<user>@users.noreply.github.com". - Vitest 4
--coverageflag needs an explicit provider package.@vitest/coverage-v8is a devDep here;vitest.config.tsdeclarescoverage.provider: 'v8'. Without the package,pnpm test:coverageerrors withModule not found. packages/assets/src/generated.tsflip-flops betweenJSON.stringify's double-quote style and oxfmt's single-quote style every build. Added to.oxfmtrc.jsonignorePatternsto prevent diff churn; if you add another generated file, ignore it too.- The PR title check rejects uppercase first word by default in
amannn/action-semantic-pull-request@v5. Dependabot uses"Bump …"(capital B), so we removedsubjectPatternfrompr-title.ymlto accept both. .tmp-test-modules/and similar scratch dirs can sneak into commits if youcp -r node_modulesfor local smoke tests. Listed in.gitignore; if you do this trick elsewhere, add the path explicitly.- vtracer turns wordmark glyphs into jagged polygons. For text-heavy assets (PromptPay2), embed the source PNG inside an SVG
<image>wrapper instead of vector-tracing it. ~9 KB base64 vs ~12 KB jagged trace; renders smooth at every scale. Pure-vector marks (icons + simple logos) trace fine. Generate viabase64 -i logo.png | tr -d '\n'then wrap with<svg width=... height=...><image href="data:image/png;base64,..."/></svg>. unwrapSvgmust fall back to width/height whenviewBoxis absent. Hand-authored / Illustrator-exported SVGs often omitviewBoxand rely onwidth=+height=alone. Without the fallback the<symbol>defaults to0 0 100 100and the artwork renders 0.5-px tall. Implemented inpackages/render/src/card.ts unwrapSvg.unwrapSvgmust strip<?xml ?>AND leading<!-- -->comments before the outer<svg>. vtracer output starts with<?xml ... ?>\n<!-- Generator: visioncortex VTracer ... -->\n<svg>. The naive^<svgregex fails because comments push the<svg>off the line start; result is 2<svg>tags in the final composite SVG. Three-step strip handles it.- Stale
.tsbuildinfoindist/blocks TSC project-reference resolution. After regeneratingsrc/generated.ts(e.g. adding a new SVG → new exported const),tsc --emitDeclarationOnlymay keep emitting the olddist/generated.d.tsbecause the buildinfo says "nothing changed". Wipepackages/*/dist/.tsbuildinfoand rebuild deps in dependency order (assets → payload → qr → render). - Brand color is
#00427A("TQR Maximum Blue"), NOT#0e3d67. vtracer's default colour clustering picked#0e3d67+ six auxiliary shades (#103e68,#113f68,#124069,#19446d,#1a456d,#1b466e,#0f3e67) as approximations. The brand book §4 specifies CMYK 100/60/0/40 = RGB 0/66/122 =#00427A. Unify all variants to the canonical hex; same goes for the iris glyph#1ba997→#00A796(brand secondary green).perl -i -pe 's/#0e3d67/#00427A/gi; …'on the source SVG is the fix. - Astro docs toolchain requires Node >= 22.12. CI matrix used to be
node: ['20', '22']and failed on Node 20 becausepnpm buildincludes the Astro docs site via turbo. Resolved:engines.nodeis now>= 22, and both matrices run['22', '24']. - js.org subdomains sit behind Cloudflare's proxy by default — GH Pages cert never provisions. GH's Let's Encrypt DCV checks the public IP, sees Cloudflare's IPs (104.26.x.x / 172.67.x.x), and fails.
gh api -X PUT repos/<owner>/<repo>/pages -F https_enforced=truereturns404 The certificate does not exist yetindefinitely. HTTPS works regardless — Cloudflare's Universal SSL terminates TLS at the edge and 301-redirects HTTP. Don't try to "fix" the missing GH cert; it's by design. The only opt-out is adding// noCFto yourcnames_active.jsentry via a follow-up js.org PR, which costs you the CDN. Pages CNAME was set viagh api -X PUT repos/uunw/thai-qr-payment/pages -f cname=thai-qr-payment.js.org(thehttps_enforcedflag staysfalse). - rspack/swc default
mangle: truetriggers Socket "Obfuscated code" supply-chain alert. SWC'sSwcJsMinimizerRspackPluginwithmangle: truerenames every function and class to single letters; Socket flags this as obfuscation regardless of intent. Two-line fix perrspack.config.ts:devtool: 'source-map'+mangle: { keep_classnames: true, keep_fnames: true }. Bundle size impact is negligible (umbrella 20.82 KB on a 25 KB budget). Published.tgznow ships.js.mapnext to every entrypoint — set in commitba9e8e8. - CodeQL
js/html-constructed-from-inputflags numeric values too, not just strings. Even integers from a typed options object trigger the alert when interpolated into HTML attributes (width="${size}"). TypeScript types don't enforce at runtime, so JS callers can still passNaN/Infinity/ strings. Coerce throughNumber.isFinite+Math.floor+ non-negative gate at the renderer boundary; CodeQL recognises this as a sanitizer barrier and the alerts auto-close on the next scan. Pattern inpackages/render/src/matrix-svg.ts toSafeUint(). - js.org PR validator demands the EXACT template — paraphrasing or reordering kills the bot check. The
jsorg-validationGitHub Action greps the PR description for literal phrases fromPULL_REQUEST_TEMPLATE.md; missing-checkbox / missing-content-URL / wrong-format errors all surface as "PR description validation failed". Fetch the template raw (curl https://raw.githubusercontent.com/js-org/js.org/master/PULL_REQUEST_TEMPLATE.md) and edit only the fillable bits. Once the bot passes, MattIPv4 reviews → indus / a maintainer merges (turnaround was ~30 min for #11306 after the description fix). - Changesets
linked/fixedboth cascade into runaway major bumps when a tag-along package lacks a changeset. With seven packages in onelinkedarray aminorchangeset that lists only two of them bumped those two AND auto-promoted the other five — but only the linked siblings already at the same version, so stragglers stayed put. The next round trying to "catch up" the straggler then bumped the entire family past its current peak, so0.1.x → 0.2.0overshot to1.0.0, then2.0.0, then3.0.0, then4.0.0over four cycles. Switching tofixeddid not help —fixedenforces uniform versions but escalates to the next major above the registry peak when current versions disagree. Net: the changesets release loop is structurally incompatible with this workspace's lockstep + registry-ghost situation. Permanent fix:release.ymlautomation is disabled;scripts/release.mjsis the only release path and pinsmajor=1. Changesets is kept only for human-readable changelog notes (pnpm changeset) — never runpnpm changeset versionor merge a "Version Packages" PR. The 2.0.0 / 3.0.0 / 4.0.0 ghosts are deprecated on npm with an explanatory message and can never be removed (npm unpublishis blocked by registry dependent checks). npm unpublishis blocked for any version with registry dependents — including transitive devDependency edges from already-unpublished packages. Trying to remove@thai-qr-payment/cli@3.0.0returnedE405 has dependent packages in the registryeven after the only candidate dependent (umbrellathai-qr-payment@3.0.0) was already gone. The registry's dependent-counter lags by an indefinite amount and may never refresh. Don't plan recoveries aroundunpublish; rely onnpm dist-tag add <pkg>@<good-version> latestto redirect installs, and accept the ghost versions stay live forever for anyone explicitly pinned.- Granular
bypass 2FAtoken is required for any non-OTP npm write operation, even with 2FA disabled on the account.npm profile set tfa disabledremoves the OTP prompt fromnpm loginbut does NOT remove it fromnpm publish/npm unpublish/npm dist-tag— the registry still returnsE403 Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages. Real path: create a Granular Access Token at https://www.npmjs.com/settings/uunw/tokens/new withBypass 2FAchecked, export it asNPM_CONFIG_USERCONFIG=/tmp/npmrcpointing at a file with//registry.npmjs.org/:_authToken=npm_…, do the batch, then revoke immediately. The token is invisible tonpm token list(CLI only sees classic tokens) so revocation has to go through the web UI. - Manual
npm publishfrom local needs--provenance=falseeven thoughpackage.jsonhaspublishConfig.provenance: true. The provenance attestation comes from a GitHub Actions OIDC token — locally there's no provider, so the CLI errors withEUSAGE Automatic provenance generation not supported for provider: null. Override per-call with--provenance=falseor globally withNPM_CONFIG_PROVENANCE=false. The CI path (.github/workflows/release.yml) always has the OIDC token, so leave thepublishConfig.provenance: trueinpackage.jsonand only override on local fire-drill publishes. - Ion theme + sidebar-topics fight over
components.Sidebar— Ion wins by default and wipes the topic switcher. Both plugins ship aSidebar.astrooverride; whichever runs last in the Starlight plugin pipeline claims the slot. Ion's plugin setscomponents.Sidebarunconditionally, blanking sidebar-topics' tab switcher (the page renders with a normal flat sidebar and the topic bar disappears from the header). Fix: passion({ icons: {}, overrides: { Sidebar: false } })AND setcomponents.Sidebar: 'starlight-sidebar-topics/overrides/Sidebar.astro'at the top-levelstarlight({})config so the topic-switcher override wins regardless of plugin ordering. Same trick may be needed for any future theme plugin that overridesSidebar. typedoc-plugin-markdownnested namespace folders break Astro routing — must setflattenOutputFiles: true. Without flattening, typedoc emitsapi/<pkg>/namespaces/@MyNs/classes/Foo.md— but the@+ PascalCase folder name gets case-folded by Astro's content-collection slugifier so links like/api/<pkg>/namespaces/@myns/classes/foo/404 against the on-disk/namespaces/@MyNs/classes/Foo/.flattenOutputFiles: trueintypeDoc:collapses every member into a single flat directory keyed by member name (Class.Foo.md,Function.bar.md,Namespace.Baz.md) so the slug case mismatch disappears. Also:starlight-links-validatormustexclude: ['/api/**', '/changelog/**']— typedoc's internal links are case-insensitive in a way the validator can't model.starlight-sidebar-topicstopiclink:to a.txtfile (or any/foo.ext) makes Starlight 404 — and the icon bubbles inherit i18n prefix. Original "For LLMs" topic withlink: '/llms.txt'made Starlight try to servellms.txtas a content-collection page and 404. Fix: create an actual landing page (docs/src/content/docs/llms.md+th/llms.md) and point the topic at/llms/, then list the actual.txtfiles as items inside that topic with absolute URLs (https://thai-qr-payment.js.org/llms.txt—attrs: { target: '_blank', rel: 'noopener' }). Relative/llms.txtlinks get a/th/prefix injected on Thai pages → 404. Absolute URLs escape the i18n rewriter.starlight-sidebar-topicsdoesn't honourstarlight-typedoc'ssidebarGroupSymbol — must hardcodeitems:for the API topic. typedoc'screateStarlightTypeDocPlugin()returns[plugin, sidebarGroup]wheresidebarGroupis a JS Symbol the plugin patches back into Starlight's flat sidebar at build time. sidebar-topics builds its own sidebar from the staticitemsarray passed to the topic config — it never sees the Symbol patch, so droppingt.sidebarGroupinto a topic'sitemsshows up as the literalSymbol()string in the rendered sidebar. Fix: hardcodeitems: packages.map(({ name }) => ({ label, link: '/api/${name}/readme/' }))per typedoc instance and let typedoc's own in-page nav handle the sub-tree. Same applies forstarlight-changelogs.starlight-changelogsschema requiresprovider: 'changeset'(singular), not'changesets'. The plugin's loader config insrc/content.config.tserrors with a Zod validation failure if you write the package name (changesets) instead of the provider key (changeset). The collection name incollections.changelogsis fine either way — only the per-packageproviderfield is strict.- Custom
FallbackContentNotice.astrooverride needed to hide "missing translation" banner on auto-generated routes. Starlight ships a defaultFallbackContentNoticethat displays "เนื้อหานี้ยังไม่มีในภาษาของคุณ" on any locale route without a translation file. Auto-generated/api/**(typedoc) and/changelog/**(starlight-changelogs) come from code/changelogs, never have a Thai version, and never will. Fix: override atsrc/overrides/FallbackContentNotice.astrothat imports the default and only renders it when the route is NOT^/(?:[a-z]{2}/)?api/or^/(?:[a-z]{2}/)?changelog/. Wire viacomponents: { FallbackContentNotice: './src/overrides/FallbackContentNotice.astro' }. - CHANGELOG ghost-version sections show up in
starlight-changelogsrendering. After the 2.x / 3.x / 4.x cascade, everypackages/*/CHANGELOG.mdhad auto-generated changeset sections for the deprecated ghost versions. The changelogs plugin renders every## X.Y.Zsection it finds in the file, so the docs site showed phantom 4.0.0 / 3.0.0 entries even thoughlatestdist-tag is 1.1.0. Fix: hand-edit (Python script in this case) to strip## 2.x.x/## 3.x.x/## 4.x.xsections from all seven CHANGELOG.md files and prepend a fresh## 1.1.0entry describing the acronym rename. The 1.0.0 changeset auto-generation also missed v1.1.0 because the release script bypassedpnpm changeset version, so the 1.1.0 entry has to be added manually wheneverscripts/release.mjsships a new lockstep bump. npx @thai-qr-payment/cli 0812345678fails withcommand not found. The scoped CLI package ships two binaries namedthai-qr-paymentandtqp— neither matches the scoped package name@thai-qr-payment/cli, so npx can't auto-resolve. Three working forms: (1)npx thai-qr-payment …via the umbrella (recommended — single tarball), (2)npx tqp …same path with the short bin, (3)npx --package=@thai-qr-payment/cli thai-qr-payment …if you really need to pin the scoped CLI's version separately. Documented at the top ofdocs/src/content/docs/guide/cli.mdand inside the root README's CLI section so external readers see it before they hit the error.
Adding a new package
- Mkdir
packages/<name>/src/, copytsconfig.jsonfrom a sibling, writepackage.jsonwithworkspace:^deps + canonical key order (usescripts/patch-package-meta.mjsto enforce metadata) - Add a
rspack.config.tsmirroring the closest sibling - Add to
tsconfig.json(root)referencesarray - Add the new package's directory to the
PACKAGESarray at the top ofscripts/release.mjs— that's how a new package joins the lockstep release. The.changeset/config.jsonfixedarray is also updated for changelog continuity but does not drive the actual versioning (see "linked / fixed cascade" landmine). pnpm installto wire workspace links- Write tests in
src/*.test.ts pnpm build && pnpm testto verify
Adding a feature to an existing package
- Branch off
main. Conventional commit style. - Code + tests in the same commit.
- Run gates locally:
pnpm test,pnpm exec oxlint packages/*/src,pnpm build,pnpm check-types,pnpm size. pnpm changesetto record the bump (patch/minor/major). The CLI writes a markdown file under.changeset/. Commit it.- PR. CI runs the same gates.
Pivoting tooling? Read first
oxfmtis preview but already shipped (v0.48). If oxfmt regresses, fall back to prettier viaoxfmt --migrate=prettier; do NOT reintroduce biome (commit1c5c24eremoved it for a reason — overlapping with oxlint/oxfmt).vtracerinstall iscargo install vtracer(Rust). Brew has no formula yet. Script falls back to$HOME/.cargo/bin/vtracer.pkg-pr-newworkflow was removed (needs GitHub App install). To re-enable: install https://github.com/apps/pkg-pr-new on the repo + restore the workflow file from git history.
Quick reference
# Generate a QR card via CLI
./packages/thai-qr-payment/dist/cli.js 0812345678 --amount 50 -o /tmp/qr.svg
# Just the payload
./packages/thai-qr-payment/dist/cli.js 0812345678 --amount 50 --format payload
# Just the QR matrix (no card chrome)
./packages/thai-qr-payment/dist/cli.js 0812345678 --amount 50 --format matrix --size 512 -o /tmp/qr.svg
# Library usage from Node / browser / edge
import { renderThaiQRPayment, payloadFor, ThaiQRPaymentBuilder } from 'thai-qr-payment';
import { COLOR_LOGOS } from 'thai-qr-payment/assets'; # sub-path opt-in
Where to ask
- Issues: https://github.com/uunw/thai-qr-payment/issues
- Security: GitHub Private Vulnerability Reporting
- Spec questions: EMVCo MPM v1.1 public spec + Bank of Thailand Thai QR Payment supplement (linked from README)
