contractId and the fold

The fold turns a declared name, the name a publisher's source gives the component, into its contractId. Same input, same output, on every machine and in every locale. No guessing. No transliteration. No silent merges.

InputThe declared name, as a sequence of Unicode code points
OutputAn identifier matching ^[a-z0-9]+(-[a-z0-9]+)*$, or a rejection
Applied byPublisher, at publish (SR-130)
LocaleThe result does not depend on host locale or Unicode version
LengthNo maximum. The fold never truncates.

Fold steps

The five steps apply in this order.

Step 1: lowercase ASCII letters #

Lowercase the ASCII letters A to Z only. Apply no other case mapping and no Unicode normalization, before, during, or after.

ICON       → icon
Café       → café     (é is unchanged)

Step 2: replace separator runs #

Replace every run of one or more characters from the separator class with a single hyphen.

SeparatorCharacterCode point
Space U+0020
Underscore_U+005F
Period.U+002E
Forward slash/U+002F

These four characters are the entire separator class. The hyphen is not in the class. Case changes are not separators: the fold does not hyphenate at case boundaries.

button_primary  → button-primary
forms/button    → forms-button
card-_header    → card--header

Step 3: collapse hyphen runs #

Collapse every run of two or more hyphens to a single hyphen.

button--primary → button-primary
card--header    → card-header

Step 4: trim hyphens #

Remove leading and trailing hyphens.

-       → (empty)

Step 5: change nothing else #

No other transformation applies. The fold does not truncate and imposes no maximum length. A 300-character declared name of ASCII letters folds to a 300-character identifier.

Classification of the result

Result after step 5OutcomeReason stringSpec
EmptyRejectedempty after foldSR-131
Contains any character outside a to z, 0 to 9, and hyphenRejectedcarries residual characterSR-132
OtherwiseThe result is the identifierNoneSR-130

A residual character is never transliterated, stripped, or decomposed. The rejection output names the offending character. Café is rejected with reason carries residual character, naming é; it never becomes cafe.

Worked examples

Each column shows the value after that step. "(same)" means the step made no change.

Declared nameStep 1Step 2Step 3Step 4Outcome
Button_Primarybutton_primarybutton-primary(same)(same)button-primary
Button Primarybutton primarybutton-primary(same)(same)button-primary
Button.Primarybutton.primarybutton-primary(same)(same)button-primary
Button--primarybutton--primary(same)button-primary(same)button-primary
card-_header(same)card--headercard-header(same)card-header
forms/Buttonforms/buttonforms-button(same)(same)forms-button
iconBadgeiconbadge(same)(same)(same)iconbadge
ICONicon(same)(same)(same)icon
Cafécafé(same)(same)(same)Rejected: carries residual character (é)
___(same)-(same)(empty)Rejected: empty after fold
/(same)-(same)(empty)Rejected: empty after fold
A single space(same)-(same)(empty)Rejected: empty after fold

Collisions

Scope #

Uniqueness
contractId is unique within a manifest
Covers
Every declared name that contributes to that manifest, whatever its source kind
Does not cover
Previously published manifests. A contractId present in an earlier publish and absent now is not a collision.
Severity
Hard failure at publish. Never a silent merge.
Spec
SR-133

Two or more declared names that fold to the same identifier are a collision. A name from a Custom Elements Manifest (CEM) and a name from a React source collide exactly as two names in one file would.

Reporting #

  • One collision report per identifier. The report names the identifier and every declared name that folded to it.
  • The publisher writes no contract for any of the colliding names.
  • A publish collects every rejection and every collision and reports them together. It does not stop at the first finding.
Example
Declared namesFold resultOutcome
Button Primary, button_primary, Button--Primarybutton-primary (all three)One collision report for button-primary, naming all three declared names. No contract is written for any of them.

Combined publish example #

A single publish with the declared names Café, ___, Button Primary, button_primary, and Button--Primary reports three findings together:

  • Café: rejected, carries residual character, naming é
  • ___: rejected, empty after fold
  • button-primary: one collision naming Button Primary, button_primary, and Button--Primary

Declared name

Derivation profile #

v0.1 defines no derivation profile. Which source identifier counts as the declared name (for example a React export name, a CEM tagName, or a design-tool name) is left to the publisher's own documentation, stated per source kind.

The specification recommends (SHOULD, not a requirement) that a publisher fold the same source identifier for a component on every publish, so contractId stays stable across publishes.

DSDS compatibility

Every fold result is a subset of the DSDS (Design System Documentation Specification) component id pattern:

contractId pattern:  ^[a-z0-9]+(-[a-z0-9]+)*$
DSDS id pattern:     ^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)*$

Every valid contractId is therefore a valid DSDS id. The reverse does not hold: a DSDS id can contain a period, and a contractId cannot.

What the schema checks

The component contract schema checks only that contractId matches ^[a-z0-9]+(-[a-z0-9]+)*$. It does not check that the value is the fold of a declared name, and it does not detect collisions. Both are publisher obligations. This repository ships no checker tool for them.