Skip to content

Commit 449ed44

Browse files
committed
feat(implementationStatus): add spec-level browser availability badge
New module that shows a Baseline browser availability badge in the spec front matter (e.g. "Limited availability [icon]:"), driven by web-features data from web-platform-dx. Config: `respecConfig.implementationStatus` accepts `true` (auto-detect from spec URLs), a feature ID string, or an options object. Features: - Auto-detects features by matching spec URLs against web-features data - Shows browser icons grouped by engine in rounded pills - Uses official Baseline SVG icons from web-platform-dx/baseline-status - Uses official support indicator SVGs (check/x with arc) - Aggregates multi-feature specs using worst-of semantics - removeOnSave collapses to a static link - Dark mode and print support
1 parent 2fec32d commit 449ed44

File tree

6 files changed

+697
-0
lines changed

6 files changed

+697
-0
lines changed

examples/baseline.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset='utf-8'>
5+
<script src='../profiles/w3c.js' type='module' class='remove'></script>
6+
<script class='remove'>
7+
var respecConfig = {
8+
specStatus: "ED",
9+
group: "das",
10+
shortName: "vibration",
11+
edDraftURI: "https://w3c.github.io/vibration/",
12+
editors: [{
13+
name: "Test Editor",
14+
}],
15+
// Try these different values to see each status:
16+
// "accelerometer" = Limited availability (orange)
17+
// "grid" = Widely available (green)
18+
// "container-queries" = Newly available (blue)
19+
implementationStatus: "accelerometer",
20+
xref: "web-platform",
21+
};
22+
</script>
23+
</head>
24+
<body>
25+
<h1 id="title">Baseline Badge Example</h1>
26+
<section id='abstract'>
27+
<p>
28+
This example demonstrates the Baseline browser availability badge.
29+
It auto-detects from the <code>edDraftURI</code> or you can set
30+
<code>baseline: "feature-id"</code> explicitly.
31+
</p>
32+
</section>
33+
<section id='sotd'>
34+
<p>
35+
This is an example document to test the baseline badge feature.
36+
</p>
37+
</section>
38+
<section>
39+
<h2>Configuration Options</h2>
40+
<p>The <code>implementationStatus</code> config option accepts:</p>
41+
<ul>
42+
<li><code>true</code> — auto-detect from spec URLs</li>
43+
<li><code>"feature-id"</code> — explicit web-features ID</li>
44+
<li><code>{ feature: "id", removeOnSave: true }</code> — full options</li>
45+
</ul>
46+
</section>
47+
</body>
48+
</html>

