Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
222 changes: 216 additions & 6 deletions app.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ <h2 id="result-count">Eventos</h2>
</div>
</div>
<div class="load-status" id="load-status" role="status" aria-live="polite" hidden></div>
<div class="spec-notice" id="spec-notice" role="status" aria-live="polite" hidden></div>
<div id="messages"></div>
<ote-events class="reader-events" id="event-list" lang="es" sort="none" event-actions="none"></ote-events>
<div class="pagination-actions" id="pagination-actions" hidden>
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "ote-reader",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"type": "module",
"description": "Attendee-facing PWA for subscribing to OpenTechEvents feeds.",
"scripts": {
"dev": "node scripts/serve.cjs",
"check": "node --check app.js"
"check": "node --check app.js && node scripts/check-spec-version.mjs",
"check:spec": "node scripts/check-spec-version.mjs"
},
"license": "MIT"
}
Loading