rclnodejs
Version:
ROS2.0 JavaScript client with Node.js
611 lines (529 loc) • 15.9 kB
JavaScript
// Copyright (c) 2026 The Robot Web Tools Contributors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
'use strict';
const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '../..');
const docsRoot = path.resolve(
process.env.RCLNODEJS_DOCS_ROOT || path.join(repoRoot, 'docs')
);
const packageJsonPath = path.resolve(
process.env.RCLNODEJS_PACKAGE_JSON_PATH || path.join(repoRoot, 'package.json')
);
const localIndexPath = process.env.RCLNODEJS_LOCAL_INDEX_PATH
? path.resolve(process.env.RCLNODEJS_LOCAL_INDEX_PATH)
: null;
const docsIndexPath = path.resolve(
process.env.RCLNODEJS_DOCS_INDEX_PATH || path.join(docsRoot, 'index.html')
);
const gitCwd = path.resolve(process.env.RCLNODEJS_GIT_CWD || repoRoot);
function readPackageInfo() {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
}
function isVersionDirectory(entryName) {
return /^\d+\.\d+\.\d+$/.test(entryName);
}
function compareVersionsDesc(left, right) {
const leftParts = left.split('.').map(Number);
const rightParts = right.split('.').map(Number);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] || 0;
const rightValue = rightParts[index] || 0;
if (leftValue !== rightValue) {
return rightValue - leftValue;
}
}
return 0;
}
function getAvailableVersions() {
return fs
.readdirSync(docsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && isVersionDirectory(entry.name))
.map((entry) => entry.name)
.sort(compareVersionsDesc);
}
function runGitCommand(args) {
try {
return childProcess
.execFileSync('git', args, {
cwd: gitCwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
.trim();
} catch {
return '';
}
}
function getGitTags() {
const tagsOutput = runGitCommand(['tag', '--list']);
return new Set(tagsOutput ? tagsOutput.split('\n').filter(Boolean) : []);
}
function getTagReleaseDate(tagName) {
const commitSha = runGitCommand(['rev-list', '-n', '1', tagName]);
if (!commitSha) {
return null;
}
return (
runGitCommand(['show', '-s', '--format=%ad', '--date=short', commitSha]) ||
null
);
}
function buildVersionEntries(packageInfo, versions) {
const gitTags = getGitTags();
return versions.map((version) => {
const hasTag = gitTags.has(version);
return {
version,
releasedOn: hasTag ? getTagReleaseDate(version) : null,
docsUrl: `./${version}/index.html`,
};
});
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderVersionCards(versionEntries, latestVersion) {
return versionEntries
.map((entry) => {
const latestBadge =
entry.version === latestVersion
? '<span class="version-badge">Latest</span>'
: '';
const releaseMeta = entry.releasedOn
? `<p class="version-meta">Released ${escapeHtml(entry.releasedOn)}</p>`
: '';
return `
<a class="version-card" href="${escapeHtml(entry.docsUrl)}">
<div class="version-card-header">
<span class="version-label">Release</span>
${latestBadge}
</div>
<strong class="version-number">v${escapeHtml(entry.version)}</strong>
${releaseMeta}
<p class="version-copy">Browse the generated API reference for rclnodejs ${escapeHtml(entry.version)}.</p>
</a>`;
})
.join('');
}
function renderIndexHtml(packageInfo, versionEntries) {
const latestEntry = versionEntries[0] || {
version: packageInfo.version,
releasedOn: null,
};
const latestVersion = latestEntry.version;
const versionCards = renderVersionCards(versionEntries, latestVersion);
const versionCount = versionEntries.length;
const latestReleaseDate = latestEntry.releasedOn
? `<p>Released on <strong>${escapeHtml(latestEntry.releasedOn)}</strong></p>`
: '<p>Release date unavailable for this version.</p>';
return `
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rclnodejs Documentation</title>
<style>
:root {
--bg: #f3f7fb;
--bg-deep: #dfeaf4;
--surface: rgba(255, 255, 255, 0.86);
--surface-strong: rgba(255, 255, 255, 0.96);
--border: rgba(28, 53, 77, 0.12);
--border-strong: rgba(28, 53, 77, 0.18);
--text: #12263a;
--text-soft: #486277;
--text-muted: #667f92;
--accent: #0b84c9;
--accent-strong: #08679b;
--accent-soft: rgba(11, 132, 201, 0.12);
--shadow-lg: 0 24px 60px rgba(14, 31, 53, 0.14);
--shadow-md: 0 12px 30px rgba(14, 31, 53, 0.1);
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
}
* {
box-sizing: border-box;
}
html {
background: linear-gradient(180deg, #eff5fa 0%, #f7fbff 100%);
color: var(--text);
font-size: 16px;
}
body {
margin: 0;
font-family: "Open Sans", "Segoe UI", sans-serif;
line-height: 1.6;
color: var(--text);
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
.site-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at top left, rgba(11, 132, 201, 0.18), transparent 32%),
radial-gradient(circle at right 10%, rgba(0, 128, 106, 0.12), transparent 24%),
linear-gradient(180deg, rgba(255, 255, 255, 0.6), rgba(243, 247, 251, 0.95));
pointer-events: none;
z-index: -2;
}
.site-grid {
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(18, 38, 58, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(18, 38, 58, 0.03) 1px, transparent 1px);
background-size: 32px 32px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.28), transparent 75%);
pointer-events: none;
z-index: -1;
}
.page-shell {
max-width: 1320px;
margin: 0 auto;
padding: 24px;
}
.hero,
.panel,
.footer {
background: var(--surface-strong);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.8fr);
gap: 24px;
padding: 34px;
overflow: hidden;
position: relative;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(231, 243, 250, 0.9)),
linear-gradient(120deg, rgba(11, 132, 201, 0.16), transparent 42%);
}
.hero::after {
content: "";
position: absolute;
top: -50px;
right: -50px;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(11, 132, 201, 0.16), transparent 68%);
pointer-events: none;
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.74rem;
font-weight: 700;
color: var(--accent-strong);
}
.hero h1 {
margin: 0;
font-size: clamp(2.8rem, 5vw, 4.8rem);
line-height: 0.95;
letter-spacing: -0.07em;
font-weight: 300;
}
.hero h1 span {
display: block;
margin-top: 8px;
color: var(--text-muted);
font-size: 0.42em;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.hero-copy {
position: relative;
z-index: 1;
}
.hero-description,
.panel p,
.summary-card p,
.version-copy,
.version-meta,
.footer {
color: var(--text-soft);
}
.hero-description {
max-width: 48rem;
margin: 18px 0 0;
font-size: 1.02rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
}
.hero-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 16px;
border-radius: 14px;
border: 1px solid var(--border-strong);
background: rgba(255, 255, 255, 0.72);
color: var(--text);
font-weight: 700;
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
}
.hero-action:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.94);
border-color: rgba(11, 132, 201, 0.22);
}
.hero-action.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-color: transparent;
color: #fff;
}
.panel {
padding: 22px;
position: relative;
z-index: 1;
background: rgba(248, 252, 255, 0.82);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.panel-title {
margin: 0 0 14px;
color: var(--text);
font-size: 0.92rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.summary-grid,
.versions-grid {
display: grid;
gap: 18px;
margin-top: 24px;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-card,
.version-card {
background: var(--surface-strong);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.summary-card {
padding: 22px;
}
.summary-number {
display: block;
font-size: 2.2rem;
line-height: 1;
letter-spacing: -0.06em;
font-weight: 300;
}
.summary-label {
display: block;
margin-top: 8px;
font-weight: 700;
color: var(--text);
}
.versions {
margin-top: 24px;
padding: 28px 30px;
}
.section-heading {
margin: 0;
font-size: clamp(1.8rem, 2.6vw, 2.6rem);
line-height: 1.04;
letter-spacing: -0.05em;
font-weight: 300;
}
.section-copy {
margin: 12px 0 0;
max-width: 46rem;
color: var(--text-soft);
}
.versions-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 22px;
}
.version-card {
padding: 22px;
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.version-card:hover {
transform: translateY(-2px);
border-color: rgba(11, 132, 201, 0.24);
box-shadow: 0 18px 36px rgba(14, 31, 53, 0.12);
}
.version-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.version-label,
.version-badge {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.version-label {
background: var(--accent-soft);
color: var(--accent-strong);
}
.version-badge {
background: rgba(0, 128, 106, 0.12);
color: #0d6a59;
}
.version-number {
display: block;
margin-top: 18px;
font-size: 2rem;
line-height: 1;
letter-spacing: -0.06em;
font-weight: 300;
color: var(--text);
}
.version-meta {
margin: 10px 0 0;
font-size: 0.92rem;
font-weight: 700;
color: var(--text-muted);
}
.version-copy {
margin: 12px 0 0;
}
.footer {
margin-top: 24px;
padding: 18px 22px;
font-size: 0.92rem;
}
@media (max-width: 1100px) {
.hero,
.versions-grid,
.summary-grid {
grid-template-columns: 1fr 1fr;
}
.hero > :first-child {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.page-shell {
padding: 14px;
}
.hero,
.versions-grid,
.summary-grid {
grid-template-columns: 1fr;
}
.hero,
.versions,
.panel,
.footer,
.summary-card,
.version-card {
border-radius: 22px;
}
.hero,
.versions {
padding: 22px 20px;
}
}
</style>
</head>
<body>
<div class="site-backdrop"></div>
<div class="site-grid"></div>
<div class="page-shell">
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Versioned API Reference</p>
<h1>rclnodejs <span>ROS 2 JavaScript Client</span></h1>
<p class="hero-description">Browse published API references across released versions. The latest version is highlighted, and every docs build refreshes this landing page automatically.</p>
<div class="hero-actions">
<a class="hero-action primary" href="./${escapeHtml(latestVersion)}/index.html">Open Latest Docs</a>
<a class="hero-action" href="#versions">Browse Versions</a>
</div>
</div>
<aside class="panel">
<p class="panel-title">Current Release</p>
<h2 class="section-heading">v${escapeHtml(latestVersion)}</h2>
<p>Package version from package.json: <strong>${escapeHtml(packageInfo.version)}</strong></p>
${latestReleaseDate}
</aside>
</section>
<section class="summary-grid">
<article class="summary-card">
<strong class="summary-number">${versionCount}</strong>
<span class="summary-label">Published Versions</span>
<p>All discovered version directories under the docs output root.</p>
</article>
<article class="summary-card">
<strong class="summary-number">v${escapeHtml(latestVersion)}</strong>
<span class="summary-label">Latest Version</span>
<p>The newest semantic version discovered during index generation, with release date information when available.</p>
</article>
</section>
<section class="panel versions" id="versions">
<p class="eyebrow">Releases</p>
<h2 class="section-heading">Choose a documentation snapshot</h2>
<p class="section-copy">Version entries are generated automatically from the directories currently present in the docs output, so new releases appear after <code>npm run docs</code> without manual HTML edits.</p>
<div class="versions-grid">${versionCards}</div>
</section>
<footer class="footer">
Documentation index for ${escapeHtml(packageInfo.name)}. Generated automatically by <code>tools/jsdoc/build-index.js</code>.
</footer>
</div>
</body>
</html>
`;
}
function writeIndexFiles(html) {
if (localIndexPath) {
fs.writeFileSync(localIndexPath, html, 'utf8');
}
fs.writeFileSync(docsIndexPath, html, 'utf8');
}
function main() {
const packageInfo = readPackageInfo();
const versions = getAvailableVersions();
const versionEntries = buildVersionEntries(packageInfo, versions);
const html = renderIndexHtml(packageInfo, versionEntries);
writeIndexFiles(html);
}
main();