profiles/w3c.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const modules = [
4242
import("../src/core/informative.js"),
4343
import("../src/core/id-headers.js"),
4444
import("../src/core/caniuse.js"),
45+
import("../src/core/implementation-status.js"),
4546
import("../src/core/mdn-annotation.js"),
4647
import("../src/ui/save-html.js"),
4748
import("../src/ui/search-specref.js"),

src/core/implementation-status.js

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
// @ts-check
2+
/**
3+
* Module: "core/implementation-status"
4+
* Adds an implementation status badge to the spec front matter,
5+
* showing Baseline browser availability from web-features data.
6+
*/
7+
import { docLink, fetchAndCache, showWarning } from "./utils.js";
8+
import { pub, sub } from "./pubsubhub.js";
9+
import css from "../styles/implementation-status.css.js";
10+
import { html } from "./import-maps.js";
11+
12+
export const name = "core/implementation-status";
13+
14+
const DATA_URL = "https://unpkg.com/web-features/data.json";
15+
const LOGO_BASE = "https://www.w3.org/assets/logos/browser-logos";
16+
17+
const ENGINES = new Map([
18+
["chrome", "Chrome"],
19+
["edge", "Edge"],
20+
["firefox", "Firefox"],
21+
["safari", "Safari"],
22+
]);
23+
24+
/** @type {Map<string|false, string>} */
25+
const STATUS_TEXT = new Map(
26+
/** @type {[string|false, string][]} */ ([
27+
["high", "Widely available"],
28+
["low", "Newly available"],
29+
[false, "Limited availability"],
30+
])
31+
);
32+
33+
/** @type {Map<string|false, () => HTMLElement>} */
34+
const BASELINE_ICONS = new Map(
35+
/** @type {[string|false, any][]} */ ([
36+
[
37+
false,
38+
() => html`<svg class="baseline-icon" viewBox="0 0 36 20" role="img">
39+
<title>Limited availability</title>
40+
<path fill="#f09409" d="M10 0L16 6L14 8L8 2L10 0Z" />
41+
<path fill="#f09409" d="M22 12L20 14L26 20L28 18L22 12Z" />
42+
<path fill="#f09409" d="M26 0L28 2L10 20L8 18L26 0Z" />
43+
<path fill="#c6c6c6" d="M8 2L10 4L4 10L10 16L8 18L0 10L8 2Z" />
44+
<path fill="#c6c6c6" d="M28 2L36 10L28 18L26 16L32 10L26 4L28 2Z" />
45+
</svg>`,
46+
],
47+
[
48+
"high",
49+
() => html`<svg class="baseline-icon" viewBox="0 0 36 20" role="img">
50+
<title>Widely available</title>
51+
<path fill="#1ea446" d="M18 8L20 10L18 12L16 10L18 8Z" />
52+
<path fill="#1ea446" d="M26 0L28 2L10 20L0 10L2 8L10 16L26 0Z" />
53+
<path
54+
fill="#c4eed0"
55+
d="M28 2L26 4L32 10L26 16L22 12L20 14L26 20L36 10L28 2Z"
56+
/>
57+
<path fill="#c4eed0" d="M10 0L2 8L4 10L10 4L14 8L16 6L10 0Z" />
58+
</svg>`,
59+
],
60+
[
61+
"low",
62+
() => html`<svg class="baseline-icon" viewBox="0 0 36 20" role="img">
63+
<title>Newly available</title>
64+
<path
65+
fill="#a8c7fa"
66+
d="m10 0 2 2-2 2-2-2 2-2Zm4 4 2 2-2 2-2-2 2-2Zm16 0 2 2-2 2-2-2 2-2Zm4 4 2 2-2 2-2-2 2-2Zm-4 4 2 2-2 2-2-2 2-2Zm-4 4 2 2-2 2-2-2 2-2Zm-4-4 2 2-2 2-2-2 2-2ZM6 4l2 2-2 2-2-2 2-2Z"
67+
/>
68+
<path fill="#1b6ef3" d="m26 0 2 2-18 18L0 10l2-2 8 8L26 0Z" />
69+
</svg>`,
70+
],
71+
])
72+
);
73+
74+
const SUPPORT_ICONS = {
75+
available: () => html`<svg
76+
class="baseline-support-icon"
77+
xmlns="http://www.w3.org/2000/svg"
78+
width="17"
79+
height="21"
80+
fill="none"
81+
aria-hidden="true"
82+
>
83+
<path
84+
fill="currentColor"
85+
d="M1.253 3.31a8.843 8.843 0 0 1 5.47-1.882c4.882 0 8.838 3.927 8.838 8.772 0 4.845-3.956 8.772-8.837 8.772a8.842 8.842 0 0 1-5.47-1.882c-.237.335-.49.657-.758.966a10.074 10.074 0 0 0 6.228 2.14c5.562 0 10.07-4.475 10.07-9.996 0-5.52-4.508-9.996-10.07-9.996-2.352 0-4.514.8-6.228 2.14.268.309.521.631.757.966Z"
86+
/>
87+
<path
88+
fill="currentColor"
89+
d="M11.348 8.125 6.34 13.056l-3.006-2.954 1.002-.985 1.999 1.965 4.012-3.942 1.002.985Z"
90+
/>
91+
</svg>`,
92+
unavailable: () => html`<svg
93+
class="baseline-support-icon"
94+
xmlns="http://www.w3.org/2000/svg"
95+
width="17"
96+
height="21"
97+
fill="none"
98+
aria-hidden="true"
99+
>
100+
<path
101+
fill="currentColor"
102+
d="M1.254 3.31a8.843 8.843 0 0 1 5.47-1.882c4.881 0 8.838 3.927 8.838 8.772 0 4.845-3.957 8.772-8.838 8.772a8.842 8.842 0 0 1-5.47-1.882c-.236.335-.49.657-.757.966a10.074 10.074 0 0 0 6.227 2.14c5.562 0 10.071-4.475 10.071-9.996 0-5.52-4.509-9.996-10.07-9.996-2.352 0-4.515.8-6.228 2.14.268.309.52.631.757.966Z"
103+
/>
104+
<path
105+
fill="currentColor"
106+
d="m10.321 8.126-1.987 1.972 1.987 1.972-.993.986-1.987-1.972-1.987 1.972-.993-.986 1.986-1.972-1.986-1.972.993-.986 1.987 1.972L9.328 7.14l.993.986Z"
107+
/>
108+
</svg>`,
109+
};
110+
111+
const ENGINE_GROUPS = [
112+
{ engines: ["chrome", "edge"] },
113+
{ engines: ["firefox"] },
114+
{ engines: ["safari"] },
115+
];
116+
117+
function fallbackResult() {
118+
return {
119+
dt: html`Implementation status:`,
120+
dd: html`<a href="https://webstatus.dev/">Web Platform Status</a>`,
121+
};
122+
}
123+
124+
export function prepare(conf) {
125+
if (!conf.implementationStatus) return;
126+
normalizeConf(conf);
127+
document.head.appendChild(
128+
html`<style id="baseline-stylesheet">
129+
${css}
130+
</style>`
131+
);
132+
}
133+
134+
export async function run(conf) {
135+
if (!conf.implementationStatus) return;
136+
137+
const options = conf.implementationStatus;
138+
const headDlElem = document.querySelector(".head dl");
139+
if (!headDlElem) return;
140+
141+
const result = fetchAndRender(conf, options).catch(err => handleError(err));
142+
143+
const dtPromise = result.then(r => r.dt);
144+
const ddPromise = result.then(r => r.dd);
145+
146+
const definitionPair = html`<dt class="baseline-title">
147+
${{ any: dtPromise, placeholder: "Implementation status:" }}
148+
</dt>
149+
<dd class="baseline-status">
150+
${{ any: ddPromise, placeholder: "Checking availability..." }}
151+
</dd>`;
152+
headDlElem.append(...definitionPair.childNodes);
153+
154+
await result;
155+
156+
if (options.removeOnSave) {
157+
sub("beforesave", outputDoc => {
158+
const dd = outputDoc.querySelector(".baseline-status");
159+
if (!dd) return;
160+
html.bind(dd)`<a href="https://webstatus.dev/">Web Platform Status</a>`;
161+
});
162+
}
163+
}
164+
165+
function handleError(err) {
166+
const msg = "Failed to retrieve implementation status data.";
167+
const hint = docLink`Check the ${"[implementationStatus]"} configuration.`;
168+
showWarning(msg, name, { hint, cause: err });
169+
return fallbackResult();
170+
}
171+
172+
function normalizeConf(conf) {
173+
const DEFAULTS = { removeOnSave: true };
174+
if (typeof conf.implementationStatus === "boolean") {
175+
conf.implementationStatus = { feature: null, ...DEFAULTS };
176+
return;
177+
}
178+
if (typeof conf.implementationStatus === "string") {
179+
conf.implementationStatus = {
180+
feature: conf.implementationStatus,
181+
...DEFAULTS,
182+
};
183+
return;
184+
}
185+
conf.implementationStatus = { ...DEFAULTS, ...conf.implementationStatus };
186+
}
187+
188+
async function fetchAndRender(conf, options) {
189+
const data = await fetchData(options);
190+
const features = findFeatures(data, conf, options);
191+
192+
if (!features.length) {
193+
showWarning("No Baseline data found for this specification.", name);
194+
return fallbackResult();
195+
}
196+
197+
const baseline = computeAggregate(features);
198+
const statusText = STATUS_TEXT.get(baseline);
199+
const support = aggregateSupport(features);
200+
201+
pub("amend-user-config", { implementationStatus: options.feature || true });
202+
203+
return renderBadge(baseline, statusText, support, features);
204+
}
205+
206+
async function fetchData(options) {
207+
const url = options.apiURL || DATA_URL;
208+
const response = await fetchAndCache(url);
209+
if (!response.ok) {
210+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
211+
}
212+
return response.json();
213+
}
214+
215+
function findFeatures(data, conf, options) {
216+
const features = data.features || data;
217+
218+
if (options.feature) {
219+
const feature = features[options.feature];
220+
if (!feature) return [];
221+
return [{ id: options.feature, ...feature }];
222+
}
223+
224+
const specUrls = getSpecUrls(conf);
225+
if (!specUrls.length) return [];
226+
227+
const matched = [];
228+
for (const [id, feature] of Object.entries(features)) {
229+
const specs = [].concat(feature.spec || []);
230+
for (const specUrl of specs) {
231+
const normalizedFeatureUrl = normalizeUrl(specUrl);
232+
if (specUrls.some(url => normalizedFeatureUrl.startsWith(url))) {
233+
matched.push({ id, ...feature });
234+
break;
235+
}
236+
}
237+
}
238+
return matched;
239+
}
240+
241+
function getSpecUrls(conf) {
242+
const urls = new Set();
243+
if (conf.edDraftURI) urls.add(normalizeUrl(conf.edDraftURI));
244+
if (conf.shortName) {
245+
urls.add(normalizeUrl(`https://www.w3.org/TR/${conf.shortName}/`));
246+
urls.add(normalizeUrl(`https://w3c.github.io/${conf.shortName}/`));
247+
}
248+
if (conf.thisVersion) urls.add(normalizeUrl(conf.thisVersion));
249+
return [...urls];
250+
}
251+
252+
function normalizeUrl(url) {
253+
try {
254+
const u = new URL(url);
255+
u.hash = "";
256+
if (!u.pathname.endsWith("/") && !u.pathname.includes(".")) {
257+
u.pathname += "/";
258+
}
259+
return u.href;
260+
} catch {
261+
return url;
262+
}
263+
}
264+
265+
/** @returns {string|false} */
266+
function computeAggregate(features) {
267+
const statuses = features.map(f => f.status?.baseline);
268+
if (statuses.every(s => s === "high")) return "high";
269+
if (statuses.every(s => s === "low" || s === "high")) return "low";
270+
return false;
271+
}
272+
273+
function aggregateSupport(features) {
274+
const browsers = new Map();
275+
for (const browserId of ENGINES.keys()) {
276+
const supported = features.every(
277+
f => f.status?.support?.[browserId] != null
278+
);
279+
browsers.set(browserId, supported);
280+
}
281+
return browsers;
282+
}
283+
284+
function getLogoSrc(browserId) {
285+
return `${LOGO_BASE}/${browserId}/${browserId}.svg`;
286+
}
287+
288+
function renderBadge(baseline, statusText, support, features) {
289+
const icon = BASELINE_ICONS.get(baseline)();
290+
291+
const pills = ENGINE_GROUPS.map(({ engines }) => {
292+
const items = engines.map(browserId => {
293+
const browserName = ENGINES.get(browserId);
294+
const isSupported = support.get(browserId);
295+
const title = isSupported
296+
? `${browserName}: Supported`
297+
: `${browserName}: Not supported`;
298+
const supportIcon = isSupported
299+
? SUPPORT_ICONS.available()
300+
: SUPPORT_ICONS.unavailable();
301+
const cls = isSupported ? "support-available" : "support-unavailable";
302+
303+
return html`<span class="baseline-browser ${cls}" title="${title}">
304+
<img
305+
class="baseline-browser-logo"
306+
width="24"
307+
height="24"
308+
src="${getLogoSrc(browserId)}"
309+
alt="${browserName}"
310+
/>${supportIcon}
311+
</span>`;
312+
});
313+
314+
const allSupported = engines.every(id => support.get(id));
315+
const pillCls = allSupported
316+
? "baseline-pill supported"
317+
: "baseline-pill unsupported";
318+
319+
return html`<span class="${pillCls}">${items}</span>`;
320+
});
321+
322+
const browserGroup = html`<span class="baseline-browsers">${pills}</span>`;
323+
324+
const featureId = features.length === 1 ? features[0].id : null;
325+
const featureName = features.length === 1 ? features[0].name : null;
326+
const moreInfoUrl = featureId
327+
? `https://webstatus.dev/features/${featureId}`
328+
: "https://webstatus.dev/";
329+
const moreInfoLabel = featureName
330+
? `More info about ${featureName} support`
331+
: "More info about browser support";
332+
333+
const dt = html`${statusText} ${icon}:`;
334+
const dd = html`${browserGroup}
335+
<a class="baseline-more-info" href="${moreInfoUrl}"
336+
aria-label="${moreInfoLabel}">More info</a>`;
337+
338+
return { dt, dd };
339+
}

0 commit comments

Comments
 (0)