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
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:prod": "PLAYWRIGHT_BASE_URL=https://raifdmueller.github.io/Semantic-Anchors/ playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:lighthouse": "lhci autorun",
Expand Down
12 changes: 11 additions & 1 deletion website/playwright.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { defineConfig, devices } from '@playwright/test'

const LOCAL_BASE_URL = 'http://127.0.0.1:4173'
const baseURL = process.env.PLAYWRIGHT_BASE_URL || LOCAL_BASE_URL
const useLocalWebServer = !process.env.PLAYWRIGHT_BASE_URL

export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
Expand All @@ -8,10 +12,16 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://raifdmueller.github.io/Semantic-Anchors/',
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: useLocalWebServer ? {
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
url: LOCAL_BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120000,
} : undefined,

projects: [
{
Expand Down
16 changes: 8 additions & 8 deletions website/src/__tests__/i18n-dom.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { i18n, applyTranslations } from '../i18n.js'

describe('applyTranslations (DOM updates)', () => {
Expand All @@ -15,12 +15,12 @@ describe('applyTranslations (DOM updates)', () => {
expect(document.querySelector('h2').textContent).toBe('Explore Semantic Anchors')
})

it('sets innerHTML for elements with data-i18n-html', () => {
document.body.innerHTML = '<p data-i18n-html="footer.tagline"></p>'
it('sets text content for footer tagline', () => {
document.body.innerHTML = '<p data-i18n="footer.tagline"></p>'
applyTranslations()
const html = document.querySelector('p').innerHTML
expect(html).toContain('Semantic Anchors')
expect(html).toContain('Shared vocabulary for LLM communication')
const text = document.querySelector('p').textContent
expect(text).toContain('Semantic Anchors')
expect(text).toContain('Shared vocabulary for LLM communication')
})

it('sets placeholder for elements with data-i18n-placeholder', () => {
Expand All @@ -47,11 +47,11 @@ describe('applyTranslations (DOM updates)', () => {
document.body.innerHTML = `
<h2 data-i18n="main.heading"></h2>
<input data-i18n-placeholder="search.placeholder" />
<p data-i18n-html="footer.tagline"></p>
<p data-i18n="footer.tagline"></p>
`
applyTranslations()
expect(document.querySelector('h2').textContent).toBe('Explore Semantic Anchors')
expect(document.querySelector('input').placeholder).toBe('Search anchors...')
expect(document.querySelector('p').innerHTML).toContain('Shared vocabulary')
expect(document.querySelector('p').textContent).toContain('Shared vocabulary')
})
})
33 changes: 25 additions & 8 deletions website/src/components/anchor-modal.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import Asciidoctor from '@asciidoctor/core'
import { i18n } from '../i18n.js'

const asciidoctor = Asciidoctor()
let asciidoctor = null

function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

async function getAsciidoctor() {
if (asciidoctor) return asciidoctor
const module = await import('@asciidoctor/core')
asciidoctor = module.default()
return asciidoctor
}

export function createModal() {
// Check if modal already exists
Expand Down Expand Up @@ -119,8 +134,9 @@ export async function loadAnchorContent(anchorId) {
const adocContent = await response.text()

// Convert AsciiDoc to HTML
const htmlContent = asciidoctor.convert(adocContent, {
safe: 'safe',
const asciidocEngine = await getAsciidoctor()
const htmlContent = asciidocEngine.convert(adocContent, {
safe: 'secure',
attributes: {
showtitle: true,
sectanchors: true
Expand All @@ -132,7 +148,7 @@ export async function loadAnchorContent(anchorId) {
const title = titleMatch ? titleMatch[1] : anchorId

titleEl.textContent = title
contentEl.innerHTML = htmlContent
contentEl.innerHTML = String(htmlContent)

// Auto-expand all collapsible sections
contentEl.querySelectorAll('details').forEach(details => {
Expand All @@ -156,12 +172,13 @@ export async function loadAnchorContent(anchorId) {
} catch (error) {
console.error('Error loading anchor content:', error)
titleEl.textContent = 'Error'
const message = error instanceof Error ? error.message : String(error)
contentEl.innerHTML = `
<div class="text-red-500">
<p><strong>Failed to load anchor content</strong></p>
<p class="text-sm mt-2">${error.message}</p>
<p class="text-sm mt-2">${escapeHtml(message)}</p>
<p class="text-sm mt-4 text-[var(--color-text-secondary)]">
Anchor ID: <code>${anchorId}</code>
Anchor ID: <code>${escapeHtml(anchorId)}</code>
</p>
</div>
`
Expand All @@ -174,7 +191,7 @@ export function showAnchorDetails(anchorId) {
modal.dataset.currentAnchor = anchorId
}
openModal()
loadAnchorContent(anchorId)
return loadAnchorContent(anchorId)
}

async function shareAnchor(anchorId, title) {
Expand Down
4 changes: 2 additions & 2 deletions website/src/components/anchor-modal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ describe('anchor-modal', () => {
delete global.fetch
})

it('should open modal when called', () => {
it('should open modal when called', async () => {
global.fetch.mockResolvedValue({
ok: true,
text: async () => '= Test Anchor\n\nTest content'
})

showAnchorDetails('test-anchor')
await showAnchorDetails('test-anchor')
const modal = document.getElementById('anchor-modal')
expect(modal.classList.contains('hidden')).toBe(false)
})
Expand Down
55 changes: 39 additions & 16 deletions website/src/components/card-grid.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { i18n } from '../i18n.js'
import { search as performFullTextSearch, isIndexReady } from '../utils/search-index.js'

function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

/**
* Category color palette (matching previous categories)
*/
Expand Down Expand Up @@ -65,10 +74,10 @@ function renderCategorySection(category, allAnchors) {
const categoryName = i18n.t(`categories.${category.id}`) || category.name

return `
<section class="category-section" data-category="${category.id}">
<section class="category-section" data-category="${escapeHtml(category.id)}">
<h2 class="category-heading">
<span class="category-icon" style="background-color: ${color}">${icon}</span>
<span data-i18n="categories.${category.id}">${categoryName}</span>
<span data-i18n="categories.${escapeHtml(category.id)}">${escapeHtml(categoryName)}</span>
</h2>

<div class="anchor-cards-grid">
Expand All @@ -83,44 +92,43 @@ function renderCategorySection(category, allAnchors) {
*/
function renderAnchorCard(anchor, categoryColor) {
const rolesCount = anchor.roles ? anchor.roles.length : 0
const tagsPreview = anchor.tags ? anchor.tags.slice(0, 3).join(', ') : ''
const githubEditUrl = `https://github.com/LLM-Coding/Semantic-Anchors/edit/main/docs/anchors/${anchor.id}.adoc`
const roleText = rolesCount === 1 ? i18n.t('card.roles') : i18n.t('card.rolesPlural')
const tagsText = i18n.t('card.tags')
const editTitle = i18n.t('card.edit')
const copyLinkTitle = i18n.t('card.copyLink')
const safeId = escapeHtml(anchor.id)

return `
<article
class="anchor-card"
data-anchor="${anchor.id}"
data-roles="${anchor.roles ? anchor.roles.join(',') : ''}"
data-tags="${anchor.tags ? anchor.tags.join(',') : ''}"
data-anchor="${safeId}"
data-roles="${escapeHtml(anchor.roles ? anchor.roles.join(',') : '')}"
data-tags="${escapeHtml(anchor.tags ? anchor.tags.join(',') : '')}"
tabindex="0"
role="button"
aria-label="${i18n.t('card.openDetails').replace('{title}', anchor.title)}"
aria-label="${escapeHtml(i18n.t('card.openDetails').replace('{title}', anchor.title))}"
>
<div class="anchor-card-header">
<h3 class="anchor-card-title">${anchor.title}</h3>
<h3 class="anchor-card-title">${escapeHtml(anchor.title)}</h3>
<div class="flex gap-1">
<button
class="anchor-copy-link-btn"
title="${copyLinkTitle}"
onclick="event.stopPropagation(); window.copyAnchorLink('${anchor.id}')"
title="${escapeHtml(copyLinkTitle)}"
data-copy-link="${safeId}"
data-i18n-title="card.copyLink"
aria-label="${copyLinkTitle}"
aria-label="${escapeHtml(copyLinkTitle)}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</button>
<a
href="${githubEditUrl}"
href="${escapeHtml(githubEditUrl)}"
target="_blank"
rel="noopener noreferrer"
class="anchor-edit-btn"
title="${editTitle}"
onclick="event.stopPropagation()"
title="${escapeHtml(editTitle)}"
data-i18n-title="card.edit"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -130,8 +138,8 @@ function renderAnchorCard(anchor, categoryColor) {
</div>
</div>

${anchor.proponents ? `
<p class="anchor-card-proponents">${anchor.proponents.slice(0, 2).join(', ')}</p>
${anchor.proponents && anchor.proponents.length > 0 ? `
<p class="anchor-card-proponents">${escapeHtml(anchor.proponents.slice(0, 2).join(', '))}</p>
` : ''}

<div class="anchor-card-meta">
Expand Down Expand Up @@ -180,6 +188,21 @@ export function initCardGrid() {

// Click handler using event delegation
clickHandler = (e) => {
if (e.target.closest('.anchor-copy-link-btn')) {
const button = e.target.closest('.anchor-copy-link-btn')
const anchorId = button?.dataset.copyLink
if (anchorId) {
e.stopPropagation()
window.copyAnchorLink(anchorId)
}
return
}

if (e.target.closest('.anchor-edit-btn')) {
e.stopPropagation()
return
}

const card = e.target.closest('.anchor-card')
if (card) {
const anchorId = card.dataset.anchor
Expand Down
21 changes: 12 additions & 9 deletions website/src/components/doc-page.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Asciidoctor from '@asciidoctor/core'
import { i18n } from '../i18n.js'

const asciidoctor = Asciidoctor()
let asciidoctor = null

async function getAsciidoctor() {
if (asciidoctor) return asciidoctor
const module = await import('@asciidoctor/core')
asciidoctor = module.default()
return asciidoctor
}

/**
* Render a documentation page from an AsciiDoc file
Expand Down Expand Up @@ -34,7 +40,6 @@ export async function loadDocContent(docPath) {
try {
// Try language-specific file first (e.g., about.de.adoc for German)
const currentLang = i18n.currentLang()
let finalPath = docPath
let response

if (currentLang !== 'en') {
Expand All @@ -45,9 +50,6 @@ export async function loadDocContent(docPath) {
// If language-specific file not found, fallback to English
if (!response.ok) {
response = await fetch(`${import.meta.env.BASE_URL}${docPath}`)
finalPath = docPath
} else {
finalPath = langPath
}
} else {
response = await fetch(`${import.meta.env.BASE_URL}${docPath}`)
Expand All @@ -58,8 +60,9 @@ export async function loadDocContent(docPath) {
}

const adocContent = await response.text()
const htmlContent = asciidoctor.convert(adocContent, {
safe: 'safe',
const asciidocEngine = await getAsciidoctor()
const htmlContent = asciidocEngine.convert(adocContent, {
safe: 'secure',
attributes: {
'source-highlighter': 'highlight.js',
'icons': 'font',
Expand All @@ -69,7 +72,7 @@ export async function loadDocContent(docPath) {
}
})

contentEl.innerHTML = htmlContent
contentEl.innerHTML = String(htmlContent)

// Auto-expand collapsible sections
contentEl.querySelectorAll('details').forEach(details => {
Expand Down
42 changes: 42 additions & 0 deletions website/src/components/doc-page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { i18n } from '../i18n.js'
import { loadDocContent } from './doc-page.js'

describe('doc-page', () => {
beforeEach(() => {
localStorage.clear()
i18n.init()
document.body.innerHTML = '<div id="doc-content"></div>'
global.fetch = vi.fn()
})

afterEach(() => {
delete global.fetch
})

it('falls back to English when localized document is missing', async () => {
i18n.setLang('de')

global.fetch
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({ ok: true, text: async () => '= About\n\nlink:https://example.com[Example]' })

await loadDocContent('docs/about.adoc')

expect(global.fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('docs/about.de.adoc'))
expect(global.fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('docs/about.adoc'))

const link = document.querySelector('#doc-content a[href="https://example.com"]')
expect(link).toBeTruthy()
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toContain('noopener')
})

it('renders an error state when loading fails', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 500 })

await loadDocContent('docs/about.adoc')

expect(document.getElementById('doc-content').textContent).toContain('Failed to Load Documentation')
})
})
2 changes: 1 addition & 1 deletion website/src/components/footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function renderFooter(version) {
<footer class="border-t border-[var(--color-border)] bg-[var(--color-bg)] transition-colors duration-300">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex flex-col items-center justify-between gap-2 sm:flex-row">
<p class="text-sm text-[var(--color-text-secondary)]" data-i18n-html="footer.tagline">
<p class="text-sm text-[var(--color-text-secondary)]" data-i18n="footer.tagline">
${i18n.t('footer.tagline')}
</p>
<div class="flex items-center gap-4">
Expand Down
3 changes: 0 additions & 3 deletions website/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ export function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = i18n.t(el.dataset.i18n)
})
document.querySelectorAll('[data-i18n-html]').forEach(el => {
el.innerHTML = i18n.t(el.dataset.i18nHtml)
})
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = i18n.t(el.dataset.i18nPlaceholder)
})
Expand Down
Loading
Loading