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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .htmltest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ IgnoreURLs: # list of regexes of paths or URLs to be ignored
- ^https://github\.com/open-telemetry/opentelemetry.io/tree/main/content/en/[^d]
- ^https://github\.com/open-telemetry/opentelemetry.io/tree/main/content/en/docs/[^s]
- ^https://github\.com/open-telemetry/opentelemetry.io/tree/main/content/en/docs/security
# TODO: remove once https://github.com/open-telemetry/opentelemetry.io/pull/9850 is merged;
# this file is new and won't exist on main until then:
- ^https://github\.com/open-telemetry/opentelemetry\.io/tree/main/content/en/docs/specs/otel-config/
# FIXME: same issue as for the OTel spec mentioned above:
- ^https://github.com/open-telemetry/semantic-conventions/tree/main

Expand Down
276 changes: 276 additions & 0 deletions assets/js/configTypesAccordion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import {
expandAll,
collapseAll,
filterItems,
normalizeForSearch,
typeMatchesSearch,
} from './shared/accordionUtils.js';

const CONTAINER_SEL = '.config-types-accordion';
const ACCORDION_ID = 'ct-accordion';
const NO_RESULTS_ID = 'ct-no-results';
const COUNT_ID = 'ct-count';

// ── i18n ──────────────────────────────────────────────────────────────────────

function readI18n(container) {
const d = container.dataset;
return {
search: d.i18nSearch,
filterAll: d.i18nFilterAll,
filterStable: d.i18nFilterStable,
filterExperimental: d.i18nFilterExperimental,
expandAll: d.i18nExpandAll,
collapseAll: d.i18nCollapseAll,
noResults: d.i18nNoResults,
loading: d.i18nLoading,
colType: d.i18nColType,
colConstraints: d.i18nColConstraints,
colDescription: d.i18nColDescription,
};
}

// ── Rendering ─────────────────────────────────────────────────────────────────

