diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f9508..b863bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # OTE Reader changelog +## 0.10.0 - 2026-11-04 + +### Fixed + +- The reader accepted only `specVersion` `0.3.0`, so three of the four + registered OTE feeds — eventos.wiki and Sarao (0.4.0) and CoruñaJUG + (0.5.0) — could not be opened at all, and the failure was silent: the + document downloaded fine and was simply not recognised as OTE. A document + is now recognised by its shape, and the version it declares decides which + rules apply to it, never whether it is OTE. +- Events now inherit `contributing` from their feed alongside `specVersion`, + `license`, `textLanguage` and `organizers` — the full set the event schema + marks as inherited since 0.5. + +### Added + +- Spec version notes: a feed on an older supported release says so when you + open it, and one outside the support window, newer than this build, or + declaring an unpublished version is flagged in the sidebar and above the + events. None of them stops the feed from being read. +- `npm run check` now also verifies the mirrored spec version list and the + feed→event inheritance list against the npm registry and the published + event schema, so neither drifts silently. + ## 0.9.0 - 2026-08-28 ### Added diff --git a/README.md b/README.md index fed1144..61508b6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,52 @@ for sharing presentation code with OTE Reader. Today it consumes a `feed` URL di OTE Reader keeps feeds and filters in memory, so the clean integration point would be a future widget API that accepts an already-filtered OTE feed object or event list. +## OTE spec version support + +The reader opens a document because of its **shape** — a declared +`specVersion` plus `events[]` for a feed — and uses the version it declares to +decide which rules apply to it. The two questions are kept apart on purpose: a +reader that treats "which version is this?" as "is this OTE?" stops opening +every feed published after it shipped, which is exactly what happened while +the test was `specVersion === '0.3.0'`. + +The support window is the **last three minors**, computed from +`PUBLISHED_SPEC_VERSIONS` in `app.js` rather than listed, so a spec release +moves it by itself. What each case means here: + +| Declared | What the reader does | +| --- | --- | +| The current release | Opens it, says nothing | +| An older supported release | Opens it, notes the version when you open that feed | +| Older than the window | Opens it, warns that fields added later are missing | +| Newer than this build knows | Opens it, warns it is ignoring what it does not recognise | +| Not a published version | Opens it, says so | + +None of these refuse the feed, and none of them blames the publisher: running +last season's release is a legitimate choice, and a reader's job is to show +the events. + +### Why there is no dependency on `@opentechevents/validate` + +That package is the right tool for judging a document — it embeds every +published version and measures each one against what it declares. The reader +is deliberately not a judge: + +- **It must be more tolerant than a validator.** `validateDocument` reports an + out-of-window or unpublished version as an *error*. Gating on that would + make a 0.3.0 feed unopenable and break every feed again on the next spec + release — the original bug, reintroduced. +- **There is no build step.** The package is ESM with a per-version dynamic + `import()` meant for a bundler, ~3.7 MB of compiled validators. Vendoring it + or pulling it from a CDN would mean megabytes, or a runtime dependency on a + third party for an offline-first PWA's ability to open a file. + +What the reader needs from it is the **policy**, not the schemas, so the +policy is mirrored in `app.js` with the same vocabulary (`current`, +`outdated`, `out-of-window`). `npm run check` reads the npm registry and the +published event schema and fails if the mirrored version list or the +feed→event inheritance list has drifted from them. + ## Run locally ```bash diff --git a/app.js b/app.js index cc57687..69a33e7 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ (function () { 'use strict'; - var APP_VERSION = '0.9.0'; + var APP_VERSION = '0.10.0'; var DEMO_MODE = new URLSearchParams(location.search).get('demo') === '1' || location.pathname.replace(/\/+$/, '').endsWith('/demo'); var STORAGE = DEMO_MODE ? 'ote-reader-demo-state-v1' : 'ote-reader-state-v1'; var INSTALL_DISMISSED = 'ote-reader-install-dismissed-v1'; @@ -339,17 +339,168 @@ return new URL(String(value || '').trim(), location.href).href; } + // --- OTE spec version support ----------------------------------------- + // A document is judged against the version it declares, never against the + // latest one: each release pins `specVersion` with a `const` in its schema, + // so measuring a healthy 0.4 feed against 0.6 produces one meaningless + // error about specVersion and nothing else. That bug has been shipped and + // fixed four times in this ecosystem; the reader was the fifth place it + // lived (`specVersion === '0.3.0'` as the test for "is this OTE?"). + // + // The policy is the spec repo's, mirrored here rather than reinvented: the + // last three minors are supported. It is computed, not listed, so appending + // a newly published version below is the only edit a spec release needs — + // and `npm run check` fails when this list drifts from what the registry + // publishes. + var PUBLISHED_SPEC_VERSIONS = ['0.1.0', '0.2.0', '0.3.0', '0.4.0', '0.5.0', '0.6.0']; + var SUPPORT_WINDOW_MINORS = 3; + var SPEC_CHANGELOG_URL = 'https://github.com/OpenTechEvents/opentechevents-spec/blob/main/CHANGELOG.md'; + // Fields an event inherits from its feed when it omits its own. These are + // exactly the ones carrying `x-inheritsFrom` in the event schema (in 0.6: + // specVersion, organizers, textLanguage, license, contributing — the last + // one new in 0.5). `npm run check` re-reads the published schema and fails + // if this list no longer matches it, so it does not drift by hand. + var INHERITED_FEED_FIELDS = ['specVersion', 'license', 'textLanguage', 'organizers', 'contributing']; + + function specMinorOf(version) { + var parts = String(version).split('.'); + return parts.length < 2 ? null : parts[0] + '.' + parts[1]; + } + + var PUBLISHED_SPEC_MINORS = PUBLISHED_SPEC_VERSIONS.reduce(function (minors, version) { + var minor = specMinorOf(version); + if (minor && minors.indexOf(minor) === -1) minors.push(minor); + return minors; + }, []); + var LATEST_SPEC_VERSION = PUBLISHED_SPEC_VERSIONS[PUBLISHED_SPEC_VERSIONS.length - 1]; + var LATEST_SPEC_MINOR = PUBLISHED_SPEC_MINORS[PUBLISHED_SPEC_MINORS.length - 1]; + var SUPPORTED_SPEC_MINORS = PUBLISHED_SPEC_MINORS.slice(-SUPPORT_WINDOW_MINORS); + var SUPPORTED_SPEC_VERSIONS = PUBLISHED_SPEC_VERSIONS.filter(function (version) { + return SUPPORTED_SPEC_MINORS.indexOf(specMinorOf(version)) !== -1; + }); + + function declaredSpecVersion(doc) { + return doc && typeof doc.specVersion === 'string' ? doc.specVersion : null; + } + + // Numeric compare of two `x.y` minors, so "0.10" sorts above "0.9". + function compareSpecMinors(a, b) { + var left = String(a).split('.').map(Number); + var right = String(b).split('.').map(Number); + for (var i = 0; i < 2; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; + } + + // Statuses share their names with @opentechevents/validate's + // classifySpecVersion, with one addition: `future` — a version newer than + // anything this build knows. A validator has nothing to check such a + // document against, but a reader can still show it and say so, which is + // the whole point of reading tolerantly. + // + // Classification is by minor, not by exact string: a patch release inside a + // supported minor does not change the format, so 0.4.1 is as supported as + // 0.4.0 even before this build has heard of it. + function classifySpecVersion(declared) { + if (!declared) return { status: 'unknown', declared: null }; + var minor = specMinorOf(declared); + if (!minor || !/^\d+\.\d+$/.test(minor)) return { status: 'unknown', declared: declared }; + if (PUBLISHED_SPEC_MINORS.indexOf(minor) !== -1) { + if (SUPPORTED_SPEC_MINORS.indexOf(minor) === -1) { + return { status: 'out-of-window', version: declared }; + } + return minor === LATEST_SPEC_MINOR + ? { status: 'current', version: declared } + : { status: 'outdated', version: declared }; + } + return compareSpecMinors(minor, LATEST_SPEC_MINOR) > 0 + ? { status: 'future', version: declared } + : { status: 'unknown', declared: declared }; + } + + // Null for a feed that has not loaded yet: "no document" is not the same + // claim as "a document declaring nothing", and the UI must not say the + // second about the first. + function specVerdictOf(doc) { + return doc ? classifySpecVersion(declaredSpecVersion(doc)) : null; + } + + // The sidebar is not the place to tell someone their feed is one release + // behind — that is normal. It is the place to flag the two cases where what + // the reader shows may be incomplete. + function feedSpecTag(feed) { + var verdict = specVerdictOf(feedCache.get(feed.url)); + if (!verdict) return ''; + if (verdict.status !== 'out-of-window' && verdict.status !== 'future' && verdict.status !== 'unknown') return ''; + var note = describeSpecVersion(verdict); + return note ? note.short : ''; + } + + // One sentence for the UI about the version a feed declares, or null when + // there is nothing worth saying. `tone` is how to show it, never whether to + // show the feed: the reader shows the events either way. Being on an older + // supported release is a legitimate choice and these messages say so — + // none of them asks the publisher to do anything. + function describeSpecVersion(verdict) { + if (!verdict || verdict.status === 'current') return null; + if (verdict.status === 'outdated') { + return { + tone: 'info', + short: 'OTE ' + verdict.version, + message: 'Este feed declara OTE ' + verdict.version + ' y se lee con las reglas de ' + + verdict.version + '. Es una versión soportada; la actual es ' + LATEST_SPEC_VERSION + '.' + }; + } + if (verdict.status === 'out-of-window') { + return { + tone: 'warn', + short: 'OTE ' + verdict.version + ' · fuera de la ventana de soporte', + message: 'OTE ' + verdict.version + ' queda fuera de la ventana de soporte, que cubre las ' + + SUPPORT_WINDOW_MINORS + ' últimas versiones (' + SUPPORTED_SPEC_VERSIONS.join(', ') + + '). El feed se muestra con las reglas de ' + verdict.version + + ', así que lo que ves es correcto; sólo no incluye los campos añadidos después.' + }; + } + if (verdict.status === 'future') { + return { + tone: 'warn', + short: 'OTE ' + verdict.version + ' · más reciente que este lector', + message: 'Este feed declara OTE ' + verdict.version + ', más reciente que la última versión ' + + 'que conoce este lector (' + LATEST_SPEC_VERSION + '). Se muestra igualmente: los campos ' + + 'que el lector no reconoce se ignoran, que es lo que OTE espera de un consumidor.' + }; + } + return { + tone: 'warn', + short: 'specVersion no reconocida', + message: verdict.declared === null + ? 'Este feed no declara specVersion, así que no hay reglas con las que leerlo. Las versiones ' + + 'publicadas son ' + PUBLISHED_SPEC_VERSIONS.join(', ') + '.' + : '"' + verdict.declared + '" no es una versión publicada de OTE. Las versiones publicadas son ' + + PUBLISHED_SPEC_VERSIONS.join(', ') + '. El feed se muestra tal cual.' + }; + } + + // What makes a document an OTE document is its shape, not its vintage: a + // declared specVersion plus the payload of its kind. The version it declares + // decides which rules apply to it, never whether it is OTE — a reader that + // conflates the two stops opening every feed published after it shipped. + // Unknown fields have always been legal to ignore in OTE, so reading + // tolerantly and saying what was read is the correct behaviour here. function isOteFeed(doc) { - return doc && doc.specVersion === '0.3.0' && Array.isArray(doc.events); + return Boolean(doc) && typeof doc === 'object' + && typeof doc.specVersion === 'string' && Array.isArray(doc.events); } function isOteEvent(doc) { - return doc && doc.specVersion === '0.3.0' && doc.id && doc.name && doc.startDate; + return Boolean(doc) && typeof doc === 'object' + && typeof doc.specVersion === 'string' && Boolean(doc.id && doc.name && doc.startDate); } function inherit(feed, event) { var out = Object.assign({}, event); - ['specVersion', 'license', 'textLanguage', 'organizers'].forEach(function (key) { + INHERITED_FEED_FIELDS.forEach(function (key) { if (out[key] == null && feed[key] != null) out[key] = feed[key]; }); out._feedTitle = feed.title || 'Evento suelto'; @@ -489,7 +640,7 @@ events: [doc] }; } - throw new Error('La URL no parece un evento ni un feed OTE v0.3'); + throw new Error('La URL no parece un evento ni un feed OTE: falta specVersion, o events[] en un feed.'); } function discoverInHtml(html, baseUrl) { @@ -556,9 +707,64 @@ function renderResults() { renderFacets(); renderLoadStatus(); + renderSpecNotice(); renderEvents(); } + // The documents whose spec version the current screen is actually showing. + function activeSpecDocs() { + if (previewMode) return [{ title: previewMode.doc.title || '', doc: previewMode.doc }]; + var source = state.activeSource || { type: 'all', id: 'all' }; + var urls; + if (source.type === 'feed') urls = [source.id]; + else if (source.type === 'category') { + var category = findCategory(source.id); + urls = category ? category.feedUrls || [] : []; + } else urls = state.feeds.map(function (feed) { return feed.url; }); + return urls.map(function (url) { + var doc = feedCache.get(url); + if (!doc) return null; + var feed = findFeed(url); + return { title: (feed && feed.title) || doc.title || url, doc: doc }; + }).filter(Boolean); + } + + // Version notes live here rather than in #messages, which renderEvents + // clears on every pass. They are notes: the events are rendered either way, + // and nothing in this box is a reason not to read the feed. + function renderSpecNotice() { + var box = $('spec-notice'); + if (!box) return; + box.replaceChildren(); + box.classList.remove('is-warn', 'is-info'); + var docs = activeSpecDocs(); + var entries = docs.map(function (entry) { + return { title: entry.title, note: describeSpecVersion(specVerdictOf(entry.doc)) }; + }).filter(function (entry) { return entry.note; }); + // On a screen about one feed, say the whole sentence — the reader asked + // about that feed. Across several, keep only what a reader has to know: + // being one release behind is legitimate and would otherwise sit in the + // way of every session, which is how a note turns into a reproach. + if (docs.length > 1) { + entries = entries.filter(function (entry) { return entry.note.tone === 'warn'; }); + } + if (!entries.length) { + box.hidden = true; + return; + } + var warn = entries.some(function (entry) { return entry.note.tone === 'warn'; }); + box.classList.add(warn ? 'is-warn' : 'is-info'); + // Name the feed whenever more than one is on screen: "a feed declares 0.3" + // is useless when you cannot tell which one it is. + var named = docs.length > 1; + entries.forEach(function (entry) { + var line = document.createElement('p'); + line.textContent = named ? entry.title + ' — ' + entry.note.message : entry.note.message; + box.appendChild(line); + }); + box.hidden = false; + } + function renderControls() { $('q').value = state.filters.q; $('mode').value = state.filters.mode; @@ -641,7 +847,7 @@ if (!feed) return; box.appendChild(libraryRow({ title: feed.title || 'Feed OTE', - meta: feed.error || '', + meta: feed.error || feedSpecTag(feed), count: visibleUnreadCount({ type: 'feed', id: feed.url }), active: isActiveSource('feed', feed.url), unread: feedHasUnread(feed), @@ -848,9 +1054,13 @@ $('options-name').value = feed.title || ''; $('options-url').value = feed.url || ''; var errorHint = $('options-error'); + var note = describeSpecVersion(specVerdictOf(feedCache.get(feed.url))); if (feed.status === 'error' && feed.error) { errorHint.textContent = 'No se pudo cargar: ' + feed.error + '. Puedes corregir la URL.'; errorHint.hidden = false; + } else if (note) { + errorHint.textContent = note.message; + errorHint.hidden = false; } else { errorHint.hidden = true; } diff --git a/index.html b/index.html index b69cc00..149dc21 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@