diff --git a/.gitignore b/.gitignore index 8346dbb..265fd8a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.phpunit.cache/ /.phpunit.result.cache /.php-cs-fixer.cache +/var/ /coverage/ /coverage.xml /clover.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7069e82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to Alto Markdown + +Contributions should preserve the public contracts, source bytes outside the +requested change, and safe defaults. + +## Prepare a checkout + +```bash +composer install +composer qa +``` + +`composer qa` runs PHPStan, the PHP CS Fixer check, and PHPUnit. Run coverage +separately when a change affects executable code: + +```bash +composer coverage +``` + +The coverage command enforces the repository's 99 percent line floor. + +## Propose a change + +Add or update tests for observable behavior. Update `docs/` and `CHANGELOG.md` +when the public contract changes. Keep parser limits, HTML policies, resource +authority, and file-conflict behavior explicit. + +Open a pull request against `main` only after the complete quality gate passes. +Describe the user-visible result, compatibility impact, and any security or +performance tradeoff. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bf1b62a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security policy + +Do not report a suspected vulnerability through a public GitHub issue. Email +[security@altocoda.com](mailto:security@altocoda.com) with the affected +version, impact, reproduction, and any known mitigation. + +Reports are accepted in English or French and handled through coordinated +disclosure. Avoid including secrets or unrelated personal data. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..c22f1d9 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,9 @@ +# Support + +Use GitHub issues for reproducible bugs and focused feature proposals. Include +the PHP version, package version, selected profile, minimal Markdown input, +expected result, and actual result. + +Usage questions should include the smallest complete code example and explain +the application boundary involved. Do not post security vulnerabilities in a +public issue; follow the private process in `SECURITY.md`. diff --git a/docs/extensions/attributes.md b/docs/extensions/attributes.md new file mode 100644 index 0000000..a004812 --- /dev/null +++ b/docs/extensions/attributes.md @@ -0,0 +1,96 @@ +# Attributes + +`AttributesExtension` adds constrained HTML attributes to Markdown elements. +Attribute names are explicitly allowed by `AttributesPolicy` before source +content can use them. + +## Install + +This extension is bundled with `alto/markdown`: + +```bash +composer require alto/markdown +``` + +## Configure + +Allow the attributes required by the application: + +```php +use Alto\Markdown\Extension\Attributes\AttributesExtension; +use Alto\Markdown\Extension\Attributes\AttributesPolicy; +use Alto\Markdown\Markdown; + +$markdown = Markdown::github()->with( + new AttributesExtension(new AttributesPolicy([ + 'id', + 'class', + 'title', + ])), +); +``` + +## Markdown + +```markdown +{#intro .lead title="Welcome home"} +# Hello +``` + +## HTML + +```html +
++``` + +An inline list decorates only the rendered element immediately before it: + +```markdown +Read the *important*{.accent} note. +``` + +```html +Quoted text
+
Read the important note.
+``` + +Attributes native to the Markdown element take precedence. Classes from the +element and the attribute list are merged without duplicates. diff --git a/docs/extensions/code-block-titles.md b/docs/extensions/code-block-titles.md new file mode 100644 index 0000000..60ed700 --- /dev/null +++ b/docs/extensions/code-block-titles.md @@ -0,0 +1,77 @@ +# Code block titles + +`CodeBlockTitleExtension` reads a title from a fenced code block's info string. +It wraps the code block in a figure with a visible caption. + +## Install + +This extension is bundled with `alto/markdown`: + +```bash +composer require alto/markdown +``` + +## Configure + +Add the extension to any factory profile: + +```php +use Alto\Markdown\Extension\CodeBlockTitle\CodeBlockTitleExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::github()->with(new CodeBlockTitleExtension()); +``` + +## Markdown + +````markdown +```php title="src/App.php" + +<?php echo 1;
+
+
+```
+
+## Options
+
+Pass a `CodeBlockTitlePolicy` to change the wrapper or parser limits:
+
+```php
+use Alto\Markdown\Extension\CodeBlockTitle\CodeBlockTitlePolicy;
+
+$extension = new CodeBlockTitleExtension(new CodeBlockTitlePolicy(
+ figureClass: 'code-block',
+ captionClass: 'code-block-title',
+ includeDataTitle: false,
+));
+```
+
+| Option | Default | Purpose |
+| --- | --- | --- |
+| `figureClass` | `code-block has-title` | Classes added to the figure |
+| `captionClass` | `code-title` | Class added to the caption |
+| `includeDataTitle` | `true` | Add the title as `data-title` on the figure |
+| `maxInfoBytes` | `4096` | Maximum fenced-code info string length |
+| `maxTitleBytes` | `512` | Maximum decoded title length |
+
+## Security
+
+The title and code content are escaped before insertion into HTML. Parser
+limits bound the info string and decoded title. The extension performs no I/O
+and does not enable raw HTML.
+
+## Behavior
+
+Both quoted and unquoted values are accepted. `filename` is an alias when no
+`title` is present. If both appear, `title` takes precedence.
+
+A missing, malformed, or oversized title leaves the ordinary ``
+output unchanged.
diff --git a/docs/extensions/content-slicer.md b/docs/extensions/content-slicer.md
new file mode 100644
index 0000000..b102584
--- /dev/null
+++ b/docs/extensions/content-slicer.md
@@ -0,0 +1,75 @@
+# Content slicer
+
+`ContentSlicerExtension` groups root-level heading sections in semantic
+`` elements. It changes HTML structure without changing the Markdown
+document.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Add the extension to any factory profile:
+
+```php
+use Alto\Markdown\Extension\ContentSlicer\ContentSlicerExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(new ContentSlicerExtension());
+```
+
+## Markdown
+
+```markdown
+# Main
+
+Intro.
+
+## Install
+
+Run Composer.
+```
+
+## HTML
+
+```html
+Main
+Intro.
+
+Install
+Run Composer.
+
+```
+
+## Options
+
+The constructor accepts the first heading level that opens a section:
+
+```php
+$markdown = Markdown::github()->with(
+ new ContentSlicerExtension(minLevel: 3),
+);
+```
+
+`minLevel` defaults to `2` and must be between `1` and `6`. Deeper matching
+headings create nested sections.
+
+## Security
+
+The extension introduces no source syntax, resource access, or raw HTML. It
+wraps existing rendered blocks in generated `` elements. The active
+HTML policy still controls the final fragment.
+
+## Behavior
+
+Only headings in the document root participate in the outline. Headings
+inside block quotes and lists keep their normal HTML without opening sections.
+
+The transformation is render-only. `toMarkdown()` preserves the original
+source. The curated HTML policy preserves the content but unwraps ``
+elements that are outside its allowed HTML subset.
diff --git a/docs/extensions/default-attributes.md b/docs/extensions/default-attributes.md
new file mode 100644
index 0000000..a1f84d4
--- /dev/null
+++ b/docs/extensions/default-attributes.md
@@ -0,0 +1,73 @@
+# Default attributes
+
+`DefaultAttributesExtension` adds application-defined HTML attributes to
+native Markdown elements. It does not add Markdown syntax.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Map node kinds to the attributes they should receive:
+
+```php
+use Alto\Markdown\Extension\DefaultAttributes\DefaultAttributesExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(
+ new DefaultAttributesExtension([
+ 'paragraph' => [
+ 'class' => ['prose', 'content'],
+ 'data-kind' => 'body',
+ ],
+ 'link' => [
+ 'class' => 'link',
+ 'target' => '_self',
+ ],
+ ]),
+);
+```
+
+## Markdown
+
+```markdown
+Read [Alto](https://altophp.com).
+```
+
+## HTML
+
+```html
+Read Alto.
+```
+
+## Options
+
+Each attribute value is a string or boolean. The `class` attribute also
+accepts a list of strings. Duplicate classes are removed, `true` emits a
+valueless attribute, and `false` omits it.
+
+Supported core kinds are `paragraph`, `atx-heading`, `setext-heading`,
+`indented-code`, `fenced-code`, `block-quote`, `list`, `list-item`,
+`thematic-break`, `hard-break`, `code-span`, `emphasis`, `strong`, `link`,
+`image`, and `autolink`. The `strikethrough` and `gfm:table` kinds require the
+GFM or GitHub profile. The `github:alert` kind requires the GitHub profile.
+
+## Security
+
+Configuration is trusted application code, not an allowlist for untrusted
+Markdown. It may add attributes such as `style` or event handlers. URL
+attributes still follow the active HTML policy.
+
+Use the curated HTML policy when the final fragment must remove attributes
+outside its allowlist.
+
+## Behavior
+
+Attributes produced by the Markdown element take precedence over configured
+defaults. Default classes are added first and merged with native or authored
+classes.
diff --git a/docs/extensions/description-lists.md b/docs/extensions/description-lists.md
new file mode 100644
index 0000000..9b4cbfe
--- /dev/null
+++ b/docs/extensions/description-lists.md
@@ -0,0 +1,80 @@
+# Description lists
+
+`DescriptionListExtension` adds terms and descriptions using a compact block
+syntax. It renders them as semantic ``, `- `, and `
- ` elements.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Add the extension to any factory profile:
+
+```php
+use Alto\Markdown\Extension\DescriptionList\DescriptionListExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(new DescriptionListExtension());
+```
+
+## Markdown
+
+```markdown
+Term
+: Definition
+```
+
+## HTML
+
+```html
+
+- Term
+- Definition
+
+```
+
+## Options
+
+This extension has no configuration options.
+
+## Security
+
+Terms and descriptions use the normal Markdown renderers. Text, links, and
+other inline content remain subject to the active HTML policy. The extension
+does not enable raw HTML or access external resources.
+
+## Behavior
+
+Adjacent terms and descriptions share one description list. A list can have
+multiple terms, multiple descriptions, and inline Markdown in either part.
+
+Indent nested blocks under a description:
+
+```markdown
+Term
+: First paragraph
+
+ - one
+ - two
+```
+
+```html
+
+- Term
+-
+
First paragraph
+
+- one
+- two
+
+
+
+```
+
+A description marker requires a preceding term and a space after `:`.
+Invalid markers remain ordinary Markdown.
diff --git a/docs/extensions/embeds.md b/docs/extensions/embeds.md
new file mode 100644
index 0000000..3198d23
--- /dev/null
+++ b/docs/extensions/embeds.md
@@ -0,0 +1,94 @@
+# Embeds
+
+`EmbedExtension` replaces an allowlisted URL with HTML returned by an
+application-provided resource resolver. The extension grants no network or
+filesystem access by itself.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Inject a resolver, an explicit host policy, and an HTML policy appropriate for
+the resolver's output:
+
+```php
+use Alto\Markdown\Extension\Embed\EmbedExtension;
+use Alto\Markdown\Extension\Embed\EmbedPolicy;
+use Alto\Markdown\Markdown;
+use Alto\Markdown\Render\HtmlPolicy;
+use Alto\Markdown\Render\RenderOptions;
+use Alto\Markdown\Resource\CallbackResourceResolver;
+use Alto\Markdown\Resource\ResolvedResource;
+use Alto\Markdown\Resource\ResourceRequest;
+
+$resolver = new CallbackResourceResolver(
+ static fn (ResourceRequest $request): ResolvedResource => new ResolvedResource(
+ 'embed:'.hash('sha256', $request->reference),
+ '',
+ ),
+);
+
+$markdown = Markdown::github()->with(
+ new EmbedExtension(
+ $resolver,
+ new EmbedPolicy(['video.example']),
+ ),
+);
+
+$options = new RenderOptions(htmlPolicy: HtmlPolicy::spec());
+$html = $markdown->toHtml(
+ "https://video.example/watch?v=1\n",
+ renderOptions: $options,
+);
+```
+
+## Markdown
+
+```markdown
+https://video.example/watch?v=1
+```
+
+## HTML
+
+```html
+
+```
+
+## Options
+
+`EmbedPolicy` requires at least one allowed host.
+
+| Option | Default | Purpose |
+| --- | --- | --- |
+| `includeSubdomains` | `false` | Allow subdomains of configured DNS hosts |
+| `allowHttp` | `false` | Allow plain HTTP in addition to HTTPS |
+| `fallback` | `EmbedFallback::Link` | Render a link when the embed cannot be emitted |
+| `maxUrlBytes` | `2048` | Maximum input URL length |
+| `maxHtmlBytes` | `262144` | Maximum resolved HTML length |
+
+Set `fallback` to `EmbedFallback::Remove` to omit a recognized embed when its
+HTML cannot be emitted, for example when the host or active HTML policy rejects
+it. Invalid Markdown remains literal, and resolver errors still propagate.
+
+## Security
+
+An embed is recognized only when one valid URL occupies a root-level line.
+HTTPS and the scheme's default port are required unless the policy explicitly
+allows HTTP. Host checks use exact DNS boundaries, and URLs with user
+information are rejected.
+
+The default safe HTML policy does not emit resolver HTML. With the default
+link fallback, the example instead renders:
+
+```html
+https://video.example/watch?v=1
+```
+
+Use `HtmlPolicy::spec()` only when the complete resolver output is trusted.
+For other rich output, attach an application sanitizer to the safe policy.
diff --git a/docs/extensions/external-links.md b/docs/extensions/external-links.md
new file mode 100644
index 0000000..70fa679
--- /dev/null
+++ b/docs/extensions/external-links.md
@@ -0,0 +1,76 @@
+# External links
+
+`ExternalLinkExtension` classifies destinations that contain a host and
+decorates external ones with controlled classes, relations, and target
+behavior.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+List the hosts that belong to the application:
+
+```php
+use Alto\Markdown\Extension\ExternalLink\ExternalLinkExtension;
+use Alto\Markdown\Extension\ExternalLink\ExternalLinkPolicy;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(
+ new ExternalLinkExtension(new ExternalLinkPolicy(
+ internalHosts: ['internal.test'],
+ )),
+);
+```
+
+## Markdown
+
+```markdown
+See [outside](https://outside.test/docs) and [inside](https://internal.test/docs).
+```
+
+## HTML
+
+```html
+
+```
+
+## Options
+
+`ExternalLinkPolicy` controls classification and every added attribute.
+
+| Option | Default | Purpose |
+| --- | --- | --- |
+| `internalHosts` | `[]` | Hosts classified as internal |
+| `includeSubdomains` | `false` | Classify subdomains of internal DNS hosts as internal |
+| `openInNewWindow` | `false` | Add `target="_blank"` to external links |
+| `htmlClass` | `''` | Class added to external links |
+| `nofollow` | `ExternalLinkScope::None` | Scope receiving `nofollow` |
+| `noopener` | `ExternalLinkScope::External` | Scope receiving `noopener` |
+| `noreferrer` | `ExternalLinkScope::External` | Scope receiving `noreferrer` |
+
+Each relation accepts `ExternalLinkScope::None`, `All`, `Internal`, or
+`External`.
+
+## Security
+
+The extension classifies links and adds attributes; it does not make a URL
+safe. Every destination remains subject to the active HTML policy, which
+filters unsafe schemes and escapes emitted attributes.
+
+`noopener` and `noreferrer` apply to external links by default. Keep these
+relations enabled when opening external links in a new window.
+
+## Behavior
+
+Host comparison is case-insensitive and ignores a trailing dot. Subdomain
+matching is disabled unless `includeSubdomains` is enabled. A sibling domain
+such as `notexample.com` never matches `example.com`.
+
+Absolute and scheme-relative destinations can contain a host. Relative links,
+fragments, and email links do not.
diff --git a/docs/extensions/footnotes.md b/docs/extensions/footnotes.md
new file mode 100644
index 0000000..dba447b
--- /dev/null
+++ b/docs/extensions/footnotes.md
@@ -0,0 +1,64 @@
+# Footnotes
+
+`FootnoteExtension` adds reference markers and block definitions. It collects
+used definitions into an accessible footnote section after the document.
+
+## Install
+
+This extension is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Add the extension to any factory profile:
+
+```php
+use Alto\Markdown\Extension\Footnote\FootnoteExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(new FootnoteExtension());
+```
+
+## Markdown
+
+```markdown
+Text[^note].
+
+[^note]: Footnote with **strong**.
+```
+
+## HTML
+
+```html
+Text1.
+
+
+
+-
+
Footnote with strong. ↩
+
+
+
+```
+
+## Options
+
+This extension has no configuration options.
+
+## Security
+
+Footnote content uses the normal Markdown renderers and remains subject to the
+active HTML policy. Generated IDs, links, and ARIA roles are owned by the
+extension. It performs no resource access and does not enable raw HTML.
+
+## Behavior
+
+Reference order determines numbering, regardless of definition order.
+Repeated references share one footnote and receive distinct return links.
+
+Definitions can contain indented nested blocks. Unused definitions are not
+rendered. Missing references, escaped markers, markers inside code spans, and
+invalid labels remain literal text.
diff --git a/docs/extensions/heading-levels.md b/docs/extensions/heading-levels.md
new file mode 100644
index 0000000..84e8892
--- /dev/null
+++ b/docs/extensions/heading-levels.md
@@ -0,0 +1,74 @@
+# Heading levels
+
+`HeadingLevelExtension` projects heading levels at render time. It is useful
+when a Markdown fragment must fit inside an existing document outline without
+rewriting its source.
+
+## Install
+
+This extension is bundled with `alto/markdown`.
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+```php
+use Alto\Markdown\Extension\HeadingLevel\HeadingLevelExtension;
+use Alto\Markdown\Extension\HeadingLevel\HeadingLevelPolicy;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::commonmark()->with(
+ new HeadingLevelExtension(HeadingLevelPolicy::shift(1)),
+);
+```
+
+## Markdown
+
+```markdown
+# Title
+
+## Details
+```
+
+## HTML
+
+```html
+Title
+Details
+```
+
+## Options
+
+Choose one policy strategy:
+
+```php
+$mapped = HeadingLevelPolicy::map([
+ 1 => 2,
+ 2 => 4,
+]);
+
+$shifted = HeadingLevelPolicy::shift(1);
+
+$custom = HeadingLevelPolicy::using(
+ static fn (int $level): ?int => 2 === $level ? null : $level + 1,
+);
+```
+
+`map()` changes only listed levels. `shift()` accepts offsets from `-5` to
+`5`. A callback passed to `using()` may return `null` to keep the effective
+level unchanged. Every rendered level must remain between 1 and 6; a document
+containing a heading projected outside that range fails when rendered.
+
+## Security
+
+The extension changes only the numeric level of existing heading elements. It
+does not emit user-provided HTML or access external resources.
+
+## Behavior
+
+The projection applies to ATX and Setext headings, including nested headings.
+It does not change the document, its Markdown output, or its edit diff. Other
+document transforms, including heading permalinks and tables of contents, use
+the projected level.
diff --git a/docs/extensions/heading-permalinks.md b/docs/extensions/heading-permalinks.md
new file mode 100644
index 0000000..fc5f76f
--- /dev/null
+++ b/docs/extensions/heading-permalinks.md
@@ -0,0 +1,86 @@
+# Heading permalinks
+
+`HeadingPermalinkExtension` gives headings stable slugs and adds configurable
+permalink anchors. Duplicate headings receive deterministic numeric suffixes.
+
+## Install
+
+This extension is bundled with `alto/markdown`.
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+```php
+use Alto\Markdown\Extension\HeadingPermalink\HeadingPermalinkExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::commonmark()->with(new HeadingPermalinkExtension());
+```
+
+## Markdown
+
+```markdown
+# Hello, **world!**
+```
+
+## HTML
+
+```html
+¶Hello, world!
+```
+
+## Options
+
+Pass a `HeadingPermalinkPolicy` to control the generated anchor:
+
+```php
+use Alto\Markdown\Extension\HeadingPermalink\HeadingPermalinkPolicy;
+use Alto\Markdown\Extension\HeadingPermalink\HeadingPermalinkPosition;
+
+$extension = new HeadingPermalinkExtension(new HeadingPermalinkPolicy(
+ minLevel: 2,
+ maxLevel: 4,
+ position: HeadingPermalinkPosition::After,
+ idPrefix: 'heading',
+ applyIdToHeading: true,
+ headingClass: 'anchored',
+ fragmentPrefix: 'heading',
+ htmlClass: 'permalink',
+ title: 'Link to this section',
+ symbol: '#',
+ ariaHidden: false,
+));
+```
+
+| Option | Default |
+| --- | --- |
+| `minLevel`, `maxLevel` | `1`, `6` |
+| `position` | `HeadingPermalinkPosition::Before` |
+| `idPrefix`, `fragmentPrefix` | `content` |
+| `applyIdToHeading` | `false` |
+| `headingClass` | Empty |
+| `htmlClass` | `heading-permalink` |
+| `title` | `Permalink` |
+| `symbol` | `¶` |
+| `ariaHidden` | `true` |
+
+Use `HeadingPermalinkPosition::None` to apply the configured heading ID and
+class without rendering an anchor.
+
+Both levels must be between 1 and 6, and `minLevel` cannot exceed `maxLevel`.
+
+## Security
+
+Configured classes, titles, symbols, IDs, and fragments are escaped through
+the active HTML output context. Generated links remain subject to the active
+HTML policy.
+
+## Behavior
+
+Slug allocation covers the complete document, including filtered headings,
+so partial node and section rendering keeps the same IDs. Renaming a heading
+invalidates the slug catalog. Rendering permalinks does not modify the source
+Markdown.
diff --git a/docs/extensions/highlight.md b/docs/extensions/highlight.md
new file mode 100644
index 0000000..e7ca530
--- /dev/null
+++ b/docs/extensions/highlight.md
@@ -0,0 +1,62 @@
+# Highlight
+
+`HighlightExtension` adds `==marked text==` as a small inline syntax. It renders
+the marked content with the semantic HTML `` element.
+
+## Install
+
+`HighlightExtension` is bundled with `alto/markdown`:
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+Add the extension to any factory profile:
+
+```php
+use Alto\Markdown\Extension\Highlight\HighlightExtension;
+use Alto\Markdown\Markdown;
+
+$markdown = Markdown::github()->with(new HighlightExtension());
+```
+
+## Markdown
+
+```markdown
+Review the ==important change== before merging.
+```
+
+## HTML
+
+```html
+Review the important change before merging.
+```
+
+## Options
+
+This extension has no configuration options.
+
+## Security
+
+The extension grants no resource or raw HTML authority. Highlighted content is
+stored as plain text and HTML-sensitive characters are escaped during output.
+
+## Behavior
+
+The opening and closing `==` delimiters must appear on the same line. The
+content cannot start or end with whitespace, and an empty pair stays literal.
+
+Highlighted content is plain text. Markdown syntax inside the delimiters is
+not parsed, and HTML-sensitive characters are escaped:
+
+```markdown
+==**important** and ==
+```
+
+```html
+**important** and <safe>
+```
+
+Escaped delimiters and delimiters inside code spans remain literal.
diff --git a/docs/extensions/import.md b/docs/extensions/import.md
new file mode 100644
index 0000000..82b8cb9
--- /dev/null
+++ b/docs/extensions/import.md
@@ -0,0 +1,72 @@
+# Import
+
+`ImportExtension` reads a bounded resource and renders its bytes as an escaped
+code block without parsing them as Markdown.
+
+## Install
+
+`ImportExtension` is bundled with `alto/markdown`.
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+```php
+use Alto\Markdown\Extension\Import\ImportExtension;
+use Alto\Markdown\Markdown;
+use Alto\Markdown\Resource\FilesystemResourceResolver;
+
+$resolver = new FilesystemResourceResolver(
+ root: __DIR__.'/content',
+ allowedExtensions: ['php'],
+ maxBytes: 100_000,
+);
+
+$markdown = Markdown::commonmark()->with(new ImportExtension($resolver));
+```
+
+Assume `content/snippet.php` contains:
+
+```php
+<?php
+echo 'Hello';
+
+```
+
+## Options
+
+Options follow the quoted resource reference inside braces.
+
+| Option | Default | Effect |
+| --- | --- | --- |
+| `lines` | All lines | Selects one 1-based line or an inclusive range such as `2-8` |
+| `lang` | None | Adds the corresponding `language-*` class to the code element |
+| `indent` | `0` | Adds up to 32 spaces to every imported line |
+
+Line numbers cannot exceed 1,000,000. Language identifiers are limited to 64
+bytes. An invalid directive remains literal and performs no resource read.
+
+## Security
+
+The directive must start at column zero outside an open paragraph. Imported
+bytes are always escaped as code, including under permissive HTML policies.
+Resolution happens once while parsing; later renders perform no I/O.
+
+The resolver defines the available resources. `FilesystemResourceResolver`
+accepts relative paths only, rejects parent traversal and symlink components,
+and enforces both the extension allowlist and `maxBytes`.
diff --git a/docs/extensions/include.md b/docs/extensions/include.md
new file mode 100644
index 0000000..7951b69
--- /dev/null
+++ b/docs/extensions/include.md
@@ -0,0 +1,91 @@
+# Include
+
+`IncludeExtension` expands bounded Markdown resources into the current
+document and parses them with the same compiled profile.
+
+## Install
+
+`IncludeExtension` is bundled with `alto/markdown`.
+
+```bash
+composer require alto/markdown
+```
+
+## Configure
+
+```php
+use Alto\Markdown\Extension\Include\IncludeExtension;
+use Alto\Markdown\Extension\Include\IncludePolicy;
+use Alto\Markdown\Markdown;
+use Alto\Markdown\Resource\FilesystemResourceResolver;
+
+$resolver = new FilesystemResourceResolver(
+ root: __DIR__.'/content',
+ allowedExtensions: ['md'],
+ maxBytes: 256_000,
+);
+
+$markdown = Markdown::commonmark()->with(new IncludeExtension(
+ $resolver,
+ new IncludePolicy(maxDepth: 4, maxResources: 16),
+));
+```
+
+Assume `content/parts/setup.md` contains:
+
+```markdown
+## Install
+
+Run **Composer**.
+```
+
+## Markdown
+
+```markdown
+Before.
+
+@include "parts/setup.md"
+
+After.
+```
+
+## HTML
+
+```html
+Before.
+Run Composer.
+After.
+``` + +## Options + +The directive has no inline options. `IncludePolicy` bounds the complete +expansion and the parser used for included content. + +| Option | Default | Effect | +| --- | --- | --- | +| `maxDepth` | `8` | Maximum recursive include depth | +| `maxResources` | `64` | Maximum resources resolved per parse | +| `maxExpandedBytes` | `1,048,576` | Maximum total bytes across included resources | +| `maxNestingDepth` | `128` | Maximum Markdown block nesting in included content | +| `maxBlockCount` | `50,000` | Maximum parsed blocks in included content | +| `maxInlineCount` | `200,000` | Maximum parsed inline nodes in included content | +| `maxReferenceCount` | `10,000` | Maximum link-reference definitions in included content | + +## Security + +The resolver owns access control and per-resource size limits. The include +policy adds aggregate byte, recursion, and parser limits. With +`FilesystemResourceResolver`, only relative allowlisted files below the +configured root are available; traversal and symlink components are rejected. + +## Behavior + +Nested references resolve relative to their parent resource. Repeated +non-cyclic resources are allowed and count toward the resource limit. Cycles +are rejected by resolved resource ID. Include directives inside fenced code, +raw HTML, or custom containers remain literal. + +The original `@include` directive is preserved when the document is rendered +back to Markdown. Included HTML follows the active HTML policy. diff --git a/docs/extensions/index.md b/docs/extensions/index.md index a0fa910..e75d7c7 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -17,32 +17,32 @@ $factory = Markdown::github()->with( ## Authoring syntax -- `PairedDelimiterExtension`: configurable leaf delimiter pairs. -- `SmartPunctuationExtension`: quotes, dashes, and ellipses. -- `HighlightExtension`: highlighted inline text. -- `DescriptionListExtension`: description lists. -- `FootnoteExtension`: definitions and references. -- `TabsExtension`: nested tab groups. -- `MentionExtension`: application-resolved mentions. -- `AttributesExtension`: constrained source attributes. +- [Paired delimiters](paired-delimiters.md): configurable leaf delimiter pairs. +- [Smart punctuation](smart-punctuation.md): quotes, dashes, and ellipses. +- [Highlight](highlight.md): highlighted inline text. +- [Description lists](description-lists.md): terms and definitions. +- [Footnotes](footnotes.md): definitions and references. +- [Tabs](tabs.md): nested tab groups. +- [Mentions](mentions.md): application-resolved references. +- [Attributes](attributes.md): constrained source attributes. ## Documents and HTML -- `HeadingLevelExtension`: project heading levels at render time. -- `ContentSlicerExtension`: group heading sections. -- `HeadingPermalinkExtension`: add stable heading links. -- `TableOfContentsExtension`: generate a document outline. -- `DefaultAttributesExtension`: add controlled HTML attributes. -- `CodeBlockTitleExtension`: render code-block titles. -- `ExternalLinkExtension`: mark external links. -- `LinkRewriterExtension`: rewrite link destinations. +- [Heading levels](heading-levels.md): project heading levels at render time. +- [Content slicer](content-slicer.md): group heading sections. +- [Heading permalinks](heading-permalinks.md): add stable heading links. +- [Table of contents](table-of-contents.md): generate a document outline. +- [Default attributes](default-attributes.md): add controlled HTML attributes. +- [Code block titles](code-block-titles.md): render code-block titles. +- [External links](external-links.md): mark external links. +- [Link rewriting](link-rewriting.md): rewrite link destinations. ## External resources -- `ImportExtension`: insert escaped source code. -- `IncludeExtension`: expand bounded Markdown resources. -- `SourceExtension`: display source excerpts. -- `EmbedExtension`: resolve allowlisted rich embeds. +- [Import](import.md): insert escaped source code. +- [Include](include.md): expand bounded Markdown resources. +- [Source](source.md): display source excerpts. +- [Embeds](embeds.md): resolve allowlisted rich content. Resource-backed extensions require an injected `ResourceResolver`; installing an extension never grants filesystem or network authority by itself. Read diff --git a/docs/extensions/link-rewriting.md b/docs/extensions/link-rewriting.md new file mode 100644 index 0000000..df1069b --- /dev/null +++ b/docs/extensions/link-rewriting.md @@ -0,0 +1,79 @@ +# Link rewriting + +`LinkRewriterExtension` changes link, image, and autolink destinations while +rendering. Use it to move documentation under a base URI, replace known paths, +or apply an application-specific URL rule without editing the source. + +## Install + +This extension is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\LinkRewrite\LinkRewriter; +use Alto\Markdown\Extension\LinkRewrite\LinkRewriterExtension; +use Alto\Markdown\Extension\LinkRewrite\LinkDestinationContext; +use Alto\Markdown\Markdown; + +$rewriter = LinkRewriter::map([ + '/guide' => '/v2/guide', + '/logo.png' => 'https://cdn.example/logo.png', +]); + +$markdown = Markdown::commonmark()->with( + new LinkRewriterExtension($rewriter), +); +``` + +## Markdown + +```markdown +[Guide](/guide)  +``` + +## HTML + +```html + +``` + +## Options + +Build a rewriter with one or more strategies: + +```php +$base = LinkRewriter::baseUri('https://docs.example/base'); +$mapped = LinkRewriter::map(['/old' => '/new']); +$pattern = LinkRewriter::pattern('~^/v1/~', '/v2/'); +$callback = LinkRewriter::callback( + static fn (LinkDestinationContext $context): string => $context->destination, +); + +$rewriter = LinkRewriter::compose($base, $mapped, $pattern) + ->then($callback); +``` + +`baseUri()` prefixes path-like destinations. It leaves empty, fragment-only, +query-only, scheme-relative, and absolute destinations unchanged. `map()` +replaces exact destinations. `pattern()` uses `preg_replace()`. `callback()` +receives a `LinkDestinationContext`. Composed strategies run in declaration +order. + +## Security + +Every strategy result is validated as a Markdown destination. The active HTML +policy still escapes the URL and filters unsafe schemes before output. + +## Behavior + +The extension rewrites destinations only for HTML rendering. The retained +document and its Markdown output stay unchanged. Call +`$rewriter->rewriteDocument($document)` when source links and images must be +edited explicitly. It requires Alto's parsed document model and must run before +any other pending edit. Reference-style destinations and overlapping nested +ranges remain untouched in that mutation lane. diff --git a/docs/extensions/mentions.md b/docs/extensions/mentions.md new file mode 100644 index 0000000..2a064c8 --- /dev/null +++ b/docs/extensions/mentions.md @@ -0,0 +1,71 @@ +# Mentions + +`MentionExtension` recognizes application-defined identifiers and resolves +them to safe links. + +## Install + +`MentionExtension` is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\Mention\MentionDefinition; +use Alto\Markdown\Extension\Mention\MentionExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::commonmark()->with(new MentionExtension( + MentionDefinition::links( + type: 'user', + prefix: '@', + pattern: '[A-Z0-9](?:[A-Z0-9-]{0,38})(?![A-Z0-9-])', + urlTemplate: 'https://github.com/%s', + ), +)); +``` + +## Markdown + +```markdown +Ask @Ada-Lovelace. +``` + +## HTML + +```html +Ask @Ada-Lovelace.
+``` + +## Options + +Each `MentionDefinition` configures one mention type. + +| Option | Default | Effect | +| --- | --- | --- | +| `type` | Required | Stable lowercase name used in the qualified node kind | +| `prefix` | Required | Trigger such as `@`, `@@`, or `#` | +| `pattern` | Required | PCRE fragment matched after the prefix | +| `resolver` | Required | Resolves an identifier to a URL, label, and optional title | +| `maxIdentifierBytes` | `128` | Rejects longer candidates before resolution | + +`MentionDefinition::links()` creates a URL-template resolver. Its template +must contain exactly one `%s`; the identifier is URL-encoded before insertion. +Pass a custom `MentionResolver` when resolution depends on application data or +when the visible label and title must differ from the source. + +## Security + +Resolved URLs pass through the active URL policy. Labels and titles are +escaped before rendering. A custom resolver is trusted application code and +must enforce any authorization required by its data source. + +## Behavior + +Definitions may share a prefix and are evaluated in registration order. A +resolver may return `null` to let the next definition try the same candidate. +Mentions do not start inside words and never create nested links. Generated +destinations pass through the active URL policy. diff --git a/docs/extensions/paired-delimiters.md b/docs/extensions/paired-delimiters.md new file mode 100644 index 0000000..e12268e --- /dev/null +++ b/docs/extensions/paired-delimiters.md @@ -0,0 +1,66 @@ +# Paired delimiters + +`PairedDelimiterExtension` turns one configured delimiter pair into a safe +inline HTML element. Use it for small, application-specific spans such as +insertions, keyboard input, or abbreviations. + +## Install + +This extension is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\PairedDelimiter\PairedDelimiterExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::github()->with(new PairedDelimiterExtension( + name: 'inserted', + opening: '++', + closing: '++', + element: 'ins', +)); +``` + +## Markdown + +```markdown +Keep ++this & **literal**++ text. +``` + +## HTML + +```html +Keep this & **literal** text.
+``` + +## Options + +| Option | Description | +| --- | --- | +| `name` | Unique name starting with a lowercase letter, followed by lowercase letters, digits, or hyphens. | +| `opening` | Opening delimiter, from 1 to 16 bytes. | +| `closing` | Closing delimiter, from 1 to 16 bytes. | +| `element` | Safe inline element. Defaults to `span`. | + +Supported elements include `abbr`, `code`, `del`, `ins`, `kbd`, `mark`, +`span`, `sub`, and `sup`. Invalid names, whitespace in delimiters, control +bytes, and unsupported elements are rejected when the extension is created. +The active profile also reserves some triggers. For example, CommonMark rejects +`**` as a custom opening delimiter when the extension is added with `with()`. + +## Security + +Only a fixed allowlist of safe inline elements is accepted. Delimited content +is escaped as text, so it cannot inject HTML through the extension output. + +## Behavior + +The delimited content is a plain-text leaf. Markdown inside it is escaped +rather than parsed. Empty, unclosed, escaped, or multiline pairs remain +literal Markdown. The original delimiters are preserved when the document is +rendered back to Markdown. diff --git a/docs/extensions/smart-punctuation.md b/docs/extensions/smart-punctuation.md new file mode 100644 index 0000000..5a51c67 --- /dev/null +++ b/docs/extensions/smart-punctuation.md @@ -0,0 +1,70 @@ +# Smart punctuation + +`SmartPunctuationExtension` replaces straight quotes, apostrophes, dash runs, +and three-dot ellipses while rendering. It leaves the source Markdown +unchanged. + +## Install + +This extension is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\SmartPunctuation\SmartPunctuationExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::github()->with(new SmartPunctuationExtension()); +``` + +## Markdown + +```markdown +"Hello," she said... It's ready -- really --- now. +``` + +## HTML + +```html +“Hello,” she said… It’s ready – really — now.
+``` + +## Options + +Pass a `SmartPunctuationPolicy` to replace the four quote characters: + +```php +use Alto\Markdown\Extension\SmartPunctuation\SmartPunctuationPolicy; + +$markdown = Markdown::commonmark()->with(new SmartPunctuationExtension( + new SmartPunctuationPolicy( + doubleQuoteOpener: '« ', + doubleQuoteCloser: ' »', + singleQuoteOpener: '‹ ', + singleQuoteCloser: ' ›', + ), +)); +``` + +Each replacement must be a non-empty valid UTF-8 string. Ellipses and dash +replacement rules are fixed: `...` becomes `…`, `--` becomes an en dash, and +`---` becomes an em dash. Longer runs are decomposed deterministically into em +and en dashes. + +## Security + +Every replacement is validated as non-empty UTF-8 and emitted as text. The +extension does not enable raw HTML or access external resources. + +## Behavior + +Quote direction is selected from the surrounding characters. Apostrophes +inside words use the configured single-quote closer. Code spans and escaped +punctuation stay literal. Punctuation inside raw HTML is not replaced; the +active HTML policy still decides whether that HTML is escaped, removed, or +emitted. Replacements also apply inside rich inline content and GFM table +cells. diff --git a/docs/extensions/source.md b/docs/extensions/source.md new file mode 100644 index 0000000..6c3d49b --- /dev/null +++ b/docs/extensions/source.md @@ -0,0 +1,80 @@ +# Source + +`SourceExtension` renders a bounded resource with its path and optional source +metadata such as a title, line numbers, and highlighted lines. + +## Install + +`SourceExtension` is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\Source\SourceExtension; +use Alto\Markdown\Markdown; +use Alto\Markdown\Resource\FilesystemResourceResolver; + +$resolver = new FilesystemResourceResolver( + root: __DIR__.'/content', + allowedExtensions: ['php'], + maxBytes: 100_000, +); + +$markdown = Markdown::commonmark()->with(new SourceExtension($resolver)); +``` + +Assume `content/src/App.php` contains: + +```text +one +one
+<two>
+
+it