diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c853e2ef8..3a5372e1b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -360,7 +360,7 @@ jobs: run: pnpm --filter @useupup/e2e-test test:e2e:drive-sandbox Lighthouse: - name: Lighthouse (landing SEO + Best Practices) + name: Lighthouse (landing SEO + Best Practices + mobile perf ratchet) runs-on: ubuntu-latest steps: - name: Checkout the repository @@ -384,17 +384,27 @@ jobs: # lhci boots `next start` itself and asserts per lighthouserc.cjs: # SEO=100 everywhere; Best Practices 100 on docs pages, ratcheted # on home/framework pages (third-party embed cookies). A11y is NOT - # asserted here — the axe ratchet above owns it; perf is excluded - # (noisy on shared runners; size-limit owns bundle weight). + # asserted here — the axe ratchet above owns it. Performance is not + # asserted in THIS config either; it has its own mobile run below. - name: Lighthouse audit + assertions (SEO / Best Practices) run: pnpm --filter @useupup/landing run lighthouse + # Mobile performance, on its own port and its own config, asserted + # at WARN level only: the score is genuinely noisy on a shared + # runner, so this is a visible ratchet in the log and the uploaded + # report rather than a gate. (`continue-on-error` is banned by the + # test-quality guard and unnecessary — warn assertions never fail.) + - name: Lighthouse mobile performance ratchet (warn-only) + run: pnpm --filter @useupup/landing run lighthouse:mobile + - name: Upload Lighthouse reports if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nightly-lighthouse-reports - path: apps/landing/.lighthouseci-reports/ + path: | + apps/landing/.lighthouseci-reports/ + apps/landing/.lighthouseci-mobile-reports/ if-no-files-found: warn retention-days: 14 diff --git a/.gitignore b/.gitignore index a13d9b2ad..9056a7098 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ apps/e2e-test/screenshots/ # filesystem report output uploaded as a CI artifact .lighthouseci/ apps/landing/.lighthouseci-reports/ +apps/landing/.lighthouseci-mobile-reports/ diff --git a/CLAUDE.md b/CLAUDE.md index 091a8f520..7ec773529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -617,8 +617,15 @@ DrivePlugin`. All three popup providers now persist a token-expiry key and refre SEO=100 on every audited page and Best Practices 100 on docs pages / ratcheted ~0.74 on home+framework pages whose StackBlitz embed + ads tag set third-party cookies; a11y is deliberately NOT asserted — the axe ratchet - owns it, Lighthouse's a11y audits are axe-core anyway — and perf is excluded - as CI-runner noise; size-limit owns bundle weight), static `build:storybook` + owns it, Lighthouse's a11y audits are axe-core anyway; perf is not asserted + in THIS config. Performance is no longer skipped outright: a second step in + the same job runs `pnpm --filter @useupup/landing run lighthouse:mobile` + (config `apps/landing/lighthouserc.mobile.cjs`: mobile emulation, its own + port 4464 so both configs run back to back, median of 5) and asserts + `categories:performance` at **warn** level — a visible ratchet in the log and + the uploaded report, never a red night, since the score really is noisy on a + shared runner and `continue-on-error` is banned by the test-quality guard; + size-limit still owns bundle weight), static `build:storybook` for all six frameworks, `smoke:packages`, the mastra LLM evals (only when the `OPENROUTER_API_KEY` Actions secret exists — absent, the job goes green with a loud skip notice, never silently), and the **Drive-Sandbox** job — the live diff --git a/README.md b/README.md index 41ce0e4d5..eec469e23 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,11 @@

One file uploader. Every framework.

- A headless upload engine with native, byte-identical UI packages for + A headless upload engine with native, DOM-identical UI packages for React, Vue, Svelte, Angular, Vanilla JS, and Preact — with optional server-mode uploads, cloud drives, camera, screen capture, and link imports.

-

