Problem
Images added in #89 render with only src and (sometimes) alt. Three attributes that browsers and Core Web Vitals care about are never emitted, and alt is dropped entirely for bare wikilink embeds.
Current output, verified against pkg/parser with BlogRoot /:
<!--  --> <p><img src="/images/cat.png" alt="A cat" /></p>
<!-- ![[dog.png]] --> <p><img src="/images/dog.png"></p>
<!-- ![[dog.png|A dog]] --> <p><img src="/images/dog.png" alt="A dog"></p>
Consequences:
- No
width/height — the browser cannot reserve space before the image loads, so content reflows as each image arrives. That is a Cumulative Layout Shift (CLS) penalty in Core Web Vitals, and it affects every post with an image.
- No
loading="lazy" — every image in a post is fetched during initial page load, even ones far below the fold.
alt missing on ![[dog.png]] — no attribute at all, not even alt="". Screen readers fall back to announcing the file name or URL. The README currently documents this as a caveat ("A bare ![[pipeline.png]] has no alt text, so prefer the |label form"); it should be a guarantee instead.
Expected output
For the same three inputs, with posts/images/cat.png a 1200×800 PNG and posts/images/dog.png a 640×480 PNG:
<p><img src="/images/cat.png" alt="A cat" width="1200" height="800" loading="lazy" decoding="async" /></p>
<p><img src="/images/dog.png" alt="" width="640" height="480" loading="lazy" decoding="async"></p>
<p><img src="/images/dog.png" alt="A dog" width="640" height="480" loading="lazy" decoding="async"></p>
Rules:
width/height are the image's intrinsic pixel dimensions, read from the asset file at parse time. Both are emitted, or neither.
loading="lazy" and decoding="async" on every image, both syntaxes. (Deliberate choice: uniform and simple. The first-image-eager/fetchpriority refinement for LCP is out of scope here.)
alt is always present. A bare ![[foo.png]] emits alt="", which is the correct signal for "no description available" and stops assistive tech reading out the URL.
- Attributes are only added to images the parser rewrote to an assets URL. Images left as written — absolute URLs,
//host/x, /foo.png, data: — keep loading/decoding but get no dimensions, since there is no local file to measure. alt handling is unchanged for those.
- The existing
.prose img { max-width: 100%; height: auto; } rule in pkg/templates/default/partials/head.tmpl must stay. With the attributes present it is what keeps images responsive while still letting the browser derive the aspect ratio from width/height.
How dimensions get read
pkg/parser has no access to the assets filesystem today — only outputter and server hold a config.AssetsDir. The decision taken is to probe the asset file:
- Add a
config.AssetsDir embedded field to the parser, following the config pattern in CLAUDE.md: the type already exists in pkg/config, so this needs a parser.WithAssetsDir option (or reuse of the existing BaseOption, whichever matches how WithBlogRoot and WithLogger are wired into parser.Config) and plumbing from the CLI's --assets-dir through to the parser in both generate and serve.
- Read dimensions with
image.DecodeConfig, which reads only the header rather than decoding pixels. Register the decoders for the formats the assets pipeline accepts (image/png, image/jpeg, image/gif via blank imports; golang.org/x/image/webp if WebP is wanted — flag it if you would rather not take the dependency, and leave WebP undimensioned).
- Graceful degradation is required, never a parse error: no assets FS configured, file missing, unreadable, an unsupported format, or SVG/AVIF (which
image.DecodeConfig cannot measure) all mean "emit no width/height" and carry on. Log at debug or warn level, consistent with the existing ".. was not rewritten" warning in pkg/parser/images.go.
- Cache measurements per path within a parse run — the same image referenced from several posts should be read once.
Implementation notes
- Standard markdown images are straightforward: set node attributes on the
*ast.Image in imageTransformer.Transform (pkg/parser/images.go). goldmark's html.ImageAttributeFilter already allows width, height, loading and decoding, so the default image renderer will emit them.
- Wikilink embeds are the harder half.
go.abhg.dev/goldmark/wikilink renders <img> itself in its own Renderer and does not consult node attributes, so setting attributes on a *wikilink.Node will have no effect. Two options, please pick whichever you find cleaner after checking the behaviour:
- In an AST transformer, replace image-target embed nodes (
n.Embed && isImageTarget(n.Target), per pkg/parser/wikilink.go) with equivalent *ast.Image nodes. This unifies both syntaxes onto one code path and makes the alt="" default fall out naturally.
- Register a GoBlog node renderer for
wikilink.Kind at a priority that beats the extension's, and emit the <img> ourselves.
assetURL returns the URL path; measuring needs the corresponding path within the assets FS (URL /images/screenshots/a.png → FS path screenshots/a.png). Derive it from the same normalisation rather than string-trimming the final URL, and keep the existing .. containment guarantee intact — an asset read must never escape the assets root.
Tests
pkg/parser: table tests over both syntaxes covering a measurable PNG, a bare embed (alt=""), a labelled embed, a subdirectory path, a missing file, an unsupported/undecodable format, an absolute URL, a /-rooted path, and no assets FS configured. Use fstest.MapFS with small real encoded images so image.DecodeConfig has genuine headers to read.
- Integration: extend the existing image coverage (
ba4a5cc) to assert the served HTML carries width, height, loading="lazy" and a present alt for a real asset, under both generate and serve.
Documentation
Per CLAUDE.md, any new exported symbol needs godoc plus README coverage:
- godoc on the new option and config type, and an entry in
pkg/parser/doc.go alongside the other options.
pkg/config/doc.go if a new option function lands there.
- README: update the images section (around line 232) to show the full rendered
<img> tag, state that dimensions are read from the asset file and that unmeasurable files simply omit them, and delete the "bare ![[pipeline.png]] has no alt text" caveat once alt="" is guaranteed.
--assets-dir in the CLI flag table now also affects parsing, not just serving and copying — worth a word.
Problem
Images added in #89 render with only
srcand (sometimes)alt. Three attributes that browsers and Core Web Vitals care about are never emitted, andaltis dropped entirely for bare wikilink embeds.Current output, verified against
pkg/parserwithBlogRoot/:Consequences:
width/height— the browser cannot reserve space before the image loads, so content reflows as each image arrives. That is a Cumulative Layout Shift (CLS) penalty in Core Web Vitals, and it affects every post with an image.loading="lazy"— every image in a post is fetched during initial page load, even ones far below the fold.altmissing on![[dog.png]]— no attribute at all, not evenalt="". Screen readers fall back to announcing the file name or URL. The README currently documents this as a caveat ("A bare![[pipeline.png]]has no alt text, so prefer the|labelform"); it should be a guarantee instead.Expected output
For the same three inputs, with
posts/images/cat.pnga 1200×800 PNG andposts/images/dog.pnga 640×480 PNG:Rules:
width/heightare the image's intrinsic pixel dimensions, read from the asset file at parse time. Both are emitted, or neither.loading="lazy"anddecoding="async"on every image, both syntaxes. (Deliberate choice: uniform and simple. The first-image-eager/fetchpriorityrefinement for LCP is out of scope here.)altis always present. A bare![[foo.png]]emitsalt="", which is the correct signal for "no description available" and stops assistive tech reading out the URL.//host/x,/foo.png,data:— keeploading/decodingbut get no dimensions, since there is no local file to measure.althandling is unchanged for those..prose img { max-width: 100%; height: auto; }rule inpkg/templates/default/partials/head.tmplmust stay. With the attributes present it is what keeps images responsive while still letting the browser derive the aspect ratio fromwidth/height.How dimensions get read
pkg/parserhas no access to the assets filesystem today — onlyoutputterandserverhold aconfig.AssetsDir. The decision taken is to probe the asset file:config.AssetsDirembedded field to the parser, following the config pattern inCLAUDE.md: the type already exists inpkg/config, so this needs aparser.WithAssetsDiroption (or reuse of the existingBaseOption, whichever matches howWithBlogRootandWithLoggerare wired intoparser.Config) and plumbing from the CLI's--assets-dirthrough to the parser in bothgenerateandserve.image.DecodeConfig, which reads only the header rather than decoding pixels. Register the decoders for the formats the assets pipeline accepts (image/png,image/jpeg,image/gifvia blank imports;golang.org/x/image/webpif WebP is wanted — flag it if you would rather not take the dependency, and leave WebP undimensioned).image.DecodeConfigcannot measure) all mean "emit nowidth/height" and carry on. Log at debug or warn level, consistent with the existing "..was not rewritten" warning inpkg/parser/images.go.Implementation notes
*ast.ImageinimageTransformer.Transform(pkg/parser/images.go). goldmark'shtml.ImageAttributeFilteralready allowswidth,height,loadinganddecoding, so the default image renderer will emit them.go.abhg.dev/goldmark/wikilinkrenders<img>itself in its ownRendererand does not consult node attributes, so setting attributes on a*wikilink.Nodewill have no effect. Two options, please pick whichever you find cleaner after checking the behaviour:n.Embed && isImageTarget(n.Target), perpkg/parser/wikilink.go) with equivalent*ast.Imagenodes. This unifies both syntaxes onto one code path and makes thealt=""default fall out naturally.wikilink.Kindat a priority that beats the extension's, and emit the<img>ourselves.assetURLreturns the URL path; measuring needs the corresponding path within the assets FS (URL/images/screenshots/a.png→ FS pathscreenshots/a.png). Derive it from the same normalisation rather than string-trimming the final URL, and keep the existing..containment guarantee intact — an asset read must never escape the assets root.Tests
pkg/parser: table tests over both syntaxes covering a measurable PNG, a bare embed (alt=""), a labelled embed, a subdirectory path, a missing file, an unsupported/undecodable format, an absolute URL, a/-rooted path, and no assets FS configured. Usefstest.MapFSwith small real encoded images soimage.DecodeConfighas genuine headers to read.ba4a5cc) to assert the served HTML carrieswidth,height,loading="lazy"and a presentaltfor a real asset, under bothgenerateandserve.Documentation
Per
CLAUDE.md, any new exported symbol needs godoc plus README coverage:pkg/parser/doc.goalongside the other options.pkg/config/doc.goif a new option function lands there.<img>tag, state that dimensions are read from the asset file and that unmeasurable files simply omit them, and delete the "bare![[pipeline.png]]has no alt text" caveat oncealt=""is guaranteed.--assets-dirin the CLI flag table now also affects parsing, not just serving and copying — worth a word.