import {localizedSections} from './localized-documents.js'; import {formatMessage} from './localization-policy.js'; import type {SiteLocalization} from './document-localization.js'; // @requirements REQ-PRODUCT-RIGHTS-CENTER import { documentKinds, isSafeLink, toPublicConfig } from './config.js'; import {declaredServices} from './service-inventory.js'; import type { DocumentKind, LegalDocument, LegalSection, PublicSiteConfig, RequirementId, ValidationOptions } from './types.js'; export const documentRequirements: Record = { privacy: ['REQ-PRODUCT-DOCUMENTS', 'REQ-PRODUCT-SAFE-CONTENT', 'REQ-EU-NOTICE', 'REQ-NZ-NOTICE'], cookies: ['REQ-PRODUCT-DOCUMENTS', 'REQ-PRODUCT-SAFE-CONTENT'], terms: ['REQ-PRODUCT-DOCUMENTS', 'REQ-PRODUCT-SAFE-CONTENT', 'REQ-EU-TERMS'], }; export const escapeHtml = (value: string): string => value.replace(/[&<>"']/gu, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); const link = (label: string, href: string): string => { if (!isSafeLink(href)) throw new Error('Unsafe document link.'); return `${escapeHtml(label)}`; }; const sentence = (value: string): string => /[.!?…]$/u.test(value.trimEnd()) ? value : `${value}.`; /** @requirements REQ-EU-NOTICE REQ-NZ-NOTICE: compile declared facts, not inferred legal assertions. */ function privacySections(config: PublicSiteConfig): LegalSection[] { const { controller, privacy } = config; const sections: LegalSection[] = [ { id: 'controller', heading: 'Who is responsible', paragraphs: [controller.name, controller.address], links: [{ label: controller.email, href: `mailto:${controller.email}` }] }, ]; for (const [key, title] of [['representative', 'Representative'], ['dpo', 'Data protection contact']] as const) { const c = controller[key]; if (c) sections.push({ id: key, heading: title, paragraphs: [c.name, ...(c.address ? [c.address] : [])], links: [{ label: c.email, href: `mailto:${c.email}` }] }); } sections.push( { id: 'purposes', heading: 'What we collect and why', ...(config.purposes.length ? {} : { paragraphs: ['No processing purposes are declared in this configuration.'] }), items: config.purposes.map(p => `${p.name}: ${sentence(p.description)} Data: ${p.dataCategories.join(', ')}. Legal basis: ${sentence(p.legalBasis)} Source (${p.collection.source}): ${sentence(p.collection.description)} Providing this information is ${p.collection.required ? 'required' : 'optional'}. ${p.collection.consequences}`) }, { id: 'retention', heading: 'How long information is kept', items: config.purposes.map(p => `${p.name}: ${p.retention}`), ...(config.purposes.length ? {} : { paragraphs: ['No purpose-specific retention periods are declared.'] }) }, { id: 'providers', heading: 'Service providers and recipients', items: declaredServices(config).map(s => `${s.name} — ${s.provider}. Location: ${sentence(s.location)} Purposes: ${s.purposes.map(p=>p.name).join(', ')}.`), ...(config.services.length ? {} : { paragraphs: ['No service providers are declared in this configuration.'] }), links: config.services.filter(s => s.privacyUrl).map(s => ({ label: `${s.name} privacy notice`, href: s.privacyUrl! })) }, { id: 'transfers', heading: 'International transfers', paragraphs: [privacy.transfers] }, { id: 'automated-decisions', heading: 'Automated decisions', paragraphs: [privacy.automatedDecisions] }, { id: 'rights', heading: 'Your rights and choices', items: privacy.rights, links: [{ label: 'Contact us about your information', href: `mailto:${controller.email}` }, ...(privacy.rightsUrl ? [{ label: 'How to exercise your rights', href: privacy.rightsUrl }] : [])] }, { id: 'complaints', heading: 'Questions and complaints', paragraphs: [privacy.complaints.description], ...(privacy.complaints.url ? { links: [{ label: 'Complaint information', href: privacy.complaints.url }] } : {}) }, ); // @requirements REQ-PRODUCT-PRIVACY-SIGNALS REQ-US-CO-GPC Host-supplied processing explanation, never inferred applicability. if(config.privacySignals)sections.push({id:'privacy-signals',heading:'Global Privacy Control',paragraphs:[config.privacySignals.globalPrivacyControl.explanation]}); return sections; } function cookieSections(config: PublicSiteConfig): LegalSection[] { return [ { id: 'about', heading: 'Cookies and similar storage', paragraphs: [`This page describes the browser storage declared for ${config.site.name}.`, 'Cookies, local storage, and similar technologies may support different purposes. The inventory below describes each declared item; publishing this page does not itself control those technologies.'] }, { id: 'storage', heading: 'Declared storage', ...(config.storage.length ? { table: { caption: 'Storage inventory', columns: ['Name', 'Type', 'Provider / service', 'Purpose', 'Duration', 'Classification'], rows: config.storage.map(s => { const service = config.services.find(service => service.id === s.serviceId)!; return [s.name, s.type, `${service.provider} / ${service.name}`, service.purposeIds.map(id => config.purposes.find(p => p.id === id)!.name).join(', '), s.duration, `${s.classification==='exception'?'Declared storage exception: '+config.storageExceptions!.entries.find(e=>e.id===s.exceptionId)!.title:s.classification}${s.necessityReason ? ` — ${s.necessityReason}` : ''}`]; }), } } : { paragraphs: ['No browser storage is declared in this configuration.'] }) }, { id: 'purposes', heading: 'Purposes', items: config.purposes.map(p => `${p.name}: ${p.description}`), ...(config.purposes.length ? {} : { paragraphs: ['No processing purposes are declared.'] }) }, { id: 'contact', heading: 'Questions about storage', links: [{ label: config.controller.email, href: `mailto:${config.controller.email}` }, { label: config.documents.privacy.title, href: config.documents.privacy.route }] }, ]; } /** @requirements REQ-PRODUCT-DOCUMENTS REQ-EU-TERMS */ export function createLegalDocument(input: unknown, kind: DocumentKind, options: ValidationOptions = {}): LegalDocument { if (!documentKinds.includes(kind)) throw new Error('Unknown document kind.'); const config = toPublicConfig(input, options); const document = config.documents[kind]; const supplied = document.sections ?? []; const generated = document.source==='supplied'||kind==='terms'?[]:config.localization?localizedSections(config,kind):kind==='privacy'?privacySections(config):cookieSections(config); return { kind, title: document.title, route: document.route, version: document.version, effectiveDate: document.effectiveDate, reviewStatus: document.review.status, sections: document.source === 'supplied' ? supplied : [...generated, ...supplied.map(s => ({ ...s, id: `additional-${s.id}` }))], requirementIds: [...documentRequirements[kind],...(config.localization?['REQ-PRODUCT-LOCALIZATION' as const]:[])], }; } function renderSection(section: LegalSection, kind: DocumentKind, localization?:SiteLocalization): string { const table = section.table; const tableHtml = table ? `
${table.columns.map(c => ``).join('')}${table.rows.map(row => `${row.map((cell, i) => i === 0 ? `` : ``).join('')}`).join('')}
${escapeHtml(table.caption)}
${escapeHtml(c)}
${escapeHtml(cell)}${escapeHtml(cell)}
` : ''; const foldout=kind==='cookies'&§ion.id==='storage'&&!!table; return `
${foldout ? '
' : ''}

${escapeHtml(section.heading)}

${foldout ? `${localization?escapeHtml(formatMessage(localization.documents[table!.rows.length===1?'storageItem':'storageItems'],{count:table!.rows.length})):`${table!.rows.length} ${table!.rows.length===1?'item':'items'}`}
` : ''}${(section.paragraphs ?? []).map(p => `

${escapeHtml(p)}

`).join('')}${section.items?.length ? `
    ${section.items.map(item => `
  • ${escapeHtml(item)}
  • `).join('')}
` : ''}${section.links?.length ? `` : ''}${tableHtml}${foldout?'
':''}
`; } /** @requirements REQ-PRODUCT-DOCUMENTS REQ-PRODUCT-SAFE-CONTENT REQ-PRODUCT-A11Y */ export function renderLegalDocument(input: unknown, kind: DocumentKind, options: ValidationOptions = {}): string { const config = toPublicConfig(input, options); const doc = createLegalDocument(config, kind, options); const titleId = `nb-${kind}-title`; const l=config.localization,m=l?.documents; const contents = doc.sections.map(s => `
  • ${escapeHtml(s.heading)}
  • `).join(''); let html = `

    ${m?escapeHtml(formatMessage(m.legalEyebrow,{siteName:config.site.name})):`${escapeHtml(config.site.name)} · Legal`}

    ${escapeHtml(doc.title)}

    ${escapeHtml(m?.versionLabel??'Version')} ${escapeHtml(doc.version)} ${escapeHtml(m?.effectiveLabel??'Effective')}

    ${doc.reviewStatus === 'draft' ? `

    ${escapeHtml(m?.draft??'Draft — for review. This document is not approved for production use.')}

    ` : ''}
    `; for (const section of doc.sections) { html += renderSection(section, kind,l); } return html + '
    '; } /** Optional footer fragment; never includes consent or acceptance checkboxes. */ export function renderLegalLinks(input: unknown): string { const config = toPublicConfig(input); const l=config.localization,m=l?.documents; return ``; } /** A no-JS full page, or use renderLegalDocument inside a host framework layout. */ export function renderLegalPage(input: unknown, kind: DocumentKind, options: ValidationOptions & { stylesheetUrl?: string } = {}): string { const config = toPublicConfig(input, options); const stylesheet = options.stylesheetUrl; const l=config.localization,m=l?.documents; if (stylesheet !== undefined && !isSafeLink(stylesheet)) throw new Error('Unsafe stylesheet URL.'); return `${escapeHtml(config.documents[kind].title)} · ${escapeHtml(config.site.name)}${stylesheet ? `` : ''}${escapeHtml(m?.skipToContent??'Skip to content')}
    ${link(config.site.name, config.site.url)}${escapeHtml(m?.legalInformation??'Legal information')}
    ${renderLegalDocument(config, kind, options)}
    `; }