feat: stored HTML keeps link rel and media names are slugged
- the HTML policy keeps rel on a when it holds only nofollow, sponsored and ugc tokens (D-83); any other rel value is removed - classes.MediaFileName slugs uploaded image names (lowercase, Polish letters transliterated, hyphens, at most 80 characters, image when empty) for the editor upload and POST /media/upload (D-85) - the editor corpus gains an external link with rel and a decorative image, pinned by TestEditorOutputPassesPolicy
This commit is contained in:
@@ -236,14 +236,14 @@ func TestCategoriesAdminBlogEditor(t *testing.T) {
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.URL != attach.PublicURL("journal/categories/ok-adka.png") {
|
||||
if result.URL != attach.PublicURL("journal/categories/okladka.png") {
|
||||
t.Fatalf("url = %q", result.URL)
|
||||
}
|
||||
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
||||
if !ok {
|
||||
t.Fatal("no media bucket")
|
||||
}
|
||||
if data, err := bucket.ReadAll(t.Context(), "journal/categories/ok-adka.png"); err != nil || !bytes.Equal(data, editorPNG) {
|
||||
if data, err := bucket.ReadAll(t.Context(), "journal/categories/okladka.png"); err != nil || !bytes.Equal(data, editorPNG) {
|
||||
t.Fatalf("stored upload: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -450,38 +450,9 @@ func uploadBlogImage(app func() *backpack.App) func(context.Context, pact.FormFi
|
||||
}
|
||||
}
|
||||
|
||||
// safeUploadName is the stored name of an editor upload: the base name,
|
||||
// lowercased, every character outside a-z, 0-9, '.', '_' and '-' turned
|
||||
// into '-' with runs collapsed and no leading or trailing '-' or '.', and
|
||||
// the extension kept. An empty name becomes "image". The result needs no
|
||||
// escaping in an src attribute.
|
||||
// safeUploadName is the stored name of an editor upload: MediaFileName of
|
||||
// its base name (D-85), so it needs no escaping in an src attribute. An
|
||||
// empty name becomes "image".
|
||||
func safeUploadName(filename string) string {
|
||||
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
||||
ext := strings.ToLower(path.Ext(base))
|
||||
if !blogUploadExtPattern.MatchString(ext) {
|
||||
ext = ""
|
||||
}
|
||||
stem := strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
|
||||
if ext == "" {
|
||||
stem = strings.ToLower(base)
|
||||
}
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range stem {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok || r == '-' {
|
||||
if !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
}
|
||||
stem = strings.Trim(b.String(), "-.")
|
||||
if stem == "" {
|
||||
stem = "image"
|
||||
}
|
||||
return stem + ext
|
||||
return MediaFileName(filename)
|
||||
}
|
||||
|
||||
@@ -699,17 +699,17 @@ func TestBlogEditorUpload(t *testing.T) {
|
||||
|
||||
t.Run("names are made safe", func(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Zdjęcie 1.PNG": "zdj-cie-1.png",
|
||||
"Zdjęcie 1.PNG": "zdjecie-1.png",
|
||||
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||
"../../etc/passwd.png": "passwd.png",
|
||||
"...": "image",
|
||||
"": "image",
|
||||
"-.-a-.png": "a.png",
|
||||
"a--b__c.webp": "a-b__c.webp",
|
||||
"a--b__c.webp": "a-b-c.webp",
|
||||
"<script>.gif": "script.gif",
|
||||
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||
"name.with.dots.jpeg": "name.with.dots.jpeg",
|
||||
"weird.ext$": "weird.ext",
|
||||
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||
"weird.ext$": "weird-ext",
|
||||
} {
|
||||
if got := safeUploadName(in); got != want {
|
||||
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
|
||||
|
||||
@@ -63,8 +63,9 @@ func tagAttrs(doc, tag string) [][]string {
|
||||
|
||||
// TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML
|
||||
// policy keeps every piece of markup the Blog Editor writes, byte for byte
|
||||
// and with an empty removal report, and its four editor-output additions
|
||||
// (figure data-embed and data-id, img alt, ol start) admit nothing else.
|
||||
// and with an empty removal report, and its editor-output additions
|
||||
// (figure data-embed and data-id, img alt, ol start, and since Phase 33 a rel
|
||||
// of nofollow, sponsored and ugc tokens on a) admit nothing else.
|
||||
func TestEditorOutputPassesPolicy(t *testing.T) {
|
||||
t.Run("corpus is the editor's full output set", func(t *testing.T) {
|
||||
corpus := editorCorpus(t)
|
||||
@@ -78,7 +79,7 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
|
||||
}
|
||||
names[e.Name] = true
|
||||
}
|
||||
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "combined"} {
|
||||
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "link-rel", "image-decorative", "combined"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("corpus has no %s entry", want)
|
||||
}
|
||||
@@ -143,6 +144,12 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
|
||||
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
|
||||
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
|
||||
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
||||
// D-83: rel keeps only nofollow, sponsored and ugc, on a only.
|
||||
{"rel noopener", `<a href="https://p.test/" rel="noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||
{"rel with another token", `<a href="https://p.test/" rel="nofollow noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||
{"rel with two spaces", `<a href="https://p.test/" rel="nofollow ugc">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||
{"rel in upper case", `<a href="https://p.test/" rel="NOFOLLOW">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||
{"rel on p", `<p rel="nofollow">x</p>`, `<p>x</p>`},
|
||||
}
|
||||
for _, tc := range stripped {
|
||||
t.Run("strips "+tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -70,6 +70,10 @@ func newHTMLPolicy() *bluemonday.Policy {
|
||||
// TipTap's ordered-list input rule writes start when a list does not
|
||||
// begin at 1.
|
||||
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
|
||||
// The Blog Editor's link form writes rel on external links (Phase 33,
|
||||
// D-83): only the nofollow, sponsored and ugc tokens, space-separated.
|
||||
// Any other rel value is removed.
|
||||
p.AllowAttrs("rel").Matching(regexp.MustCompile(`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`)).OnElements("a")
|
||||
return p
|
||||
}
|
||||
|
||||
|
||||
61
classes/media_names.go
Normal file
61
classes/media_names.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxMediaStem is the longest file name stem MediaFileName keeps (D-85).
|
||||
const MaxMediaStem = 80
|
||||
|
||||
// mediaExtPattern is an extension MediaFileName keeps.
|
||||
var mediaExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||
|
||||
// polishLetters transliterates the Polish diacritics (lower case).
|
||||
var polishLetters = map[rune]rune{'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z'}
|
||||
|
||||
// MediaFileName is the stored name of an uploaded image (D-85, the Blog
|
||||
// Editor's J4 rule, applied on the server whatever the client sends): the
|
||||
// base name's stem lowercased, Polish diacritics transliterated, every other
|
||||
// run of characters outside a-z and 0-9 turned into one hyphen, trimmed and
|
||||
// cut to MaxMediaStem characters, "image" when nothing is left; and its
|
||||
// extension lowercased when it is a dot and letters or digits (dropped
|
||||
// otherwise). "Lwia Grzywa – w lesie!.JPG" is "lwia-grzywa-w-lesie.jpg".
|
||||
// The result needs no escaping in a URL or an src attribute.
|
||||
func MediaFileName(name string) string {
|
||||
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||
if base == "." || base == "/" {
|
||||
base = ""
|
||||
}
|
||||
stem, ext := base, ""
|
||||
if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
|
||||
if e := strings.ToLower(base[dot:]); mediaExtPattern.MatchString(e) {
|
||||
stem, ext = base[:dot], e
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(stem) {
|
||||
if t, ok := polishLetters[r]; ok {
|
||||
r = t
|
||||
}
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
continue
|
||||
}
|
||||
if !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > MaxMediaStem {
|
||||
out = strings.TrimRight(out[:MaxMediaStem], "-")
|
||||
}
|
||||
if out == "" {
|
||||
out = "image"
|
||||
}
|
||||
return out + ext
|
||||
}
|
||||
30
classes/media_names_test.go
Normal file
30
classes/media_names_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaFileName pins the D-85 slug rule shared with the editor's J4
|
||||
// form (UI-SPEC J4 backstop).
|
||||
func TestMediaFileName(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Lwia Grzywa – w lesie!.JPG": "lwia-grzywa-w-lesie.jpg",
|
||||
"": "image",
|
||||
".png": "image.png",
|
||||
"...": "image",
|
||||
"ŻÓŁĆ gęślą jaźń.webp": "zolc-gesla-jazn.webp",
|
||||
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||
"../../etc/passwd.png": "passwd.png",
|
||||
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||
"weird.ext$": "weird-ext",
|
||||
"Okładka.png": "okladka.png",
|
||||
strings.Repeat("a", 81) + ".gif": strings.Repeat("a", 80) + ".gif",
|
||||
strings.Repeat("a", 79) + " b.gif": strings.Repeat("a", 79) + ".gif",
|
||||
} {
|
||||
if got := MediaFileName(in); got != want {
|
||||
t.Errorf("MediaFileName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,9 @@ func validMediaFolder(folder string) bool {
|
||||
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
|
||||
// and returns its public URL and path. It is the storage of POST
|
||||
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
|
||||
// file name's base is kept; its extension must be jpg, jpeg, png, gif or
|
||||
// webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
||||
// file name is stored as MediaFileName makes it (lowercase, hyphens, at
|
||||
// most 80 characters before the extension); its extension must be jpg,
|
||||
// jpeg, png, gif or webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
||||
// *MediaError with the public route's message; any other error (no bucket,
|
||||
// a read or write failure) is not. It never replaces an existing object:
|
||||
// concurrent calls in this process reserve the key they chose, and the
|
||||
@@ -85,8 +86,9 @@ func StoreMedia(ctx context.Context, bucket *blob.Bucket, folder, filename strin
|
||||
if !validMediaFolder(folder) {
|
||||
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
||||
switch strings.ToLower(strings.TrimPrefix(path.Ext(name), ".")) {
|
||||
// D-85: the stored name is slugged whatever the client sends.
|
||||
name := MediaFileName(filename)
|
||||
switch strings.TrimPrefix(path.Ext(name), ".") {
|
||||
case "jpg", "jpeg", "png", "gif", "webp":
|
||||
default:
|
||||
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}
|
||||
|
||||
@@ -68,7 +68,9 @@ func TestStoreMedia(t *testing.T) {
|
||||
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
|
||||
for _, tc := range []struct{ folder, name, path string }{
|
||||
{"photos", "a.jpg", "/journal/photos/a.jpg"},
|
||||
{"/journal/photos/", "b.JPEG", "/journal/photos/b.JPEG"},
|
||||
{"/journal/photos/", "b.JPEG", "/journal/photos/b.jpeg"},
|
||||
// D-85: the stored name is slugged.
|
||||
{"photos", "Lwia Grzywa – w lesie!.JPG", "/journal/photos/lwia-grzywa-w-lesie.jpg"},
|
||||
{"", "c.png", "/journal/c.png"},
|
||||
{"x//y", "d.gif", "/journal/x/y/d.gif"},
|
||||
{"Journal", "e.webp", "/journal/e.webp"},
|
||||
|
||||
@@ -496,10 +496,10 @@ func TestBlogEditorUploadSmoke(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") {
|
||||
if got.URL != attach.PublicURL("journal/posts/zdjecie-1.png") {
|
||||
t.Fatalf("URL = %q", got.URL)
|
||||
}
|
||||
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) {
|
||||
if !bytes.Equal(stored(t, "journal/posts/zdjecie-1.png"), pngBytes) {
|
||||
t.Fatal("stored bytes differ from the upload")
|
||||
}
|
||||
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
|
||||
|
||||
@@ -32,15 +32,18 @@ function startBlock(editor: Editor): void {
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
}
|
||||
|
||||
/** link types text and links it through setLink, then leaves the link. */
|
||||
function link(editor: Editor, text: string, href: string): void {
|
||||
/** link types text and links it through setLink (with rel, as the link form writes it), then leaves the link. */
|
||||
function link(editor: Editor, text: string, href: string, rel?: string): void {
|
||||
const from = editor.state.selection.from
|
||||
type(editor, text)
|
||||
const to = editor.state.selection.from
|
||||
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
|
||||
expect(editor.chain().setTextSelection({ from, to }).setLink(rel ? { href, rel } : { href }).run(), href).toBe(true)
|
||||
editor.chain().setTextSelection(to).unsetMark('link').run()
|
||||
}
|
||||
|
||||
/** The rel the link form writes (J3, D-83): the three tokens, external links only. */
|
||||
const REL_VALUE = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
|
||||
|
||||
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
||||
expect(toggle()).toBe(true)
|
||||
type(editor, text)
|
||||
@@ -153,6 +156,20 @@ const builders: Record<string, (editor: Editor) => void> = {
|
||||
expect(editor.commands.setHardBreak()).toBe(true)
|
||||
type(editor, 'Druga linia')
|
||||
},
|
||||
// J3: an external link with the rel tokens of the link form (D-83).
|
||||
'link-rel': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Partner: ')
|
||||
link(editor, 'sklep', 'https://partner.example.test/', 'nofollow sponsored')
|
||||
type(editor, ', ')
|
||||
link(editor, 'forum', 'https://forum.example.test/watek', 'ugc')
|
||||
type(editor, '.')
|
||||
},
|
||||
// J4: a decorative image carries alt="" (D-85).
|
||||
'image-decorative': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: '' })).toBe(true)
|
||||
},
|
||||
}
|
||||
|
||||
function build(steps: ((editor: Editor) => void)[]): string {
|
||||
@@ -184,11 +201,11 @@ describe('editor corpus (D-05)', () => {
|
||||
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
||||
})
|
||||
|
||||
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
|
||||
it('holds no iframe, handler, target or style, rel only on external links, and only policy URLs', () => {
|
||||
expect(entries.length).toBeGreaterThanOrEqual(15)
|
||||
const doc = document.implementation.createHTMLDocument('')
|
||||
for (const { name, html } of entries) {
|
||||
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
|
||||
expect(html, name).not.toMatch(/<iframe|target=| on[a-z]+=|style=/i)
|
||||
expect(html, name).not.toBe('')
|
||||
expect(html, name).not.toMatch(/<p><\/p>$/)
|
||||
const host = doc.createElement('div')
|
||||
@@ -198,7 +215,14 @@ describe('editor corpus (D-05)', () => {
|
||||
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
||||
}
|
||||
for (const a of Array.from(host.querySelectorAll('a'))) {
|
||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||
const rel = a.getAttribute('rel')
|
||||
if (rel === null) {
|
||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||
continue
|
||||
}
|
||||
expect(a.getAttributeNames().sort(), name).toEqual(['href', 'rel'])
|
||||
expect(rel, name).toMatch(REL_VALUE)
|
||||
expect(a.getAttribute('href'), name).toMatch(/^https?:\/\//)
|
||||
}
|
||||
}
|
||||
const html = entries.map((e) => e.html).join('\n')
|
||||
@@ -206,5 +230,7 @@ describe('editor corpus (D-05)', () => {
|
||||
expect(html).toContain('<figcaption>')
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('start="3"')
|
||||
expect(html).toContain('rel="nofollow sponsored"')
|
||||
expect(html).toContain('alt=""')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,8 +55,16 @@
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "link-rel",
|
||||
"html": "<p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "image-decorative",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p><p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user