iArt.ai
AI 모션 그래픽 소프트웨어, 누구나 사용할 수 있는 After Effects 대안, 텍스트 프롬프트로 몇 분 안에 애니메이션·설명 영상 제작
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Marketing, documentation, press, and blog site for chordlist, the local-first songbook app for
iPhone and iPad. It is a Next.js App Router project deployed on Vercel.
Use pnpm; pnpm-lock.yaml is the repository's source of truth.
Open http://localhost:3000. pnpm dev runs the app-asset sync first, so a
sibling ../chordlist-app checkout automatically supplies its latest screenshots. If the app lives
elsewhere, set CHORDLIST_APP_REPO to its absolute path.README 전문 보기 ↗Marketing, documentation, press, and blog site for chordlist, the local-first songbook app for iPhone and iPad. It is a Next.js App Router project deployed on Vercel.
Use pnpm; pnpm-lock.yaml is the repository's source of truth.
pnpm install
pnpm dev
Open http://localhost:3000. pnpm dev runs the app-asset sync first, so a
sibling ../chordlist-app checkout automatically supplies its latest screenshots. If the app lives
elsewhere, set CHORDLIST_APP_REPO to its absolute path.
| Command | Purpose |
|---|---|
pnpm dev | Sync app assets, then start the local development server. |
pnpm check | Run ESLint, TypeScript, and a production build. This is the required pre-commit check. |
pnpm build | Sync app assets and create the production build. |
pnpm build:all | Sync app assets, regenerate every visual asset, build the App Store sets, and create the production build. |
pnpm start | Serve an existing production build locally. |
pnpm lint | Run ESLint with warnings treated as failures. |
pnpm test | Run the chordlink URL-contract and AASA tests. |
pnpm typecheck | Run TypeScript without emitting files. |
pnpm sync:app | Copy current iPhone/iPad screenshots and the available press-kit archive from the app repository. |
pnpm sync:video | Copy prepared demo clips and rebuild the Remotion asset manifest. |
pnpm sync:all | Run both app-asset syncs. |
pnpm sync:assets | Backward-compatible alias for sync:app, used by website dev and builds. |
pnpm build:icons | Regenerate the favicon, app icon, and related icon assets in public/. |
pnpm build:og | Regenerate the site, page, and blog Open Graph images. |
pnpm build:social | Render social images and their manifest from content/social/. |
pnpm build:emails | Render the email templates and their manifest from content/emails/. |
pnpm build:screens | Sync app captures, then generate the iPhone and iPad App Store upload sets. |
pnpm video:studio | Sync current demo clips and open the interactive Remotion editor. |
pnpm video:render:campaign | Render the short, standard, and documentary light promo cuts. |
pnpm video:render:both | Render the light and dark vertical demo masters. |
pnpm check remains the full project gate; run pnpm test alongside it for the chordlink route contract.
locales/en.ts, product facts and links in lib/site-config.ts, and layout or
behavior in app/ or components/.pnpm dev and review the affected routes in light and dark mode.pnpm check before committing.The site uses Tailwind CSS v4 tokens defined in app/globals.css. Components should use semantic
tokens such as bg-background and text-muted-foreground instead of raw colors.
Simulator captures are owned by the chordlist-app repository. From this repository, run:
pnpm sync:assets
The script copies light and dark iPhone and iPad captures into public/app-screenshots/. Captures in
another language come along with them: English stays at the root and every other language nests under
its code (public/app-screenshots/de/light/), mirroring the app repository. If
chordlist-app/build/press-kit/chordlist-press-kit.zip exists, it also updates the downloadable
archive in public/press/.
When adding or renaming a screenshot, update all of the following:
screenshotNames in scripts/sync-app-assets.mjs;components/app-showcase.tsx and app/press/page.tsx;locales/en.ts; andCommit the synced outputs with the code that references them.
Generated files in public/ should not be edited by hand. Change their source or generator, run the
matching command, visually inspect the result, and commit both the source change and output.
pnpm build:all
# Or regenerate one asset family:
pnpm build:icons
pnpm build:og
pnpm build:social
pnpm build:emails
pnpm build:all is the release-oriented one-shot workflow. It syncs app inputs before any generator
uses them, regenerates all visual outputs, builds the App Store screenshots, and finishes by proving
the website can compile for production.
scripts/.content/social/<slug>.md; rendered images and metadata go to
public/social/.See Social media system and Visual language for formats and review rules.
The iOS repository owns recording and chapter extraction; this repository owns video composition,
branding, copy, animation, and final exports. Prepare the footage in chordlist-app first:
scripts/capture-video.sh --no-open
scripts/prepare-video-editor-assets.sh
Then work from this repository:
pnpm video:studio
pnpm video:render:both
The Studio exposes three linked compositions: a roughly 15-second short cut, the standard
roughly 30-second promo, and a roughly 45-second documentary cut with explanatory scene copy.
Run pnpm video:render:campaign to render all three light versions.
Both commands run sync:video before opening or rendering. With the standard sibling checkout,
the app repository is found automatically; otherwise set CHORDLIST_APP_REPO to its absolute path.
Use pnpm sync:all when screenshots, the downloadable press archive, and video footage should all
be refreshed together. See the Remotion editor guide for timeline controls,
audio, manual shots, and repeatable render presets.
pnpm build:screens
This syncs the raw app captures first, validates them, and writes the generated upload sets and
manifest to public/app-store-screenshots/. Screenshot selection, colors, and layout geometry are
configured in scripts/build-app-store-screenshots.mjs; the words are in
scripts/lib/app-store-copy.mjs, one block per language, built on the shared VOCABULARY.md
wording. Every language there gets its own sets, rendered from that language's captures. The
generated sets can be reviewed and downloaded from /screens; each language, device, and treatment
also gets a ZIP archive.
See App Store screenshot system for supported sizes, treatments, and the full pipeline.
content/blog/<slug>.md. The filename becomes the permanent URL, so choose it carefully.title, description, created, published, and tags; cover,
coverAlt, and draft are optional.public/blog/<slug>/.pnpm build:og to generate the post's share card.pnpm check and review the post plus its Open Graph image.Draft and future-dated posts appear in local and Vercel preview builds but stay hidden in production. Scheduled posts become public through hourly revalidation without requiring a new deployment. See Blog editorial guidelines before publishing.
Three wordings of the home page live in locales/copy-variants.ts and are
readable side by side at /copy, an unlisted noindex review page:
| Variant | Leads with |
|---|---|
files (default) | Ownership — plain Markdown, offline, your files stay yours |
progressions | Finish a song and see what else you can already play |
setlist | Find a song, play it, keep moving |
A variant replaces whole sections of homeCopy, in every language, so the page is never half
rewritten. Select one at build time:
NEXT_PUBLIC_COPY_VARIANT=setlist pnpm build
Unset ships the default; an unknown value fails the build. On Vercel it is an environment variable on a preview branch, so a variant can be read on a real URL before it is promoted. See Marketing plan for which one to ship when.
app/ Routes, layouts, metadata routes, and global styles
components/ Shared sections and interactive components
content/blog/ Markdown blog posts
content/social/ Social asset definitions and captions
docs/ Editorial and generated-asset documentation
lib/ Site configuration, content parsing, and shared utilities
locales/en.ts User-facing website copy
scripts/ Asset sync and image-generation scripts
public/ Static, generated, and synced assets
video/ Remotion demo editor, render presets, and disposable local media
Vercel deploys every merge to main automatically. Pull requests and branches receive preview
deployments. Run pnpm check locally before merging; it reproduces the production compilation and
catches invalid content as well as code errors.
The repository is also linked to its v0 project, so changes may arrive as commits from v0 chats.
The public product routes are /chordlink and /de/chordlink; shared setup routes sit behind
/link/<public-id>. lib/chordlink.ts treats two-to-six digit IDs as opaque strings. The shipped
three-digit edition rule and generic fallback are data, so a future two-digit dark-edition rule can
be inserted without changing an NFC URL.
The backend's private chordlink_storefront_settings.sales_enabled value is the authoritative
launch gate. The header switch in the protected chordlink admin changes it. Checkout remains
disabled unless the availability response explicitly enables sales, stock remains, and the server
has STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, CHORDLINK_STRIPE_PRICE_ID, and
BREVO_API_KEY. The Brevo key is part of the sales gate because the online withdrawal function
must immediately confirm receipt by email. Before enabling sales:
chordlink — first edition at EUR 9.99 including German postage and put its
live price_… ID in CHORDLINK_STRIPE_PRICE_ID on Vercel.STRIPE_SECRET_KEY as a Vercel sensitive environment variable and
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as the corresponding public key. Prefer a dedicated rk_…
restricted key with Checkout Sessions write access and Prices read access; the checkout verifies
the live Price still says EUR 9.99 before it creates a session. Never expose the secret key
through a NEXT_PUBLIC_ variable.chordlink-stripe-webhook function as a Stripe webhook destination for
checkout.session.completed and checkout.session.async_payment_succeeded, configure its
signing secret, and test one immediate and one delayed-payment event.CHORDLINK_AVAILABILITY_URL to the backend's public chordlink-availability endpoint. The
page and buy form read its salesEnabled flag before creating a session. The check fails closed:
without a valid response, while sales are disabled, or once the edition is gone, Checkout cannot
open. The request bypasses Next.js caching so closing the switch takes effect on the next page or
form request.CHORDLINK_AUTOMATION_URL to the backend's public chordlink-automation
endpoint; it defaults to that function under NEXT_PUBLIC_SUPABASE_URL. /chordlink/automation
redirects to the shared iCloud Shortcut and /chordlink/automation.json returns it for the app,
both taking an optional publicId for a unit's override. The install button in the NFC aside on
the chordlink setup and DIY pages links to that route rather than to iCloud, so the Shortcut is
resolved when someone taps it and changing it in the chordlink admin needs no deploy. The backend
lookup and both routes cache for a minute, so a change reaches new installs within about that. The route
always answers: a unit's override first, then the backend's shared default, then
siteConfig.chordlink.automationShortcut.BREVO_API_KEY can send from [email protected]. Submit a test through
/de/chordlink/widerruf and verify that both the operator notice and the immediate customer
confirmation contain the declaration timestamp.sendChordlinkWithdrawal is unauthenticated by necessity: § 356 BGB wants the declaration easy to
make, and the form asks for a name, a contact address, and "order number, order date, or order email
address" — free text, because the model form accepts a date. There is nothing to look up and nothing
to authenticate against, so an automated caller can mail an arbitrary recipient from this domain and
flood support@ at the same time. That is a real abuse path, and it cannot be closed in application
code without breaking the statutory form.
It is closed in the firewall instead. Both pages render a Server Action, and a Server Action POSTs
to the URL of the page it is on, so the rule matches POST to the two page paths — not a separate
API route, and not GET, which would rate-limit people merely reading the page:
vercel firewall rules add "Throttle chordlink withdrawals" \
--condition '{"type":"path","op":"inc","value":["/chordlink/withdraw","/de/chordlink/widerruf"]}' \
--condition '{"type":"method","op":"eq","value":"POST"}' \
--action rate_limit \
--rate-limit-window 600 \
--rate-limit-requests 5 \
--rate-limit-keys ip \
--rate-limit-action deny \
--yes
Five submissions per ten minutes per IP is far above anything a person withdrawing from one order
does, and far below what makes the endpoint worth abusing. rules add only stages a draft — run
vercel firewall diff to review it and vercel firewall publish --yes to make it live. Counters are
per region, so the effective ceiling is roughly five times the number of regions serving traffic.
If this rule is ever removed, the abuse path is open again. Do not delete it without replacing it.
The buy link opens a local order page backed by a custom Checkout Session and Stripe Elements. It
collects one German shipping address and payment details, then puts the product, seller, delivery
time, and €9.99 total including postage immediately above a button whose complete label is
zahlungspflichtig bestellen. Its return URL includes {CHECKOUT_SESSION_ID}. The server retrieves
that session before showing the localized confirmation page; invalid or unpaid sessions return to
the product page. The signed webhook separately allocates the physical unit, so the browser redirect
is never treated as fulfillment proof.
Never add a unit number, Checkout Session, buyer email, delivery address, or Apple offer code to
Vercel Analytics. Individual /link/* requests redirect to the shared setup route and are noindex.
/emails, /screens, /copy, /gallery, /social/posts, /social/editor, /translations, and
the /api/translations endpoints are internal tools, not part of the marketing site. They sit
behind a Supabase Auth login at /login; /logout signs out.
Two checks, and they are not the same check:
proxy.ts (Next 16's rename of middleware.ts) refreshes the Supabase session cookie and
turns an expired one into a redirect. It is not the authorization. Next's own documentation
warns that Server Functions are POSTs to the route that defines them, so a matcher change or a
refactor can move one out from under the proxy without anything failing loudly.requireAdmin() runs inside every protected page, and refuseUnlessAdmin() inside every
protected route handler. This is what actually decides access. It calls supabase.auth.getUser()
rather than getSession(), because the session comes from a cookie the browser sent and
revalidating the token with Supabase is what makes the answer worth acting on.Being signed in is not enough. A Supabase project accepts new sign-ups by default, so
authenticating proves only that somebody made an account. ADMIN_EMAILS is a comma-separated
allowlist, and an unset value means nobody — never everybody.
To set it up:
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, and ADMIN_EMAILS on
Vercel and in .env.local. The first two are safe in the browser; the secret key is never used
by this site.The proxy matcher is scoped to those paths alone, so every marketing page still renders statically
and never pays for a proxy hop. Note that guarded pages are necessarily dynamic — see the
ordering comment in lib/supabase/server.ts for why cookies() is read before the configuration
check, and tests/admin-routes.test.ts for the test that keeps it that way.
Static files under public/ are not covered — /emails/*.html and /social/* are served
directly by the CDN. The pages that index them are behind the login; the generated files themselves
stay reachable to anyone who knows the URL.
A Supabase recovery email sends the administrator back to the site with an implicit-flow
fragment on the end of the URL: #access_token=…&refresh_token=…&type=recovery. A fragment is
never sent to a server, so Next only ever sees the path — which is why this is handled entirely in
the browser, and why there is no server action anywhere that takes an access token. That token is a
bearer credential for the account; the only thing that holds it is Supabase's own client, for the
few seconds it takes to set a new password, and the fragment is scrubbed out of the address on
every path through the flow.
Three pieces:
lib/supabase/password-recovery.ts decides what a fragment is — a recovery link, an expired
or reused one, or an ordinary #anchor — and whether a new password is acceptable. It is pure,
and it deliberately returns a verdict rather than the tokens it read, so there is no way to get
one out of it and into component state or a request body. tests/password-recovery.test.ts.components/password-recovery-gate.tsx rides along in components/root-shell.tsx, so it is
on every page in both languages. Recovery emails sent before this existed point at /, and those
still work: the gate notices the fragment wherever it lands and floats the form over the page.
It renders nothing otherwise, and the panel — with supabase-js behind it — is loaded on demand,
so an ordinary visit never pays for any of it.app/(en)/account/update-password/page.tsx is where new links should point. It is noindex
and nothing links to it; opened without a fragment it says the link has expired, rather than
showing a page that appears to ignore the link somebody just clicked.The panel is a client component and talks to Supabase directly, with createClient from
@supabase/supabase-js — not createBrowserClient from @supabase/ssr, which pins
flowType: "pkce" and would refuse every link already sitting in an inbox. The session is created
with persistSession: false and autoRefreshToken: false: it exists to carry one
updateUser({ password }) call and is signed out immediately afterwards, so it never reaches local
storage and closing the tab is enough to end it.
In the Supabase dashboard, under Authentication → URL Configuration, add
https://chordlist.app/account/update-password to the redirect allow-list (Vercel preview URLs
too, if you want to test one), and pass it as the redirectTo of resetPasswordForEmail. Nothing
needs to change in .env — the flow uses the same two NEXT_PUBLIC_SUPABASE_* values as the login.
Nothing in this repository sends a reset email, and that is deliberate rather than missing: there
is one administrator account and it is created by hand, so a new link is sent from the dashboard
under Authentication → Users. The expired-link message says so, rather than sending somebody to
a sign-in page that has no such form. If a self-service "forgot password" field on /login is ever
wanted, resetPasswordForEmail with this path as its redirectTo is the call it would make, and
the flow above already handles what comes back.
While chordlink is not on sale, the product page offers a "Get notified" form in place of the buy button. The list lives at Brevo rather than in the backend: its double opt-in sends the confirmation, records the consent that German law expects to be produced on demand, and owns unsubscribing. No email address is stored on this site or in the chordlink inventory, and the two are deliberately kept apart — buying a chordlink does not add anyone to the list.
There are two lists, chosen by the same availability response that gates checkout:
| State | List | Mailed when |
|---|---|---|
| Sales not open yet | BREVO_INTEREST_LIST_ID | the switch opens |
| Edition sold out | BREVO_WAITLIST_LIST_ID | a further batch is seeded |
Which list a signup lands on is decided on the server from the backend's own answer, never from the submitted form, so nobody can put themselves on the restock list while the first edition is selling.
To turn it on:
LANGUAGE and
a SIGNUP_REASON contact attribute so a later campaign can segment on them. The template must
carry the tag optin and be set to Active — a new template starts inactive — and its content
is uploaded from this repository rather than built in Brevo's editor (see
Uploading the confirmation template).{{ params.DOIurl }}; the request sends the localized
redirectionUrl (/chordlink/notified, /de/chordlink/notified), which is where a confirmed
subscriber lands.BREVO_API_KEY as a Vercel sensitive environment variable, plus
BREVO_INTEREST_DOI_TEMPLATE_ID, BREVO_INTEREST_LIST_ID, and BREVO_WAITLIST_LIST_ID.
BREVO_INTEREST_DOI_TEMPLATE_ID_DE is the German confirmation mail, sent to signups from the
German page; without it they get the English one.privacyCopy.sections.chordlinkNotifications
accurate if what is stored changes.The form fails closed the same way checkout does: it is rendered only when the key, the template, and the list for the current state are all configured, and only when availability could actually be read. An outage shows no form, because a form that takes an address and drops it is worse than none — the visitor leaves believing they will hear from us. A submission that Brevo rejects says so rather than thanking anybody. An address already on the list is reported as a fresh success, so the form cannot be used to test whether a given person is subscribed.
Measurement is deliberately the free path: /chordlink page views in Vercel Web Analytics at the
top of the funnel and confirmed Brevo contacts at the bottom. Vercel custom events are a Pro
feature, so nothing here depends on them.
Every rejection reaches the visitor as "The signup is temporarily unavailable", so the reason is in
the production logs, not on the page. Brevo's message is logged beside its code:
vercel logs --environment production --since 7d --query "Brevo" --expand
An active DOI template does not exist means BREVO_INTEREST_DOI_TEMPLATE_ID (or _DE, for a
German signup) names a template that is missing, inactive, or not tagged optin. A 401 status means BREVO_API_KEY is wrong or
revoked. A change to the template in Brevo takes effect immediately; a changed environment variable needs a
redeploy.
Brevo's visual editor wraps pasted HTML in a container of its own, so the generated mail is written
to the template through the API instead, which stores it byte for byte. The English template is ID
1; for the German one, put its ID in place of the 1 in the URL and de.html in place of
en.html. Run both in the same interactive terminal — the first prompts for the API key without
echoing it or leaving it in shell history:
read -s "BREVO_API_KEY?Brevo API key: " && export BREVO_API_KEY
node -e 'fetch("https://api.brevo.com/v3/smtp/templates/1",{method:"PUT",headers:{"api-key":process.env.BREVO_API_KEY,"content-type":"application/json"},body:JSON.stringify({htmlContent:require("fs").readFileSync("public/emails/chordlink-confirm/en.html","utf8")})}).then(async r=>console.log(r.status,await r.text()))'
204 means it was stored. Re-run it after every pnpm build:emails that changes chordlink-confirm,
and after anyone saves the template in Brevo's editor, which can put the wrapper back. Afterwards
check that the template is still Active and tagged optin, and send one test signup through
/chordlink.
Both mails go out as Brevo campaigns, one per language. Recipients are a Brevo segment each —
the list membership and the LANGUAGE attribute together — created once in Brevo's Contacts →
Segments:
| ID | Segment | Conditions | Campaign |
|---|---|---|---|
1 | chordlink launch · EN | in list 2 and LANGUAGE is EN | chordlink-on-sale en |
2 | chordlink launch · DE | in list 2 and LANGUAGE is DE | chordlink-on-sale de |
| — | chordlink restock · EN | in list 2 or 3, and LANGUAGE is EN | chordlink-restock en |
| — | chordlink restock · DE | in list 2 or 3, and LANGUAGE is DE | chordlink-restock de |
The restock segments are not created yet; add their IDs here when they are.
The restock mail goes to both lists on purpose: the launch mail tells anyone who misses the first
run to stay on the list for the next batch, and those people are on list 2, not the waitlist.
Brevo knows nothing about purchases — buying does not touch either list — so someone who signed up
and then bought one still gets the restock mail. That is accepted rather than fixed: excluding buyers
would mean sending buyer addresses to Brevo, which the privacy policy does not describe.
pnpm brevo:campaign writes one built email into Brevo as a draft — it never sends. With the
key set in the shell as for the template upload:
pnpm build:emails
pnpm brevo:campaign chordlink-on-sale en --segment 1
pnpm brevo:campaign chordlink-on-sale de --segment 2
Each prints the new campaign's ID. Pass --campaign <id> to overwrite that draft instead of creating
another, after an edit to the definition. Then, for the launch:
--campaign./chordlink to see the buy button.The restock is the same with chordlink-restock, once the new batch is seeded and on sale.
Emails are built from content/emails/ rather than written in Brevo's editor, so the wording is
reviewed in a pull request and the layout is fixed once instead of per campaign:
pnpm build:emails
One definition per language — <slug>.<language>.md, frontmatter plus a Markdown body — produces
public/emails/<slug>/<language>.{html,txt} and a manifest.json. /emails previews every one in
an iframe at the width a client renders it. Paste the HTML into Brevo and keep the .txt beside it
as the plain-text alternative; a message with no text part scores worse with spam filters and some
readers never see anything else.
A slug must exist in every language before the build passes, and the frontmatter language must
match the filename. That is the same honesty rule the Dictionary type enforces on the site: a
half-translated campaign is not something to discover at send time, with half the list already
mailed in the wrong language.
Four definitions ship. chordlink-confirm is the double opt-in mail and is the one the signup flow
actually depends on — its button must keep the {{ params.DOIurl }} merge field, which Brevo's
API-based double-opt-in flow replaces with the confirmation link. chordlink-on-sale is the launch
campaign for list 2, and chordlink-restock goes to lists 2 and 3 together — see
Sending the launch and restock mail. chordlist-announcement is a deliberately empty skeleton to copy for the next
announcement.
Layout lives in scripts/lib/email-templates.mjs and is shaped by three constraints rather than
taste: Outlook on Windows renders with Word, so structure is tables and there is no flexbox or
border-radius; several clients strip <style>, so everything that must render is inlined and the
<style> block carries only the dark palette and the narrow breakpoint; and images are blocked by
default on first open, so nothing in these emails is an image — the lockup is the wordmark in
the same mono voice the site uses. Campaign mail carries {{ unsubscribe }}; transactional mail
does not, because a confirmation message has nothing to unsubscribe from yet. Every footer carries
the operator, legal name, and postal address, which a commercial email from Germany has to state.
public/model.html is a self-contained 3D configurator and STL exporter that can also be downloaded
and shared as one file. Its public default is deliberately unnumbered:
/model.html — unnumbered model./model.html?nfc=1 — unnumbered model with the NFC cavity enabled./model.html?nfc=1&numbering=1 — opt-in personal numbering controls.The product page uses ChordlinkModelViewer to render the optimized static
/models/chordlink.glb directly on a transparent stage. The DIY page has a single model action that
opens the full generator with the NFC cavity enabled.
The bilingual instructions at /chordlink/diy and /de/chordlink/diy recommend six-digit personal
link IDs so separate home-made tags can have independent actions. These IDs are public routing
identifiers, not globally unique credentials. The DIY page
must continue to state that self-printed tags are outside the official edition and do not include
the paid unlimited entitlement.
Copy lives in locales/. en.ts is the site as it ships today; de.ts is a partial German
translation of locale, commonCopy, metadataCopy and homeCopy. The remaining objects —
docsCopy, faqCopy, pressCopy, screensCopy, privacyCopy, blogCopy, galleryCopy,
screenshotGalleryCopy, pianoCopy — are still English only, and every page still imports
@/locales/en directly. Locale selection and routing are not wired up yet.
Each German object is typed Localized<typeof …> (see locales/types.ts): the English shape with
its wording set free. Adding a key to en.ts therefore fails the German build instead of silently
rendering English, which is what keeps a partial translation honest.
pnpm dev # then http://localhost:3000/translations
A table of every app string across every language, editable in place, plus the shared glossary and a button that provisions a new language.
Strings carry a badge for where they came from: share extension for the extension's own catalog,
debug for wording only compiled into debug builds. Debug strings are hidden by default — nobody
using the app will ever read the debug menu — and the toggle says how many are being held back.
That comes from string-origins.json, which the app repository generates from the compiler's own
extraction; without it nothing is badged rather than anything being guessed at.
It only runs locally. Editing means writing files in the chordlist-app checkout beside this
one; a deployed build can reach neither the filesystem nor that repository, so the route 404s in
production and the API refuses with an explanation. It finds the app repository the same way
sync:app does — ../chordlist-app, or CHORDLIST_APP_REPO.
Each edit writes straight through to the file it came from. There is no save button, because a staged buffer that can disagree with disk is the drift this whole arrangement exists to prevent. Writes land in:
| Edited | Written to |
|---|---|
| A string or plural | scripts/translations/<language>.json, then straight into the String Catalogs |
| A glossary term or phrase | VOCABULARY.md, then vocabulary.json |
| A new language | both of the above, plus LANGUAGES in apply-translations.py |
The editor writes the sources and hands off to the app repository's own scripts —
apply-translations.py and build-vocabulary.py — for everything derived from them, rather than
deriving it a second time in TypeScript. So an edit reaches the String Catalogs immediately, and
there is exactly one implementation of each derivation to keep correct. Commit the result in the
app repository.
Run scripts/sync-string-catalogs.sh there only when you have added or changed a string in Swift,
which is what re-extracts the keys.
Provisioning deliberately stops short of the steps that need judgement or that risk a file worth
protecting: knownRegions in the Xcode project, a case in the capture script, a fixture set of
songs a speaker would recognize, and locales/<code>.ts here. The UI lists them when it finishes.
A provisioned language starts empty rather than machine-translated — an untranslated string is
visible and fails the build, a plausible wrong one is not.
Terms the app and the site both use — Songtext, Akkordfolge, Interpret, the tagline, the
product description — are not retyped here. They come from VOCABULARY.md in the chordlist-app
repository, which is the single source of truth for wording across the app, this site, the App
Store listing and the press kit.
pnpm sync:app copies the generated vocabulary.json into locales/, exactly as it copies
screenshots, and locales/vocabulary.ts exposes it:
import { phrase, term } from "@/locales/vocabulary"
phrase("tagline", "de") // Deine Songtexte und Akkorde – als Dateien in deiner Tasche.
term("lyrics", "de") // Songtext
Both throw on an unknown key rather than falling back, so a term that has been renamed in the app
repository surfaces at build time. To change a shared word, edit VOCABULARY.md in chordlist-app,
run scripts/build-vocabulary.py there, then pnpm sync:app here. The committed copy means a
build without the app repository checked out still works.
siteConfig.chordlink; do not add a second launch flag there.