Skip to content

Commit 753d85e

Browse files
authored
Merge pull request #93 from raifdmueller/main
fix: Force sans-serif font on all card descendants
2 parents c76e2b6 + d6719b0 commit 753d85e

21 files changed

Lines changed: 496 additions & 255 deletions

website/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test": "vitest run",
1111
"test:watch": "vitest",
1212
"test:e2e": "playwright test",
13+
"test:e2e:prod": "PLAYWRIGHT_BASE_URL=https://raifdmueller.github.io/Semantic-Anchors/ playwright test",
1314
"test:e2e:ui": "playwright test --ui",
1415
"test:e2e:headed": "playwright test --headed",
1516
"test:lighthouse": "lhci autorun",

website/playwright.config.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { defineConfig, devices } from '@playwright/test'
22

3+
const LOCAL_BASE_URL = 'http://127.0.0.1:4173'
4+
const baseURL = process.env.PLAYWRIGHT_BASE_URL || LOCAL_BASE_URL
5+
const useLocalWebServer = !process.env.PLAYWRIGHT_BASE_URL
6+
37
export default defineConfig({
48
testDir: './tests/e2e',
59
fullyParallel: true,
@@ -8,10 +12,16 @@ export default defineConfig({
812
workers: process.env.CI ? 1 : undefined,
913
reporter: 'html',
1014
use: {
11-
baseURL: 'https://raifdmueller.github.io/Semantic-Anchors/',
15+
baseURL,
1216
trace: 'on-first-retry',
1317
screenshot: 'only-on-failure',
1418
},
19+
webServer: useLocalWebServer ? {
20+
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
21+
url: LOCAL_BASE_URL,
22+
reuseExistingServer: !process.env.CI,
23+
timeout: 120000,
24+
} : undefined,
1525

1626
projects: [
1727
{

website/src/__tests__/i18n-dom.test.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest'
1+
import { describe, it, expect, beforeEach } from 'vitest'
22
import { i18n, applyTranslations } from '../i18n.js'
33

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

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

2626
it('sets placeholder for elements with data-i18n-placeholder', () => {
@@ -47,11 +47,11 @@ describe('applyTranslations (DOM updates)', () => {
4747
document.body.innerHTML = `
4848
<h2 data-i18n="main.heading"></h2>
4949
<input data-i18n-placeholder="search.placeholder" />
50-
<p data-i18n-html="footer.tagline"></p>
50+
<p data-i18n="footer.tagline"></p>
5151
`
5252
applyTranslations()
5353
expect(document.querySelector('h2').textContent).toBe('Explore Semantic Anchors')
5454
expect(document.querySelector('input').placeholder).toBe('Search anchors...')
55-
expect(document.querySelector('p').innerHTML).toContain('Shared vocabulary')
55+
expect(document.querySelector('p').textContent).toContain('Shared vocabulary')
5656
})
5757
})

website/src/components/anchor-modal.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1-
import Asciidoctor from '@asciidoctor/core'
21
import { i18n } from '../i18n.js'
32

4-
const asciidoctor = Asciidoctor()
3+
let asciidoctor = null
4+
5+
function escapeHtml(value) {
6+
return String(value ?? '')
7+
.replaceAll('&', '&amp;')
8+
.replaceAll('<', '&lt;')
9+
.replaceAll('>', '&gt;')
10+
.replaceAll('"', '&quot;')
11+
.replaceAll("'", '&#39;')
12+
}
13+
14+
async function getAsciidoctor() {
15+
if (asciidoctor) return asciidoctor
16+
const module = await import('@asciidoctor/core')
17+
asciidoctor = module.default()
18+
return asciidoctor
19+
}
520

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

121136
// Convert AsciiDoc to HTML
122-
const htmlContent = asciidoctor.convert(adocContent, {
123-
safe: 'safe',
137+
const asciidocEngine = await getAsciidoctor()
138+
const htmlContent = asciidocEngine.convert(adocContent, {
139+
safe: 'secure',
124140
attributes: {
125141
showtitle: true,
126142
sectanchors: true
@@ -132,7 +148,7 @@ export async function loadAnchorContent(anchorId) {
132148
const title = titleMatch ? titleMatch[1] : anchorId
133149

134150
titleEl.textContent = title
135-
contentEl.innerHTML = htmlContent
151+
contentEl.innerHTML = String(htmlContent)
136152

137153
// Auto-expand all collapsible sections
138154
contentEl.querySelectorAll('details').forEach(details => {
@@ -156,12 +172,13 @@ export async function loadAnchorContent(anchorId) {
156172
} catch (error) {
157173
console.error('Error loading anchor content:', error)
158174
titleEl.textContent = 'Error'
175+
const message = error instanceof Error ? error.message : String(error)
159176
contentEl.innerHTML = `
160177
<div class="text-red-500">
161178
<p><strong>Failed to load anchor content</strong></p>
162-
<p class="text-sm mt-2">${error.message}</p>
179+
<p class="text-sm mt-2">${escapeHtml(message)}</p>
163180
<p class="text-sm mt-4 text-[var(--color-text-secondary)]">
164-
Anchor ID: <code>${anchorId}</code>
181+
Anchor ID: <code>${escapeHtml(anchorId)}</code>
165182
</p>
166183
</div>
167184
`
@@ -174,7 +191,7 @@ export function showAnchorDetails(anchorId) {
174191
modal.dataset.currentAnchor = anchorId
175192
}
176193
openModal()
177-
loadAnchorContent(anchorId)
194+
return loadAnchorContent(anchorId)
178195
}
179196

180197
async function shareAnchor(anchorId, title) {

website/src/components/anchor-modal.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@ describe('anchor-modal', () => {
100100
delete global.fetch
101101
})
102102

103-
it('should open modal when called', () => {
103+
it('should open modal when called', async () => {
104104
global.fetch.mockResolvedValue({
105105
ok: true,
106106
text: async () => '= Test Anchor\n\nTest content'
107107
})
108108

109-
showAnchorDetails('test-anchor')
109+
await showAnchorDetails('test-anchor')
110110
const modal = document.getElementById('anchor-modal')
111111
expect(modal.classList.contains('hidden')).toBe(false)
112112
})

website/src/components/card-grid.js

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { i18n } from '../i18n.js'
22
import { search as performFullTextSearch, isIndexReady } from '../utils/search-index.js'
33

4+
function escapeHtml(value) {
5+
return String(value ?? '')
6+
.replaceAll('&', '&amp;')
7+
.replaceAll('<', '&lt;')
8+
.replaceAll('>', '&gt;')
9+
.replaceAll('"', '&quot;')
10+
.replaceAll("'", '&#39;')
11+
}
12+
413
/**
514
* Category color palette (matching previous categories)
615
*/
@@ -65,10 +74,10 @@ function renderCategorySection(category, allAnchors) {
6574
const categoryName = i18n.t(`categories.${category.id}`) || category.name
6675

6776
return `
68-
<section class="category-section" data-category="${category.id}">
77+
<section class="category-section" data-category="${escapeHtml(category.id)}">
6978
<h2 class="category-heading">
7079
<span class="category-icon" style="background-color: ${color}">${icon}</span>
71-
<span data-i18n="categories.${category.id}">${categoryName}</span>
80+
<span data-i18n="categories.${escapeHtml(category.id)}">${escapeHtml(categoryName)}</span>
7281
</h2>
7382
7483
<div class="anchor-cards-grid">
@@ -83,44 +92,43 @@ function renderCategorySection(category, allAnchors) {
8392
*/
8493
function renderAnchorCard(anchor, categoryColor) {
8594
const rolesCount = anchor.roles ? anchor.roles.length : 0
86-
const tagsPreview = anchor.tags ? anchor.tags.slice(0, 3).join(', ') : ''
8795
const githubEditUrl = `https://github.com/LLM-Coding/Semantic-Anchors/edit/main/docs/anchors/${anchor.id}.adoc`
8896
const roleText = rolesCount === 1 ? i18n.t('card.roles') : i18n.t('card.rolesPlural')
8997
const tagsText = i18n.t('card.tags')
9098
const editTitle = i18n.t('card.edit')
9199
const copyLinkTitle = i18n.t('card.copyLink')
100+
const safeId = escapeHtml(anchor.id)
92101

93102
return `
94103
<article
95104
class="anchor-card"
96-
data-anchor="${anchor.id}"
97-
data-roles="${anchor.roles ? anchor.roles.join(',') : ''}"
98-
data-tags="${anchor.tags ? anchor.tags.join(',') : ''}"
105+
data-anchor="${safeId}"
106+
data-roles="${escapeHtml(anchor.roles ? anchor.roles.join(',') : '')}"
107+
data-tags="${escapeHtml(anchor.tags ? anchor.tags.join(',') : '')}"
99108
tabindex="0"
100109
role="button"
101-
aria-label="${i18n.t('card.openDetails').replace('{title}', anchor.title)}"
110+
aria-label="${escapeHtml(i18n.t('card.openDetails').replace('{title}', anchor.title))}"
102111
>
103112
<div class="anchor-card-header">
104-
<h3 class="anchor-card-title">${anchor.title}</h3>
113+
<h3 class="anchor-card-title">${escapeHtml(anchor.title)}</h3>
105114
<div class="flex gap-1">
106115
<button
107116
class="anchor-copy-link-btn"
108-
title="${copyLinkTitle}"
109-
onclick="event.stopPropagation(); window.copyAnchorLink('${anchor.id}')"
117+
title="${escapeHtml(copyLinkTitle)}"
118+
data-copy-link="${safeId}"
110119
data-i18n-title="card.copyLink"
111-
aria-label="${copyLinkTitle}"
120+
aria-label="${escapeHtml(copyLinkTitle)}"
112121
>
113122
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
114123
<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>
115124
</svg>
116125
</button>
117126
<a
118-
href="${githubEditUrl}"
127+
href="${escapeHtml(githubEditUrl)}"
119128
target="_blank"
120129
rel="noopener noreferrer"
121130
class="anchor-edit-btn"
122-
title="${editTitle}"
123-
onclick="event.stopPropagation()"
131+
title="${escapeHtml(editTitle)}"
124132
data-i18n-title="card.edit"
125133
>
126134
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -130,8 +138,8 @@ function renderAnchorCard(anchor, categoryColor) {
130138
</div>
131139
</div>
132140
133-
${anchor.proponents ? `
134-
<p class="anchor-card-proponents">${anchor.proponents.slice(0, 2).join(', ')}</p>
141+
${anchor.proponents && anchor.proponents.length > 0 ? `
142+
<p class="anchor-card-proponents">${escapeHtml(anchor.proponents.slice(0, 2).join(', '))}</p>
135143
` : ''}
136144
137145
<div class="anchor-card-meta">
@@ -180,6 +188,21 @@ export function initCardGrid() {
180188

181189
// Click handler using event delegation
182190
clickHandler = (e) => {
191+
if (e.target.closest('.anchor-copy-link-btn')) {
192+
const button = e.target.closest('.anchor-copy-link-btn')
193+
const anchorId = button?.dataset.copyLink
194+
if (anchorId) {
195+
e.stopPropagation()
196+
window.copyAnchorLink(anchorId)
197+
}
198+
return
199+
}
200+
201+
if (e.target.closest('.anchor-edit-btn')) {
202+
e.stopPropagation()
203+
return
204+
}
205+
183206
const card = e.target.closest('.anchor-card')
184207
if (card) {
185208
const anchorId = card.dataset.anchor

website/src/components/doc-page.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import Asciidoctor from '@asciidoctor/core'
21
import { i18n } from '../i18n.js'
32

4-
const asciidoctor = Asciidoctor()
3+
let asciidoctor = null
4+
5+
async function getAsciidoctor() {
6+
if (asciidoctor) return asciidoctor
7+
const module = await import('@asciidoctor/core')
8+
asciidoctor = module.default()
9+
return asciidoctor
10+
}
511

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

4045
if (currentLang !== 'en') {
@@ -45,9 +50,6 @@ export async function loadDocContent(docPath) {
4550
// If language-specific file not found, fallback to English
4651
if (!response.ok) {
4752
response = await fetch(`${import.meta.env.BASE_URL}${docPath}`)
48-
finalPath = docPath
49-
} else {
50-
finalPath = langPath
5153
}
5254
} else {
5355
response = await fetch(`${import.meta.env.BASE_URL}${docPath}`)
@@ -58,8 +60,9 @@ export async function loadDocContent(docPath) {
5860
}
5961

6062
const adocContent = await response.text()
61-
const htmlContent = asciidoctor.convert(adocContent, {
62-
safe: 'safe',
63+
const asciidocEngine = await getAsciidoctor()
64+
const htmlContent = asciidocEngine.convert(adocContent, {
65+
safe: 'secure',
6366
attributes: {
6467
'source-highlighter': 'highlight.js',
6568
'icons': 'font',
@@ -69,7 +72,7 @@ export async function loadDocContent(docPath) {
6972
}
7073
})
7174

72-
contentEl.innerHTML = htmlContent
75+
contentEl.innerHTML = String(htmlContent)
7376

7477
// Auto-expand collapsible sections
7578
contentEl.querySelectorAll('details').forEach(details => {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import { i18n } from '../i18n.js'
3+
import { loadDocContent } from './doc-page.js'
4+
5+
describe('doc-page', () => {
6+
beforeEach(() => {
7+
localStorage.clear()
8+
i18n.init()
9+
document.body.innerHTML = '<div id="doc-content"></div>'
10+
global.fetch = vi.fn()
11+
})
12+
13+
afterEach(() => {
14+
delete global.fetch
15+
})
16+
17+
it('falls back to English when localized document is missing', async () => {
18+
i18n.setLang('de')
19+
20+
global.fetch
21+
.mockResolvedValueOnce({ ok: false, status: 404 })
22+
.mockResolvedValueOnce({ ok: true, text: async () => '= About\n\nlink:https://example.com[Example]' })
23+
24+
await loadDocContent('docs/about.adoc')
25+
26+
expect(global.fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('docs/about.de.adoc'))
27+
expect(global.fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('docs/about.adoc'))
28+
29+
const link = document.querySelector('#doc-content a[href="https://example.com"]')
30+
expect(link).toBeTruthy()
31+
expect(link.getAttribute('target')).toBe('_blank')
32+
expect(link.getAttribute('rel')).toContain('noopener')
33+
})
34+
35+
it('renders an error state when loading fails', async () => {
36+
global.fetch.mockResolvedValue({ ok: false, status: 500 })
37+
38+
await loadDocContent('docs/about.adoc')
39+
40+
expect(document.getElementById('doc-content').textContent).toContain('Failed to Load Documentation')
41+
})
42+
})

website/src/components/footer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export function renderFooter(version) {
55
<footer class="border-t border-[var(--color-border)] bg-[var(--color-bg)] transition-colors duration-300">
66
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
77
<div class="flex flex-col items-center justify-between gap-2 sm:flex-row">
8-
<p class="text-sm text-[var(--color-text-secondary)]" data-i18n-html="footer.tagline">
8+
<p class="text-sm text-[var(--color-text-secondary)]" data-i18n="footer.tagline">
99
${i18n.t('footer.tagline')}
1010
</p>
1111
<div class="flex items-center gap-4">

website/src/i18n.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ export function applyTranslations() {
1111
document.querySelectorAll('[data-i18n]').forEach(el => {
1212
el.textContent = i18n.t(el.dataset.i18n)
1313
})
14-
document.querySelectorAll('[data-i18n-html]').forEach(el => {
15-
el.innerHTML = i18n.t(el.dataset.i18nHtml)
16-
})
1714
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
1815
el.placeholder = i18n.t(el.dataset.i18nPlaceholder)
1916
})

0 commit comments

Comments
 (0)