feat(editor): blog editor element with exact untouched value

- golem15-journal-blogeditor (BlogEditorElement): light-DOM element with a
  TipTap WYSIWYG tab and a Source tab that edits the exact HTML string
- The value is the exact served string until a user edit; external values
  go in with emitUpdate false; summer-change fires on user edits only and
  an echo set is a no-op
- StarterKit without the trailing node, links without target, rel or class;
  injectCSS false with the ProseMirror base rules in blogeditor.css under
  the element tag, colours from --c-* only
- Committed bundle embedded through admin.go; vitest smoke on an imported
  post fixture (footer, style block, spans, figure, table, entities)
This commit is contained in:
2026-10-10 11:35:51 +02:00
parent 234e6169cc
commit 47f3032dcf
10 changed files with 745 additions and 2 deletions

View File

@@ -0,0 +1,48 @@
<h2>Czym jest ekstrakt z Turkey Tail?</h2>
<p>Turkey Tail (<em>Trametes versicolor</em>, wrośniak różnobarwny) to jeden z najlepiej przebadanych grzybów funkcjonalnych. Ekstrakt powstaje z <strong>owocników</strong> poddanych ekstrakcji gorącą wodą, która uwalnia beta-glukany.</p>
<h3>Najważniejsze związki aktywne</h3>
<ul>
<li><strong>PSK</strong> (polisacharyd K, krestin)</li>
<li><strong>PSP</strong> (polisacharopeptyd)</li>
<li>Beta-glukany i antyoksydanty fenolowe</li>
</ul>
<h3>Jak stosować?</h3>
<ol>
<li>Zacznij od mniejszej porcji przez pierwszy tydzień.</li>
<li>Przyjmuj ekstrakt z posiłkiem.</li>
<li>Obserwuj reakcję organizmu i skonsultuj się z lekarzem.</li>
</ol>
<blockquote>
<p>Grzyby funkcjonalne wspierają organizm, ale nie zastępują leczenia.</p>
</blockquote>
<p>Więcej przeczytasz w <a href="/blog/beta-glukany">artykule o beta-glukanach</a> &amp; <a href="https://example.test/badania">przeglądzie badań</a>.</p>
<p>Data: 12.03 &ndash; aktualizacja opisu&nbsp;dawkowania.</p>
<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
<div class="author-info">
<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">
<div class="author-text">
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jan Kowalski</div>
<span style="color: #138c74;" itemprop="jobTitle">Ekspert od grzybów funkcjonalnych</span>
<p itemprop="description">Od lat zajmuje się mykologią i suplementacją.</p>
</div>
</div>
</footer>
<style>
.author-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; }
.author-info { display: flex; align-items: center; gap: 1rem; }
.author-photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; }
</style>
<table>
<thead>
<tr><th>Forma</th><th>Dawka</th></tr>
</thead>
<tbody>
<tr><td>Proszek</td><td>1&ndash;2 g</td></tr>
<tr><td>Kapsułki</td><td>2 &times; 500 mg</td></tr>
</tbody>
</table>
<figure><img src="/storage/app/uploads/public/journal/2026/turkey-tail.jpg" alt="Owocniki Turkey Tail na pniu"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>
<p>Zobacz też <a href="https://example.test/sklep" target="_blank" rel="noopener">sklep z ekstraktami</a>.</p>
<p>Producent nazywa go &quot;grzybem odporności&quot;, a badacze &quot;wrośniakiem&quot;.</p>

View File

@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from 'vitest'
import fixture from './fixtures/imported-post.html?raw'
import '../../assets/src/blogeditor/index'
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
function mount(value?: string): { el: BlogEditorElement; changes: string[] } {
const el = document.createElement(TAG) as BlogEditorElement
const changes: string[] = []
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
if (value !== undefined) el.value = value
document.body.append(el)
return { el, changes }
}
afterEach(() => {
document.body.replaceChildren()
})
describe('blog editor element (tracer)', () => {
it('defines the custom element tag once', () => {
expect(customElements.get(TAG)).toBe(BlogEditorElement)
expect(document.createElement(TAG)).toBeInstanceOf(BlogEditorElement)
})
it('returns the imported post byte for byte without an edit', () => {
const { el, changes } = mount(fixture)
expect(el.editor).toBeDefined()
expect(el.value).toBe(fixture)
// Setting the value after connect is untouched too.
const { el: late, changes: lateChanges } = mount()
late.value = fixture
expect(late.value).toBe(fixture)
expect(changes).toEqual([])
expect(lateChanges).toEqual([])
})
it('emits no summer-change on set or on an echo set', () => {
const { el, changes } = mount(fixture)
el.value = fixture
el.value = el.value
expect(changes).toEqual([])
expect(el.value).toBe(fixture)
})
it('emits exactly one summer-change per user edit with the element value', () => {
const { el, changes } = mount('<p>Hello</p>')
const editor = el.editor!
editor.view.dispatch(editor.state.tr.insertText('X', 1))
expect(changes).toHaveLength(1)
expect(changes[0]).toBe(el.value)
expect(el.value).toBe('<p>XHello</p>')
// The SPA echoes the emitted value back: no reset, no event.
el.value = changes[0]!
expect(changes).toHaveLength(1)
expect(el.value).toBe('<p>XHello</p>')
})
it('shows the exact value in the source tab', () => {
const { el, changes } = mount(fixture)
el.showSource(true)
const textarea = el.querySelector('textarea')!
expect(textarea.hidden).toBe(false)
expect(textarea.value).toBe(fixture)
textarea.value = '<p>Edited in source</p>\n<footer>kept</footer>'
textarea.dispatchEvent(new Event('input'))
expect(changes).toEqual(['<p>Edited in source</p>\n<footer>kept</footer>'])
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
// Back to WYSIWYG: re-parsed, but the value stays the exact text.
el.showSource(false)
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
expect(changes).toHaveLength(1)
})
it('a new external value replaces the content without an event', () => {
const { el, changes } = mount('<p>One</p>')
el.value = '<p>Two</p>'
expect(el.value).toBe('<p>Two</p>')
expect(el.editor!.state.doc.textContent).toBe('Two')
expect(changes).toEqual([])
})
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
const { el } = mount('<p>x</p>')
el.setAttribute('disabled', '')
el.setAttribute('invalid', '')
expect(el.editor!.isEditable).toBe(false)
expect(el.querySelector('textarea')!.readOnly).toBe(true)
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
el.removeAttribute('disabled')
el.removeAttribute('invalid')
expect(el.editor!.isEditable).toBe(true)
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
})
})