openvibers/openvibe.deals Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#129)
Changes
@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"devDependencies": {@@@ -29,6 +29,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",@@@ -118,6 +124,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -220,6 +239,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -257,6 +309,73 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -307,6 +426,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -565,6 +696,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -710,6 +945,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -848,6 +1107,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -935,12 +1210,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1383,6 +1659,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -29,7 +29,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"devDependencies": {nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// What openvibe.deals tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves; Deals publishes no OpenAPI document or docs page, so++// neither is named. Prices are observations with the time they were seen, on the page and in its twin.++module.exports = {++ id: 'deals',++ name: 'OpenVibe.Deals',++ purpose: 'Deals from DealNews and the OpenVibe community, by shop, category and end date, with price watches and crowdfunding search; every price has its source and the time it was observed.',++ home: 'https://openvibe.deals',++ site: 'openvibe.deals',++ api: '/api/v1',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit: '240 API requests a minute per address; 60 form posts and 60 sign-in requests per 15 minutes',++ },++};nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++// The Markdown twins of openvibe.deals' public pages (openvibe-shared/agent-layer), rendered by the same++// view builders the routes use (createPages().publicViews + render/layout.js's renderPage), so a twin++// cannot drift from its page. Anonymous only: no vote, edit or moderation controls are ever rendered here.++const { renderPage } = require('./render/layout');++++function createAgentPages(ctx, publicViews) {++ const views = publicViews || require('./http/pages').createPages(ctx).publicViews;++ const twin = (page, path) => (page ? { html: renderPage({ ...page, config: ctx.config, path, viewer: { kind: 'anonymous' } }) } : null);++ const pageOf = (query) => { const n = parseInt(query && query.get('page'), 10); return Number.isInteger(n) && n > 0 ? n : 1; };++++ async function renderPublicPage(path, { query } = {}) {++ if (path === '/') return twin(await views.home(), path);++ if (path === '/updates') return twin(views.updates(), path);++ const list = { '/hot': 'hot', '/new': 'new', '/ending': 'ending', '/picks': 'picks' }[path];++ if (list) return twin(await views.list(list, pageOf(query)), path);++ if (path === '/at') return twin(await views.sellers(), path);++ if (path === '/c') return twin(await views.categories(), path);++ let m = /^\/d\/([^/]+)$/.exec(path);++ if (m) return twin(await views.offer(decodeURIComponent(m[1])), path);++ m = /^\/p\/([^/]+)$/.exec(path);++ if (m) {++ const out = await views.product(decodeURIComponent(m[1]));++ return twin(out && out.page, path);++ }++ m = /^\/s\/([^/]+)$/.exec(path);++ if (m) return twin(await views.store(decodeURIComponent(m[1]), pageOf(query)), path);++ m = /^\/at\/([^/]+)$/.exec(path);++ if (m) return twin(await views.seller(decodeURIComponent(m[1]), pageOf(query)), path);++ m = /^\/c\/([^/]+)$/.exec(path);++ if (m) return twin(await views.category(decodeURIComponent(m[1]), pageOf(query)), path);++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -197,6 +197,19 @@ async function createApp(opts = {}) {// ── Discovery, public pages ─────────────────────────────app.use(createDiscoveryRoutes(ctx));const pages = createPages(ctx);++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public++ // pages. The pages themselves keep their own routes — a signed-in visitor's vote buttons and account bar are++ // theirs — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.++ const agentLayer = express();++ require('openvibe-shared/agent-layer').mount(agentLayer, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages(ctx, pages.publicViews).renderPublicPage,++ });++ app.use((req, res, next) => {++ const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');++ if (req.path !== '/.well-known/agent.json' && !twin) return next();++ return agentLayer(req, res, next);++ });const formLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });app.use(['/submit', '/watches', '/mod', '/d/:slug/:action'], (req, res, next) => (req.method === 'POST' ? formLimit(req, res, next) : next()));app.use(require('./http/crowdfunding').createCrowdfundingRoutes(ctx));@@@ -93,6 +93,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {'# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read','# public deal, product and store pages, feeds and sitemaps. Prices are observations with times;','# read the "as of" time and the JSON twin (<deal URL>.json) rather than assuming a price is current.',++ '# Agent manifest: https://openvibe.deals/.well-known/agent.json; every public page has a .md twin.','# Forms, sign-in, moderation and the API are not for crawling. Pages decide their own','# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),@@@ -62,6 +62,8 @@ function createPages(ctx) {function send(req, res, status, page, { cacheable = false } = {}) {cacheHeaders(req, res, { cacheable, robots: page.decision.robots, status });++ // A page with a Markdown twin names it in the Link header, exactly as the agent layer's own responses do.++ if (page.agent) res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(page.canonical, `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));}@@@ -128,16 +130,16 @@ function createPages(ctx) {count: async () => await listings.countPicks(), rows: async (n, off) => await listings.picks(n, off), empty: 'No picks right now.' },};-- async function listPage(req, res, kind) {++ async function listPage(kind, page = 1) {const L = LISTS[kind];-- const page = pageNumber(req);const path = L.path;const total = await L.count();const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });-- if (pager.outOfRange && total) return notFound(req, res);++ if (pager.outOfRange && total) return null;const list = await Promise.all((await L.rows(PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });-- send(req, res, 200, {++ return {++ agent: true,title: L.heading,description: L.description || 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),@@@ -146,7 +148,7 @@ function createPages(ctx) {prev: pager.prev ? pager.prev.href : null,next: pager.next ? pager.next.href : null,body: views.offerList({ heading: L.heading, intro: L.intro, views: list, pager, urls, tabs: views.tabs(kind), empty: L.empty }),-- }, { cacheable: true });++ };}// ── the front page ──────────────────────────────────────@@@ -155,7 +157,7 @@ function createPages(ctx) {const UNDER = { amount: 20, currency: 'USD' };const HOME_CARDS = 8;-- async function homePage(req, res) {++ async function homePage() {const now = store.now();const fresh = now - config.freshnessMs;const [total, sellers, categories, seenDay, endingWeek, cf] = await Promise.all([@@@ -194,7 +196,8 @@ function createPages(ctx) {});const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });const listed = [...picks, ...upvoted, ...latest];-- send(req, res, 200, {++ return {++ agent: true,title: 'Today\'s deals, every price with its time',description: 'Deals from Amazon, Walmart, Best Buy and more, by shop, category and end date, with price watches and crowdfunding. Every price says when it was seen.',// The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).@@@ -213,33 +216,40 @@ function createPages(ctx) {stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,}, urls),-- }, { cacheable: true });++ };}router.get('/', wrap(async (req, res) => {// The hot list's later pages moved to /hot with the front page.if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);-- await homePage(req, res);++ send(req, res, 200, await homePage(), { cacheable: true });}));-- router.get('/hot', wrap(async (req, res) => await listPage(req, res, 'hot')));-- router.get('/ending', wrap(async (req, res) => await listPage(req, res, 'ending')));-- router.get('/picks', wrap(async (req, res) => await listPage(req, res, 'picks')));++ const sendList = (kind) => wrap(async (req, res) => {++ const page = await listPage(kind, pageNumber(req));++ if (!page) return notFound(req, res);++ send(req, res, 200, page, { cacheable: true });++ });++ router.get('/hot', sendList('hot'));++ router.get('/ending', sendList('ending'));++ router.get('/picks', sendList('picks'));// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.-- router.get('/updates', (req, res) => send(req, res, 200, {++ const updatesPage = () => ({++ agent: true,title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,-- }, { cacheable: true }));-- router.get('/new', wrap(async (req, res) => await listPage(req, res, 'new')));++ });++ router.get('/updates', (req, res) => send(req, res, 200, updatesPage(), { cacheable: true }));++ router.get('/new', sendList('new'));// ── offers ──────────────────────────────────────────────-- async function readComments(offer, label, req) {++ async function readComments(offer, label, ov) {if (!community.enabled) return { state: 'unavailable' };try {-- const threadId = await community.threadFor(offer, label, req.ov);++ const threadId = await community.threadFor(offer, label, ov);if (!threadId) return { state: 'unavailable' };-- const data = await community.readThread(threadId, { ctx: req.ov });++ const data = await community.readThread(threadId, { ctx: ov });return { state: 'ok', comments: data.comments || [] };} catch (err) {console.warn(`[Deals] comments for ${offer.id} unavailable: ${err.message}`);@@@ -247,38 +257,48 @@ function createPages(ctx) {}}-- async function renderOffer(req, res, offer, { json = false } = {}) {++ /** One public offer, as the route and its Markdown twin (server/agent-pages.js) both render it. */++ async function offerPage(offer, { viewer = { kind: 'anonymous' }, ov = null, notice = null } = {}) {const v = await publication.offerView(offer);const r = v.root;-- const dto = publication.offerDto(v);-- if (r.status === 'disabled') {-- if (json) return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });-- return messagePage(req, res, 410, 'This deal was removed', `Moderators removed this deal${r.disabled_reason ? `: ${r.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });-- }-- if (json) return sendJson(req, res, 200, { offer: dto }, v.decision);-- const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);++ if (r.status === 'disabled') return { status: 410, page: null, removed: r };++ const person = viewer.kind === 'user' && Boolean(viewer.subject);++ const c = await readComments(r, r.title, ov);const mergedComments = [];for (const m of v.members.slice(0, 3)) {const tid = community.enabled ? await community.knownThread(m) : null;if (!tid) continue;-- try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }++ try { const data = await community.readThread(tid, { ctx: ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }}-- const isMod = access.isStaff(req.viewer);++ const isMod = access.isStaff(viewer);const canonical = publication.abs(publication.offerPath(r));-- send(req, res, 200, {-- title: r.title,-- description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,-- decision: v.decision,-- canonical,-- type: 'website',-- jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],-- body: views.offerPage({-- v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? await votes.mine(req.viewer.subject, r) : 0,-- comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,-- log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,-- notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,-- }),-- }, { cacheable: true });++ return {++ status: 200,++ page: {++ agent: true,++ title: r.title,++ description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,++ decision: v.decision,++ canonical,++ type: 'website',++ jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],++ body: views.offerPage({++ v, dto: publication.offerDto(v), viewer, csrf: person ? csrfToken(config, viewer) : '', myVote: person ? await votes.mine(viewer.subject, r) : 0,++ comments: c, mergedComments, canEdit: access.canEdit(viewer, r), isMod,++ log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,++ notice,++ }),++ },++ };++ }++++ /** One public offer by slug for the agent twin, following a merge to its root; null when unknown or removed. */++ async function offerBySlug(slug) {++ const offer = await reads.find(String(slug || ''));++ if (!offer) return null;++ const root = offer.merged_into ? await reads.root(offer) : offer;++ const out = await offerPage(root);++ return out.status === 200 ? out.page : null;}async function offerFor(req, res) {@@@ -297,7 +317,17 @@ function createPages(ctx) {router.get('/d/:slug', wrap(async (req, res) => {const f = await offerFor(req, res);-- if (f) await renderOffer(req, res, f.offer, { json: f.json });++ if (!f) return;++ if (f.json) {++ const v = await publication.offerView(f.offer);++ const r = v.root;++ if (r.status === 'disabled') return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });++ return sendJson(req, res, 200, { offer: publication.offerDto(v) }, v.decision);++ }++ const notice = req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null;++ const out = await offerPage(f.offer, { viewer: req.viewer, ov: req.ov, notice });++ if (out.status !== 200) return messagePage(req, res, out.status, 'This deal was removed', `Moderators removed this deal${out.removed.disabled_reason ? `: ${out.removed.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });++ send(req, res, 200, out.page, { cacheable: true });}));const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;@@@ -343,96 +373,137 @@ function createPages(ctx) {// ── products and stores ─────────────────────────────────-- router.get('/p/:slug', wrap(async (req, res) => {-- const slug = String(req.params.slug || '');-- const json = slug.endsWith('.json');-- const product = await catalog.productBySlug(json ? slug.slice(0, -5) : slug);-- if (!product) return notFound(req, res);++ async function productPage(slug) {++ const raw = String(slug || '');++ const json = raw.endsWith('.json');++ const product = await catalog.productBySlug(json ? raw.slice(0, -5) : raw);++ if (!product) return null;const pv = await publication.productView(product);-- if (json) {++ if (json) return { json: true, product, pv };++ const canonical = publication.abs(publication.productPath(product));++ return {++ status: 200,++ page: {++ agent: true,++ title: `${product.name} — price comparison`,++ description: `Offers for ${product.name}, each with the time its price was observed.`,++ decision: pv.decision, canonical,++ jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],++ body: views.productPage({ pv, urls }),++ },++ };++ }++++ router.get('/p/:slug', wrap(async (req, res) => {++ const out = await productPage(req.params.slug);++ if (!out) return notFound(req, res);++ if (out.json) {++ const { product, pv } = out;return sendJson(req, res, 200, {product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },offers: pv.offers.map((v) => { const d = publication.offerDto(v); return { id: d.id, slug: d.slug, url: d.url, title: d.title, status: d.status, store: d.store, latest_observation: d.latest_observation, freshness: d.freshness }; }),indexability: { indexable: pv.decision.indexable, robots: pv.decision.robots, reasons: pv.decision.reasons },}, pv.decision);}-- const canonical = publication.abs(publication.productPath(product));-- send(req, res, 200, {-- title: `${product.name} — price comparison`,-- description: `Offers for ${product.name}, each with the time its price was observed.`,-- decision: pv.decision, canonical,-- jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],-- body: views.productPage({ pv, urls }),-- }, { cacheable: true });++ send(req, res, 200, out.page, { cacheable: true });}));-- router.get('/s/:domain', wrap(async (req, res) => {-- const st = await catalog.storeByDomain(req.params.domain);-- if (!st) return notFound(req, res);++ async function storePage(domain, page = 1) {++ const st = await catalog.storeByDomain(String(domain || ''));++ if (!st) return null;const path = publication.storePath(st);const total = await listings.countStore(st.id);-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });-- if (pager.outOfRange && total) return notFound(req, res);++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return null;const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });-- send(req, res, 200, {++ return {++ agent: true,title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,body: views.storePage({ st, views: list, pager, urls }),-- }, { cacheable: true });++ };++ }++++ router.get('/s/:domain', wrap(async (req, res) => {++ const page = await storePage(req.params.domain, pageNumber(req));++ if (!page) return notFound(req, res);++ send(req, res, 200, page, { cacheable: true });}));// ── shops (the seller a source names, migration 0006) ──-- router.get('/at', wrap(async (req, res) => {++ async function sellersIndexPage() {const sellers = await listings.sellers();-- send(req, res, 200, {++ return {++ agent: true,title: 'Deals by shop', decision: pageDecision('/at', { empty: sellers.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/at'),body: views.sellersPage({ sellers }),-- }, { cacheable: true });-- }));-- router.get('/at/:seller', wrap(async (req, res) => {-- const want = String(req.params.seller || '').toLowerCase();++ };++ }++++ router.get('/at', wrap(async (req, res) => send(req, res, 200, await sellersIndexPage(), { cacheable: true })));++++ async function sellerPage(seller, page = 1) {++ const want = String(seller || '').toLowerCase();const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.sellers()).find((x) => x.slug === want) : null;-- if (!hit) return notFound(req, res);++ if (!hit) return null;const path = `/at/${hit.slug}`;const total = await listings.countSeller(hit.seller);-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });-- if (pager.outOfRange && total) return notFound(req, res);++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return null;const list = await Promise.all((await listings.bySeller(hit.seller, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });-- send(req, res, 200, {++ return {++ agent: true,title: `Deals at ${hit.seller}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,description: `${total} deal${total === 1 ? '' : 's'} at ${hit.seller}, each with its source, price and when it was seen.`,prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,body: views.sellerPage({ seller: hit.seller, views: list, pager, urls, total }),-- }, { cacheable: true });++ };++ }++++ router.get('/at/:seller', wrap(async (req, res) => {++ const page = await sellerPage(req.params.seller, pageNumber(req));++ if (!page) return notFound(req, res);++ send(req, res, 200, page, { cacheable: true });}));// ── categories (as each deal's source names it, migration 0007) ──-- router.get('/c', wrap(async (req, res) => {++ async function categoriesIndexPage() {const categories = await listings.categories();-- send(req, res, 200, {++ return {++ agent: true,title: 'Deals by category', decision: pageDecision('/c', { empty: categories.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/c'),description: 'Every deal category on OpenVibe.Deals, from TVs to cleaning supplies, with how many active deals each has.',body: views.categoriesPage({ categories }),-- }, { cacheable: true });-- }));-- router.get('/c/:category', wrap(async (req, res) => {-- const want = String(req.params.category || '').toLowerCase();++ };++ }++++ router.get('/c', wrap(async (req, res) => send(req, res, 200, await categoriesIndexPage(), { cacheable: true })));++++ async function categoryPage(category, page = 1) {++ const want = String(category || '').toLowerCase();const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.categories()).find((x) => x.slug === want) : null;-- if (!hit) return notFound(req, res);++ if (!hit) return null;const path = `/c/${hit.slug}`;const total = await listings.countCategory(hit.category);-- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });-- if (pager.outOfRange && total) return notFound(req, res);++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return null;const list = await Promise.all((await listings.byCategory(hit.category, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });-- send(req, res, 200, {++ return {++ agent: true,title: `${hit.category} deals`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,description: `${total} active ${hit.category} deal${total === 1 ? '' : 's'}, each with its shop, its price and when that price was seen.`,prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,body: views.categoryPage({ category: hit.category, views: list, pager, urls, total }),-- }, { cacheable: true });++ };++ }++++ router.get('/c/:category', wrap(async (req, res) => {++ const page = await categoryPage(req.params.category, pageNumber(req));++ if (!page) return notFound(req, res);++ send(req, res, 200, page, { cacheable: true });}));// ── search ──────────────────────────────────────────────@@@ -577,7 +648,14 @@ function createPages(ctx) {});router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));-- return { router, notFound, messagePage };++ // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.++ const publicViews = {++ home: homePage, list: listPage, offer: offerBySlug, product: productPage,++ store: storePage, sellers: sellersIndexPage, seller: sellerPage,++ categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,++ };++++ return { router, notFound, messagePage, publicViews };}module.exports = { createPages };@@@ -76,7 +76,12 @@ function renderPage(o) {const account = signedIn? `${viewer.staff ? '<a href="/mod">Moderation</a> · ' : ''}<a href="/watches">Watches</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.++ const agentHead = o.agent && o.canonical++ ? require('openvibe-shared/agent-layer').linkHeaderTags(o.canonical, `${o.config.baseUrl.replace(/\/+$/, '')}/llms.txt`).head++ : '';return layout.renderDocument({++ head: agentHead,site: 'deals',siteName: SITE_NAME,lang: o.lang,
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -29,6 +29,12 @@
@@ -29,6 +29,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -118,6 +124,19 @@
@@ -118,6 +124,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -220,6 +239,39 @@
@@ -220,6 +239,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -257,6 +309,73 @@
@@ -257,6 +309,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -307,6 +426,18 @@
@@ -307,6 +426,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -565,6 +696,110 @@
@@ -565,6 +696,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -710,6 +945,30 @@
@@ -710,6 +945,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -848,6 +1107,22 @@
@@ -848,6 +1107,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -935,12 +1210,13 @@
@@ -935,12 +1210,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1383,6 +1659,12 @@
@@ -1383,6 +1659,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// What openvibe.deals tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves; Deals publishes no OpenAPI document or docs page, so
+// neither is named. Prices are observations with the time they were seen, on the page and in its twin.
+module.exports = {
+ id: 'deals',
+ name: 'OpenVibe.Deals',
+ purpose: 'Deals from DealNews and the OpenVibe community, by shop, category and end date, with price watches and crowdfunding search; every price has its source and the time it was observed.',
+ home: 'https://openvibe.deals',
+ site: 'openvibe.deals',
+ api: '/api/v1',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit: '240 API requests a minute per address; 60 form posts and 60 sign-in requests per 15 minutes',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+// The Markdown twins of openvibe.deals' public pages (openvibe-shared/agent-layer), rendered by the same
+// view builders the routes use (createPages().publicViews + render/layout.js's renderPage), so a twin
+// cannot drift from its page. Anonymous only: no vote, edit or moderation controls are ever rendered here.
+const { renderPage } = require('./render/layout');
+
+function createAgentPages(ctx, publicViews) {
+ const views = publicViews || require('./http/pages').createPages(ctx).publicViews;
+ const twin = (page, path) => (page ? { html: renderPage({ ...page, config: ctx.config, path, viewer: { kind: 'anonymous' } }) } : null);
+ const pageOf = (query) => { const n = parseInt(query && query.get('page'), 10); return Number.isInteger(n) && n > 0 ? n : 1; };
+
+ async function renderPublicPage(path, { query } = {}) {
+ if (path === '/') return twin(await views.home(), path);
+ if (path === '/updates') return twin(views.updates(), path);
+ const list = { '/hot': 'hot', '/new': 'new', '/ending': 'ending', '/picks': 'picks' }[path];
+ if (list) return twin(await views.list(list, pageOf(query)), path);
+ if (path === '/at') return twin(await views.sellers(), path);
+ if (path === '/c') return twin(await views.categories(), path);
+ let m = /^\/d\/([^/]+)$/.exec(path);
+ if (m) return twin(await views.offer(decodeURIComponent(m[1])), path);
+ m = /^\/p\/([^/]+)$/.exec(path);
+ if (m) {
+ const out = await views.product(decodeURIComponent(m[1]));
+ return twin(out && out.page, path);
+ }
+ m = /^\/s\/([^/]+)$/.exec(path);
+ if (m) return twin(await views.store(decodeURIComponent(m[1]), pageOf(query)), path);
+ m = /^\/at\/([^/]+)$/.exec(path);
+ if (m) return twin(await views.seller(decodeURIComponent(m[1]), pageOf(query)), path);
+ m = /^\/c\/([^/]+)$/.exec(path);
+ if (m) return twin(await views.category(decodeURIComponent(m[1]), pageOf(query)), path);
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -197,6 +197,19 @@ async function createApp(opts = {}) {
@@ -197,6 +197,19 @@ async function createApp(opts = {}) {
// ── Discovery, public pages ─────────────────────────────
// ── Discovery, public pages ─────────────────────────────
app.use(createDiscoveryRoutes(ctx));
app.use(createDiscoveryRoutes(ctx));
const pages = createPages(ctx);
const pages = createPages(ctx);
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
+ // pages. The pages themselves keep their own routes — a signed-in visitor's vote buttons and account bar are
+ // theirs — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.
+ const agentLayer = express();
+ require('openvibe-shared/agent-layer').mount(agentLayer, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages(ctx, pages.publicViews).renderPublicPage,
+ });
+ app.use((req, res, next) => {
+ const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
+ if (req.path !== '/.well-known/agent.json' && !twin) return next();
+ return agentLayer(req, res, next);
+ });
const formLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
const formLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
app.use(['/submit', '/watches', '/mod', '/d/:slug/:action'], (req, res, next) => (req.method === 'POST' ? formLimit(req, res, next) : next()));
app.use(['/submit', '/watches', '/mod', '/d/:slug/:action'], (req, res, next) => (req.method === 'POST' ? formLimit(req, res, next) : next()));
app.use(require('./http/crowdfunding').createCrowdfundingRoutes(ctx));
app.use(require('./http/crowdfunding').createCrowdfundingRoutes(ctx));
@@ -93,6 +93,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -93,6 +93,7 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
'# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read',
'# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read',
'# public deal, product and store pages, feeds and sitemaps. Prices are observations with times;',
'# public deal, product and store pages, feeds and sitemaps. Prices are observations with times;',
'# read the "as of" time and the JSON twin (<deal URL>.json) rather than assuming a price is current.',
'# read the "as of" time and the JSON twin (<deal URL>.json) rather than assuming a price is current.',
+ '# Agent manifest: https://openvibe.deals/.well-known/agent.json; every public page has a .md twin.',
'# Forms, sign-in, moderation and the API are not for crawling. Pages decide their own',
'# Forms, sign-in, moderation and the API are not for crawling. Pages decide their own',
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
'# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
@@ -62,6 +62,8 @@ function createPages(ctx) {
@@ -62,6 +62,8 @@ function createPages(ctx) {
function send(req, res, status, page, { cacheable = false } = {}) {
function send(req, res, status, page, { cacheable = false } = {}) {
cacheHeaders(req, res, { cacheable, robots: page.decision.robots, status });
cacheHeaders(req, res, { cacheable, robots: page.decision.robots, status });
+ // A page with a Markdown twin names it in the Link header, exactly as the agent layer's own responses do.
+ if (page.agent) res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(page.canonical, `${String(config.baseUrl).replace(/\/+$/, '')}/llms.txt`).header);
res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
}
}
@@ -128,16 +130,16 @@ function createPages(ctx) {
@@ -128,16 +130,16 @@ function createPages(ctx) {
count: async () => await listings.countPicks(), rows: async (n, off) => await listings.picks(n, off), empty: 'No picks right now.' },
count: async () => await listings.countPicks(), rows: async (n, off) => await listings.picks(n, off), empty: 'No picks right now.' },
};
};
- async function listPage(req, res, kind) {
+ async function listPage(kind, page = 1) {
const L = LISTS[kind];
const L = LISTS[kind];
- const page = pageNumber(req);
const path = L.path;
const path = L.path;
const total = await L.count();
const total = await L.count();
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
- if (pager.outOfRange && total) return notFound(req, res);
+ if (pager.outOfRange && total) return null;
const list = await Promise.all((await L.rows(PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const list = await Promise.all((await L.rows(PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
- send(req, res, 200, {
+ return {
+ agent: true,
title: L.heading,
title: L.heading,
description: L.description || 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
description: L.description || 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
@@ -146,7 +148,7 @@ function createPages(ctx) {
@@ -146,7 +148,7 @@ function createPages(ctx) {
prev: pager.prev ? pager.prev.href : null,
prev: pager.prev ? pager.prev.href : null,
next: pager.next ? pager.next.href : null,
next: pager.next ? pager.next.href : null,
body: views.offerList({ heading: L.heading, intro: L.intro, views: list, pager, urls, tabs: views.tabs(kind), empty: L.empty }),
body: views.offerList({ heading: L.heading, intro: L.intro, views: list, pager, urls, tabs: views.tabs(kind), empty: L.empty }),
- }, { cacheable: true });
+ };
}
}
// ── the front page ──────────────────────────────────────
// ── the front page ──────────────────────────────────────
@@ -155,7 +157,7 @@ function createPages(ctx) {
@@ -155,7 +157,7 @@ function createPages(ctx) {
const UNDER = { amount: 20, currency: 'USD' };
const UNDER = { amount: 20, currency: 'USD' };
const HOME_CARDS = 8;
const HOME_CARDS = 8;
- async function homePage(req, res) {
+ async function homePage() {
const now = store.now();
const now = store.now();
const fresh = now - config.freshnessMs;
const fresh = now - config.freshnessMs;
const [total, sellers, categories, seenDay, endingWeek, cf] = await Promise.all([
const [total, sellers, categories, seenDay, endingWeek, cf] = await Promise.all([
@@ -194,7 +196,8 @@ function createPages(ctx) {
@@ -194,7 +196,8 @@ function createPages(ctx) {
});
});
const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });
const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });
const listed = [...picks, ...upvoted, ...latest];
const listed = [...picks, ...upvoted, ...latest];
- send(req, res, 200, {
+ return {
+ agent: true,
title: 'Today\'s deals, every price with its time',
title: 'Today\'s deals, every price with its time',
description: 'Deals from Amazon, Walmart, Best Buy and more, by shop, category and end date, with price watches and crowdfunding. Every price says when it was seen.',
description: 'Deals from Amazon, Walmart, Best Buy and more, by shop, category and end date, with price watches and crowdfunding. Every price says when it was seen.',
// The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
// The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
@@ -213,33 +216,40 @@ function createPages(ctx) {
@@ -213,33 +216,40 @@ function createPages(ctx) {
stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
}, urls),
}, urls),
- }, { cacheable: true });
+ };
}
}
router.get('/', wrap(async (req, res) => {
router.get('/', wrap(async (req, res) => {
// The hot list's later pages moved to /hot with the front page.
// The hot list's later pages moved to /hot with the front page.
if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);
if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);
- await homePage(req, res);
+ send(req, res, 200, await homePage(), { cacheable: true });
}));
}));
- router.get('/hot', wrap(async (req, res) => await listPage(req, res, 'hot')));
- router.get('/ending', wrap(async (req, res) => await listPage(req, res, 'ending')));
- router.get('/picks', wrap(async (req, res) => await listPage(req, res, 'picks')));
+ const sendList = (kind) => wrap(async (req, res) => {
+ const page = await listPage(kind, pageNumber(req));
+ if (!page) return notFound(req, res);
+ send(req, res, 200, page, { cacheable: true });
+ });
+ router.get('/hot', sendList('hot'));
+ router.get('/ending', sendList('ending'));
+ router.get('/picks', sendList('picks'));
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
- router.get('/updates', (req, res) => send(req, res, 200, {
+ const updatesPage = () => ({
+ agent: true,
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
- }, { cacheable: true }));
- router.get('/new', wrap(async (req, res) => await listPage(req, res, 'new')));
+ });
+ router.get('/updates', (req, res) => send(req, res, 200, updatesPage(), { cacheable: true }));
+ router.get('/new', sendList('new'));
// ── offers ──────────────────────────────────────────────
// ── offers ──────────────────────────────────────────────
- async function readComments(offer, label, req) {
+ async function readComments(offer, label, ov) {
if (!community.enabled) return { state: 'unavailable' };
if (!community.enabled) return { state: 'unavailable' };
try {
try {
- const threadId = await community.threadFor(offer, label, req.ov);
+ const threadId = await community.threadFor(offer, label, ov);
if (!threadId) return { state: 'unavailable' };
if (!threadId) return { state: 'unavailable' };
- const data = await community.readThread(threadId, { ctx: req.ov });
+ const data = await community.readThread(threadId, { ctx: ov });
return { state: 'ok', comments: data.comments || [] };
return { state: 'ok', comments: data.comments || [] };
} catch (err) {
} catch (err) {
console.warn(`[Deals] comments for ${offer.id} unavailable: ${err.message}`);
console.warn(`[Deals] comments for ${offer.id} unavailable: ${err.message}`);
@@ -247,38 +257,48 @@ function createPages(ctx) {
@@ -247,38 +257,48 @@ function createPages(ctx) {
}
}
}
}
- async function renderOffer(req, res, offer, { json = false } = {}) {
+ /** One public offer, as the route and its Markdown twin (server/agent-pages.js) both render it. */
+ async function offerPage(offer, { viewer = { kind: 'anonymous' }, ov = null, notice = null } = {}) {
const v = await publication.offerView(offer);
const v = await publication.offerView(offer);
const r = v.root;
const r = v.root;
- const dto = publication.offerDto(v);
- if (r.status === 'disabled') {
- if (json) return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });
- return messagePage(req, res, 410, 'This deal was removed', `Moderators removed this deal${r.disabled_reason ? `: ${r.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });
- }
- if (json) return sendJson(req, res, 200, { offer: dto }, v.decision);
- const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);
+ if (r.status === 'disabled') return { status: 410, page: null, removed: r };
+ const person = viewer.kind === 'user' && Boolean(viewer.subject);
+ const c = await readComments(r, r.title, ov);
const mergedComments = [];
const mergedComments = [];
for (const m of v.members.slice(0, 3)) {
for (const m of v.members.slice(0, 3)) {
const tid = community.enabled ? await community.knownThread(m) : null;
const tid = community.enabled ? await community.knownThread(m) : null;
if (!tid) continue;
if (!tid) continue;
- try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
+ try { const data = await community.readThread(tid, { ctx: ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
}
}
- const isMod = access.isStaff(req.viewer);
+ const isMod = access.isStaff(viewer);
const canonical = publication.abs(publication.offerPath(r));
const canonical = publication.abs(publication.offerPath(r));
- send(req, res, 200, {
- title: r.title,
- description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,
- decision: v.decision,
- canonical,
- type: 'website',
- jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
- body: views.offerPage({
- v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? await votes.mine(req.viewer.subject, r) : 0,
- comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,
- log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,
- notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,
- }),
- }, { cacheable: true });
+ return {
+ status: 200,
+ page: {
+ agent: true,
+ title: r.title,
+ description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${r.seller ? ` at ${r.seller}` : v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,
+ decision: v.decision,
+ canonical,
+ type: 'website',
+ jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
+ body: views.offerPage({
+ v, dto: publication.offerDto(v), viewer, csrf: person ? csrfToken(config, viewer) : '', myVote: person ? await votes.mine(viewer.subject, r) : 0,
+ comments: c, mergedComments, canEdit: access.canEdit(viewer, r), isMod,
+ log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,
+ notice,
+ }),
+ },
+ };
+ }
+
+ /** One public offer by slug for the agent twin, following a merge to its root; null when unknown or removed. */
+ async function offerBySlug(slug) {
+ const offer = await reads.find(String(slug || ''));
+ if (!offer) return null;
+ const root = offer.merged_into ? await reads.root(offer) : offer;
+ const out = await offerPage(root);
+ return out.status === 200 ? out.page : null;
}
}
async function offerFor(req, res) {
async function offerFor(req, res) {
@@ -297,7 +317,17 @@ function createPages(ctx) {
@@ -297,7 +317,17 @@ function createPages(ctx) {
router.get('/d/:slug', wrap(async (req, res) => {
router.get('/d/:slug', wrap(async (req, res) => {
const f = await offerFor(req, res);
const f = await offerFor(req, res);
- if (f) await renderOffer(req, res, f.offer, { json: f.json });
+ if (!f) return;
+ if (f.json) {
+ const v = await publication.offerView(f.offer);
+ const r = v.root;
+ if (r.status === 'disabled') return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });
+ return sendJson(req, res, 200, { offer: publication.offerDto(v) }, v.decision);
+ }
+ const notice = req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null;
+ const out = await offerPage(f.offer, { viewer: req.viewer, ov: req.ov, notice });
+ if (out.status !== 200) return messagePage(req, res, out.status, 'This deal was removed', `Moderators removed this deal${out.removed.disabled_reason ? `: ${out.removed.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });
+ send(req, res, 200, out.page, { cacheable: true });
}));
}));
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
@@ -343,96 +373,137 @@ function createPages(ctx) {
@@ -343,96 +373,137 @@ function createPages(ctx) {
// ── products and stores ─────────────────────────────────
// ── products and stores ─────────────────────────────────
- router.get('/p/:slug', wrap(async (req, res) => {
- const slug = String(req.params.slug || '');
- const json = slug.endsWith('.json');
- const product = await catalog.productBySlug(json ? slug.slice(0, -5) : slug);
- if (!product) return notFound(req, res);
+ async function productPage(slug) {
+ const raw = String(slug || '');
+ const json = raw.endsWith('.json');
+ const product = await catalog.productBySlug(json ? raw.slice(0, -5) : raw);
+ if (!product) return null;
const pv = await publication.productView(product);
const pv = await publication.productView(product);
- if (json) {
+ if (json) return { json: true, product, pv };
+ const canonical = publication.abs(publication.productPath(product));
+ return {
+ status: 200,
+ page: {
+ agent: true,
+ title: `${product.name} — price comparison`,
+ description: `Offers for ${product.name}, each with the time its price was observed.`,
+ decision: pv.decision, canonical,
+ jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
+ body: views.productPage({ pv, urls }),
+ },
+ };
+ }
+
+ router.get('/p/:slug', wrap(async (req, res) => {
+ const out = await productPage(req.params.slug);
+ if (!out) return notFound(req, res);
+ if (out.json) {
+ const { product, pv } = out;
return sendJson(req, res, 200, {
return sendJson(req, res, 200, {
product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
offers: pv.offers.map((v) => { const d = publication.offerDto(v); return { id: d.id, slug: d.slug, url: d.url, title: d.title, status: d.status, store: d.store, latest_observation: d.latest_observation, freshness: d.freshness }; }),
offers: pv.offers.map((v) => { const d = publication.offerDto(v); return { id: d.id, slug: d.slug, url: d.url, title: d.title, status: d.status, store: d.store, latest_observation: d.latest_observation, freshness: d.freshness }; }),
indexability: { indexable: pv.decision.indexable, robots: pv.decision.robots, reasons: pv.decision.reasons },
indexability: { indexable: pv.decision.indexable, robots: pv.decision.robots, reasons: pv.decision.reasons },
}, pv.decision);
}, pv.decision);
}
}
- const canonical = publication.abs(publication.productPath(product));
- send(req, res, 200, {
- title: `${product.name} — price comparison`,
- description: `Offers for ${product.name}, each with the time its price was observed.`,
- decision: pv.decision, canonical,
- jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
- body: views.productPage({ pv, urls }),
- }, { cacheable: true });
+ send(req, res, 200, out.page, { cacheable: true });
}));
}));
- router.get('/s/:domain', wrap(async (req, res) => {
- const st = await catalog.storeByDomain(req.params.domain);
- if (!st) return notFound(req, res);
+ async function storePage(domain, page = 1) {
+ const st = await catalog.storeByDomain(String(domain || ''));
+ if (!st) return null;
const path = publication.storePath(st);
const path = publication.storePath(st);
const total = await listings.countStore(st.id);
const total = await listings.countStore(st.id);
- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
- if (pager.outOfRange && total) return notFound(req, res);
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
- send(req, res, 200, {
+ return {
+ agent: true,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.storePage({ st, views: list, pager, urls }),
body: views.storePage({ st, views: list, pager, urls }),
- }, { cacheable: true });
+ };
+ }
+
+ router.get('/s/:domain', wrap(async (req, res) => {
+ const page = await storePage(req.params.domain, pageNumber(req));
+ if (!page) return notFound(req, res);
+ send(req, res, 200, page, { cacheable: true });
}));
}));
// ── shops (the seller a source names, migration 0006) ──
// ── shops (the seller a source names, migration 0006) ──
- router.get('/at', wrap(async (req, res) => {
+ async function sellersIndexPage() {
const sellers = await listings.sellers();
const sellers = await listings.sellers();
- send(req, res, 200, {
+ return {
+ agent: true,
title: 'Deals by shop', decision: pageDecision('/at', { empty: sellers.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/at'),
title: 'Deals by shop', decision: pageDecision('/at', { empty: sellers.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/at'),
body: views.sellersPage({ sellers }),
body: views.sellersPage({ sellers }),
- }, { cacheable: true });
- }));
- router.get('/at/:seller', wrap(async (req, res) => {
- const want = String(req.params.seller || '').toLowerCase();
+ };
+ }
+
+ router.get('/at', wrap(async (req, res) => send(req, res, 200, await sellersIndexPage(), { cacheable: true })));
+
+ async function sellerPage(seller, page = 1) {
+ const want = String(seller || '').toLowerCase();
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.sellers()).find((x) => x.slug === want) : null;
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.sellers()).find((x) => x.slug === want) : null;
- if (!hit) return notFound(req, res);
+ if (!hit) return null;
const path = `/at/${hit.slug}`;
const path = `/at/${hit.slug}`;
const total = await listings.countSeller(hit.seller);
const total = await listings.countSeller(hit.seller);
- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
- if (pager.outOfRange && total) return notFound(req, res);
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.bySeller(hit.seller, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const list = await Promise.all((await listings.bySeller(hit.seller, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
- send(req, res, 200, {
+ return {
+ agent: true,
title: `Deals at ${hit.seller}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
title: `Deals at ${hit.seller}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
description: `${total} deal${total === 1 ? '' : 's'} at ${hit.seller}, each with its source, price and when it was seen.`,
description: `${total} deal${total === 1 ? '' : 's'} at ${hit.seller}, each with its source, price and when it was seen.`,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.sellerPage({ seller: hit.seller, views: list, pager, urls, total }),
body: views.sellerPage({ seller: hit.seller, views: list, pager, urls, total }),
- }, { cacheable: true });
+ };
+ }
+
+ router.get('/at/:seller', wrap(async (req, res) => {
+ const page = await sellerPage(req.params.seller, pageNumber(req));
+ if (!page) return notFound(req, res);
+ send(req, res, 200, page, { cacheable: true });
}));
}));
// ── categories (as each deal's source names it, migration 0007) ──
// ── categories (as each deal's source names it, migration 0007) ──
- router.get('/c', wrap(async (req, res) => {
+ async function categoriesIndexPage() {
const categories = await listings.categories();
const categories = await listings.categories();
- send(req, res, 200, {
+ return {
+ agent: true,
title: 'Deals by category', decision: pageDecision('/c', { empty: categories.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/c'),
title: 'Deals by category', decision: pageDecision('/c', { empty: categories.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/c'),
description: 'Every deal category on OpenVibe.Deals, from TVs to cleaning supplies, with how many active deals each has.',
description: 'Every deal category on OpenVibe.Deals, from TVs to cleaning supplies, with how many active deals each has.',
body: views.categoriesPage({ categories }),
body: views.categoriesPage({ categories }),
- }, { cacheable: true });
- }));
- router.get('/c/:category', wrap(async (req, res) => {
- const want = String(req.params.category || '').toLowerCase();
+ };
+ }
+
+ router.get('/c', wrap(async (req, res) => send(req, res, 200, await categoriesIndexPage(), { cacheable: true })));
+
+ async function categoryPage(category, page = 1) {
+ const want = String(category || '').toLowerCase();
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.categories()).find((x) => x.slug === want) : null;
const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.categories()).find((x) => x.slug === want) : null;
- if (!hit) return notFound(req, res);
+ if (!hit) return null;
const path = `/c/${hit.slug}`;
const path = `/c/${hit.slug}`;
const total = await listings.countCategory(hit.category);
const total = await listings.countCategory(hit.category);
- const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
- if (pager.outOfRange && total) return notFound(req, res);
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return null;
const list = await Promise.all((await listings.byCategory(hit.category, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const list = await Promise.all((await listings.byCategory(hit.category, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
- send(req, res, 200, {
+ return {
+ agent: true,
title: `${hit.category} deals`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
title: `${hit.category} deals`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
description: `${total} active ${hit.category} deal${total === 1 ? '' : 's'}, each with its shop, its price and when that price was seen.`,
description: `${total} active ${hit.category} deal${total === 1 ? '' : 's'}, each with its shop, its price and when that price was seen.`,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
body: views.categoryPage({ category: hit.category, views: list, pager, urls, total }),
body: views.categoryPage({ category: hit.category, views: list, pager, urls, total }),
- }, { cacheable: true });
+ };
+ }
+
+ router.get('/c/:category', wrap(async (req, res) => {
+ const page = await categoryPage(req.params.category, pageNumber(req));
+ if (!page) return notFound(req, res);
+ send(req, res, 200, page, { cacheable: true });
}));
}));
// ── search ──────────────────────────────────────────────
// ── search ──────────────────────────────────────────────
@@ -577,7 +648,14 @@ function createPages(ctx) {
@@ -577,7 +648,14 @@ function createPages(ctx) {
});
});
router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
- return { router, notFound, messagePage };
+ // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
+ const publicViews = {
+ home: homePage, list: listPage, offer: offerBySlug, product: productPage,
+ store: storePage, sellers: sellersIndexPage, seller: sellerPage,
+ categories: categoriesIndexPage, category: categoryPage, updates: updatesPage,
+ };
+
+ return { router, notFound, messagePage, publicViews };
}
}
module.exports = { createPages };
module.exports = { createPages };
@@ -76,7 +76,12 @@ function renderPage(o) {
@@ -76,7 +76,12 @@ function renderPage(o) {
const account = signedIn
const account = signedIn
? `${viewer.staff ? '<a href="/mod">Moderation</a> · ' : ''}<a href="/watches">Watches</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? `${viewer.staff ? '<a href="/mod">Moderation</a> · ' : ''}<a href="/watches">Watches</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
+ const agentHead = o.agent && o.canonical
+ ? require('openvibe-shared/agent-layer').linkHeaderTags(o.canonical, `${o.config.baseUrl.replace(/\/+$/, '')}/llms.txt`).head
+ : '';
return layout.renderDocument({
return layout.renderDocument({
+ head: agentHead,
site: 'deals',
site: 'deals',
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: o.lang,
lang: o.lang,