Skip to content

feat: Netlify website and HTTP API, Node 22 floor - #9

Open
brianfunk wants to merge 19 commits into
feat/v1.1.0-hardeningfrom
feat/netlify-site-api
Open

brianfunk wants to merge 19 commits into
feat/v1.1.0-hardeningfrom
feat/netlify-site-api

Conversation

@brianfunk

@brianfunk brianfunk commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Stacked on #8. Adds the fun face (website) and the serious face (HTTP API) of capstring, in this repo, with zero build step. Also raises the Node floor to 22.12 so the test toolchain can move to vitest 5 / eslint 10 and clear every Dependabot alert.

Website (web/)

  • Textarea in, every style out, grouped by category. Click a row to copy, toast confirms.
  • Chain builder: press + on any row to compose up to 10 styles; result, copy button, and /api/chain curl update live. Shareable as ?chain=upper+reverse.
  • Shareable ?t= URLs, dark mode, mobile layout, Open Graph + Twitter meta, 404 page, version in footer from /api.
  • No frameworks, no analytics. Imports ./capstring.js, which the Netlify build copies from index.js.

API (netlify/functions/api.js, one Functions 2.0 handler owning /api/*)

  • GET /api/styles, GET /api/:style/:text, GET /api/all/:text, GET /api/chain/:styles/:text, POST /api/batch
  • Ported from cAPIta: GET /api/badge/:style/:text (shields-style SVG, ?label=), GET /api/lorem/:count?style=, GET /api/spell/:text?style= (nspell + dictionary-en, lazy-loaded so other routes don't pay for it)
  • CORS on everything, Cache-Control public except for random and POST, ?format=txt, ?pretty=1, ?text= override
  • Limits: 2,000 chars, 100 texts per batch, 10 styles per chain, 1,000 lorem words. Structured { error: { code, message } } responses.

Dependencies: nspell and dictionary-en are devDependencies on purpose. esbuild bundles them into the function; the npm package stays zero-dependency. dictionary-en is marked external + included_files in netlify.toml because it reads its .aff/.dic files by path.

Node: engines >=22.12.0, CI matrix 22 and 24. Node 18 and 20 are end-of-life. Documented under Behavior changes in CHANGELOG.

Test plan

  • npm run lint clean (eslint 10)
  • npm run test:coverage 141 tests, 100% statements / branches / functions / lines on index.js, cli.js, api.js
  • npm audit 0 vulnerabilities
  • npm pack --dry-run still excludes web/ and netlify/
  • npm run dev: curled transform, slug, batch, spell (helo speling → Hello Spelling), badge (SVG), lorem (?style=kebab), 404 page, /capstring.js
  • Loaded the page in Chrome: grid rendered from ?t=, click-to-copy toast fired, curl line switched to the selected style, no console errors

After merging: netlify init in the Brian Funk team as site capstring, production branch master, branch deploys for dev.

- web/: static site with live results for every style, click to copy,
  shareable ?t= URLs, dark mode, no build step (imports index.js directly)
- netlify/functions/api.js: single Functions 2.0 handler for /api/*
  (styles, :style/:text, all, chain, batch) with CORS, caching, limits
- netlify.toml: publish web/, copy index.js at build, security headers
- Tests for the API handler via Request objects and a browser-safety guard
- Docs: README Try it online + HTTP API sections, CLAUDE.md, AGENTS.md
- Trigger CI for pull requests against any branch so stacked PRs get checks
- npm audit fix: clears 22 Dependabot alerts in dev-only transitive deps
… in range

npm audit fix had bumped vitest to 4.1 / vite 8, which requires Node 20.19+
and broke the Node 18 CI job. Revert that and update only the transitive
packages that have fixes within vitest 4.0.18's ranges (18 of 22 alerts).
The remaining 4 are in vite 7 / @vitest/mocker and are dev-only; they clear
when Node 18 support is dropped.
… polish

- Require Node 22.12+; CI matrix 22/24; vitest 5, eslint 10; zero audit findings
- API: GET /api/badge/:style/:text (SVG), /api/lorem/:count?style=,
  /api/spell/:text?style= (nspell + dictionary-en as devDependencies,
  bundled into the function; the npm package stays zero-dependency)
- Site: chain builder (+ on each row, ?chain= in the URL), Open Graph and
  Twitter meta tags, 404 page, version in footer from /api
- 141 tests, 100% coverage on index.js, cli.js, api.js
@brianfunk brianfunk changed the title feat: Netlify website and HTTP API feat: Netlify website and HTTP API, Node 22 floor Oct 2, 2026
- web: keep '+' in text intact in shareable URLs (only chain uses literal +)
- cli: flags may follow text ('capstring hello --all' no longer drops the text)
- api/spell: 500-char limit, at most 50 suggestions per request (reported as
  'limited'), dedupe case variants, uncache a failed dictionary load, log
  unexpected errors before the 500
- api/lorem: count must be plain digits (no hex, exponent, whitespace)
- api/badge: ?label= capped at 100 chars
- api: export RESERVED segments and test they never collide with STYLES
- index: tokenize lazily so non-code styles skip toWords
- vitest: testTimeout was nested under coverage and ignored
- CLAUDE.md: branch settings live in the Netlify UI, not netlify.toml
@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 073075b73e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread netlify.toml Outdated
command = "cp index.js web/capstring.js"
functions = "netlify/functions"
# Skip the build when nothing deployable changed (docs-only commits)
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- index.js web netlify netlify.toml package.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force uncached Netlify deploys to run

On an initial or cache-cleared Git deploy, Netlify sets CACHED_COMMIT_REF equal to COMMIT_REF, so this git diff --quiet exits 0 and cancels the build instead of running the copy and function-bundling steps. Netlify documents both the equal-ref behavior and that exit code 0 stops the build (environment variables, ignore commands); explicitly return 1 when the refs match before running the diff.

AGENTS.md reference: AGENTS.md:L63-L65

Useful? React with 👍 / 👎.

Comment thread package.json Outdated
"prepublishOnly": "npm run lint && npm run test:coverage"
"prepublishOnly": "npm run lint && npm run test:coverage",
"web:prep": "cp index.js web/capstring.js",
"dev": "npm run web:prep && netlify dev"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Install the CLI invoked by the dev script

On a fresh checkout, npm install does not provide a netlify executable because netlify-cli is absent from both dependencies and the lockfile, so npm run dev exits with netlify: not found unless contributors happen to have an unrelated global installation. Netlify's installation guide requires either a global install or adding netlify-cli as a development dependency; add the local dependency so the documented command is reproducible. Netlify CLI installation documentation

AGENTS.md reference: AGENTS.md:L65-L65

Useful? React with 👍 / 👎.

Comment thread web/app.js
Comment on lines +63 to +66
/** The curl command for the chain, the selected style, or all styles */
const curlFor = (text) => {
const encoded = encodeURIComponent(text);
if (chain.length) return `curl ${API}/chain/${chain.join('+')}/${encoded}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Shell-escape the generated curl URL

For input containing characters that encodeURIComponent leaves untouched, such as don't or foo(bar), the displayed unquoted command is invalid shell syntax (' opens an unmatched quote and parentheses are parsed by the shell). Since this value is explicitly offered through “Copy curl,” percent-encode the remaining shell metacharacters or safely quote the entire URL before presenting it.

Useful? React with 👍 / 👎.

Comment thread netlify/functions/api.js Outdated
Comment on lines +256 to +260
if (corrections.length >= MAX_SPELL_SUGGESTIONS) {
limited = true;
return word;
}
const [suggestion] = checker.suggest(word);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count spellcheck attempts rather than successful corrections

When checker.suggest(word) returns no suggestion, corrections.length never increases, so the advertised limit does not bound the expensive operation at all. For example, repeating the test's known-unsuggestable xqzjvwpk within the 500-character limit invokes suggest() for every occurrence without ever reaching this guard; track suggestion attempts separately so malformed public requests cannot bypass the intended CPU bound.

AGENTS.md reference: AGENTS.md:L64-L64

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T05:00:56.065956Z a00acc6 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

- netlify.toml: force the build when CACHED_COMMIT_REF equals COMMIT_REF
  (first or cache-cleared deploys) instead of skipping it
- npm run dev uses npx netlify-cli so it works without a global install
- web: percent-encode shell metacharacters in the generated curl line
- api/spell: the 50-suggestion budget counts attempts, so unsuggestable
  words can no longer bypass the CPU bound
@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5501475b1e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
"@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^5.0.3",
"dictionary-en": "^4.0.0",
"eslint": "^10.11.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match ESLint's Node 22.13 development floor

On Node 22.12.x—which the package's engines range and documentation explicitly support—an engine-strict npm ci fails because the locked ESLint 10.11.0 and several of its packages require ^22.13.0 (see package-lock.json around lines 983-1028). Either retain an ESLint version supporting 22.12 or raise and consistently document the minimum development Node version.

Useful? React with 👍 / 👎.

Comment thread netlify/functions/api.js Outdated
if (head === 'styles') {
requireMethod(req, 'GET');
noExtraSegments(rest);
return respond({ count: STYLES.length, styles: STYLES, categories: CATEGORIES }, { pretty });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the requested text format for the styles route

For GET /api/styles?format=txt, getFormat() accepts txt, but this direct respond() call omits the parsed format and therefore still returns JSON with an application/json content type. This contradicts the documented global ?format=txt behavior; provide an appropriate plain-text representation here or explicitly reject/limit that option for this endpoint.

Useful? React with 👍 / 👎.

@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0aae7367a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread netlify.toml Outdated
functions = "netlify/functions"
# Skip the build when nothing deployable changed (docs-only commits)
# Netlify sets CACHED_COMMIT_REF == COMMIT_REF on first or cache-cleared deploys; exit 1 forces the build then
ignore = "test \"$CACHED_COMMIT_REF\" = \"$COMMIT_REF\" && exit 1; git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- index.js web netlify netlify.toml package.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild when the dependency lockfile changes

When a commit changes only package-lock.json—for example, a Dependabot transitive dependency update—this path-limited command sees no difference and Netlify skips the build, leaving the deployed function on the old locked dependencies. Git documents that --quiet implies --exit-code, returning 0 when the selected paths have no differences (git-diff options); include package-lock.json among the deployable paths.

Useful? React with 👍 / 👎.

Comment thread netlify/functions/api.js Outdated
if (input.length > MAX_TEXT) return { input, output: null, error: 'text_too_long' };
return { input, output: capstring(input, style) };
});
return respond({ style, count: results.length, results }, { pretty, cache: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor text formatting in batch responses

When a client sends POST /api/batch?format=txt, getFormat() accepts the requested format, but this direct respond() call omits it and always returns JSON with application/json. That contradicts the global plain-text behavior advertised in web/index.html:55 and README.md:220; either serialize batch results as text and pass format, or explicitly reject txt for this endpoint.

Useful? React with 👍 / 👎.

@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 44b3c4efaa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread netlify/functions/api.js Outdated
if (styles.length > MAX_CHAIN) throw new ApiError(400, 'chain_too_long', `Chain at most ${MAX_CHAIN} styles.`);
styles.forEach(getStyle);
const text = getText(textSegments, query);
const output = styles.reduce((acc, s) => capstring(acc, s), text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound intermediate chain output

A chain of expanding styles can exhaust the function's memory even though the input and chain length pass their limits. For example, applying binary five times to a permitted 2,000-character input produces about 118 MB, while subsequent iterations exceed a gigabyte and continue toward the allowed ten styles. Enforce a size limit after each reduction step, rather than bounding only the initial text and number of styles, so a single public request cannot crash the function.

Useful? React with 👍 / 👎.

Comment thread web/app.js

const params = new URLSearchParams(location.search);
if (params.get('t')) input.value = params.get('t');
chain = (params.get('chain') ?? '').split(/[+,\s]/).filter((s) => STYLES.includes(s)).slice(0, MAX_CHAIN);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound chain expansion when loading shared URLs

Because shared URLs may supply ten arbitrary valid styles, visiting a URL whose chain repeats binary can synchronously allocate enormous strings during the initial render(). With a 2,000-character t value, five binary entries already produce about 118 MB and a sixth exceeds 1 GB, so a crafted share link can freeze or terminate the visitor's tab. Limit intermediate output size or reject repeated expanding transformations before rendering the restored chain.

Useful? React with 👍 / 👎.

@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02d6837d6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/app.js Outdated
}
});

document.getElementById('chain-copy').addEventListener('click', () => copy(chainResult(), `chain: ${chainResult()}`));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy the rendered chain result

When a chain contains random, clicking “Copy result” calls chainResult() twice instead of copying chainOut, so the clipboard value can differ from both the visible output and the value named in the toast. Compute the result once—or copy chainOut.textContent—so users receive the result they selected.

Useful? React with 👍 / 👎.

Comment thread netlify/functions/api.js
Comment on lines +307 to +309
const body = format === 'txt'
? STYLES.map((s) => `${s}\t${results[s]}`).join('\n')
: { input: text, count: STYLES.length, results };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape multiline values in all-style text output

For valid input containing CR/LF, styles such as same, title, and upper preserve those line breaks, so inserting results directly into this newline-delimited format destroys the one-row-per-style structure. For example, /api/all/a%0Ab?format=txt returns 58 lines for 37 styles, making the output ambiguous to consumers; escape embedded line breaks as the CLI table does or use an unambiguous framing format.

Useful? React with 👍 / 👎.

Comment thread netlify/functions/api.js
Comment on lines +393 to +395
const payload = format === 'txt'
? results.map((r) => r.output ?? '').join('\n')
: { style, count: results.length, results };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve batch record boundaries for multiline outputs

Although the batch route now forwards format=txt, the new newline join is ambiguous when a valid input itself transforms to multiline text. For example, two same inputs ['a\nb', 'c'] produce three apparent output lines, violating the route's intended one-output-line-per-text behavior; escape embedded CR/LF or use a framing format that preserves item boundaries.

Useful? React with 👍 / 👎.

@brianfunk

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a00acc6148

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread netlify/functions/api.js Outdated
const variants = new Set([word, word.toLowerCase(), word.toUpperCase(), word[0].toUpperCase() + word.slice(1).toLowerCase()]);
return [...variants].some((variant) => checker.correct(variant));
};
const output = text.replace(/[A-Za-z]+/g, (word) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve contractions as single spellcheck tokens

For correctly spelled contractions, the regex checks each alphabetic fragment instead of the dictionary entry containing the apostrophe. For example, 51 repetitions of isn't fit within the 500-character limit but cause isn to be treated as an unknown word each time, exhausting the suggestion budget, setting limited, potentially rewriting correct text, and leaving a real typo later in the request unchecked. Include internal straight and typographic apostrophes in each word token.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant