From 4e03653652b5d1a93abeff09f5bbce73b520c93d Mon Sep 17 00:00:00 2001 From: Raul Jimenez Ortega Date: Wed, 4 Nov 2026 20:36:39 +0100 Subject: [PATCH] fix: judge feeds by the spec version they declare, not 0.3.0 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader decided whether a document was OTE with `specVersion === '0.3.0'`, so three of the four registered feeds — eventos.wiki and Sarao (0.4.0) and CoruñaJUG (0.5.0) — could not be opened, and the failure was silent: the document downloaded fine and was simply not recognised. The same check ran over the IndexedDB cache, so those feeds were also evicted from the offline cache on every start. A document is now OTE because of its shape — a declared specVersion plus events[] — and the version it declares decides which rules apply to it, never whether it is OTE. Across the four live feeds that moves the reader from 25 events to 701. The support window is the last three minors, computed from the published list rather than written out, so a spec release moves it by itself, and classification is by minor so a patch inside a supported minor is supported before this build has heard of it. Versions outside the window, newer than known, or unpublished are notes next to the events and in the sidebar — never a refusal, and never a reproach: running last season's release is a legitimate choice. No dependency on @opentechevents/validate, deliberately. It reports an out-of-window or unpublished version as an error, so gating on it would make the 0.3.0 feed that works today unopenable and would break every feed again at 0.7.0 — this bug, reintroduced. What the reader needs is the policy, not 3.7 MB of compiled schemas in a no-build PWA, so the policy is mirrored with the same vocabulary and `npm run check` now fails when it drifts from the registry or the published event schema. Also adds `contributing` to the fields an event inherits from its feed, completing the set the event schema marks with x-inheritsFrom since 0.5. Closes #11 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 ++++ README.md | 46 +++++++ app.js | 222 ++++++++++++++++++++++++++++++++- index.html | 1 + package.json | 5 +- scripts/check-spec-version.mjs | 91 ++++++++++++++ styles.css | 28 +++++ 7 files changed, 409 insertions(+), 8 deletions(-) create mode 100644 scripts/check-spec-version.mjs 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 @@

Eventos

+