function escapeAttr(str) {
return String(str)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

function renderControls(types, i18n) {
const stableCount = types.filter((t) => !t.isExperimental).length;
const expCount = types.filter((t) => t.isExperimental).length;
const total = types.length;

return `
<div class="config-types-controls mb-3">
<div class="row g-2 align-items-center">
<div class="col-md-5">
<input type="search"
id="ct-search"
class="form-control"
placeholder="${escapeAttr(i18n.search)}"
aria-label="${escapeAttr(i18n.search)}">
</div>
<div class="col-md-4">
<div class="btn-group" role="group" aria-label="Filter by stability">
<button type="button" class="btn btn-outline-primary active"
data-ct-filter="all">${escapeHtml(i18n.filterAll)} (${total})</button>
<button type="button" class="btn btn-outline-primary"
data-ct-filter="stable">${escapeHtml(i18n.filterStable)} (${stableCount})</button>
<button type="button" class="btn btn-outline-primary"
data-ct-filter="experimental">${escapeHtml(i18n.filterExperimental)} (${expCount})</button>
</div>
</div>
<div class="col-md-3 text-end">
<button type="button" class="btn btn-sm btn-outline-primary me-1"
id="ct-expand-all">${escapeHtml(i18n.expandAll)}</button>
<button type="button" class="btn btn-sm btn-outline-primary"
id="ct-collapse-all">${escapeHtml(i18n.collapseAll)}</button>
</div>
</div>
<div id="${COUNT_ID}" class="text-body-secondary small mt-1"
aria-live="polite" aria-atomic="true">
Showing ${total} of ${total} types
</div>
</div>`;
}

function renderPropertiesTable(type, i18n) {
if (type.hasNoProperties) {
return `<p class="fst-italic text-body-secondary mb-0">No configurable properties.</p>`;
}

const hasConstraints = type.properties.some((p) => p.constraints);

const rows = type.properties
.map(
(prop) => `
<tr>
<td><code>${escapeHtml(prop.name)}</code></td>
<td><code class="ct-prop-type">${escapeHtml(prop.type)}</code></td>
${hasConstraints ? `<td class="ct-prop-constraints">${escapeHtml(prop.constraints)}</td>` : ''}
<td data-prop-desc="${escapeAttr(prop.name)}"></td>
</tr>`,
)
.join('');

return `
<table class="table table-sm table-bordered ct-props-table">
<thead>
<tr>
<th>Name</th>
<th>${escapeHtml(i18n.colType)}</th>
${hasConstraints ? `<th>${escapeHtml(i18n.colConstraints)}</th>` : ''}
<th>${escapeHtml(i18n.colDescription)}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}

function renderTypeItem(type, i18n) {
const propCount = type.hasNoProperties ? 0 : (type.properties?.length ?? 0);
const countText = propCount === 1 ? '1 property' : `${propCount} properties`;
const constraintsHtml = type.constraints
? `<p class="ct-type-constraints"><strong>Type constraints:</strong> ${escapeHtml(type.constraints)}</p>`
: '';

return `
<div class="accordion-item"
data-type-id="${escapeAttr(type.id)}"
data-is-experimental="${type.isExperimental}">
<h3 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ct-${escapeAttr(type.id)}"
aria-expanded="false"
aria-controls="ct-${escapeAttr(type.id)}">
${escapeHtml(type.name)}
<small class="ms-2 fw-normal text-body-secondary ct-prop-count">${escapeHtml(countText)}</small>
</button>
</h3>
<div id="ct-${escapeAttr(type.id)}" class="accordion-collapse collapse">
<div class="accordion-body">
${renderPropertiesTable(type, i18n)}
${constraintsHtml}
</div>
</div>
</div>`;
}

function renderAccordion(types, i18n) {
const items = types.map((t) => renderTypeItem(t, i18n)).join('');
return `
<div id="${ACCORDION_ID}" class="accordion">
${items}
</div>
<p id="${NO_RESULTS_ID}" class="d-none text-body-secondary mt-3">${escapeHtml(i18n.noResults)}</p>`;
}

// ── Description injection ─────────────────────────────────────────────────────

// Descriptions are pre-rendered safe HTML (ul/ol/li/a only, per configSchemaTransform.mjs).
// They are injected via innerHTML after the DOM is built to avoid double-escaping.
function injectDescriptions(container, types) {
for (const type of types) {
for (const prop of type.properties ?? []) {
if (!prop.description) continue;
const cell = container.querySelector(
`[data-type-id="${CSS.escape(type.id)}"] [data-prop-desc="${CSS.escape(prop.name)}"]`,
);
if (cell) cell.innerHTML = prop.description;
}
}
}

// ── Filter / search ───────────────────────────────────────────────────────────

function applyFilters(container, types, state) {
const accordion = container.querySelector(`#${ACCORDION_ID}`);
const countEl = container.querySelector(`#${COUNT_ID}`);
const noResults = container.querySelector(`#${NO_RESULTS_ID}`);

const q = normalizeForSearch(state.query);
let count = 0;

filterItems(accordion, (item) => {
const isExp = item.dataset.isExperimental === 'true';
const typeObj = types.find((t) => t.id === item.dataset.typeId);
const filterMatch =
state.filter === 'all' ||
(state.filter === 'stable' && !isExp) ||
(state.filter === 'experimental' && isExp);
const searchMatch = typeMatchesSearch(typeObj, q);
if (filterMatch && searchMatch) count++;
return filterMatch && searchMatch;
});

if (countEl)
countEl.textContent = `Showing ${count} of ${types.length} types`;
if (noResults) noResults.classList.toggle('d-none', count > 0);
}

// ── Event wiring ──────────────────────────────────────────────────────────────

function wireControls(container, types) {
const accordion = container.querySelector(`#${ACCORDION_ID}`);
const state = { query: '', filter: 'all' };
let debounceTimer;

// Search
const searchInput = container.querySelector('#ct-search');
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
state.query = searchInput.value;
applyFilters(container, types, state);
}, 300);
});
}

