diff --git a/config/roadmaps/PHP.json b/config/roadmaps/PHP.json new file mode 100644 index 0000000..5026297 --- /dev/null +++ b/config/roadmaps/PHP.json @@ -0,0 +1,45 @@ +{ + "library": "PHP", + "branches": [ + { + "version": "8.2", + "status": "released", + "plannedReleaseDate": null, + "releaseDate": "2022-12-08", + "activeSupportUntil": "2024-12-31", + "securitySupportUntil": "2026-12-31", + "php": null, + "notes": null + }, + { + "version": "8.3", + "status": "released", + "plannedReleaseDate": null, + "releaseDate": "2023-11-23", + "activeSupportUntil": "2025-12-31", + "securitySupportUntil": "2027-12-31", + "php": null, + "notes": null + }, + { + "version": "8.4", + "status": "released", + "plannedReleaseDate": null, + "releaseDate": "2024-11-21", + "activeSupportUntil": "2026-12-31", + "securitySupportUntil": "2028-12-31", + "php": null, + "notes": null + }, + { + "version": "8.5", + "status": "released", + "plannedReleaseDate": null, + "releaseDate": "2025-11-20", + "activeSupportUntil": "2027-12-31", + "securitySupportUntil": "2029-12-31", + "php": null, + "notes": null + } + ] +} diff --git a/config/roadmaps/window.json b/config/roadmaps/window.json index ba359d8..b1d4c3d 100644 --- a/config/roadmaps/window.json +++ b/config/roadmaps/window.json @@ -1,4 +1,4 @@ { - "startDate": "2024-01-01", - "endDate": "2029-12-31" + "startDate": "2025-01-01", + "endDate": "2029-03-31" } diff --git a/docs/shared/support-timeline-generation.md b/docs/shared/support-timeline-generation.md index d96e66a..a164197 100644 --- a/docs/shared/support-timeline-generation.md +++ b/docs/shared/support-timeline-generation.md @@ -98,7 +98,7 @@ The forecast and actual release may differ; the generator does not require one t A `null` end date does not mean indefinite support. Only announced, placeable periods are drawn. The implementation uses UTC calendar days and treats support end dates as inclusive; interval geometry ends at the start of the following day. -No countdown, elapsed duration, current-date marker or automatic release-state promotion is generated. +No countdown, elapsed duration, current-date marker or automatic release-state promotion is generated in the standalone SVG. ## Node.js API @@ -122,6 +122,8 @@ const svg = generateSupportTimeline(roadmap, window); The validators and `RoadmapDataError` are exported from `scripts/roadmap-data.mjs`. `options.width` defaults to `960` and must be a safe integer of at least `360` pixels. +`options.phpReference` optionally accepts a PHP roadmap object, validated by `validatePhpReference(input)` from the same module. +Omitting it leaves the standalone library calendar unchanged. The website build supplies the shared PHP configuration automatically. The SVG may be wider than requested to preserve at least 54 pixels per displayed year; its `viewBox` preserves all content when scaled. Long headings wrap. Branch names appear inside active-support bars; detailed dates and notes remain in the accessible description and support table. The opaque ivory background, site-palette text, patterned security bars and distinct marker shapes remain readable independently of the surrounding theme. @@ -225,3 +227,38 @@ Images are linked so readers can open and zoom them. HTML calendars and tables s the table retains semantic column headers and the standalone SVG retains its own title, description and legend. Portable Markdown includes the complete GFM table and relative image reference, without build metadata, MDX or raw HTML. The [shared assets publication contract](./shared-assets-publication-contract.md) validates and publishes the generated images alongside other shared resources. + +## Maintaining the PHP reference bars + +Edit the exact file `config/roadmaps/PHP.json` to maintain the secondary reference bars shared by all configured library timelines. +It uses the same roadmap model: `library` is `PHP`, and each branch's `version` is a major.minor value such as `8.4`. +Keep every field, using `null` for inapplicable Composer constraints and notes. The same date, chronology, uniqueness and unknown-field validation applies. +Use explicit release and support dates from the official [PHP Supported Versions page](https://www.php.net/supported-versions.php). +The initial configuration records PHP 8.2, 8.3, 8.4 and 8.5 from that page, checked on October 29, 2026. +For historical branches, consult PHP's [end-of-life branches](https://www.php.net/eol.php). +Do not calculate dates from support durations or copy these dates into LongitudeOne library configurations. + +The build places intersecting PHP periods directly after the library bars on the same display window and date axis. +The compact bars are 16 pixels high, compared with 24 pixels for library bars, with muted solid active support and hatched security-only support. +Only labels such as `PHP 8.4` are visible; full dates remain in the non-visible SVG description, with no PHP tooltip commentary. +Labels are centered in the visible active-support segment when it has enough room; otherwise they start in the hatched security segment. +Label placement leaves space for continuation arrows. An active-only sliver retains its readable label within the calendar bounds. +Labels keep their font size even when a period is clipped to a few days. The existing horizontal scroll and zoom preserve readability on narrow screens. + +PHP remains reference data: it creates no landing page, documentation-version list, navigation entry or support table. +Both HTML and portable Markdown use the same generated SVG asset. Missing or unpopulated PHP data adds no reference bars; +invalid non-empty data fails before any images are replaced. PHP data never establishes a missing or empty library roadmap. +When adding a PHP version, verify its announced dates, then run the roadmap tests, full tests, build and Markdown lint. +Inspect representative released and planned library timelines in both site themes and at mobile widths. + +## Browser date marker + +The website adds a one-pixel muted red line across the library and PHP bar area at the visitor's current local calendar day. +Browser JavaScript calculates its position from the same calendar geometry used to generate the SVG, including the inclusive final display day. +The line refreshes at local midnight and when a suspended page becomes visible again; no daily build is required. +It follows the image when resized or scrolled, leaves continuation arrows unchanged, and disappears when today is outside the display window. +An accessible non-visible label identifies the date. No visible date label or additional legend is added. + +This is a website enhancement, not a support commitment. Without JavaScript, the complete static calendar and support table remain available. +Standalone SVGs and portable Markdown retain the same script-free calendar assets and announced dates; they do not include a current-date marker. +Use the Markdown page's HTML counterpart link to view the live marker. diff --git a/docusaurus.config.js b/docusaurus.config.js index ef160b5..d5057bc 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -51,7 +51,7 @@ const config = { staticDirectories: ['static', '.generated-markdown'], trailingSlash: false, headTags: isDevelopment ? [{ tagName: 'meta', attributes: { name: 'robots', content: 'noindex, follow' } }] : [], - clientModules: ['./src/resource-route-aliases.js'], + clientModules: ['./src/resource-route-aliases.js', './src/roadmap-today-client.js'], onBrokenLinks: 'throw', markdown: { parseFrontMatter: parseRepresentationFrontMatter, diff --git a/scripts/roadmap-data.mjs b/scripts/roadmap-data.mjs index ab5aea6..ef2a16b 100644 --- a/scripts/roadmap-data.mjs +++ b/scripts/roadmap-data.mjs @@ -86,6 +86,18 @@ export function validateRoadmapData(input) { return { library, branches }; } +/** PHP uses the roadmap model as shared reference data, never as a documented library. */ +export function validatePhpReference(input) { + const roadmap = validateRoadmapData(input); + if (roadmap.library !== 'PHP') fail({ library: roadmap.library, branch: '' }, 'library', 'must be PHP for shared PHP reference data.'); + for (const branch of roadmap.branches) { + if (!/^[0-9]+\.[0-9]+$/u.test(branch.version)) { + fail({ library: 'PHP', branch: branch.version }, 'version', 'must be a PHP major.minor version, for example 8.4.'); + } + } + return roadmap; +} + export function validateTimelineWindow(input, library = '') { const context = { library, branch: '' }; record(input, ['startDate', 'endDate'], context, 'window'); diff --git a/scripts/roadmap-publication.mjs b/scripts/roadmap-publication.mjs index 7dc8b93..b9f493c 100644 --- a/scripts/roadmap-publication.mjs +++ b/scripts/roadmap-publication.mjs @@ -1,10 +1,10 @@ import { readdir, readFile, mkdir, writeFile, rm } from 'node:fs/promises'; import path from 'node:path'; import { load } from 'js-yaml'; -import { validateRoadmapData, validateTimelineWindow, RoadmapDataError } from './roadmap-data.mjs'; +import { validateRoadmapData, validatePhpReference, validateTimelineWindow, RoadmapDataError } from './roadmap-data.mjs'; import { discoverLibraries } from './library-versions.mjs'; import { baseUrl } from './site-settings.mjs'; -import { generateSupportTimeline } from './roadmap-timeline.mjs'; +import { buildSupportTimeline } from './roadmap-timeline.mjs'; const text = (value) => ({ type: 'text', value }); const paragraph = (value) => ({ type: 'paragraph', children: [text(value)] }); @@ -24,13 +24,15 @@ function documentationVersionNodes(library, versions, html) { } /** Portable MDAST shared by Docusaurus and Markdown export; no HTML or MDX. */ -export function roadmapSupportNodes(input, { library = input?.library, versions = [], html = false } = {}) { +export function roadmapSupportNodes(input, { library = input?.library, versions = [], html = false, timeline = null } = {}) { if (input === null) return [paragraph('The roadmap for this library has not been established yet.'), ...documentationVersionNodes(library, versions, html)]; const roadmap = validateRoadmapData(input); if (!roadmap.branches.length) return roadmapSupportNodes(null, { library: roadmap.library, versions, html }); const image = `../assets/roadmaps/${roadmap.library}.svg`; const children = [heading('Release timeline'), { - type: 'paragraph', data: { hProperties: { className: ['roadmap-timeline'] } }, children: [{ type: 'link', url: image, children: [{ + type: 'paragraph', data: { hProperties: { className: ['roadmap-timeline'], + ...(html && timeline ? { 'data-timeline-layout': JSON.stringify(timeline) } : {}), + } }, children: [{ type: 'link', url: image, children: [{ type: 'image', url: image, alt: `${roadmap.library} release and support timeline. Planned releases use diamonds; actual releases use circles. Full dates and PHP compatibility follow in the support table.`, }] }], @@ -69,10 +71,10 @@ export function appendRoadmapSupport(tree, sourcePath, roadmaps, { html = false if (!/^[^/]+\/index\.md$/.test(sourcePath)) return; const library = sourcePath.split('/')[0]; if (!Object.hasOwn(roadmaps, library)) return; - const { roadmap, versions } = roadmaps[library]; + const { roadmap, versions, timeline } = roadmaps[library]; // Refresh only build-owned nodes; authored content (including metadata) stays in order. const authored = tree.children.filter((node) => !node.data?.generatedRoadmapSupport); - const generated = roadmapSupportNodes(roadmap, { library, versions, html }); + const generated = roadmapSupportNodes(roadmap, { library, versions, html, timeline }); for (const node of generated) node.data = { ...node.data, generatedRoadmapSupport: true }; tree.children = [...authored, ...generated]; } @@ -106,8 +108,11 @@ export async function generateRoadmapAssets(root) { try { configurations = (await readdir(directory)).filter((name) => name.endsWith('.json') && name !== 'window.json'); } catch (error) { if (error.code !== 'ENOENT') throw error; } for (const filename of configurations) { + if (filename === 'PHP.json') continue; if (!Object.hasOwn(libraries, filename.slice(0, -5))) throw new Error(`Roadmap configuration ${filename} has no documented library.`); } + const phpInput = await optionalRoadmap(path.join(directory, 'PHP.json'), 'PHP'); + const phpReference = phpInput === null ? null : validatePhpReference(phpInput); const roadmaps = {}; const images = []; let window; @@ -130,7 +135,9 @@ export async function generateRoadmapAssets(root) { try { window = validateTimelineWindow(JSON.parse(await readFile(filename, 'utf8'))); } catch (error) { throw new Error(`Cannot read roadmap display window ${filename}: ${error.message}`, { cause: error }); } } - images.push([`${library}.svg`, generateSupportTimeline(roadmap, window)]); + const { svg, layout } = buildSupportTimeline(roadmap, window, { phpReference }); + roadmaps[library].timeline = layout; + images.push([`${library}.svg`, svg]); } } const destination = path.join(root, 'static/assets/roadmaps'); diff --git a/scripts/roadmap-timeline.mjs b/scripts/roadmap-timeline.mjs index 745f905..4fcbad8 100644 --- a/scripts/roadmap-timeline.mjs +++ b/scripts/roadmap-timeline.mjs @@ -1,7 +1,8 @@ -import { calendarDay, validateRoadmapData, validateTimelineWindow } from './roadmap-data.mjs'; +import { calendarDay, validateRoadmapData, validatePhpReference, validateTimelineWindow } from './roadmap-data.mjs'; // LongitudeOne identity from src/css/custom.css. The opaque background remains stable in either site theme. const colors = { background: '#f7f3eb', surface: '#fffcf7', text: '#172b40', active: '#35546b', security: '#a66a35', planned: '#c58a51' }; +const phpColors = { active: '#aebbc2', security: '#d8ccbd', outline: '#647078' }; const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); const number = (value) => String(Math.round(value * 1000) / 1000); @@ -34,7 +35,7 @@ function marker(kind, x, y, title) { return `${escape(title)}${shape}`; } -function period(kind, start, end, label, geometry, y, planned, branchLabel) { +function period(kind, start, end, label, geometry, y, planned, branchLabel, secondary = false) { const { first, last, x } = geometry; // Intervals are half-open internally, with one added day for inclusive support end dates. if (end <= first || start >= last) return ''; @@ -42,23 +43,29 @@ function period(kind, start, end, label, geometry, y, planned, branchLabel) { const after = end > last; const left = x(Math.max(start, first)); const right = x(Math.min(end, last)); - const fill = kind === 'active' ? colors.active : 'url(#security-pattern)'; - let result = `${escape(label)}${before ? '; continues before display window' : ''}${after ? '; continues after display window' : ''}`; - result += ``; + const fill = secondary + ? (kind === 'active' ? phpColors.active : 'url(#php-security-pattern)') + : (kind === 'active' ? colors.active : 'url(#security-pattern)'); + const halfHeight = secondary ? 8 : 12; + let result = ``; + // PHP details belong only to the non-visible description, without tooltip commentary. + if (!secondary) result += `${escape(label)}${before ? '; continues before display window' : ''}${after ? '; continues after display window' : ''}`; + result += ``; if (kind === 'active' && branchLabel) { const available = Math.max(1, right - left - 28); const fit = Array.from(branchLabel).length * 8 > available ? ` textLength="${number(available)}" lengthAdjust="spacingAndGlyphs"` : ''; result += `${escape(branchLabel)}`; } - // White arrows remain visible against both support fills, without relying on color alone. - if (before) result += ``; - if (after) result += ``; + // Continuation arrows remain distinct against each group's support fills. + if (before) result += ``; + if (after) result += ``; return `${result}`; } -/** Return a deterministic standalone SVG; no clock, support-duration policy or version classification is consulted. */ -export function generateSupportTimeline(input, displayWindow, { width: requestedWidth = 960 } = {}) { +/** Build a standalone SVG and its shared calendar geometry without consulting the clock. */ +export function buildSupportTimeline(input, displayWindow, { width: requestedWidth = 960, phpReference = null } = {}) { const roadmap = validateRoadmapData(input); + const php = phpReference === null ? null : validatePhpReference(phpReference); const window = validateTimelineWindow(displayWindow, roadmap.library); if (!Number.isSafeInteger(requestedWidth) || requestedWidth < 360) throw new Error('Timeline width must be a safe integer of at least 360 pixels.'); const first = dayOf(window.startDate); @@ -106,6 +113,46 @@ export function generateSupportTimeline(input, displayWindow, { width: requested y += 24; } if (!rows.length) { rows.push(textLines(['No branches announced.'], 24, y, { weight: 600 })); y += 48; } + const phpRows = []; + // Reference data alone must never establish a library roadmap. + for (const branch of roadmap.branches.length ? php?.branches ?? [] : []) { + const releaseDate = branch.status === 'planned' ? branch.plannedReleaseDate : branch.releaseDate; + if (releaseDate === null || branch.activeSupportUntil === null) continue; + const release = dayOf(releaseDate); + const active = dayOf(branch.activeSupportUntil) + 1; + const end = branch.securitySupportUntil === null ? active : dayOf(branch.securitySupportUntil) + 1; + if (end <= first || release >= last) continue; + const label = `PHP ${branch.version}`; + description.push(`${label}; ${branch.status} release: ${releaseDate}; active support until: ${branch.activeSupportUntil}; security support until: ${shown(branch.securitySupportUntil)}.`); + const trackY = y + 10; + let row = ``; + row += period('active', release, active, '', geometry, trackY, branch.status === 'planned', null, true); + if (end > active) row += period('security', active, end, '', geometry, trackY, branch.status === 'planned', null, true); + const left = geometry.x(Math.max(release, first)); + const right = geometry.x(Math.min(end, last)); + const labelWidth = label.length * 7; + const activeRight = geometry.x(Math.min(active, last)); + // Reserve room for continuation arrows as well as the uncompressed label. + const activePadding = release < first || active > last ? 16 : 6; + let anchor = 'middle'; + let labelX; + if (activeRight - left >= labelWidth + activePadding * 2) { + labelX = (left + activeRight) / 2; + } else if (active < last && end > Math.max(active, first)) { + anchor = 'start'; + const securityLeft = geometry.x(Math.max(active, first)); + const padding = active < first ? 16 : 6; + const rightPadding = end > last ? 16 : 6; + labelX = Math.max(30, Math.min(securityLeft + padding, width - 24 - rightPadding - labelWidth)); + } else { + // A clipped active-only sliver still keeps its label inside the calendar. + labelX = Math.max(24 + labelWidth / 2, Math.min(width - 24 - labelWidth / 2, (left + right) / 2)); + } + row += `${escape(label)}`; + phpRows.push(`${row}`); + y += 16; + } + const barBottom = y + 2; y += 26; const legend = []; for (const [index, label] of ['Planned release (diamond)', 'Actual release (circle)', 'Active support', 'Security fixes only (hatched)'].entries()) { @@ -116,16 +163,23 @@ export function generateSupportTimeline(input, displayWindow, { width: requested } y += 130; const height = y + 20; - return [ + const svg = [ ``, `${escape(title)}`, `${escape(description.join(' '))} Planned releases use diamonds, actual releases circles, active support a solid bar, and security fixes only a hatched bar. Arrows mark clipped support periods.`, ``, + ...(phpRows.length ? [``] : []), ``, ``, textLines(heading, 24, 30, { size: 20, weight: 650 }), ``, ...years.map((tick) => `${tick.year}`), - ...rows, ...legend, '', '', '', + ...rows, ...(phpRows.length ? [`${phpRows.join('')}`] : []), ...legend, '', '', '', ].join('\n'); + return { svg, layout: { first, last, width, height, left: 24, right: width - 24, top: axisY, bottom: barBottom } }; +} + +/** Return a deterministic standalone SVG; browser-only decoration is not part of the portable image. */ +export function generateSupportTimeline(input, displayWindow, options) { + return buildSupportTimeline(input, displayWindow, options).svg; } diff --git a/src/css/custom.css b/src/css/custom.css index ee93fa4..01a7491 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -255,6 +255,7 @@ html[data-theme='dark'] { } .markdown .roadmap-timeline a { + position: relative; display: block; width: 100%; min-width: 720px; @@ -266,6 +267,13 @@ html[data-theme='dark'] { max-width: none; } +.markdown .roadmap-today-marker { + position: absolute; + width: 1px; + background: #a65d59; + pointer-events: none; +} + .markdown .roadmap-support-table { display: block; max-width: 100%; diff --git a/src/roadmap-today-client.js b/src/roadmap-today-client.js new file mode 100644 index 0000000..adeb9f0 --- /dev/null +++ b/src/roadmap-today-client.js @@ -0,0 +1,8 @@ +import { installTimelineToday } from './roadmap-today.mjs'; + +let stop; + +export function onRouteDidUpdate() { + stop?.(); + stop = installTimelineToday(document, window); +} diff --git a/src/roadmap-today.mjs b/src/roadmap-today.mjs new file mode 100644 index 0000000..319c93f --- /dev/null +++ b/src/roadmap-today.mjs @@ -0,0 +1,62 @@ +const day = 86400000; + +/** Align the visitor's local calendar day to the SVG's UTC calendar-day axis. */ +export function todayMarkerPosition(layout, date) { + if (!layout || !['first', 'last', 'width', 'height', 'left', 'right', 'top', 'bottom'].every((key) => Number.isFinite(layout[key]))) return null; + const { first, last, width, height, left, right, top, bottom } = layout; + if (last <= first || width <= 0 || height <= 0 || left < 0 || right <= left || right > width || top < 0 || bottom <= top || bottom > height) return null; + if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return null; + const year = date.getFullYear(); + const month = date.getMonth(); + const dateOfMonth = date.getDate(); + const calendarDay = new Date(0); + calendarDay.setUTCFullYear(year, month, dateOfMonth); + const today = calendarDay.getTime() / day; + if (today < first || today >= last) return null; + return { + left: (left + (right - left) * (today - first) / (last - first)) / width * 100, + top: top / height * 100, + height: (bottom - top) / height * 100, + date: `${String(year).padStart(4, '0')}-${String(month + 1).padStart(2, '0')}-${String(dateOfMonth).padStart(2, '0')}`, + }; +} + +/** Refresh at local midnight and when a suspended page returns, without rebuilding SVGs. */ +export function installTimelineToday(document, browser, clock = () => new Date()) { + let timer; + function refresh() { + const now = clock(); + for (const timeline of document.querySelectorAll('.roadmap-timeline[data-timeline-layout]')) { + const link = timeline.querySelector('a'); + if (!link) continue; + let layout; + try { layout = JSON.parse(timeline.getAttribute('data-timeline-layout')); } + catch { layout = null; } + const position = todayMarkerPosition(layout, now); + let marker = link.querySelector('.roadmap-today-marker'); + if (!position) { marker?.remove(); continue; } + if (!marker) { + marker = document.createElement('span'); + marker.className = 'roadmap-today-marker'; + marker.setAttribute('role', 'img'); + link.append(marker); + } + marker.setAttribute('aria-label', `Today: ${position.date}`); + marker.style.left = `${position.left}%`; + marker.style.top = `${position.top}%`; + marker.style.height = `${position.height}%`; + } + browser.clearTimeout(timer); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + timer = browser.setTimeout(refresh, Math.max(1000, midnight.getTime() - now.getTime() + 100)); + } + const visible = () => { if (document.visibilityState === 'visible') refresh(); }; + document.addEventListener('visibilitychange', visible); + browser.addEventListener('pageshow', refresh); + refresh(); + return () => { + browser.clearTimeout(timer); + document.removeEventListener('visibilitychange', visible); + browser.removeEventListener('pageshow', refresh); + }; +} diff --git a/tests/export-markdown.test.mjs b/tests/export-markdown.test.mjs index 8bee9a4..6aa7a39 100644 --- a/tests/export-markdown.test.mjs +++ b/tests/export-markdown.test.mjs @@ -48,7 +48,12 @@ test('export generates roadmap images and portable support tables from one confi const manifest = JSON.parse(await readFile(path.join(root, '.generated-markdown/asset-manifest.json'), 'utf8')); assert.ok(manifest['assets/roadmaps/example-library.svg']); assert.match(await readFile(path.join(root, 'static/assets/roadmaps/example-library.svg'), 'utf8'), /2027-02-01/); - assert.deepEqual(JSON.parse(await readFile(path.join(root, '.docusaurus/roadmap-support.json'), 'utf8')), { 'example-library': { roadmap, versions: ['v1'] } }); + const snapshot = JSON.parse(await readFile(path.join(root, '.docusaurus/roadmap-support.json'), 'utf8'))['example-library']; + assert.deepEqual(snapshot.roadmap, roadmap); + assert.deepEqual(snapshot.versions, ['v1']); + assert.equal(snapshot.timeline.bottom - snapshot.timeline.top, 24); + assert.ok(snapshot.timeline.last > snapshot.timeline.first); + assert.doesNotMatch(output, /data-timeline-layout|roadmap-today-marker/); const original = output; await exportRoadmap(); assert.equal(await readFile(path.join(root, '.generated-markdown/example-library/index.md'), 'utf8'), original); diff --git a/tests/roadmap-publication.test.mjs b/tests/roadmap-publication.test.mjs index 01dc54e..272e13d 100644 --- a/tests/roadmap-publication.test.mjs +++ b/tests/roadmap-publication.test.mjs @@ -21,6 +21,7 @@ const branch = (changes = {}) => ({ activeSupportUntil: '2024-06-30', securitySupportUntil: '2024-12-31', php: '^8.4 || ^8.5', notes: null, ...changes, }); const roadmap = (branches = [branch()]) => ({ library: 'example-library', branches }); +const phpReference = { library: 'PHP', branches: [branch({ version: '8.4' }), branch({ version: '8.5' })] }; const window = { startDate: '2024-01-01', endDate: '2029-12-31' }; const processor = unified().use(remarkParse).use(remarkGfm).use(remarkFrontmatter, ['yaml']).use(remarkStringify); const markdown = (data) => processor.stringify({ type: 'root', children: roadmapSupportNodes(data) }); @@ -34,13 +35,14 @@ async function fixture(t) { await mkdir(path.join(root, 'config/roadmaps'), { recursive: true }); await writeFile(path.join(root, 'config/roadmaps/window.json'), JSON.stringify(window)); await writeFile(path.join(root, 'config/roadmaps/example-library.json'), JSON.stringify(roadmap())); + await writeFile(path.join(root, 'config/roadmaps/PHP.json'), JSON.stringify(phpReference)); return root; } test('all ten real roadmaps have validated JSON and retain shared data and explicit display bounds', async () => { const docs = new URL('docs/', repository); const names = Object.keys(await discoverLibraries(docs.pathname)); - const files = (await readdir(new URL('config/roadmaps/', repository))).filter((name) => name !== 'window.json'); + const files = (await readdir(new URL('config/roadmaps/', repository))).filter((name) => !['window.json', 'PHP.json'].includes(name)); assert.equal(names.length, 10); assert.deepEqual(files.sort(), names.map((name) => `${name}.json`).sort()); for (const name of names) { @@ -241,3 +243,81 @@ test('documentation versions list includes every published version even when lin } assert.ok(!roadmapSupportNodes(roadmap(), { versions: [] }).some(({ type }) => type === 'list')); }); + +test('PHP is shared SVG reference data without library discovery, tables, version lists or navigation', async (t) => { + const root = await fixture(t); + const data = await generateRoadmapAssets(root); + assert.deepEqual(Object.keys(data), ['example-library']); + assert.deepEqual(Object.keys(await discoverLibraries(path.join(root, 'docs'))), ['example-library']); + assert.deepEqual(await readdir(path.join(root, 'static/assets/roadmaps')), ['example-library.svg']); + const svg = await readFile(path.join(root, 'static/assets/roadmaps/example-library.svg'), 'utf8'); + assert.match(svg, /data-php-version="8.4"/); + assert.match(svg, /data-php-version="8.5"/); + const portable = { type: 'root', children: [] }; + const html = structuredClone(portable); + appendRoadmapSupport(portable, 'example-library/index.md', data); + remarkRoadmapSupport({ roadmaps: data, docsDirectory: path.join(root, 'docs') })(html, { path: path.join(root, 'docs/example-library/index.md') }); + const image = (tree) => tree.children[1].children[0].children[0]; + assert.deepEqual(image(html), image(portable)); + for (const tree of [portable, html]) { + assert.doesNotMatch(JSON.stringify(tree), /PHP 8\.[45]|PHP support|PHP\/|php\/|PHP\.svg/); + assert.equal(tree.children.filter(({ type }) => type === 'table').length, 1); + const table = tree.children.find(({ type }) => type === 'table'); + assert.equal(table.children.length, 2, 'Only library branches appear in the support table'); + } + await rm(path.join(root, 'config/roadmaps/PHP.json')); + await generateRoadmapAssets(root); + assert.doesNotMatch(await readFile(path.join(root, 'static/assets/roadmaps/example-library.svg'), 'utf8'), /data-php-version/); +}); + +test('one PHP configuration refreshes every library image while preserving library tables and fallback', async (t) => { + const root = await fixture(t); + await mkdir(path.join(root, 'docs/other-library/v1'), { recursive: true }); + await writeFile(path.join(root, 'docs/other-library/index.md'), 'Other roadmap'); + await writeFile(path.join(root, 'config/roadmaps/other-library.json'), JSON.stringify({ ...roadmap(), library: 'other-library' })); + const before = await generateRoadmapAssets(root); + const php = structuredClone(phpReference); + php.branches[0].activeSupportUntil = '2024-07-31'; + await writeFile(path.join(root, 'config/roadmaps/PHP.json'), JSON.stringify(php)); + const after = await generateRoadmapAssets(root); + assert.deepEqual(after, before, 'PHP reference data must not modify library support data'); + for (const library of ['example-library', 'other-library']) { + const svg = await readFile(path.join(root, `static/assets/roadmaps/${library}.svg`), 'utf8'); + assert.match(svg, /PHP 8.4; released release: 2024-01-01; active support until: 2024-07-31/); + } + await rm(path.join(root, 'config/roadmaps/example-library.json')); + const fallback = await generateRoadmapAssets(root); + assert.equal(fallback['example-library'].roadmap, null); + assert.deepEqual(await readdir(path.join(root, 'static/assets/roadmaps')), ['other-library.svg']); +}); + +test('invalid PHP reference configuration fails before replacing any generated images', async (t) => { + const root = await fixture(t); + await generateRoadmapAssets(root); + const image = path.join(root, 'static/assets/roadmaps/example-library.svg'); + const original = await readFile(image, 'utf8'); + for (const invalid of ['{', 'null', JSON.stringify({ ...phpReference, library: 'php' }), + JSON.stringify({ library: 'PHP', branches: [branch({ version: '8.4', releaseDate: '2024-02-30' })] }), + JSON.stringify({ library: 'PHP', branches: [branch({ version: 'PHP 8.4' })] })]) { + await writeFile(path.join(root, 'config/roadmaps/PHP.json'), invalid); + await assert.rejects(generateRoadmapAssets(root), /Cannot read roadmap JSON|Roadmap/); + assert.equal(await readFile(image, 'utf8'), original); + } +}); + +test('only the HTML timeline receives clock-free geometry for the browser date marker', async (t) => { + const root = await fixture(t); + const data = await generateRoadmapAssets(root); + const metadata = data['example-library'].timeline; + assert.equal(metadata.bottom - metadata.top, 24 + 2 * 16); + const html = { type: 'root', children: [] }; + const portable = structuredClone(html); + appendRoadmapSupport(html, 'example-library/index.md', data, { html: true }); + appendRoadmapSupport(portable, 'example-library/index.md', data); + assert.deepEqual(JSON.parse(html.children[1].data.hProperties['data-timeline-layout']), metadata); + assert.equal(portable.children[1].data.hProperties['data-timeline-layout'], undefined); + assert.doesNotMatch(processor.stringify(portable), /data-timeline-layout|roadmap-today-marker| ({ activeSupportUntil: '2024-06-30', securitySupportUntil: '2024-12-31', php: '^8.4', notes: null, ...changes, }); const data = (...branches) => ({ library: 'example-library', branches }); +const phpData = (...branches) => ({ library: 'PHP', branches }); +const phpRows = (svg) => all(xml(svg), (node) => Object.hasOwn(node.attributes, 'data-php-version')); function xml(svg) { const parser = sax.parser(true); @@ -335,3 +337,165 @@ test('branch bars have zero spacing from the calendar and each other', () => { assert.equal(background.height, bar.height); } }); + +test('PHP references share the library date axis without moving or resizing library bars', () => { + const library = data(branch(), branch({ version: '2.x' })); + const phpReference = phpData(branch({ version: '8.4' }), branch({ version: '8.5' })); + const snapshot = structuredClone(phpReference); + const svg = generateSupportTimeline(library, window, { phpReference }); + const primary = rows(svg); + const secondary = phpRows(svg); + assert.deepEqual(primary, rows(generateSupportTimeline(library, window))); + assert.deepEqual(phpReference, snapshot); + assert.equal(secondary.length, 2); + for (const row of secondary) { + for (const kind of ['active', 'security']) { + const reference = rect(row, kind).attributes; + const main = rect(primary[0], kind).attributes; + assert.equal(reference.x, main.x); + assert.equal(reference.width, main.width); + assert.ok(Number(reference.height) < Number(main.height)); + assert.notEqual(reference.fill, main.fill); + } + } + const libraryEnd = Number(rect(primary.at(-1), 'active').attributes.y) + 24; + assert.equal(Number(rect(secondary[0], 'active').attributes.y), libraryEnd); + assert.equal(Number(rect(secondary[1], 'active').attributes.y), libraryEnd + 16); + assert.notEqual(rect(secondary[0], 'active').attributes.fill, rect(secondary[0], 'security').attributes.fill); + assert.match(rect(secondary[0], 'security').attributes.fill, /pattern/); + assert.equal(generateSupportTimeline(library, window, { phpReference }), svg); +}); + +test('only PHP version labels are visible, with dates in non-visible metadata and no tooltip commentary', () => { + const input = data(branch()); + const phpReference = phpData(branch({ version: '8.4', notes: 'Private reference notes', php: null }), branch({ version: '8.5' })); + for (const width of [360, 960]) { + const svg = generateSupportTimeline(input, window, { phpReference, width }); + const visible = (source) => all(xml(source), (node) => node.name === 'text' || node.name === 'tspan').map((node) => node.text).filter(Boolean); + const original = visible(generateSupportTimeline(input, window, { width })); + assert.deepEqual(visible(svg).filter((value) => !original.includes(value)), ['PHP 8.4', 'PHP 8.5']); + for (const row of phpRows(svg)) { + assert.equal(all(row, (node) => node.name === 'title').length, 0); + const label = all(row, (node) => node.name === 'text')[0]; + assert.equal(label.attributes['font-size'], '12'); + assert.equal(label.attributes.textLength, undefined, 'PHP labels must not be compressed'); + } + assert.match(svg, /PHP 8\.4; released release: 2024-01-01; active support until: 2024-06-30; security support until: 2024-12-31/); + assert.doesNotMatch(svg, /Private reference notes/); + } +}); + +test('PHP periods clip on the same inclusive window and versions wholly outside it are omitted', () => { + const phpReference = phpData( + branch({ version: '8.1', releaseDate: '2022-01-01', activeSupportUntil: '2023-12-31', securitySupportUntil: '2023-12-31' }), + branch({ version: '8.2', releaseDate: '2022-01-01', activeSupportUntil: '2023-12-31', securitySupportUntil: '2024-01-01' }), + branch({ version: '8.3', releaseDate: '2023-01-01', activeSupportUntil: '2024-06-30', securitySupportUntil: '2025-12-31' }), + branch({ version: '8.4', releaseDate: '2024-12-31', activeSupportUntil: '2025-12-31', securitySupportUntil: '2026-12-31' }), + branch({ version: '8.5', releaseDate: '2025-01-01', activeSupportUntil: '2025-12-31', securitySupportUntil: '2026-12-31' }), + ); + const secondary = phpRows(generateSupportTimeline(data(branch()), window, { phpReference, width: 360 })); + assert.deepEqual(secondary.map((row) => row.attributes['data-php-version']), ['8.2', '8.3', '8.4']); + close(rect(secondary[0], 'security').attributes.width, 312 / 366); + assert.equal(kinds(secondary[0], 'active').length, 0); + assert.equal(kinds(secondary[1], 'active')[0].attributes['data-continues-before'], 'true'); + assert.equal(kinds(secondary[1], 'security')[0].attributes['data-continues-after'], 'true'); + close(rect(secondary[2], 'active').attributes.width, 312 / 366); + for (const row of secondary) { + const label = all(row, (node) => node.name === 'text')[0]; + const left = Number(label.attributes.x) - (label.attributes['text-anchor'] === 'middle' ? label.text.length * 7 / 2 : 0); + assert.ok(left >= 24); + assert.ok(left + label.text.length * 7 <= 336); + } +}); + +test('PHP labels center in active support or start in security-only support without obscuring continuation arrows', async () => { + const phpReference = JSON.parse(await readFile(new URL('../config/roadmaps/PHP.json', import.meta.url), 'utf8')); + const bounds = { startDate: '2025-01-01', endDate: '2029-03-31' }; + for (const width of [360, 960]) { + const references = phpRows(generateSupportTimeline(data(branch()), bounds, { phpReference, width })); + const php82 = references.find((row) => row.attributes['data-php-version'] === '8.2'); + const php84 = references.find((row) => row.attributes['data-php-version'] === '8.4'); + const php85 = references.find((row) => row.attributes['data-php-version'] === '8.5'); + const label = (row) => all(row, (node) => node.name === 'text')[0]; + assert.equal(kinds(php82, 'active').length, 0); + assert.equal(label(php82).attributes['text-anchor'], 'start'); + close(label(php82).attributes.x, Number(rect(php82, 'security').attributes.x) + 6); + assert.equal(label(php84).attributes['text-anchor'], 'middle'); + const blue = rect(php84, 'active').attributes; + close(label(php84).attributes.x, Number(blue.x) + Number(blue.width) / 2); + const before = kinds(php84, 'active')[0]; + assert.equal(before.attributes['data-continues-before'], 'true'); + assert.equal(before.children.filter((node) => node.name === 'path').length, 1); + assert.match(before.children.find((node) => node.name === 'path').attributes.d, /l -6 6 6 6$/); + const after = kinds(php85, 'security')[0]; + assert.equal(after.attributes['data-continues-after'], 'true'); + assert.equal(after.children.filter((node) => node.name === 'path').length, 1); + assert.match(after.children.find((node) => node.name === 'path').attributes.d, /l 6 6 -6 6$/); + } +}); + +test('PHP labels fall back from a narrow active segment to the hatched start and keep clear of a left continuation arrow', () => { + const phpReference = phpData( + branch({ version: '8.4', activeSupportUntil: '2024-01-02' }), + branch({ version: '8.2', releaseDate: '2022-01-01', activeSupportUntil: '2023-12-30' }), + ); + for (const width of [360, 960]) { + const [narrow, securityOnly] = phpRows(generateSupportTimeline(data(branch()), window, { phpReference, width })); + for (const row of [narrow, securityOnly]) { + const label = all(row, (node) => node.name === 'text')[0]; + const security = rect(row, 'security').attributes; + assert.equal(label.attributes['text-anchor'], 'start'); + close(label.attributes.x, Number(security.x) + (row === securityOnly ? 16 : 6)); + assert.equal(label.attributes.textLength, undefined); + assert.equal(label.attributes['font-size'], '12'); + } + const continuation = kinds(securityOnly, 'security')[0]; + assert.equal(continuation.attributes['data-continues-before'], 'true'); + assert.equal(continuation.children.filter((node) => node.name === 'path').length, 1); + } +}); + +test('PHP unknown dates do not invent periods, equal support ends do not invent security days, and PHP alone creates no roadmap', () => { + const phpReference = phpData( + branch({ version: '8.2', status: 'planned', releaseDate: null, plannedReleaseDate: null }), + branch({ version: '8.3', activeSupportUntil: null }), + branch({ version: '8.4', securitySupportUntil: null }), + branch({ version: '8.5', securitySupportUntil: '2024-06-30' }), + ); + const secondary = phpRows(generateSupportTimeline(data(branch()), window, { phpReference })); + assert.deepEqual(secondary.map((row) => row.attributes['data-php-version']), ['8.4', '8.5']); + assert.ok(secondary.every((row) => kinds(row, 'security').length === 0)); + assert.equal(generateSupportTimeline(data(), window, { phpReference }), generateSupportTimeline(data(), window)); +}); + +test('PHP reference configuration reuses schema and calendar diagnostics and rejects non-version labels', () => { + const phpReference = phpData(branch({ version: '8.4' })); + assert.deepEqual(validatePhpReference(phpReference), phpReference); + for (const input of [ + { ...phpReference, library: 'php' }, + phpData(branch({ version: 'PHP 8.4' })), + phpData(branch({ version: '8.4 extra commentary' })), + phpData(branch({ version: '8.4', activeSupportUntil: '2024-02-30' })), + phpData(branch({ version: '8.4', securitySupportUntil: '2024-06-29' })), + phpData(branch({ version: '8.4', extra: true })), + phpData(branch({ version: '8.4' }), branch({ version: '8.4' })), + ]) assert.throws(() => generateSupportTimeline(data(branch()), window, { phpReference: input }), RoadmapDataError); +}); + +test('browser overlay geometry matches the SVG axis and exact library/PHP bar area, excluding heading and legend', () => { + const input = data(branch(), branch({ version: '2.x' })); + const phpReference = phpData(branch({ version: '8.4' }), branch({ version: '8.5' })); + const { svg, layout } = buildSupportTimeline(input, window, { phpReference }); + assert.equal(svg, generateSupportTimeline(input, window, { phpReference })); + const primary = rows(svg); + const secondary = phpRows(svg); + assert.equal(layout.top, Number(rect(primary[0], 'active').attributes.y)); + const last = rect(secondary.at(-1), 'active').attributes; + assert.equal(layout.bottom, Number(last.y) + Number(last.height)); + assert.equal(layout.left, Number(rect(primary[0], 'active').attributes.x)); + const end = rect(primary[0], 'security').attributes; + close(layout.right, Number(end.x) + Number(end.width)); + assert.equal(layout.last - layout.first, 366); + assert.ok(layout.top > 0 && layout.bottom < layout.height); + assert.doesNotMatch(svg, /roadmap-today-marker|'); + const stop = installTimelineToday(document, browser, () => new Date(2024, 0, 1)); + assert.equal(link.marker, null); + stop(); +});