Skip to content

fix(scripts): stop the frontmatter field regex backtracking quadratically - #1206

Merged
BryanFRD merged 1 commit into
mainfrom
fix/frontmatter-regex-backtracking
Sep 27, 2026
Merged

BryanFRD merged 1 commit into
mainfrom
fix/frontmatter-regex-backtracking

Conversation

@BryanFRD

Copy link
Copy Markdown
Contributor

Closes #1205.

-const FIELD = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/;
+const FIELD = /^([A-Za-z_]\w*):(.*)$/;

and the value is trimmed with String.prototype.trim() where it is stored.

The old pattern had two ways to consume the same whitespace: the lazy (.*?) and the trailing \s*$. On a run of spaces followed by anything else, each one-character extension of .*? sent \s* across the rest of the run before $ failed, so the work grew with the square of the run. The new pattern has one quantifier after the colon and nothing competing with it, and the trimming moves to code that cannot backtrack.

It is only a safe rewrite because of how the function is called: the frontmatter block is split on \r?\n first, so FIELD only ever sees a single line and .* runs straight to $. \w without the u flag is exactly [A-Za-z0-9_], and trim() strips the same set \s matches, including the no-break space and the BOM.

Verified

  • a:x + n spaces + y: 11, 48 and 176 ms at n = 5000, 10000 and 20000 before (doubling n quadruples it), 0.0 ms after at every size.
  • The old and new parse compared on 200,000 random lines built from letters, digits, _, :, spaces, tabs,  , quotes and non-ASCII: no difference.
  • The same comparison on every real page under docs/site/: 169 files, identical frontmatter.
  • node scripts/validate-site-docs.mjs: 169 pages validated, as before.

The same function also exists in a local FerrFlow-Docs checkout, but that repository no longer exists on GitHub: the docs are back under docs/site/ here and reach the site through @ferrflow/doc. Nothing to fix there.

@BryanFRD
BryanFRD enabled auto-merge (squash) September 27, 2026 17:23

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix holds. Two quantifiers competing for the same whitespace run is exactly the shape that goes quadratic, and moving the trimming into trim() removes the competition rather than just shifting it.

Equivalence checks I confirmed independently:

  • \w without u or i is exactly [A-Za-z0-9_], so the key group is unchanged.
  • String.prototype.trim() strips WhiteSpace plus LineTerminator, which is the same set regex \s matches, U+00A0 and U+FEFF included.
  • Trimming still happens before the quote stripping, so title: "x" still yields x.
  • FIELD has one call site, on lines already split by /\r?\n/, so the single-line assumption the rewrite relies on is actually enforced.

The pre-existing replace(/^['"]|['"]$/g, '') still strips an unpaired quote (title: "x gives x), but that is untouched by this PR and not something to fix here.

One nit inline about a trailing-character case where (.*) is slightly stricter than the old pattern. Not blocking.


const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
const FIELD = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/;
const FIELD = /^([A-Za-z_]\w*):(.*)$/;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: (.*)$ is not quite equivalent to the old \s*(.*?)\s*$, because . excludes \r, 
 and 
 while \s includes the first two. On a line ending in one of those (title:a\r), the old pattern matched and stored a; the new one cannot match at all, so the field silently disappears and validation reports a missing title.

Not reachable through the current call path, as far as I can tell: the block is split on /\r?\n/, and a CR-only file never matches FRONTMATTER in the first place, so it takes something like a doubled \r\r\n to get there. Still, [\s\S] costs nothing and is just as linear (greedy, runs straight to the end, $ matches in one step):

Suggested change
const FIELD = /^([A-Za-z_]\w*):(.*)$/;
const FIELD = /^([A-Za-z_]\w*):([\s\S]*)$/;

@BryanFRD
BryanFRD merged commit 54e76dd into main Sep 27, 2026
31 checks passed
@BryanFRD
BryanFRD deleted the fix/frontmatter-regex-backtracking branch September 27, 2026 17:26
@BryanFRD

Copy link
Copy Markdown
Contributor Author

Right, and it contradicted my own "no difference" claim: the alphabet I fuzzed had no line terminator in it. Rerun with CR, U+2028 and U+2029 included: (.*) loses the field on 1338 of 400,000 lines, all with the terminator in surrounding whitespace, while [\s\S]* never loses one and only differs where the terminator is inside the value, where it now keeps a field the original dropped by accident. This merged before I could push, so it is #1208.

ferrflow Bot added a commit that referenced this pull request Sep 27, 2026
## [7.26.8] - 2026-09-27

### Bug Fixes

- fix(scripts): keep a frontmatter field whose value ends in a line terminator (#1208)
- fix(scripts): stop the frontmatter field regex backtracking quadratically (#1206)
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.

fix(scripts): the frontmatter field regex backtracks quadratically

1 participant