CI License: MIT @@ -31,15 +30,15 @@ **upup** is a free, MIT-licensed file uploader built as one headless [`@useupup/core`](packages/core) engine with a matching native UI for every major framework. React is the visual canon; the Vue, Svelte, Angular, Vanilla, and -Preact ports render the **same DOM** with the **same props**, verified -byte-for-byte by a cross-framework parity harness. Upload straight from the -browser to any S3-compatible storage (Client Mode), or route through your own -backend with an HMAC-signed trust model (Server Mode via -[`@useupup/server`](packages/server)). +Preact ports render the **same DOM** with the **same props**, verified by a +cross-framework parity harness (structure, classes, accessibility attributes, +text). Upload straight from the browser to any S3-compatible storage (Client +Mode), or route through your own backend with an HMAC-signed trust model +(Server Mode via [`@useupup/server`](packages/server)). ## Install -Pick the package for your framework — the component API and rendered DOM are identical across all of them: +Pick the package for your framework — the component API and the rendered DOM contract are identical across all of them: | Package | Install | Get started | | ------------------ | ------------------------ | ------------------------------------------------------------------ | @@ -113,7 +112,7 @@ Express, Fastify, and Hono handlers ship as subpath exports ## Features - **Headless core.** `@useupup/core` is a zero-framework-dependency engine: file state, an upload pipeline (compression, HEIC→JPEG, EXIF stripping, checksums, thumbnails, optional Web Worker offload), cloud-drive plugins, i18n, and theming. Build your own UI on it, or use a native package. -- **Native UI for six frameworks.** React, Vue, Svelte, Angular, Vanilla JS, and Preact — same DOM, same Tailwind classes, enforced byte-for-byte by a parity harness. +- **Native UI for six frameworks.** React, Vue, Svelte, Angular, Vanilla JS, and Preact — same DOM structure, same `upup-` classes, enforced by a cross-framework parity harness. - **Client or Server mode.** Direct browser → storage presigned uploads, or a server-proxied [`@useupup/server`](packages/server) with an HMAC-signed trust model (signed length, key/uploadId binding, mandatory secrets). - **S3-compatible storage.** AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi — any S3-compatible endpoint. - **Cloud drives.** Import from Google Drive, OneDrive, Dropbox, and Box, in client or server mode. @@ -123,7 +122,10 @@ Express, Fastify, and Hono handlers ship as subpath exports - **i18n & theming.** ICU-based localization with 9 bundled locales and RTL support, plus a slot-level theming system that targets every rendered element. - **TypeScript-first.** Full type definitions out of the box. -## Battle-tested in production +## Used in production + +Both apps below run the upup v3.1 engine under its previous `@upupjs/*` scope — +the same codebase, published before the rename to `@useupup/*`. - **uNotes** — AI doc uploads for past exams → [unotes.net](https://unotes.net) - **Shorty** — media uploads for transcripts → [aishorty.com](https://aishorty.com) diff --git a/apps/e2e-test/landing/docs.spec.ts b/apps/e2e-test/landing/docs.spec.ts index e2de929af..70ac3b1aa 100644 --- a/apps/e2e-test/landing/docs.spec.ts +++ b/apps/e2e-test/landing/docs.spec.ts @@ -319,4 +319,99 @@ test.describe('docs', () => { await expect(vueTabAgain).toHaveAttribute('aria-selected', 'true') }).toPass({ timeout: 15_000 }) }) + + // ── SEO surfaces ────────────────────────────────────────────────────── + // The dev server this project boots leaves NEXT_PUBLIC_BASE_URL unset, so + // next.config.mjs computes SITE_BASE = https://useupup.com and takes the + // PRODUCTION branch of headers()/redirects(). That is what lets these + // assertions exercise the real prod rules without a second webServer. + const PRODUCTION_ORIGIN = 'https://useupup.com' + + test('docs page declares its canonical URL and its markdown twin as an alternate', async ({ + page, + request, + }) => { + await page.goto('/docs/getting-started/') + await expect(page.locator('link[rel=canonical]')).toHaveAttribute( + 'href', + `${PRODUCTION_ORIGIN}/docs/getting-started/`, + ) + const alternate = page.locator( + 'link[rel=alternate][type="text/markdown"]', + ) + await expect(alternate).toHaveAttribute( + 'href', + `${PRODUCTION_ORIGIN}/docs-md/getting-started/`, + ) + // Fetch the PATH against this server — following the absolute href + // would test production, not the build under test. + const href = await alternate.getAttribute('href') + const twin = await request.get(new URL(href ?? '').pathname) + expect(twin.status()).toBe(200) + expect(twin.headers()['content-type']).toContain('text/markdown') + // The twin must declare the HTML page as its original, or it is a + // duplicate of every docs page under a second URL. + expect(twin.headers()['link']).toContain('rel="canonical"') + expect(twin.headers()['link']).toContain( + `${PRODUCTION_ORIGIN}/docs/getting-started/`, + ) + }) + + test('plaintext request identified by the Cloudflare visitor header is redirected to https', async ({ + request, + }) => { + const redirected = await request.get('/react/', { + headers: { 'cf-visitor': '{"scheme":"http"}' }, + maxRedirects: 0, + }) + expect(redirected.status()).toBe(308) + expect(redirected.headers()['location']).toBe( + `${PRODUCTION_ORIGIN}/react/`, + ) + // The header is the ONLY trigger: an ordinary request must still be + // served, or the rule would loop every visitor behind the proxy. + const plain = await request.get('/react/') + expect(plain.status()).toBe(200) + }) + + test('stale search-console sitemap URL permanently redirects to the live sitemap', async ({ + request, + }) => { + // /sitemap-landing.xml is a Docusaurus-era submission that nothing has + // ever served; it 404'd until this rule landed. + const res = await request.get('/sitemap-landing.xml', { + maxRedirects: 0, + }) + expect(res.status()).toBe(308) + expect(res.headers()['location']).toContain('/sitemap.xml') + }) + + test('production responses carry a preload-eligible HSTS header', async ({ + request, + }) => { + const res = await request.get('/') + expect(res.headers()['strict-transport-security']).toBe( + 'max-age=63072000; includeSubDomains; preload', + ) + }) + + test('robots.txt names the AI crawler allow-list explicitly', async ({ + request, + }) => { + const res = await request.get('/robots.txt') + expect(res.status()).toBe(200) + const body = await res.text() + expect(body).toContain('User-Agent: GPTBot') + expect(body).toContain('User-Agent: ClaudeBot') + expect(body).toContain('User-Agent: PerplexityBot') + }) + + test('footer links the llms.txt corpus from every page', async ({ + page, + }) => { + await page.goto('/docs/getting-started/') + await expect( + page.locator('footer a[href="/llms.txt"]'), + ).toHaveAttribute('href', '/llms.txt') + }) }) diff --git a/apps/e2e-test/landing/thumbs-flow.spec.ts b/apps/e2e-test/landing/thumbs-flow.spec.ts index ada1b3840..fbea39467 100644 --- a/apps/e2e-test/landing/thumbs-flow.spec.ts +++ b/apps/e2e-test/landing/thumbs-flow.spec.ts @@ -168,6 +168,13 @@ test.describe('Ask AI thumbs feedback', () => { ) .toBe(true) + // The demo section is client-only and mounts the first time it comes + // within ~400px of the viewport (it is the heaviest thing on the page, + // and on a phone it costs seconds of main-thread time nobody who never + // scrolls to it should pay). Scroll it into view so the Ask-AI panel + // below actually exists. + await page.locator('#demo').scrollIntoViewIfNeeded() + // Ask one short question and wait for the assistant's completed turn. const panel = page.locator('.upup-ie-ai-panel') const input = panel.locator('#upup-ai-message') diff --git a/apps/landing/content/docs/ai-assistants.mdx b/apps/landing/content/docs/ai-assistants.mdx index 71d0cbafc..854d6e1d3 100644 --- a/apps/landing/content/docs/ai-assistants.mdx +++ b/apps/landing/content/docs/ai-assistants.mdx @@ -1,6 +1,6 @@ --- title: Use upup with AI assistants -description: How coding agents (Claude Code, Cursor, and others) can discover and correctly integrate upup — including machine-readable context files and a paste-ready context block. +description: How coding agents (Claude Code, Cursor, and others) discover and correctly integrate upup — machine-readable context files and a paste-ready context block. --- Coding agents choose a library by retrieving the docs page that answers the exact diff --git a/apps/landing/content/docs/api-reference/error-codes.mdx b/apps/landing/content/docs/api-reference/error-codes.mdx index 9596fe04c..d66fdecd1 100644 --- a/apps/landing/content/docs/api-reference/error-codes.mdx +++ b/apps/landing/content/docs/api-reference/error-codes.mdx @@ -1,6 +1,6 @@ --- title: Error Codes -description: The complete UpupErrorCode enum, the seven UpupError classes that carry them, restriction reasons, and the response parser that turns server or S3 error bodies into typed errors. +description: The complete UpupErrorCode enum, the seven UpupError classes that carry them, restriction reasons, and the parser that turns error bodies into typed errors. --- Most failures upup raises are an `UpupError` (or one of its six subclasses) diff --git a/apps/landing/content/docs/api-reference/events.mdx b/apps/landing/content/docs/api-reference/events.mdx index e9e699f8e..9e263baed 100644 --- a/apps/landing/content/docs/api-reference/events.mdx +++ b/apps/landing/content/docs/api-reference/events.mdx @@ -1,5 +1,5 @@ --- -title: Events +title: File Upload Events description: The complete upup event catalog — all 52 typed core events with their payloads, plus the React callback props and the core event each one is driven by. --- diff --git a/apps/landing/content/docs/api-reference/server-http.mdx b/apps/landing/content/docs/api-reference/server-http.mdx index 55cccdfbd..92885c5e9 100644 --- a/apps/landing/content/docs/api-reference/server-http.mdx +++ b/apps/landing/content/docs/api-reference/server-http.mdx @@ -1,6 +1,6 @@ --- title: Server HTTP API -description: The complete HTTP contract of @useupup/server — every route, request and response shape, status code, and operational header — for wiring a custom client or debugging a live handler. +description: The complete HTTP contract of @useupup/server — every route, request and response shape, status code and header, for custom clients and live debugging. --- `createUpupHandler(config)` returns one `(req: Request) => Promise` diff --git a/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx b/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx index e2ca3fa87..02c8c7c77 100644 --- a/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx +++ b/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx @@ -1,6 +1,6 @@ --- title: Event Handlers -description: The UpupUploader callback props — onFilesSelected, onUploadStart, progress, per-file and batch completion, onFileRemoved, and onError — mirroring core upload state. +description: The UpupUploader callback props — onFilesSelected, onUploadStart, progress, per-file and batch completion, onFileRemoved and onError — mirroring core state. --- React events mirror core upload state. diff --git a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx index 726964c3a..b4b90eba0 100644 --- a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx +++ b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx @@ -1,6 +1,6 @@ --- title: Optional Props -description: The complete optional-prop reference for UpupUploader — behavior, file validation, the processing pipeline, upload reliability, sources, appearance, localization, plus the headless-only engine options. +description: The complete optional-prop reference for UpupUploader — behavior, file validation, the processing pipeline, upload reliability, sources, appearance and i18n. --- Every prop on this page is optional. The three upload-target props (`uploadEndpoint`, `serverUrl`, `resumable.endpoint`) and `provider` are covered in [Required Props](/docs/api-reference/upupuploader/required-props/); the 34 `on*` callbacks are covered in [Events & Callbacks](/docs/api-reference/events/). diff --git a/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx b/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx index ae0586434..94f486766 100644 --- a/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx +++ b/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx @@ -14,17 +14,17 @@ capture, and an S3-compatible server with an HMAC-signed trust model. ## At a glance -| Feature | FilePond | upup | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact | +| Feature | FilePond | upup | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | No — renders its own UI component | Yes (`@useupup/core`) | -| License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source | +| License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source | | Self-host incl. S3-compatible | Yes — uploads to your own server endpoint (process / revert / restore), with chunk uploads; no built-in S3 signing | Yes — `@useupup/server` presigns and proxies to any S3-compatible storage | -| Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box | -| Camera / screen capture | No | Yes (both) | -| Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only | -| Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) | -| i18n | Configurable label strings (no locale bundles) | Yes (ICU locale bundles) | +| Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box | +| Camera / screen capture | No | Yes (both) | +| Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only | +| Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) | +| i18n | 48 locale files in `locale/`, plus configurable label strings | Yes (ICU locale bundles) | ## Choose FilePond if diff --git a/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx b/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx index cdcdab006..355058eb5 100644 --- a/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx +++ b/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx @@ -1,6 +1,6 @@ --- -title: upup vs UploadThing -description: An honest, factual comparison of upup and UploadThing — a self-hosted MIT library versus a managed hosted upload service — so you can choose the right model. +title: upup vs UploadThing — Self-Hosted Alternative +description: A factual upup vs UploadThing comparison — a free, MIT-licensed uploader you self-host against your own S3 bucket versus a managed, hosted upload service. --- UploadThing (by Ping Labs) is a hosted file-upload **service** for full-stack @@ -15,18 +15,18 @@ screen capture, image editing, and native UI for six frameworks. ## At a glance -| Feature | UploadThing | upup | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) | -| Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact | +| Feature | UploadThing | upup | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) | +| Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact | | Headless core | React hooks (coupled to the service) | Yes (`@useupup/core`, framework-agnostic) | -| License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) | +| License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) | | Storage / self-host | Managed — files are stored on UploadThing's infrastructure | Any S3-compatible storage you own (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) via `@useupup/server` | -| Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box | -| Camera / screen capture | No | Yes (both) | -| Image editor | No | Yes — React/Preact only | -| Resumable uploads | Not a primary feature | Yes — optional (tus or S3 multipart) | -| i18n | No | Yes (ICU locale bundles) | +| Cloud-drive sources | Not built in | Google Drive, OneDrive, Dropbox, Box | +| Camera / screen capture | Not built in | Yes (both) | +| Image editor | Not built in | Yes — React/Preact only | +| Resumable uploads | Yes — `createUpload` (start, pause, resume), since v7 | Yes — optional (tus or S3 multipart) | +| i18n | No | Yes (ICU locale bundles) | ## Choose UploadThing if diff --git a/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx b/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx index 9a3ae779e..28db1aeb7 100644 --- a/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx +++ b/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx @@ -1,6 +1,6 @@ --- -title: upup vs Uppy -description: An honest, factual comparison of upup and Uppy — two MIT-licensed file uploaders with headless cores, cloud drives, and resumable uploads — to help you choose. +title: upup vs Uppy — Which Uploader to Choose +description: A factual upup vs Uppy comparison — two free, MIT-licensed file uploaders with headless cores, cloud drives and resumable uploads, and where they differ. --- Uppy (by Transloadit) is the most established open-source JavaScript uploader, diff --git a/apps/landing/content/docs/guides/accessibility.mdx b/apps/landing/content/docs/guides/accessibility.mdx index 68b377b76..2938a0cd6 100644 --- a/apps/landing/content/docs/guides/accessibility.mdx +++ b/apps/landing/content/docs/guides/accessibility.mdx @@ -1,6 +1,6 @@ --- title: Accessibility -description: What upup's uploader ships by default — keyboard interaction, ARIA roles and live regions, focus management on overlays, the animations prop and prefers-reduced-motion handling — and the parts that stay your responsibility. +description: What upup ships by default — keyboard interaction, ARIA roles and live regions, focus management on overlays, and prefers-reduced-motion handling. --- upup's accessibility semantics are not an opt-in layer. Roles, labels, live diff --git a/apps/landing/content/docs/guides/auth/next-auth.mdx b/apps/landing/content/docs/guides/auth/next-auth.mdx index 0da7901e6..a207504e7 100644 --- a/apps/landing/content/docs/guides/auth/next-auth.mdx +++ b/apps/landing/content/docs/guides/auth/next-auth.mdx @@ -1,6 +1,6 @@ --- title: Authenticate uploads with NextAuth (Auth.js v5) -description: Scope @useupup/server uploads to the NextAuth session — the auth() helper in the App Router, getToken outside a request context, and the missing session.user.id. +description: Scope @useupup/server uploads to the NextAuth session — the auth() helper in the App Router, getToken outside a request, and the missing session.user.id. --- In the App Router, the `auth()` helper exported from your `auth.ts` reads the diff --git a/apps/landing/content/docs/guides/file-processing.mdx b/apps/landing/content/docs/guides/file-processing.mdx index 957b1b0bb..536c92847 100644 --- a/apps/landing/content/docs/guides/file-processing.mdx +++ b/apps/landing/content/docs/guides/file-processing.mdx @@ -1,6 +1,6 @@ --- title: File Processing -description: upup's client-side pipeline — image compression, HEIC to JPEG conversion, EXIF stripping, thumbnails, SHA-256 checksums and deduplication, Web Worker offload, and custom steps. +description: upup client-side pipeline — image compression, HEIC to JPEG conversion, EXIF stripping, thumbnails, SHA-256 checksums, Web Worker offload and custom steps. --- Before a byte leaves the browser, upup can run each file through a processing diff --git a/apps/landing/content/docs/guides/headless.mdx b/apps/landing/content/docs/guides/headless.mdx index bec5bb57c..80506bf80 100644 --- a/apps/landing/content/docs/guides/headless.mdx +++ b/apps/landing/content/docs/guides/headless.mdx @@ -1,6 +1,6 @@ --- title: Headless Usage -description: Build your own uploader UI on top of upup's engine — the useUpupUpload React hook, driving UpupCore directly in any runtime, and the webWorker, HEIC, and resumable pipeline opt-ins. +description: Build your own uploader UI on the upup engine — the useUpupUpload React hook, driving UpupCore directly, and the webWorker, HEIC and resumable opt-ins. --- The upup UI is optional. Under it sits a framework-agnostic engine — diff --git a/apps/landing/content/docs/guides/reliability.mdx b/apps/landing/content/docs/guides/reliability.mdx index 4513fc0e0..fb00304e5 100644 --- a/apps/landing/content/docs/guides/reliability.mdx +++ b/apps/landing/content/docs/guides/reliability.mdx @@ -1,6 +1,6 @@ --- title: Reliability -description: How upup survives flaky networks — the per-file retry policy and its backoff math, custom success predicates, upload concurrency, online/offline events, and the difference between crash recovery and multipart session resume. +description: How upup survives flaky networks — the per-file retry policy and its backoff math, custom success predicates, upload concurrency, and multipart resume. --- Uploads fail for boring reasons: a tunnel, a dropped VPN, a 502 from a storage @@ -298,7 +298,7 @@ all, tus remains the resumable option. ## A note on `fastAbortThreshold` `fastAbortThreshold` is accepted by the core options and forwarded internally, -but nothing reads it in v3.1.0 — setting it has no effect on behavior. It is +but nothing reads it as of v3.3 — setting it has no effect on behavior. It is documented here only so you do not spend an afternoon tuning a value that does nothing. Use `maxRetries` to control how long upup persists on a failing file. diff --git a/apps/landing/content/docs/guides/server-auth.mdx b/apps/landing/content/docs/guides/server-auth.mdx index 7af7e788c..838e3ed33 100644 --- a/apps/landing/content/docs/guides/server-auth.mdx +++ b/apps/landing/content/docs/guides/server-auth.mdx @@ -1,6 +1,6 @@ --- title: Server Auth & Trust Model -description: How @useupup/server authenticates uploads — the mandatory HMAC upload-token secret, the secure-by-default 403 on anonymous uploads, per-user key scoping, and what forged requests get. +description: How @useupup/server authenticates uploads — the mandatory HMAC upload-token secret, the secure-by-default 403 on anonymous uploads, and per-user scoping. --- `@useupup/server` is the trust boundary between the browser and your storage. @@ -128,7 +128,7 @@ The token binds: from `exp`, since `init` is the only issuer and has always used the same TTL. It is signed with HMAC-SHA-256 over the payload using your -`uploadTokenSecret`, via Web Crypto (so it works on Node 18+, edge runtimes, +`uploadTokenSecret`, via Web Crypto (so it works on Node 20+, edge runtimes, and Cloudflare Workers). On every continuation request the handler verifies the signature **before** trusting any payload byte, compares it in constant time, and checks expiry — the one exception being `resume`, which applies the resume diff --git a/apps/landing/content/docs/guides/server-mode-setup.mdx b/apps/landing/content/docs/guides/server-mode-setup.mdx index e0f5f425a..f4e2e281d 100644 --- a/apps/landing/content/docs/guides/server-mode-setup.mdx +++ b/apps/landing/content/docs/guides/server-mode-setup.mdx @@ -1,6 +1,6 @@ --- -title: Server Mode — Setup -description: Set up mode="server" — mount createUpupHandler, configure storage, secrets, limits and hooks, then pick an adapter — Express, Fastify, Hono, or Next.js. +title: Server Mode Setup — Upload Through Your Server +description: Set up upup server mode — mount createUpupHandler, configure S3 storage, secrets, limits and hooks, then pick an Express, Fastify, Hono or Next.js adapter. --- End-to-end setup for `mode="server"`. In server mode the browser talks only diff --git a/apps/landing/content/docs/guides/sources.mdx b/apps/landing/content/docs/guides/sources.mdx index 63e3ef5d5..5c928fb98 100644 --- a/apps/landing/content/docs/guides/sources.mdx +++ b/apps/landing/content/docs/guides/sources.mdx @@ -1,6 +1,6 @@ --- title: Upload Sources -description: Every input the uploader ships — local files, drag-and-drop, paste, folder upload, camera, microphone, screen capture, URL import, and cloud drives — plus the sources prop that decides which chips appear. +description: Every input the uploader ships — local files, drag and drop, paste, folder upload, camera, microphone, screen capture, URL import, and cloud drives. --- upup collects files from nine sources. One prop — `sources` — decides which of diff --git a/apps/landing/content/docs/guides/storage-providers.mdx b/apps/landing/content/docs/guides/storage-providers.mdx index 75c436727..9fab035ad 100644 --- a/apps/landing/content/docs/guides/storage-providers.mdx +++ b/apps/landing/content/docs/guides/storage-providers.mdx @@ -1,6 +1,6 @@ --- title: Storage Providers -description: Connect upup to any S3-compatible storage — AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi, and more — with the full provider list, the shared storage config shape, and a setup guide per provider. +description: Connect upup to any S3-compatible storage — AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi and more, with a guide per provider. --- upup uploads to any **S3-compatible** object store. One uploader UI, one config diff --git a/apps/landing/content/docs/guides/storage/azure-blob.mdx b/apps/landing/content/docs/guides/storage/azure-blob.mdx index 59020cd2c..b5a64b856 100644 --- a/apps/landing/content/docs/guides/storage/azure-blob.mdx +++ b/apps/landing/content/docs/guides/storage/azure-blob.mdx @@ -1,6 +1,6 @@ --- -title: Upload files to Azure Blob Storage -description: Azure Blob Storage has no S3 API, so @useupup/server rejects it. Upload to Azure from React, Vue, Svelte, or plain JS with upup via client mode and a SAS URL. +title: Upload Files to Azure Blob Storage with a SAS URL +description: Upload files to Azure Blob Storage from the browser with upup — a server-issued SAS URL, the mandatory x-ms-blob-type PUT header, no account key in the client. --- Azure Blob Storage is the one provider in upup's list that server mode cannot diff --git a/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx b/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx index 58283b2ec..6c936629c 100644 --- a/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx +++ b/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx @@ -1,6 +1,6 @@ --- -title: Upload files to Cloudflare R2 -description: Upload files to Cloudflare R2 from React, Vue, Svelte, or plain JS with upup — account-scoped endpoint, region auto, API tokens, CORS, and a copy-paste config. +title: Upload Files to Cloudflare R2 from the Browser +description: Upload files to Cloudflare R2 with upup — the account-scoped endpoint, region auto, API tokens, CORS, and a copy-paste React, Vue or plain JS config. --- Cloudflare R2 speaks the S3 API with zero egress fees, which makes it a common diff --git a/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx b/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx index 344c2dc83..a41378f99 100644 --- a/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx +++ b/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx @@ -1,6 +1,6 @@ --- -title: Upload files to DigitalOcean Spaces -description: Upload files to DigitalOcean Spaces from React, Vue, Svelte, or plain JS with upup — Spaces keys, the region endpoint, CORS, the CDN, and a copy-paste config. +title: Upload Files to DigitalOcean Spaces from the Browser +description: Upload files to DigitalOcean Spaces with upup — Spaces access keys, the regional S3 endpoint, CORS, the CDN, and a copy-paste React, Vue or plain JS config. --- DigitalOcean Spaces is S3-compatible object storage where the datacenter region diff --git a/apps/landing/content/docs/guides/storage/minio.mdx b/apps/landing/content/docs/guides/storage/minio.mdx index 70ece3386..dbffe7fbb 100644 --- a/apps/landing/content/docs/guides/storage/minio.mdx +++ b/apps/landing/content/docs/guides/storage/minio.mdx @@ -1,6 +1,6 @@ --- -title: Upload files to MinIO -description: Upload files to a self-hosted MinIO server from React, Vue, Svelte, or plain JS with upup — Docker setup, path-style addressing, CORS, and a copy-paste config. +title: Upload Files to MinIO from the Browser +description: Upload files to a self-hosted MinIO server with upup — Docker setup, path-style addressing, CORS, and a copy-paste React, Vue, Svelte or plain JS config. --- MinIO is self-hosted S3-compatible storage, which makes it the fastest way to @@ -45,7 +45,7 @@ docker run -p 9100:9000 -p 9101:9001 \ -e MINIO_ROOT_PASSWORD=upupadmin123 \ -e MINIO_API_CORS_ALLOW_ORIGIN='*' \ -v minio-data:/data \ - minio/minio server /data --console-address ':9001' + quay.io/minio/minio server /data --console-address ':9001' ``` MinIO listens on two ports and they are not interchangeable. The **S3 API** is diff --git a/apps/landing/content/docs/guides/theming.mdx b/apps/landing/content/docs/guides/theming.mdx index c4dffc8a5..d8c1255ef 100644 --- a/apps/landing/content/docs/guides/theming.mdx +++ b/apps/landing/content/docs/guides/theming.mdx @@ -1,6 +1,6 @@ --- title: Theming -description: Style the upup uploader with the theme prop — light/dark/system modes, design tokens exposed as CSS variables, per-component slot class overrides, and the stable data-upup-slot DOM hooks for raw CSS. +description: Style the upup uploader with the theme prop — light, dark and system modes, design tokens as CSS variables, slot class overrides, and data-upup-slot hooks. --- upup gives you three layers of control, from coarse to surgical: diff --git a/apps/landing/content/docs/index.mdx b/apps/landing/content/docs/index.mdx index 20ae27dc8..a57221572 100644 --- a/apps/landing/content/docs/index.mdx +++ b/apps/landing/content/docs/index.mdx @@ -1,6 +1,6 @@ --- -title: upup Documentation -description: A file uploader with a native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact, built on a shared headless core with an optional server mode. +title: File Uploader Documentation +description: Docs for upup, the free and open-source file uploader with native UI for React, Vue, Svelte, Angular, Vanilla JS and Preact, plus an optional server mode. --- upup is a file uploader with a native UI for **React, Vue, Svelte, Angular, diff --git a/apps/landing/content/docs/localization.mdx b/apps/landing/content/docs/localization.mdx index 8f9fe84b4..3d7db37a2 100644 --- a/apps/landing/content/docs/localization.mdx +++ b/apps/landing/content/docs/localization.mdx @@ -1,6 +1,6 @@ --- title: Localization (i18n) -description: upup ships nine ICU locale bundles from @useupup/core/i18n — set the locale on any framework's uploader, override individual namespaced message keys, and get automatic RTL and pluralization. +description: upup ships nine ICU locale bundles from @useupup/core/i18n — set the locale on any framework uploader, override message keys, and get RTL and plurals. --- upup uses ICU locale bundles from `@useupup/core/i18n`. Messages are grouped diff --git a/apps/landing/content/docs/migration/v1-to-v3.mdx b/apps/landing/content/docs/migration/v1-to-v3.mdx index a232bc85e..a45633351 100644 --- a/apps/landing/content/docs/migration/v1-to-v3.mdx +++ b/apps/landing/content/docs/migration/v1-to-v3.mdx @@ -1,6 +1,6 @@ --- title: Migrating from v1 to v3 -description: Upgrade from upup v1 (upup-react-file-uploader) to v3 — the package rename to @useupup/react, the full v1→v3 prop map, the UpupError/UpupErrorCode taxonomy, and client- vs server-mode uploads. +description: Upgrade from upup v1 (upup-react-file-uploader) to v3 — the rename to @useupup/react, the v1 to v3 prop map, the error taxonomy, and client vs server mode. --- v1 shipped as a single React package, `upup-react-file-uploader`. v3 is a diff --git a/apps/landing/content/docs/quickstarts/angular.mdx b/apps/landing/content/docs/quickstarts/angular.mdx index 2ea2d05e6..2f0bb1895 100644 --- a/apps/landing/content/docs/quickstarts/angular.mdx +++ b/apps/landing/content/docs/quickstarts/angular.mdx @@ -1,6 +1,6 @@ --- title: Angular Quickstart -description: Add a full-featured file uploader to an Angular 19+ app with @useupup/angular — a standalone component port of the canonical React UI, with cloud drives, camera, and resumable uploads. +description: Add a file uploader to an Angular 19+ app with @useupup/angular — a standalone component, drag and drop, cloud drives, camera, and resumable uploads. --- `@useupup/angular` is a native Angular 19+ port of the canonical upup React UI — diff --git a/apps/landing/content/docs/quickstarts/next.mdx b/apps/landing/content/docs/quickstarts/next.mdx index 4166db73b..91e5d0152 100644 --- a/apps/landing/content/docs/quickstarts/next.mdx +++ b/apps/landing/content/docs/quickstarts/next.mdx @@ -1,6 +1,6 @@ --- title: Next.js Quickstart -description: Add a full-featured file uploader to a Next.js app with @useupup/next — the client UI and the server handlers in one install, split so the AWS SDK never reaches your client bundle. +description: Add a file uploader to a Next.js app with @useupup/next — client UI and server handlers in one install, split so the AWS SDK never reaches your bundle. --- `@useupup/next` is the Next.js integration for upup: one install gives you the client diff --git a/apps/landing/content/docs/quickstarts/preact.mdx b/apps/landing/content/docs/quickstarts/preact.mdx index 95ecc8b23..e0eac6a4b 100644 --- a/apps/landing/content/docs/quickstarts/preact.mdx +++ b/apps/landing/content/docs/quickstarts/preact.mdx @@ -1,6 +1,6 @@ --- title: Preact Quickstart -description: Add a full-featured file uploader to a Preact app with @useupup/preact — a preact/compat re-export of @useupup/react, same UI and API, with cloud drives, camera, and resumable uploads. +description: Add a file uploader to a Preact app with @useupup/preact — a preact/compat re-export of @useupup/react, drag and drop, cloud drives, and resumable uploads. --- `@useupup/preact` is a **`preact/compat` re-export of diff --git a/apps/landing/content/docs/quickstarts/react.mdx b/apps/landing/content/docs/quickstarts/react.mdx index 783adb572..f2446a884 100644 --- a/apps/landing/content/docs/quickstarts/react.mdx +++ b/apps/landing/content/docs/quickstarts/react.mdx @@ -1,6 +1,6 @@ --- title: React Quickstart -description: Add a full-featured file uploader to a React 19 app with @useupup/react — drag-and-drop, cloud drives, camera, and resumable uploads, with no upload server to run. +description: Add a file uploader to a React 19 app with @useupup/react — one npm install, drag and drop, cloud drives, camera, and resumable uploads, no server to run. --- `@useupup/react` is the canonical upup UI: a drag-and-drop uploader with file diff --git a/apps/landing/content/docs/quickstarts/svelte.mdx b/apps/landing/content/docs/quickstarts/svelte.mdx index 33048de38..2cd08f5a2 100644 --- a/apps/landing/content/docs/quickstarts/svelte.mdx +++ b/apps/landing/content/docs/quickstarts/svelte.mdx @@ -1,6 +1,6 @@ --- title: Svelte Quickstart -description: Add a full-featured file uploader to a Svelte 5 app with @useupup/svelte — a native port of the canonical React UI, DOM-identical, with cloud drives, camera, and resumable uploads. +description: Add a file uploader to a Svelte 5 app with @useupup/svelte — one npm install, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage. --- `@useupup/svelte` is a native Svelte 5 port of the canonical upup React UI — diff --git a/apps/landing/content/docs/quickstarts/vanilla.mdx b/apps/landing/content/docs/quickstarts/vanilla.mdx index 241fbe9a3..556fbf4bb 100644 --- a/apps/landing/content/docs/quickstarts/vanilla.mdx +++ b/apps/landing/content/docs/quickstarts/vanilla.mdx @@ -1,6 +1,6 @@ --- title: Vanilla JS Quickstart -description: Add a full-featured file uploader to any page with @useupup/vanilla — framework-free, DOM-identical to the canonical React UI, with cloud drives, camera, and resumable uploads. +description: Add a file uploader to any page with @useupup/vanilla — framework-free, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage. --- `@useupup/vanilla` is the framework-free upup uploader (built on lit-html) — diff --git a/apps/landing/content/docs/quickstarts/vue.mdx b/apps/landing/content/docs/quickstarts/vue.mdx index f92eb906c..119b18b9d 100644 --- a/apps/landing/content/docs/quickstarts/vue.mdx +++ b/apps/landing/content/docs/quickstarts/vue.mdx @@ -1,6 +1,6 @@ --- title: Vue Quickstart -description: Add a full-featured file uploader to a Vue 3 app with @useupup/vue — a native port of the canonical React UI, DOM-identical, with cloud drives, camera, and resumable uploads. +description: Add a file uploader to a Vue 3 app with @useupup/vue — one npm install, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage. --- `@useupup/vue` is a native Vue 3 port of the canonical upup React UI — DOM-identical diff --git a/apps/landing/content/docs/resumable-uploads.mdx b/apps/landing/content/docs/resumable-uploads.mdx index 4a4500021..ba69b259a 100644 --- a/apps/landing/content/docs/resumable-uploads.mdx +++ b/apps/landing/content/docs/resumable-uploads.mdx @@ -1,6 +1,6 @@ --- -title: Resumable Uploads -description: Two resumable protocols — S3 multipart through @useupup/server, or tus against an external tus-compatible service. Full option reference, part-size rules, and what each protocol actually resumes. +title: Resumable Uploads — S3 Multipart & tus +description: Resumable file uploads in upup — S3 multipart through @useupup/server or the tus protocol, with part-size rules and what each protocol actually resumes. --- upup supports two resumable protocols. Both are opt-in through the single diff --git a/apps/landing/lighthouserc.cjs b/apps/landing/lighthouserc.cjs index 1b28bf615..f7048aaa3 100644 --- a/apps/landing/lighthouserc.cjs +++ b/apps/landing/lighthouserc.cjs @@ -1,8 +1,10 @@ // Nightly Lighthouse gate for the public site — SEO + Best Practices ONLY. // Accessibility is deliberately not asserted here (the nightly axe ratchet in -// `pnpm run e2e:a11y` owns it — Lighthouse's a11y audits are axe-core anyway), -// and Performance is excluded (score is unusably noisy on shared CI runners; -// bundle weight is already gated by size-limit). +// `pnpm run e2e:a11y` owns it — Lighthouse's a11y audits are axe-core anyway). +// Performance is not asserted in THIS config: it runs alongside, mobile-only +// and warn-only, from `lighthouserc.mobile.cjs` (the score is noisy on shared +// CI runners, so it is a visible ratchet rather than a gate; bundle weight +// stays gated by size-limit). // // Run locally after building: // pnpm exec turbo run build --filter=@useupup/landing diff --git a/apps/landing/lighthouserc.mobile.cjs b/apps/landing/lighthouserc.mobile.cjs new file mode 100644 index 000000000..f1b247f34 --- /dev/null +++ b/apps/landing/lighthouserc.mobile.cjs @@ -0,0 +1,76 @@ +// Nightly Lighthouse MOBILE PERFORMANCE ratchet for the public site. +// +// Separate from `lighthouserc.cjs` (SEO + Best Practices, desktop defaults) +// because it needs mobile emulation, more runs, and a different port so both +// configs can run back to back in the same job. Every assertion here is `warn`, +// deliberately: the score IS noisy on a shared CI runner, so this exists to make +// a regression visible in the job log and the uploaded report, not to red the +// night. `continue-on-error` is banned by scripts/ci/test-quality-guard.mjs and +// is not needed — a warn-level assertion never fails the run. +// +// Thresholds are the measured post-fix local medians minus 0.05. Raise them when +// the site gets faster; never lower one to make a red go away without saying why. +// +// Run locally after building: +// pnpm exec turbo run build --filter=@useupup/landing +// pnpm --filter @useupup/landing run lighthouse:mobile + +const PORT = 4464 +const page = path => `http://localhost:${PORT}${path}` + +module.exports = { + ci: { + collect: { + // Both marketing shapes (home and a framework page share one + // parameterized layout) plus one docs page, the third template. + url: [page('/'), page('/react/'), page('/docs/getting-started/')], + numberOfRuns: 5, + startServerCommand: `pnpm exec next start -p ${PORT}`, + startServerReadyPattern: 'Ready in', + startServerReadyTimeout: 60000, + settings: { + onlyCategories: ['performance'], + // Default lighthouse emulation is already mobile (Moto G Power + // + simulated 4G); stated here so a future default change + // cannot silently turn this into a desktop run. + formFactor: 'mobile', + screenEmulation: { + mobile: true, + width: 412, + height: 823, + deviceScaleFactor: 1.75, + disabled: false, + }, + }, + }, + assert: { + // Judge the median of the five runs, not the worst one — a single + // runner hiccup must not read as a regression. + aggregationMethod: 'median', + assertMatrix: [ + { + // Home + framework pages: the live uploader demo, the + // StackBlitz editor and the scene animations all load + // behind viewport gates, so the initial page is light. + matchingUrlPattern: '^https?://[^/]+/(react/)?$', + // Measured medians on this build: / = 0.74, /react/ = 0.71. + assertions: { + 'categories:performance': ['warn', { minScore: 0.66 }], + }, + }, + { + // Docs pages carry no embeds at all. + matchingUrlPattern: '.*/docs/.*', + // Measured median on this build: 0.73. + assertions: { + 'categories:performance': ['warn', { minScore: 0.68 }], + }, + }, + ], + }, + upload: { + target: 'filesystem', + outputDir: '.lighthouseci-mobile-reports', + }, + }, +} diff --git a/apps/landing/next.config.mjs b/apps/landing/next.config.mjs index 4d3c1b7ca..9614d9686 100644 --- a/apps/landing/next.config.mjs +++ b/apps/landing/next.config.mjs @@ -72,6 +72,46 @@ const nextConfig = { // then one trailingSlash hop appends the slash). async redirects() { return [ + // http -> https, FIRST so no other rule can answer a plaintext + // request with a 200. Keyed on Cloudflare's `cf-visitor` header + // (`{"scheme":"http"}`) and NOTHING else: `x-forwarded-proto` is + // rewritten by Traefik on the way to this container, so a rule + // reading it sees "http" on every request and redirects forever. + // Cloudflare's own "Always Use HTTPS" toggle is the belt (an owner + // item); this is the braces that lives in the repo and survives a + // zone-settings change. + // + // TWO rules, because Next strips the trailing slash before matching + // a source and does NOT re-append it to an ABSOLUTE destination + // (it does for relative ones) — a single `${SITE_BASE}/:path*` + // sends /react/ to `…/react`, costing a second 308. The first rule + // therefore matches extensionless paths only (`[^/.]+` final + // segment) and restores the slash; file paths and the bare root + // fall through to the second, which must NOT gain one. + { + source: '/:path((?:[^/]+/)*[^/.]+)', + has: [ + { + type: 'header', + key: 'cf-visitor', + value: '.*"scheme":"http".*', + }, + ], + destination: `${SITE_BASE}/:path/`, + permanent: true, + }, + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'cf-visitor', + value: '.*"scheme":"http".*', + }, + ], + destination: `${SITE_BASE}/:path*`, + permanent: true, + }, // The wildcard `/documentation/:path*` rule below also covers the // bare path (`:path*` matches zero segments) — this explicit entry // is kept for clarity, not necessity. @@ -121,11 +161,30 @@ const nextConfig = { destination: '/docs/api-reference/upupuploader/required-props/', permanent: true, }, + // Two sitemap URLs still registered in Search Console from the + // Docusaurus era, both currently dead: `/sitemap-landing.xml` is a + // bare 404 (nothing ever served it), and `/documentation/sitemap.xml` + // fell through to the wildcard below, which appends a slash to a + // FILE path and lands on the docs catch-all's 404. GSC has been + // reporting "couldn't fetch" for both ever since. Extension paths + // get no trailing-slash hop, so each of these is a single 308. + // They must precede the wildcard. (Owner follow-up: delete the two + // stale submissions in Search Console once these are live.) + { + source: '/sitemap-landing.xml', + destination: '/sitemap.xml', + permanent: true, + }, + { + source: '/documentation/sitemap.xml', + destination: '/sitemap.xml', + permanent: true, + }, // Destination carries the trailing slash so trailingSlash:true // does not have to spend a SECOND 308 appending it. Safe here only // because every extensionless legacy path maps to a real page and - // the two file paths under /documentation are handled by the - // explicit llms rules above — a slash appended to a file URL would + // the file paths under /documentation are handled by the explicit + // llms + sitemap rules above — a slash appended to a file URL would // break it (Next never slashes paths with an extension). { source: '/documentation/:path*', @@ -137,12 +196,30 @@ const nextConfig = { // /documentation/* -> /docs/* hop first (staying on the alias // host), then the /docs/* rule below moves it to the main host. // - // The three explicit rules come before the two catch-alls because - // a catch-all destination of `/docs/:path*/` is wrong for exactly + // The explicit rules come before the two catch-alls because a + // catch-all destination of `/docs/:path*/` is wrong for exactly // two shapes: an EMPTY `:path*` (which would render `/docs//`) and // a FILE path (which must not gain a trailing slash). Listing them // explicitly lets the catch-alls stay slashed for the page shapes // that are 99% of alias traffic. + // + // robots.txt and sitemap.xml are the file paths a crawler probes on + // any host it meets. Without these two they took the catch-all to + // `${SITE_BASE}/docs/robots.txt/`, then a trailing-slash hop, then + // the docs catch-all's 404 — a broken robots on a live alias host. + // They belong on the APEX copies, not under /docs. + { + source: '/robots.txt', + has: [{ type: 'host', value: DOCS_ALIAS_HOST }], + destination: `${SITE_BASE}/robots.txt`, + permanent: true, + }, + { + source: '/sitemap.xml', + has: [{ type: 'host', value: DOCS_ALIAS_HOST }], + destination: `${SITE_BASE}/sitemap.xml`, + permanent: true, + }, { source: '/llms.txt', has: [{ type: 'host', value: DOCS_ALIAS_HOST }], @@ -200,11 +277,35 @@ const nextConfig = { }, ] }, - // Non-production hosts (dev, previews) serve a byte-identical copy of the - // whole site. robots.txt disallows crawling there; this header is what - // actually keeps a URL discovered some other way out of the index. + // Two mutually exclusive header sets. + // + // PRODUCTION: HSTS. Two years + includeSubDomains + preload is the + // preload-list-eligible value; the site is https-only in practice and the + // cf-visitor redirect above guarantees a plaintext request never gets a + // 200, so there is no http-only subdomain this can strand. (Submitting to + // hstspreload.org is an owner step, taken only after the header has been + // live for a while — `preload` in the value is a prerequisite, not the + // submission itself.) + // + // NON-PRODUCTION (dev, previews): those hosts serve a byte-identical copy + // of the whole site. robots.txt disallows crawling there; this header is + // what actually keeps a URL discovered some other way out of the index. + // They get no HSTS — a preload directive from a preview host is a + // liability, not a protection. async headers() { - if (IS_PRODUCTION_SITE) return [] + if (IS_PRODUCTION_SITE) { + return [ + { + source: '/:path*', + headers: [ + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + ], + }, + ] + } return [ { source: '/:path*', diff --git a/apps/landing/package.json b/apps/landing/package.json index 22778bdb5..4c3123896 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint . --max-warnings 0", "lighthouse": "lhci autorun --config=lighthouserc.cjs", + "lighthouse:mobile": "lhci autorun --config=lighthouserc.mobile.cjs", "typecheck": "fumadocs-mdx && tsc --noEmit", "test": "vitest run", "clean": "rm -rf .next node_modules/.cache" diff --git a/apps/landing/src/__tests__/claim-integrity.test.ts b/apps/landing/src/__tests__/claim-integrity.test.ts new file mode 100644 index 000000000..5b8417233 --- /dev/null +++ b/apps/landing/src/__tests__/claim-integrity.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +// Ratchet for the 2026-09 claim-integrity pass. Marketing copy is read +// literally by the people evaluating upup, so the wording the audit retired +// must not creep back in. Two classes are gated here: +// +// 1. Claims the code cannot back. The cross-framework parity suite compares a +// NORMALIZED tree — structure, `upup-` class tokens, a11y attributes and +// text, with framework-injected attributes stripped and Angular host +// elements unwrapped (apps/e2e-test/cross-framework/parity-dom.ts) — so +// "byte-identical" / "byte-for-byte" overstates what it proves. The site +// also has no account system (src/app has no sign-up route), so copy about +// data collected "when you sign up" describes a flow that does not exist. +// 2. Social proof upup has never been able to substantiate: user counts, +// "trusted by" endorsements, and ranking superlatives. None appear today — +// this locks that in rather than fixing it after it ships. +// +// The sources are read as TEXT on purpose: the claims live in string literals +// and JSX prose, and a text read catches them wherever in the file they move. + +const COPY_SOURCES: Record = { + 'src/lib/faqs.ts': '../lib/faqs.ts', + 'src/components/FeatureShowcase/index.tsx': + '../components/FeatureShowcase/index.tsx', + 'src/components/HomepageFeatures/index.tsx': + '../components/HomepageFeatures/index.tsx', + 'src/app/privacy/page.tsx': '../app/privacy/page.tsx', +} + +function readCopySources(): [string, string][] { + return Object.entries(COPY_SOURCES).map(([label, relative]) => [ + label, + readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8'), + ]) +} + +/** JSX prose is hard-wrapped, so a phrase can straddle a newline + indent. */ +function normalizeWhitespace(source: string): string { + return source.replace(/\s+/g, ' ') +} + +describe('landing copy claim integrity', () => { + it('reads every landing copy source this guard is supposed to cover', () => { + for (const [label, text] of readCopySources()) { + expect( + text.length, + `${label} is empty or unreadable`, + ).toBeGreaterThan(500) + } + }) + + it('states no byte-identical or byte-for-byte DOM claim, because the parity suite compares a normalized tree', () => { + for (const [label, text] of readCopySources()) { + const prose = normalizeWhitespace(text).toLowerCase() + expect( + prose, + `${label} claims byte-identical rendering the parity suite does not verify`, + ).not.toContain('byte-identical') + expect( + prose, + `${label} claims byte-for-byte rendering the parity suite does not verify`, + ).not.toContain('byte-for-byte') + } + }) + + it('describes no data collected at sign-up, because the site ships no sign-up route', () => { + for (const [label, text] of readCopySources()) { + const prose = normalizeWhitespace(text).toLowerCase() + expect( + prose, + `${label} references a sign-up flow that does not exist on useupup.com`, + ).not.toContain('when you sign up') + } + }) + + it('carries no unsubstantiated social proof such as trusted-by endorsements, user counts or ranking superlatives', () => { + const UNSUBSTANTIATED = [ + 'trusted by', + 'users worldwide', + '#1', + 'most popular', + ] + for (const [label, text] of readCopySources()) { + const prose = normalizeWhitespace(text).toLowerCase() + for (const phrase of UNSUBSTANTIATED) { + expect( + prose, + `${label} makes an unsubstantiated "${phrase}" claim — upup has no published adoption figures to back it`, + ).not.toContain(phrase) + } + } + }) +}) diff --git a/apps/landing/src/__tests__/indexnow.test.ts b/apps/landing/src/__tests__/indexnow.test.ts new file mode 100644 index 000000000..8e046ddaa --- /dev/null +++ b/apps/landing/src/__tests__/indexnow.test.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { INDEXNOW_KEY, INDEXNOW_KEY_PATH } from '@/lib/indexnow' +import { GET } from '../app/5cb30cbda540958e8d033652400e59e3.txt/route' + +// IndexNow verification hinges on one brittle coupling: the App Router +// directory that serves the key file is NAMED after the key, so a change to +// the constant without the matching directory rename (or vice versa) leaves +// the site serving one key while claiming another — and every submission an +// engine receives fails ownership. These pin the two to each other, plus the +// response shape indexnow.org requires (exact body, text/plain). +describe('IndexNow key file served by the landing app', () => { + it('key constant is 32 lowercase hexadecimal characters as IndexNow requires', () => { + expect(INDEXNOW_KEY).toMatch(/^[0-9a-f]{32}$/) + }) + + it('key path constant is the key served as a root-level .txt file', () => { + expect(INDEXNOW_KEY_PATH).toBe(`/${INDEXNOW_KEY}.txt`) + }) + + it('route directory on disk is named exactly after the key constant', () => { + const appDir = fileURLToPath(new URL('../app/', import.meta.url)) + expect(existsSync(`${appDir}${INDEXNOW_KEY}.txt/route.ts`)).toBe(true) + }) + + it('GET responds with the bare key as plain text and nothing else', async () => { + const response = GET() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'text/plain; charset=utf-8', + ) + await expect(response.text()).resolves.toBe(INDEXNOW_KEY) + }) +}) diff --git a/apps/landing/src/__tests__/seo-copy-budgets.test.ts b/apps/landing/src/__tests__/seo-copy-budgets.test.ts new file mode 100644 index 000000000..9f391aa53 --- /dev/null +++ b/apps/landing/src/__tests__/seo-copy-budgets.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { generateMetadata as frameworkMetadata } from '@/app/[framework]/page' +import { metadata as homeMetadata } from '@/app/page' +import { FRAMEWORK_IDS, FRAMEWORKS } from '@/lib/frameworks' +import { siteConfig } from '@/lib/siteConfig' +import { source } from '@/lib/docs/source' + +// Search Console (90 days to 2026-09-08, sc-domain:useupup.com) showed the +// money pages earning impressions and almost no clicks with titles Google +// truncated and descriptions two to three times the width a SERP renders. +// These are the widths that actually display, so a future copy edit that +// blows past them fails here instead of silently costing clicks. +const MAX_TITLE = 60 +const MAX_DOCS_TITLE = 65 // frontmatter title + the " | upup docs" template +const DOCS_TITLE_TEMPLATE_SUFFIX = ' | upup docs' +const MIN_DESCRIPTION = 120 +const MAX_DESCRIPTION = 160 + +// A separate PR removes this claim from the marketing copy; nothing may +// reintroduce it through a title or a description. +const RETIRED_CLAIM = 'byte-identical' + +function textOf(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +describe('home page search-result copy', () => { + it('renders a title short enough to survive SERP truncation', () => { + expect(homeMetadata.title.length).toBeLessThanOrEqual(MAX_TITLE) + }) + + it('leads the title with the brand, which is the site top query', () => { + expect(homeMetadata.title.startsWith('upup')).toBe(true) + }) + + it('renders a description inside the width Google displays', () => { + expect(homeMetadata.description.length).toBeGreaterThanOrEqual( + MIN_DESCRIPTION, + ) + expect(homeMetadata.description.length).toBeLessThanOrEqual( + MAX_DESCRIPTION, + ) + }) + + it('keeps the site-wide fallback title and tagline inside the same budgets', () => { + // layout.tsx serves these to every route that declares no metadata of + // its own, so they are search-result copy too. + expect(siteConfig.title.length).toBeLessThanOrEqual(MAX_TITLE) + expect(siteConfig.tagline.length).toBeGreaterThanOrEqual( + MIN_DESCRIPTION, + ) + expect(siteConfig.tagline.length).toBeLessThanOrEqual(MAX_DESCRIPTION) + }) +}) + +describe('framework page search-result copy', () => { + it.each(FRAMEWORK_IDS)( + 'gives /%s/ a title inside the 60-character budget', + async id => { + const meta = await frameworkMetadata({ + params: Promise.resolve({ framework: id }), + }) + expect(textOf(meta.title).length).toBeLessThanOrEqual(MAX_TITLE) + }, + ) + + it.each(FRAMEWORK_IDS)( + 'leads the /%s/ title with the phrase the impressions arrive on', + async id => { + const meta = await frameworkMetadata({ + params: Promise.resolve({ framework: id }), + }) + // "vue file uploader", "react uploader", "angular file uploader" — + // the query language has to be in front of the brand, not behind it. + expect(textOf(meta.title)).toContain( + `${FRAMEWORKS[id].name} File Uploader`, + ) + }, + ) + + it.each(FRAMEWORK_IDS)( + 'gives /%s/ a description between 120 and 160 characters', + async id => { + const meta = await frameworkMetadata({ + params: Promise.resolve({ framework: id }), + }) + const description = textOf(meta.description) + expect(description.length).toBeGreaterThanOrEqual(MIN_DESCRIPTION) + expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION) + }, + ) + + it.each(FRAMEWORK_IDS)( + 'names the install command for /%s/ in its description', + async id => { + const meta = await frameworkMetadata({ + params: Promise.resolve({ framework: id }), + }) + expect(textOf(meta.description)).toContain( + `npm install ${FRAMEWORKS[id].pkg}`, + ) + }, + ) +}) + +describe('docs frontmatter search-result copy', () => { + it('keeps every docs title inside 65 characters once the template is appended', () => { + const overBudget = source + .getPages() + .map(page => ({ + url: page.url, + rendered: `${page.data.title}${DOCS_TITLE_TEMPLATE_SUFFIX}`, + })) + .filter(entry => entry.rendered.length > MAX_DOCS_TITLE) + .map(entry => `${entry.url} (${entry.rendered.length})`) + expect(overBudget).toEqual([]) + }) + + it('keeps every docs description that exists inside 160 characters', () => { + const overBudget = source + .getPages() + .map(page => ({ + url: page.url, + description: textOf(page.data.description), + })) + .filter(entry => entry.description.length > MAX_DESCRIPTION) + .map(entry => `${entry.url} (${entry.description.length})`) + expect(overBudget).toEqual([]) + }) +}) + +describe('retired parity claim', () => { + it('is absent from every title and description the site emits', async () => { + const copy: string[] = [ + homeMetadata.title, + homeMetadata.description, + siteConfig.title, + siteConfig.tagline, + ] + for (const id of FRAMEWORK_IDS) { + const meta = await frameworkMetadata({ + params: Promise.resolve({ framework: id }), + }) + copy.push(textOf(meta.title), textOf(meta.description)) + } + for (const page of source.getPages()) { + copy.push(page.data.title, textOf(page.data.description)) + } + const offenders = copy.filter(text => + text.toLowerCase().includes(RETIRED_CLAIM), + ) + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/landing/src/__tests__/seo-surfaces.test.ts b/apps/landing/src/__tests__/seo-surfaces.test.ts new file mode 100644 index 000000000..00cbfd0c3 --- /dev/null +++ b/apps/landing/src/__tests__/seo-surfaces.test.ts @@ -0,0 +1,222 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import robots from '@/app/robots' +import sitemap from '@/app/sitemap' +import EntityStructuredData from '@/components/StructuredData/EntityStructuredData' +import StructuredData from '@/components/StructuredData' +import { AI_CRAWLER_USER_AGENTS } from '@/lib/seo/ai-crawlers' + +// The public site's machine-readable surfaces — sitemap, robots, JSON-LD — are +// the ones nobody looks at until a search engine has already acted on them. +// Every assertion here pins a decision that was made deliberately and would be +// silently reversible otherwise (a fake lastmod, a stray /mobile-demo entry, a +// Review node nobody can substantiate). + +const PRODUCTION_ORIGIN = 'https://useupup.com' + +/** Every `application/ld+json` payload in a rendered markup string. */ +function parseJsonLdBlocks(markup: string): unknown[] { + const blocks = [ + ...markup.matchAll( + /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g, + ), + ] + return blocks.map(match => JSON.parse(match[1]) as unknown) +} + +/** Flattens `@graph` containers so nodes can be looked up by `@type`. */ +function flattenGraph(documents: unknown[]): Record[] { + const nodes: Record[] = [] + for (const doc of documents) { + const record = doc as Record + const graph = record['@graph'] + if (Array.isArray(graph)) + nodes.push(...(graph as Record[])) + else nodes.push(record) + } + return nodes +} + +function nodeOfType( + nodes: Record[], + type: string, +): Record { + const found = nodes.find(node => node['@type'] === type) + expect(found, `no ${type} node in the rendered JSON-LD`).toBeDefined() + return found as Record +} + +describe('sitemap enumerates only canonical, indexable page URLs', () => { + const entries = sitemap() + + it('lists the homepage, six framework pages, support and privacy, and all 64 docs pages', () => { + // 1 home + 6 frameworks + support + privacy + 64 fumadocs pages. The + // docs count is independently pinned by docs-source.test.ts, so a page + // added to content/docs updates both or neither. + expect(entries).toHaveLength(1 + 6 + 2 + 64) + }) + + it('points every entry at the production origin with the trailing slash the site actually serves', () => { + for (const entry of entries) { + expect(entry.url.startsWith(`${PRODUCTION_ORIGIN}/`)).toBe(true) + expect(entry.url.endsWith('/')).toBe(true) + } + }) + + it('omits the demo harness, the API routes, and the agent-only text surfaces', () => { + // /mobile-demo and /api are robots-disallowed; /docs-md and /llms*.txt + // are alternate representations of pages already listed here, not pages. + const excluded = ['/mobile-demo', '/api/', '/docs-md', '/llms'] + for (const entry of entries) { + for (const fragment of excluded) { + expect( + entry.url.includes(fragment), + `${entry.url} must not contain ${fragment}`, + ).toBe(false) + } + } + }) + + it('stamps no lastModified date on any entry', () => { + // A build-time `new Date()` on all 73 URLs claimed the whole site + // changed on every deploy; Google's response to an uncorroborated + // lastmod is to ignore the field site-wide. No real per-page date + // exists, so the field stays absent rather than invented. + for (const entry of entries) { + expect(entry.lastModified, `${entry.url} lastModified`).toBe( + undefined, + ) + } + }) +}) + +describe('robots.txt grants crawl access per host and names the AI agents', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('allows the whole site to the wildcard agent except the API and demo harness', () => { + const rules = [robots().rules].flat() + const wildcard = rules.find(rule => rule.userAgent === '*') + expect(wildcard?.allow).toBe('/') + expect(wildcard?.disallow).toEqual(['/api/', '/mobile-demo/']) + }) + + it('carries a second rule listing exactly the AI crawler allow-list', () => { + const rules = [robots().rules].flat() + const agentRule = rules.find(rule => Array.isArray(rule.userAgent)) + expect(agentRule?.userAgent).toEqual([...AI_CRAWLER_USER_AGENTS]) + expect(agentRule?.allow).toBe('/') + // The named agents must never get a laxer disallow set than `*`. + expect(agentRule?.disallow).toEqual(['/api/', '/mobile-demo/']) + }) + + it('advertises the production sitemap URL', () => { + expect(robots().sitemap).toBe(`${PRODUCTION_ORIGIN}/sitemap.xml`) + }) + + it('blanket-disallows crawling when the deployment is not the production site', async () => { + // clientEnv is parsed once at module load, so the stub has to land + // before a FRESH import of the robots route and the site-url helper + // underneath it. + vi.stubEnv('NEXT_PUBLIC_BASE_URL', 'https://dev.useupup.com') + vi.resetModules() + const devRobots = (await import('@/app/robots')).default + const result = devRobots() + expect(result.rules).toEqual([{ userAgent: '*', disallow: '/' }]) + expect(result.sitemap).toBe('https://dev.useupup.com/sitemap.xml') + }) +}) + +describe('entity JSON-LD ties the brand to one referenced organization', () => { + const entityNodes = flattenGraph( + parseJsonLdBlocks( + renderToStaticMarkup(createElement(EntityStructuredData)), + ), + ) + + it('describes the Organization with a stable id, alternate names, and verified profiles', () => { + const org = nodeOfType(entityNodes, 'Organization') + expect(org['@id']).toBe(`${PRODUCTION_ORIGIN}/#organization`) + expect(org.name).toBe('upup') + expect(org.alternateName).toContain('useupup') + expect(org.alternateName).toContain('@useupup') + const sameAs = org.sameAs as string[] + expect(sameAs.length).toBeGreaterThanOrEqual(2) + for (const profile of sameAs) + expect(profile.startsWith('https://')).toBe(true) + }) + + it('names Devino as the parent organization', () => { + const org = nodeOfType(entityNodes, 'Organization') + expect(org.parentOrganization).toEqual({ + '@type': 'Organization', + name: 'Devino', + url: 'https://devino.ca/', + }) + }) + + it('makes the WebSite publisher reference the organization node by id', () => { + const site = nodeOfType(entityNodes, 'WebSite') + expect(site['@id']).toBe(`${PRODUCTION_ORIGIN}/#website`) + expect(site.publisher).toEqual({ + '@id': `${PRODUCTION_ORIGIN}/#organization`, + }) + }) + + it('makes the page-level SoftwareApplication publisher reference the same organization node', () => { + const pageNodes = flattenGraph( + parseJsonLdBlocks( + renderToStaticMarkup(createElement(StructuredData)), + ), + ) + const app = nodeOfType(pageNodes, 'SoftwareApplication') + expect(app['@id']).toBe(`${PRODUCTION_ORIGIN}/#software`) + expect(app.publisher).toEqual({ + '@id': `${PRODUCTION_ORIGIN}/#organization`, + }) + expect(app.author).toEqual({ + '@id': `${PRODUCTION_ORIGIN}/#organization`, + }) + }) + + it('emits no AggregateRating or Review anywhere in the rendered markup', () => { + // We have no first-party review corpus. Rating markup we cannot + // substantiate is a manual-action risk, so its absence is a pin, not + // an omission — this fails the moment someone adds one. + const markup = + renderToStaticMarkup(createElement(EntityStructuredData)) + + renderToStaticMarkup(createElement(StructuredData)) + expect(markup).not.toContain('AggregateRating') + expect(markup).not.toContain('"Review"') + }) +}) + +describe('AI crawler allow-list holds the exact agreed agent names', () => { + it('pins all eighteen user-agent tokens in their published spelling', () => { + // Spelling is load-bearing: robots.txt user-agent matching is on these + // literal tokens, so a "tidied" name silently drops that agent's group. + expect([...AI_CRAWLER_USER_AGENTS]).toEqual([ + 'GPTBot', + 'OAI-SearchBot', + 'ChatGPT-User', + 'ClaudeBot', + 'Claude-User', + 'Claude-SearchBot', + 'anthropic-ai', + 'PerplexityBot', + 'Perplexity-User', + 'Google-Extended', + 'Googlebot', + 'Bingbot', + 'Applebot', + 'Applebot-Extended', + 'CCBot', + 'Amazonbot', + 'Bytespider', + 'meta-externalagent', + ]) + }) +}) diff --git a/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts b/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts new file mode 100644 index 000000000..ff8c6eb17 --- /dev/null +++ b/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts @@ -0,0 +1,15 @@ +import { INDEXNOW_KEY } from '@/lib/indexnow' + +// The IndexNow ownership key file. The directory name above is the key and +// must match INDEXNOW_KEY exactly — src/__tests__/indexnow.test.ts pins it. +// Static: the body is a constant, so there is nothing to compute per request. +export const dynamic = 'force-static' + +export function GET() { + return new Response(INDEXNOW_KEY, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=86400', + }, + }) +} diff --git a/apps/landing/src/app/[framework]/page.tsx b/apps/landing/src/app/[framework]/page.tsx index 000eb7825..5db8d5e71 100644 --- a/apps/landing/src/app/[framework]/page.tsx +++ b/apps/landing/src/app/[framework]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' import '@useupup/interactive-example/styles' -import { InteractiveExampleClient } from '@/components/InteractiveExampleClient' +import DeferredInteractiveExample from '@/components/DeferredInteractiveExample' import { interactiveExampleEnvProps } from '@/lib/interactive-example-props' import { FRAMEWORK_IDS, getFramework } from '@/lib/frameworks' import StructuredData from '@/components/StructuredData' @@ -31,8 +31,13 @@ export async function generateMetadata({ const fw = getFramework(framework) if (!fw) return {} - const title = `${fw.name} File Uploader — upup` - const description = `${fw.tagline} Open-source & MIT-licensed — npm install ${fw.pkg}.` + // " File Uploader" leads because that is the phrase the + // impressions arrive on ("vue file uploader", "react uploader", + // "angular file uploader"); the brand trails. Widest case is + // "Vanilla JS" at 57 chars — the ≤60 title and ≤155 description budgets + // are pinned by src/__tests__/seo-copy-budgets.test.ts. + const title = `${fw.name} File Uploader – Open-Source Drag & Drop | upup` + const description = fw.tagline const url = canonicalUrl(fw.id) const image = `${siteUrl()}/img/social-card.png` @@ -73,7 +78,7 @@ export default async function FrameworkPage({ />

- on the article page), so // prepend it — mirrors buildLlmsFull's per-page shape. const markdown = `# ${page.title}\n\n${page.body}\n` + // RFC 8288 canonical link. This route serves the same content as the HTML + // docs page under a second URL, so without it a crawler that discovers the + // twin has two competing originals. A header (rather than a noindex) is the + // right tool here: agents are welcome to fetch and quote these bytes, they + // just must not treat the twin as a page in its own right. + const canonical = canonicalUrl(target ? `docs/${target}` : 'docs') return new Response(markdown, { - headers: { 'content-type': 'text/markdown; charset=utf-8' }, + headers: { + 'content-type': 'text/markdown; charset=utf-8', + link: `<${canonical}>; rel="canonical"`, + }, }) } diff --git a/apps/landing/src/app/docs/[[...slug]]/page.tsx b/apps/landing/src/app/docs/[[...slug]]/page.tsx index 44cb304e2..0e44d0458 100644 --- a/apps/landing/src/app/docs/[[...slug]]/page.tsx +++ b/apps/landing/src/app/docs/[[...slug]]/page.tsx @@ -8,6 +8,7 @@ import { DocsToc } from '@/components/docs/DocsToc' import { DocsHome } from '@/components/docs/DocsHome' import { DocsPageNav } from '@/components/docs/DocsPageNav' import { DocsCopyPage } from '@/components/docs/DocsCopyPage' +import { DocsStructuredData } from '@/components/docs/DocsStructuredData' import { canonicalUrl, siteUrl } from '@/lib/site-url' // content/docs is edited on the canonical master branch on GitHub. @@ -33,11 +34,22 @@ export async function generateMetadata(props: { const description = page.data.description const url = canonicalUrl(slug?.length ? `docs/${slug.join('/')}` : 'docs') const image = `${siteUrl()}/img/social-card.png` + // The raw-markdown twin is an alternate REPRESENTATION of this page, not a + // second page. Declaring it renders , which is how an agent finds the token-cheap copy + // without us needing it to be indexed in its own right (the route answers + // with a `Link: …; rel="canonical"` header pointing back here). + const markdownUrl = `${siteUrl()}/docs-md/${ + slug?.length ? `${slug.join('/')}/` : '' + }` return { title, description, - alternates: { canonical: url }, + alternates: { + canonical: url, + types: { 'text/markdown': markdownUrl }, + }, openGraph: { title, description, @@ -80,8 +92,27 @@ export default async function DocsPage(props: { with the copy button, so no extra bottom margin here. */}
- + {/* The Copy button fetches the twin from JS; this anchor is + the crawlable path to the same bytes — a link an agent + (or a person who wants the markdown) can actually + follow. Both point at the slashed canonical URL. */} +
+ {/* prose-code:before/after content-none: the typography plugin's default renders literal backtick glyphs around inline code; the chip styling replaces them, scoped via diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css index 050d57a57..59f4f2f75 100644 --- a/apps/landing/src/app/globals.css +++ b/apps/landing/src/app/globals.css @@ -26,6 +26,68 @@ html { scroll-behavior: smooth; } + +/* ── Hero entrance — CSS only, never JS ─────────────────────────── + The hero copy (badge, H1, subtitle, CTAs, install box) used to enter + through framer-motion `initial="hidden"`, which means the SERVER HTML + ships it at opacity 0 and it only becomes visible once hydration has + finished. On a throttled phone that was 92% of a 10.5 s LCP, all of it + "render delay" — the text was in the HTML the whole time, just painted + transparent. A CSS animation runs off the server HTML instead, so the + first paint already carries the copy. + Stagger with an inline `--hero-delay`; keep every delay short, because + the element is invisible until its delay elapses (fill-mode `both`) and + the subtitle is the LCP element. */ +@keyframes hero-rise { + from { + opacity: 0; + transform: translate3d(0, 18px, 0); + } + to { + opacity: 1; + transform: none; + } +} + +/* Transform-only twin, for the H1 and the subtitle. An element at opacity 0 + has not painted, so fading in the LCP text pushes LCP out by the fade's + delay AND duration — measured at ~+1.9 s on throttled mobile even though the + text was in the server HTML the whole time. These elements are therefore + painted at full opacity from the first frame and only slide into place. */ +@keyframes hero-lift { + from { + transform: translate3d(0, 12px, 0); + } + to { + transform: none; + } +} + +.hero-rise { + animation: hero-rise 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +.hero-lift { + animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +/* Per-word variant for the H1 (the old BlurText). Words are plain text nodes + either way — this only slides them in. */ +.hero-word { + display: inline-block; + animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +@media (prefers-reduced-motion: reduce) { + .hero-rise, + .hero-lift, + .hero-word { + animation: none; + } +} body { background: var(--bg-base); color: var(--fg-base); diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx index bd1bfaac8..c2913e6c9 100644 --- a/apps/landing/src/app/layout.tsx +++ b/apps/landing/src/app/layout.tsx @@ -10,6 +10,7 @@ import { Providers } from '@/components/providers' import { PostHogProvider } from '@/components/posthog-provider' import Navbar from '@/components/Navbar' import Footer from '@/components/Footer' +import EntityStructuredData from '@/components/StructuredData/EntityStructuredData' const geistSans = Geist({ variable: '--font-geist-sans', @@ -35,7 +36,7 @@ export const metadata: Metadata = { title: siteConfig.title, description: siteConfig.tagline, openGraph: { - title: 'upup – One File Uploader for Every Framework', + title: 'upup – Open-Source File Uploader for Every Framework', description: 'One open-source file uploader with a headless core and native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact. Cloud drives, camera, screen capture, and secure server-mode uploads to any S3-compatible storage. MIT-licensed.', images: [`${siteUrl()}/img/social-card.png`], @@ -45,7 +46,7 @@ export const metadata: Metadata = { }, twitter: { card: 'summary_large_image', - title: 'upup – One File Uploader for Every Framework', + title: 'upup – Open-Source File Uploader for Every Framework', description: 'One uploader, native UI for React, Vue, Svelte, Angular, Vanilla JS & Preact. Headless core, cloud drives, and secure server-mode uploads to any S3-compatible storage. Open-source, MIT.', images: [`${siteUrl()}/img/social-card.png`], @@ -152,11 +153,20 @@ export default function RootLayout({ })(); `} + {/* Organization + WebSite JSON-LD. It lives in the ROOT + layout on purpose: the entity graph has to be on every + page — the 64 docs pages are the bulk of the indexable + surface and they mount no page-level . */} + {process.env.NODE_ENV === 'production' && ( <> + {/* lazyOnload: Hotjar is session-recording, never + needed for the page to work, and on a throttled + phone its loader competed with hydration for the + main thread. */}