// Filter buttons
container.querySelectorAll('[data-ct-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
container
.querySelectorAll('[data-ct-filter]')
.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
state.filter = btn.dataset.ctFilter;
applyFilters(container, types, state);
});
});

// Expand / collapse all
const expandBtn = container.querySelector('#ct-expand-all');
const collapseBtn = container.querySelector('#ct-collapse-all');
if (expandBtn)
expandBtn.addEventListener('click', () => expandAll(accordion));
if (collapseBtn)
collapseBtn.addEventListener('click', () => collapseAll(accordion));
}

// ── Init ──────────────────────────────────────────────────────────────────────

async function init() {
const container = document.querySelector(CONTAINER_SEL);
if (!container) return;

const schemaUrl = container.dataset.schemaUrl;
const i18n = readI18n(container);

container.innerHTML = `<p class="text-body-secondary">${escapeHtml(i18n.loading)}</p>`;

try {
const res = await fetch(schemaUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const types = data.types;

container.innerHTML =
renderControls(types, i18n) + renderAccordion(types, i18n);
injectDescriptions(container, types);
wireControls(container, types);
} catch (err) {
container.innerHTML = `<div class="alert alert-danger">Failed to load configuration types.</div>`;
console.error('config-types-accordion:', err);
}
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
62 changes: 62 additions & 0 deletions assets/js/shared/accordionUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Shared utilities for config documentation accordions.
* Used by configTypesAccordion.js and configLangStatusAccordion.js.
*/

export function expandAll(container) {
container.querySelectorAll('.accordion-collapse').forEach((el) => {
el.classList.add('show');
const btn = el
.closest('.accordion-item')
?.querySelector('.accordion-button');
if (btn) {
btn.classList.remove('collapsed');
btn.setAttribute('aria-expanded', 'true');
}
});
}

export function collapseAll(container) {
container.querySelectorAll('.accordion-collapse').forEach((el) => {
el.classList.remove('show');
const btn = el
.closest('.accordion-item')
?.querySelector('.accordion-button');
if (btn) {
btn.classList.add('collapsed');
btn.setAttribute('aria-expanded', 'false');
}
});
}

/**
* Show or hide accordion items based on a predicate.
* @param {Element} container
* @param {function(Element): boolean} predicate
*/
export function filterItems(container, predicate) {
container.querySelectorAll('.accordion-item').forEach((item) => {
item.classList.toggle('d-none', !predicate(item));
});
}

export function normalizeForSearch(str) {
return str.toLowerCase().replace(/\s+/g, ' ').trim();
}

/**
* Check if a type object matches a search query.
* Searches type name, property names, and property types.
* Descriptions are not searched because they contain pre-rendered HTML.
* @param {{ name: string, properties?: Array<{name: string, type: string}> }} typeObj
* @param {string} normalizedQuery - result of normalizeForSearch()
*/
export function typeMatchesSearch(typeObj, normalizedQuery) {
if (!normalizedQuery) return true;
if (normalizeForSearch(typeObj.name).includes(normalizedQuery)) return true;
for (const prop of typeObj.properties ?? []) {
if (normalizeForSearch(prop.name).includes(normalizedQuery)) return true;
if (normalizeForSearch(prop.type).includes(normalizedQuery)) return true;
}
return false;
}
22 changes: 22 additions & 0 deletions assets/scss/_config_accordion_shared.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Shared styles for config documentation accordions (config types, lang status).

.config-types-controls {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.75rem 1rem;
}

// Visual indicator for experimental types: left border accent.
.accordion-item[data-is-experimental='true'] {
.accordion-button {
border-left: 3px solid var(--bs-warning);
}
}

// Keep the property count readable when the accordion button is in its open
// (expanded) state — the button background darkens and `text-body-secondary`
// would otherwise become hard to read.
.accordion-button:not(.collapsed) .ct-prop-count {
opacity: 0.7;
}
Loading