semantic-selector
v0.1.1
Published
Generate a semantic, identity-only CSS selector for a DOM element — ranks each ancestor by meaningful identity (id/url/name/class/ARIA), never by position; resolves ambiguity out-of-band rather than via positional ordinals
Maintainers
Readme
A semantic selector (not a unique one)
This project aims to generate a semantic or 'meaningful' CSS selector for a DOM element. We prefer to use good class names, ids, and certain attributes over DOM position / structure to give a greater chance that the selector will still point to the same element(s) even if the DOM content is shifted around and the surrounding page has been updated over time. The library is picky and detects and demotes common framework-generated and presentational classes which are assumed to be more often swapped in and out to change the appearance or position of an element.
It does not guarantee that it will generate a unique selector across all the elements on the page.
Selector Uniqueness: on-page vs. between page versions
Selector uniqueness is often achieved in other libraries with positional ordinals (:nth-of-type, :nth-child) and/or over specification of DOM structure (div > span > div). This library deliberately avoids that, and instead focuses only on generating a good selector. The uniqueness constraint means that the choice of selector is dictated by other elements which may only be present in the current version of the page. Other libraries have to continue iterating, adding incidental noise to fabricate a distinction in order to satisfy the uniqueness constraint.
Instead we let the calling code decide on how to distinguish between multiple elements if required, e.g. by also recording element dimensions, or by recording that the target element is the 2nd on the page (a global 'nth' positional in terms of document.querySelectorAll instead of a brittle local 'nth' somewhere in the selector).
Install
npm install semantic-selectorUsage
import { semanticSelector } from 'semantic-selector';
const el = document.querySelector('.buy-button')!;
semanticSelector(el); // relative to document.body (default)
semanticSelector(el, someRoot); // relative to a given rootsemanticSelector(el: Element, root: Element = document.body): stringBrowser (UMD global semanticSelector):
<script src="https://unpkg.com/semantic-selector"></script>
<script>
semanticSelector(document.querySelector('#target'));
</script>What you get back
semanticSelector returns a plain string — the selector.
Given the following markup (two identical buy buttons):
<main>
<section class="wp-block-group product-card">
<a class="wp-block-button__link buy-button" href="/checkout?utm_source=x">Buy now</a>
</section>
<section class="wp-block-group product-card">
<a class="wp-block-button__link buy-button" href="/checkout?utm_source=y">Buy now</a>
</section>
</main>const el = document.querySelectorAll('.buy-button')[1];
semanticSelector(el);
// → '.product-card a[href^="/checkout"]'Note what happened: the framework classes (wp-block-group,
wp-block-button__link) and the volatile ?utm_source=… query were dropped, the
semantic .product-card ancestor was kept, and — because both buttons in this toy example can be considered to have the same identity — the result deliberately matches both (no:nth-of-type inserted to force uniqueness).
Since the selector is not guaranteed unique, the caller resolves any residual ambiguity out-of-band by pairing it with a match index + count (see Selector Uniqueness):
const selector = semanticSelector(el);
const matches = Array.from(document.querySelectorAll(selector));
const result = {
selector, // → '.product-card a[href^="/checkout"]'
selectorMatchIndex: matches.indexOf(el), // → 1 (0-based position among matches)
selectorMatchCount: matches.length, // → 2 (total elements this matches)
};To relocate the element later, re-run the selector against the new page and take
the element at selectorMatchIndex (optionally cross-checked against recorded
geometry).
How it ranks identity
Per element, best → worst:
- a strong own id (stops the walk)
- url —
href/src, with volatile query/hash stripping - a form control's name — the backend submission key
- class and ARIA, interleaved by quality:
tier-A class > aria-label > tier-B class > role > tier-C class > rel - a stable id in the element's subtree —
:has(#id) - a stable id on the immediately preceding sibling —
#prev + tag
Class quality tiers: A = semantic/component (entry-content, product-card),
B = framework-namespaced (wp-…, elementor…, Mui…), C = utility/atomic
(Bootstrap grid, spacing helpers). The best-tier class is chosen (not the first
in DOM order), and a low-quality class loses to an explicit aria-label.
Structural noise is dropped. Only the clicked element keeps its tag; ancestor
tags are stripped (#nav a[href="/x"], not nav#nav > ul > li > a…). Ancestors
with no identity are omitted entirely, and a redundant low-quality class
ancestor (one whose removal doesn't grow the match set) is pruned — so semantic
context and strong-id anchors survive, framework wrappers don't.
Generated values are rejected for both ids and classes: ember, React
useId, Radix, MUI, Headless UI, Angular Material/CDK, uuid/hex hashes,
styled-components, emotion, CSS-module hashes, state classes, and over-long
identifiers.
Comparison
finder is the best-in-class jumping off point for this library; it has as it's main goals uniqueness and brevity, both important, but not what we're aiming for here.
stable-selector addresses the same problem but is still strongly weighted towards finding a unique selector in the current document, whereas semantic-selector aims to produce a selector which will still point to the same element in future versions of the document.
semantic-selector deliberately avoids structure and position.
| | semantic-selector | finder (antonmedv) | stable-selector (qaz1230sp) |
| --------------------------- | ------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------------------------------------------- |
| Goal | Semantic, long term 'identity' of an element between page versions | Shortest unique selector | Unique, stable selector |
| Selection | Fixed-priority ladder | Penalty search for shortest unique | 4-dimension weighted scoring (uniqueness 0.4, stability 0.35, brevity, readability) |
| Selector on-page uniqueness | Not required → caller computes matchIndex + count out-of-band | Required — keeps searching | Required — scored down; structural fallback forces it |
| Positional ordinals | never in selector itself (see matchIndex) | :nth-child when needed | :nth-of-type when needed |
| Combinators | Descendant | Descendant | Direct child > |
| Ancestor structure | Identity-only; tags stripped; redundant low-quality pruned | Minimal unique path | Path up to maxDepth, nth-enriched |
| Value filtering | Reject-lists for known frameworks | wordLike (rejects digits/short names) | 3 layers: built-in patterns + Shannon-entropy heuristic + user blacklist |
| Class quality | A/B/C semantic tiers, best chosen | first N matching classes | stable classes (up to 3), no semantic tier |
| Output | CSS | CSS | CSS + XPath + Playwright |
| Config | (el, root) only | predicates + threshold | extensive (configure(), priorities, blacklist, formats, maxDepth) |
The trade-off is about when the selector is used:
- finder / stable-selector optimise for a unique locator against the DOM in
front of you now (scraping, a Playwright test run). Uniqueness is paramount, and
nthis fine because the DOM won't move under you mid-session. - semantic-selector optimises for drift between capture and retrieval. Because
out-of-band
matchIndexcan resolve ambiguity, we can let go of the uniqueness requirement and produce a more semantic selector that has a higher likelihood of surviving long term page restructuring or even a swap out of the framework used to produce the page.
License
MIT © Eoghan Murray
