import {requireEnglishSurface} from './document-localization.js';
/** @requirements REQ-PRODUCT-SERVICE-CATALOG REQ-PRODUCT-A11Y REQ-PRODUCT-MOBILE */
import {toPublicConfig,isSafeLink} from './config.js';
import {escapeHtml} from './documents.js';
import {declaredServices} from './service-inventory.js';
import type {DeclaredService} from './service-inventory.js';
import type {ValidationOptions} from './types.js';
export {createServiceInventory} from './service-inventory.js';
export type {DeclaredService} from './service-inventory.js';
export interface ServiceDirectoryFilter {query?:string;purposeId?:string}
export interface ServiceDirectory {
mode:'preview'|'production';
site:{name:string;url:string;locale:string};
policies:{privacy:{title:string;route:string;version:string;effectiveDate:string};cookies:{title:string;route:string;version:string;effectiveDate:string}};
purposes:{id:string;name:string}[];totalServices:number;services:DeclaredService[];
}
export interface ServiceDirectoryOptions extends ValidationOptions,ServiceDirectoryFilter {
/** Unique within the host page. Also gives each service a stable fragment link. */
idPrefix:string;headingLevel?:1|2|3|4;title?:string;description?:string;
}
function safeText(value:unknown,max:number):value is string {
return typeof value==='string'&&value.length<=max&&!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value);
}
function filterServices(model:ServiceDirectory,filter:ServiceDirectoryFilter):DeclaredService[] {
const query=filter.query===undefined?'':filter.query;
if(!safeText(query,120)||filter.purposeId!==undefined&&(typeof filter.purposeId!=='string'||!model.purposes.some(p=>p.id===filter.purposeId)))throw Error('Use a short service search and a declared purpose ID.');
const terms=query.toLocaleLowerCase('en').trim().split(/\s+/u).filter(Boolean);
return model.services.filter(service=>(filter.purposeId===undefined||service.purposes.some(p=>p.id===filter.purposeId))&&terms.every(term=>[
service.name,service.provider,service.location,...service.purposes.flatMap(p=>[p.name,p.description,...p.dataCategories]),...service.storage.map(s=>s.name),
].join(' ').toLocaleLowerCase('en').includes(term)));
}
/** Confirmed inventory only. This module never imports reference data or performs discovery. */
export function createServiceDirectory(input:unknown,options:ValidationOptions&ServiceDirectoryFilter={}):ServiceDirectory {
const config=toPublicConfig(input,{mode:options.mode??'production'});
const policy=(kind:'privacy'|'cookies')=>{
const {title,route,version,effectiveDate}=config.documents[kind];return {title,route,version,effectiveDate};
};
const model:ServiceDirectory={mode:options.mode??'production',site:{name:config.site.name,url:config.site.url,locale:config.site.locale},policies:{privacy:policy('privacy'),cookies:policy('cookies')},purposes:config.purposes.map(p=>({id:p.id,name:p.name})),totalServices:config.services.length,services:declaredServices(config)};
model.services=filterServices(model,options);return model;
}
function render(model:ServiceDirectory,options:ServiceDirectoryOptions):string {
if(typeof options.idPrefix!=='string'||!/^[a-z][a-z0-9-]{0,79}$/u.test(options.idPrefix)||options.headingLevel!==undefined&&![1,2,3,4].includes(options.headingLevel)||options.title!==undefined&&(!safeText(options.title,160)||!options.title.trim())||options.description!==undefined&&!safeText(options.description,1000))throw Error('Supply a unique lowercase directory ID, heading level 1–4 and bounded display text.');
const e=escapeHtml,id=options.idPrefix,level=options.headingLevel??1;
const title=options.title??'The services behind this site';
const description=options.description??'Who provides each service, why it is used, and the data and storage declared for it.';
const resultText=model.services.length===model.totalServices?`${model.totalServices} declared service${model.totalServices===1?'':'s'}`:`Showing ${model.services.length} of ${model.totalServices} declared services`;
const cards=model.services.map(service=>`
${model.totalServices?'No declared services match this selection.':'No services are declared in this inventory.'}
`;
}
/** Script-free HTML. Custom host filters may call this on a server or during build. */
export function renderServiceDirectory(input:unknown,options:ServiceDirectoryOptions):string {
const model=createServiceDirectory(input,options);requireEnglishSurface(model,'Service directory');return render(model,options);
}
export function renderServiceDirectoryPage(input:unknown,options:ServiceDirectoryOptions&{stylesheetUrl?:string}):string {
const model=createServiceDirectory(input,options),body=render(model,{...options,headingLevel:1});
requireEnglishSurface(model,'Service directory');
if(options.stylesheetUrl!==undefined&&!isSafeLink(options.stylesheetUrl))throw Error('Unsafe directory stylesheet URL.');
return `${escapeHtml(options.title??'Service directory')} · ${escapeHtml(model.site.name)}${options.stylesheetUrl?``:''}Skip to content${escapeHtml(model.site.name)}Services used by this site${body}`;
}
const mounts=new WeakSet();
/** Optional local search. Static content and native details remain usable without this enhancement. */
export function mountServiceDirectory(container:HTMLElement,input:unknown,options:ServiceDirectoryOptions):{destroy():void} {
if(!container?.ownerDocument||mounts.has(container))throw Error('Use an unmounted browser element.');
// Search all declared services; supplied filters only set the initial control values.
const model=createServiceDirectory(input,{...(options.mode?{mode:options.mode}:{})});
requireEnglishSurface(model,'Service directory');
filterServices(model,options);
const html=render(model,options),document=container.ownerDocument,template=document.createElement('template');template.innerHTML=html;
const existing=container.firstElementChild,comparison=existing?.cloneNode(true) as Element|undefined;
// Preserve native details and focus if script-free markup was already being read before enhancement.
comparison?.querySelectorAll('details[open]').forEach(details=>details.removeAttribute('open'));
if(container.childElementCount!==1||!comparison?.isEqualNode(template.content.firstElementChild))container.replaceChildren(template.content);
const tools=container.querySelector('[data-service-tools]')!;
const toolbar=document.createElement('div');toolbar.className='nb-service-search';
const searchLabel=document.createElement('label');searchLabel.textContent='Find a service';
const search=document.createElement('input');search.type='search';search.id=options.idPrefix+'-search';search.maxLength=120;search.autocomplete='off';search.placeholder='Service, provider or data';search.value=options.query??'';search.setAttribute('aria-describedby',options.idPrefix+'-results');searchLabel.append(search);
const purposeLabel=document.createElement('label');purposeLabel.textContent='Purpose';
const purpose=document.createElement('select');purpose.id=options.idPrefix+'-purpose';purpose.setAttribute('aria-describedby',options.idPrefix+'-results');
for(const item of [{id:'',name:'All purposes'},...model.purposes]){const option=document.createElement('option');option.value=item.id;option.textContent=item.name;purpose.append(option);}purpose.value=options.purposeId??'';purposeLabel.append(purpose);
toolbar.append(searchLabel,purposeLabel);tools.append(toolbar);
const count=container.querySelector('[data-service-results]')!,empty=container.querySelector('[data-service-empty]')!,cards=[...container.querySelectorAll('.nb-service-list > li')];
count.setAttribute('role','status');count.setAttribute('aria-live','polite');count.setAttribute('aria-atomic','true');
const update=()=>{
const shown=filterServices(model,{query:search.value,...(purpose.value?{purposeId:purpose.value}:{})}),ids=new Set(shown.map(s=>s.id));
for(const card of cards)card.hidden=!ids.has(card.dataset.serviceId!);
count.textContent=`Showing ${shown.length} of ${model.totalServices} declared services`;
empty.hidden=shown.length>0;
};
// Free typing may include controls copied from another application; treat those as a literal nonmatch.
const onInput=()=>{try{update();}catch{for(const card of cards)card.hidden=true;count.textContent=`Showing 0 of ${model.totalServices} declared services`;empty.hidden=false;}};
search.addEventListener('input',onInput);purpose.addEventListener('change',onInput);update();mounts.add(container);
let destroyed=false;
return {destroy(){if(destroyed)return;destroyed=true;search.removeEventListener('input',onInput);purpose.removeEventListener('change',onInput);toolbar.remove();for(const card of cards)card.hidden=false;empty.hidden=model.totalServices>0;count.textContent=`${model.totalServices} declared service${model.totalServices===1?'':'s'}`;count.removeAttribute('role');count.removeAttribute('aria-live');count.removeAttribute('aria-atomic');mounts.delete(container);}};
}