diff --git a/docs/app.js b/docs/app.js index 5445d2a..ffeedd4 100644 --- a/docs/app.js +++ b/docs/app.js @@ -87,6 +87,14 @@ /* ---------- renderers ---------- */ + /* `license` is an SPDX identifier or a URL. The identifier is the label; a URL has no + short form that says anything true about the terms, so it says only that it is custom + and links nowhere — the tooltip carries the URL. */ + function licenseLabel(license) { + if (!/^https?:/i.test(license)) return license; + return get(state.dict, 'adopters.licenseCustom') || 'Custom licence'; + } + function renderEntries(items, containerId, emptyId) { var container = document.getElementById(containerId); var empty = document.getElementById(emptyId); @@ -146,6 +154,20 @@ if (title) badge.title = title.replace('{version}', item.specVersion); metas.appendChild(badge); } + // What may be done with the data, which is the other half of "can I consume this + // feed". A feed whose events carry different licences says so instead of picking + // one: an aggregator has to check every event, and the badge must not imply + // otherwise. Inheritance is already resolved in feed-health.json. + if (item.licenses && item.licenses.length) { + var mixed = item.licenses.length > 1; + var label = mixed + ? (get(state.dict, 'adopters.licenseMixed') || '{count} licences').replace('{count}', item.licenses.length) + : licenseLabel(item.licenses[0]); + var lic = el('span', 'entry-meta entry-badge entry-badge-license', label); + var licTitle = get(state.dict, mixed ? 'adopters.licenseMixedTitle' : 'adopters.licenseBadge'); + if (licTitle) lic.title = licTitle.replace('{licenses}', item.licenses.join(', ')); + metas.appendChild(lic); + } if (metas.childNodes.length) body.appendChild(metas); card.appendChild(body); @@ -280,7 +302,8 @@ var health = (state.data.health && state.data.health.feeds) || []; return (adopters || []).map(function (adopter) { var entry = health.find(function (f) { return f.feed === adopter.feed; }); - return entry && entry.specVersion ? Object.assign({}, adopter, { specVersion: entry.specVersion }) : adopter; + if (!entry) return adopter; + return Object.assign({}, adopter, { specVersion: entry.specVersion, licenses: entry.licenses }); }); } diff --git a/docs/data/feed-health.json b/docs/data/feed-health.json index 7bd25c6..640c751 100644 --- a/docs/data/feed-health.json +++ b/docs/data/feed-health.json @@ -1,5 +1,5 @@ { - "_comment": "Health of the registered adopter feeds, refreshed daily by scripts/check-feeds.mjs. Feeds are validated against the spec version they declare; `supported` false means older than the supported window, which is information, not a failure. `specVersion` is the last version seen, kept through an outage. `updated` is when this status last changed.", + "_comment": "Health of the registered adopter feeds, refreshed daily by scripts/check-feeds.mjs. Feeds are validated against the spec version they declare; `supported` false means older than the supported window, which is information, not a failure. `specVersion` and `licenses` are the last values seen, kept through an outage; `licenses` lists every licence a consumer ends up with, feed-level inheritance already resolved. `updated` is when this status last changed.", "updated": "2026-08-30", "latestSpecVersion": "0.4.0", "feeds": [ @@ -10,6 +10,9 @@ "status": "ok", "specVersion": "0.3.0", "supported": true, + "licenses": [ + "CC0-1.0" + ], "cors": true, "failureKind": null, "failingSince": null, @@ -22,6 +25,9 @@ "status": "ok", "specVersion": "0.4.0", "supported": true, + "licenses": [ + "CC-BY-4.0" + ], "cors": true, "failureKind": null, "failingSince": null, diff --git a/docs/i18n/en.json b/docs/i18n/en.json index ef5b7e9..9e3df00 100644 --- a/docs/i18n/en.json +++ b/docs/i18n/en.json @@ -184,8 +184,12 @@ }, "adopters": { "title": "Who publishes in OTE", - "lead": "Communities and events already exposing their data in this format.", + "lead": "Communities, conferences and directories exposing their event data in this format.", "specVersionBadge": "Publishes OTE {version}. Every published version stays supported — feeds are validated against the one they declare.", + "licenseBadge": "Licence of the data: {licenses}. It is what lets you reuse the feed, not the licence of the event.", + "licenseMixed": "{count} licences", + "licenseMixedTitle": "Its events don't share a licence: {licenses}. Anyone aggregating the whole feed has to check each event's, not only the feed's.", + "licenseCustom": "Custom licence", "empty": "The spec is brand new, so this list is still short. Be one of the first — early adopters get a say in what the standard becomes.", "emptyCta": "Be the first", "machine": "This list is data, not markup: it renders adopters.json, a machine-readable registry you can build on. See the developer docs." diff --git a/docs/i18n/es.json b/docs/i18n/es.json index fc35d2d..b96e7b9 100644 --- a/docs/i18n/es.json +++ b/docs/i18n/es.json @@ -184,8 +184,12 @@ }, "adopters": { "title": "Quién publica en OTE", - "lead": "Comunidades y eventos que ya exponen sus datos en este formato.", + "lead": "Comunidades, conferencias, directorios que exponen los datos de sus eventos en este formato.", "specVersionBadge": "Publica OTE {version}. Todas las versiones publicadas siguen soportadas — cada feed se valida contra la que declara.", + "licenseBadge": "Licencia de los datos: {licenses}. Es lo que permite reutilizar el feed, no la licencia del evento.", + "licenseMixed": "{count} licencias", + "licenseMixedTitle": "Sus eventos no comparten licencia: {licenses}. Quien agregue el feed entero tiene que mirar la de cada evento, no solo la del feed.", + "licenseCustom": "Licencia propia", "empty": "La spec acaba de nacer, así que esta lista todavía es corta. Sé de los primeros: quien llega pronto decide en qué se convierte el estándar.", "emptyCta": "Ser de los primeros", "machine": "Esta lista es datos, no HTML: pinta adopters.json, un registro machine-readable sobre el que puedes construir. Mira la documentación para desarrolladores." diff --git a/scripts/check-feeds.mjs b/scripts/check-feeds.mjs index 053ed71..2571149 100644 --- a/scripts/check-feeds.mjs +++ b/scripts/check-feeds.mjs @@ -166,13 +166,25 @@ async function attempt(url) { // Older than the support window: reachable, and that is all we claim. Judging it // against rules we no longer support would report a failure nobody agreed to fix. - if (!supported.has(version)) return ok({ cors, version }); + if (!supported.has(version)) return ok({ cors, version, licenses: licensesIn(doc) }); const { ajv, validate } = validatorFor(version); if (!validate(doc)) { return hard("schema errors:\n" + ajv.errorsText(validate.errors, { separator: "\n" }), { cors, version }); } - return ok({ cors, version }); + return ok({ cors, version, licenses: licensesIn(doc) }); +} + +/* Every licence a consumer would actually end up with, inheritance resolved. An event + that declares its own replaces the feed's, and an aggregator may omit `feed.license` + entirely as long as every event carries one — so reporting `feed.license` alone would + be wrong precisely for the mixed feeds where the answer matters most. See the spec's + "license and source" section. */ +function licensesIn(doc) { + const events = Array.isArray(doc.events) ? doc.events : []; + const effective = events.map((e) => (e && typeof e.license === "string" ? e.license : doc.license)); + const all = (effective.length ? effective : [doc.license]).filter((l) => typeof l === "string"); + return [...new Set(all)].sort(); } async function checkFeed(url) { @@ -202,7 +214,7 @@ const state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, "utf8")) : {}; const previous = existsSync(PUBLIC_STATE) ? JSON.parse(readFileSync(PUBLIC_STATE, "utf8")) : {}; // A feed that times out tells us nothing about its version: keep the last one we saw // rather than blanking it, so the public status stays useful through an outage. -const lastKnown = new Map((previous.feeds || []).map((f) => [f.feed, f.specVersion])); +const lastKnown = new Map((previous.feeds || []).map((f) => [f.feed, { specVersion: f.specVersion, licenses: f.licenses }])); const today = new Date().toISOString().slice(0, 10); const next = {}; const status = []; @@ -210,10 +222,11 @@ let failing = 0; const noCors = []; for (const adopter of adopters) { - const { error, kind, cors, version } = await checkFeed(adopter.feed); + const { error, kind, cors, version, licenses } = await checkFeed(adopter.feed); const prev = state[adopter.feed]; + const last = lastKnown.get(adopter.feed) || {}; const stale = version && !supported.has(version); - const seen = version || lastKnown.get(adopter.feed) || null; + const seen = version || last.specVersion || null; status.push({ name: adopter.name, @@ -224,6 +237,9 @@ for (const adopter of adopters) { // Old but still checkable against its own schemas: information for consumers, // never a health failure. See the support window at the top of this file. supported: seen ? supported.has(seen) : null, + // Inheritance resolved, so a feed whose events carry different licences reports all + // of them: whoever aggregates has to look at each event, not only at feed.license. + licenses: licenses && licenses.length ? licenses : last.licenses || null, cors: error ? null : cors === null, failureKind: kind, failingSince: error ? (prev ? prev.since : today) : null, @@ -318,7 +334,7 @@ writeFileSync( JSON.stringify( { _comment: - "Health of the registered adopter feeds, refreshed daily by scripts/check-feeds.mjs. Feeds are validated against the spec version they declare; `supported` false means older than the supported window, which is information, not a failure. `specVersion` is the last version seen, kept through an outage. `updated` is when this status last changed.", + "Health of the registered adopter feeds, refreshed daily by scripts/check-feeds.mjs. Feeds are validated against the spec version they declare; `supported` false means older than the supported window, which is information, not a failure. `specVersion` and `licenses` are the last values seen, kept through an outage; `licenses` lists every licence a consumer ends up with, feed-level inheritance already resolved. `updated` is when this status last changed.", updated: unchanged && previous.updated ? previous.updated : today, latestSpecVersion: LATEST, feeds: status,