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 +

Hello

+``` + +## Options + +Without an explicit policy, only `id` and `class` are allowed. + +| Option | Default | Purpose | +| --- | ---: | --- | +| `allowed` | `['id', 'class']` | HTML attribute names accepted from Markdown | +| `maxAttributes` | `32` | Maximum attributes in one list | +| `maxListBytes` | `1024` | Maximum byte length of one attribute list | +| `maxValueBytes` | `512` | Maximum byte length of one value | + +Event handler names such as `onclick` cannot be allowed. Attribute names are +matched case-insensitively. + +## Security + +The policy is an allowlist for attributes authored in Markdown. Event handler +names are always rejected, and configured length limits bound each parsed +attribute list. + +URL attributes remain subject to the active HTML policy. Allow only attributes +the application intends to expose to content authors. + +## Behavior + +A block attribute list before a block decorates the next sibling. Prefix a +list with `:` to decorate the previous sibling instead: + +```markdown +> Quoted text +> {: .quoted} +``` + +```html +
+

Quoted text

+
+``` + +An inline list decorates only the rendered element immediately before it: + +```markdown +Read the *important*{.accent} note. +``` + +```html +

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" + +
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 +

See outside and inside.

+``` + +## 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.

+
+
+
    +
  1. +

    Footnote with strong.

    +
  2. +
+
+``` + +## 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.

+

Install

+

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) ![Logo](/logo.png) +``` + +## HTML + +```html +

Guide Logo

+``` + +## 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 + +``` + +## Markdown + +```markdown +@source "src/App.php" {title: "Application", numbers: true, highlight: 2} +``` + +## HTML + +```html +
+
Application
+
src/App.php
+
one
+<two>
+
+
+``` + +## 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 `9-12` | +| `lang` | Detected from path | Overrides the code language | +| `title` | None | Adds an escaped visible title; the value must be double-quoted | +| `numbers` | `false` | Adds the original line number to every rendered line | +| `highlight` | None | Highlights line numbers or ranges such as `2, 5-7` | + +Line and highlight numbers refer to the original resource, including when +`lines` selects a smaller range. At most 64 highlight ranges are accepted. +Line numbers cannot exceed 1,000,000; language identifiers are limited to 64 +bytes and titles to 256 bytes. + +## Security + +The directive must start at column zero outside an open paragraph. Resource +bytes, paths, and titles are escaped before rendering. Invalid directives stay +literal and 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/table-of-contents.md b/docs/extensions/table-of-contents.md new file mode 100644 index 0000000..b324f70 --- /dev/null +++ b/docs/extensions/table-of-contents.md @@ -0,0 +1,105 @@ +# Table of contents + +`TableOfContentsExtension` replaces an explicit marker with a document outline +and assigns stable IDs to root headings. Each marker may select its own heading +range and list style. + +## Install + +This extension is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\TableOfContents\TableOfContentsExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::commonmark()->with(new TableOfContentsExtension()); +``` + +## Markdown + +```markdown +# Guide + +@toc {min: 2} + +## Intro *now* + +### Use [`it`](https://example.com) +``` + +## HTML + +```html +

Guide

+ +

Intro now

+

Use it

+``` + +## Options + +Configure document-wide defaults with `TableOfContentsPolicy`: + +```php +use Alto\Markdown\Extension\TableOfContents\TableOfContentsPolicy; +use Alto\Markdown\Extension\TableOfContents\TableOfContentsStyle; + +$extension = new TableOfContentsExtension(new TableOfContentsPolicy( + minLevel: 2, + maxLevel: 4, + style: TableOfContentsStyle::Ordered, + htmlClass: 'contents', + id: 'contents', + title: 'On this page', + marker: '[[toc]]', +)); +``` + +| Option | Default | +| --- | --- | +| `minLevel`, `maxLevel` | `1`, `6` | +| `style` | `TableOfContentsStyle::Bullet` | +| `htmlClass` | `table-of-contents` | +| `id` | `toc` | +| `title` | `null` | +| `marker` | `@toc` | + +A marker may override three values locally: + +```markdown +@toc {min: 2, max: 3, ordered: true} +``` + +Levels must be between 1 and 6. `ordered` accepts only `true` or `false`. +`htmlClass` is limited to 256 bytes without control characters. `id` is +limited to 128 bytes without whitespace or control characters. A title is +limited to 512 bytes without unsupported control characters. Custom markers +contain 1 to 64 visible ASCII bytes and cannot contain braces. + +## Security + +Titles, classes, IDs, heading text, and link fragments are escaped through the +active HTML output context. Malformed directives remain literal Markdown and +do not partially apply options. + +## Behavior + +Only root headings appear in the outline. Headings inside block quotes, lists, +and extension containers still receive stable IDs but are excluded from the +catalog. Multiple markers share one catalog and receive unique navigation IDs. +If no heading matches a marker, the marker renders no HTML. The source marker +is preserved when rendering back to Markdown. diff --git a/docs/extensions/tabs.md b/docs/extensions/tabs.md new file mode 100644 index 0000000..929ef12 --- /dev/null +++ b/docs/extensions/tabs.md @@ -0,0 +1,70 @@ +# Tabs + +`TabsExtension` groups Markdown into named panels while keeping the content +available to the normal parser and renderer. + +## Install + +`TabsExtension` is bundled with `alto/markdown`. + +```bash +composer require alto/markdown +``` + +## Configure + +```php +use Alto\Markdown\Extension\Tabs\TabsExtension; +use Alto\Markdown\Markdown; + +$markdown = Markdown::github()->with(new TabsExtension()); +``` + +## Markdown + +```markdown +@tabs +@tab PHP +Use Composer. +@tab CLI +Run the command. +@endtabs +``` + +## HTML + +```html +
+
+PHP +CLI +
+
+
+

Use Composer.

+
+
+

Run the command.

+
+
+
+``` + +## Options + +`TabsExtension` has no application-level configuration options. + +## Security + +Tab titles are escaped as text, and panel content follows the active HTML +policy. The extension performs no resource or network access. + +## Behavior + +The first tab and panel receive the `is-active` class. The extension generates +stable links and associations between tabs and panels; the application owns +their styling and interactive behavior. + +Nested groups use one additional `@` per level: `@@tabs`, `@@tab`, and +`@@endtabs`. A title may be quoted, cannot be empty, and is limited to 256 +bytes. Nesting is limited to 32 levels. diff --git a/docs/index.md b/docs/index.md index a65d68a..471eef6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,13 +34,42 @@ $html = Markdown::github()->toHtml("# Hello\n\nWelcome.\n"); - [Fixing](quality/fixing.md): apply enabled safe corrections. - [Formatting](quality/formatting.md): normalize selected Markdown style. -## Extensions +## Extension API - [All extensions](extensions/index.md): choose bundled capabilities. - [Compatibility](extensions/compatibility.md): understand extension versioning. - [Extension points](extensions/extension-points.md): select the correct contract. - [Custom extension](extensions/custom.md): register trusted application behavior. +## Syntax + +- [Paired delimiters](extensions/paired-delimiters.md): define a safe inline syntax. +- [Smart punctuation](extensions/smart-punctuation.md): replace quotes, dashes, and ellipses. +- [Highlight](extensions/highlight.md): mark inline text with paired delimiters. +- [Description lists](extensions/description-lists.md): render terms and definitions. +- [Footnotes](extensions/footnotes.md): connect references to endnotes. +- [Tabs](extensions/tabs.md): group nested content into accessible panels. +- [Mentions](extensions/mentions.md): resolve application-specific references. +- [Attributes](extensions/attributes.md): attach constrained attributes to elements. + +## Rendering + +- [Heading levels](extensions/heading-levels.md): project headings to different HTML levels. +- [Content slicer](extensions/content-slicer.md): wrap heading sections in semantic elements. +- [Permalinks](extensions/heading-permalinks.md): add stable links to headings. +- [Contents](extensions/table-of-contents.md): build a document outline. +- [Default attributes](extensions/default-attributes.md): add controlled HTML attributes. +- [Code titles](extensions/code-block-titles.md): label fenced code blocks. +- [External links](extensions/external-links.md): classify and mark outbound links. +- [Link rewriting](extensions/link-rewriting.md): transform destinations at render time. + +## Resources + +- [Import](extensions/import.md): insert escaped source code. +- [Include](extensions/include.md): expand bounded Markdown resources. +- [Source](extensions/source.md): display annotated source excerpts. +- [Embeds](extensions/embeds.md): resolve allowlisted rich content. + ## Engine - [Security](engine/security.md): keep untrusted input within safe policies. diff --git a/tests/Documentation/ExtensionDocumentationTest.php b/tests/Documentation/ExtensionDocumentationTest.php new file mode 100644 index 0000000..c433c96 --- /dev/null +++ b/tests/Documentation/ExtensionDocumentationTest.php @@ -0,0 +1,126 @@ + + */ + private const array EXTENSIONS = [ + 'attributes', + 'code-block-titles', + 'content-slicer', + 'default-attributes', + 'description-lists', + 'embeds', + 'external-links', + 'footnotes', + 'heading-levels', + 'heading-permalinks', + 'highlight', + 'import', + 'include', + 'link-rewriting', + 'mentions', + 'paired-delimiters', + 'smart-punctuation', + 'source', + 'table-of-contents', + 'tabs', + ]; + + #[DataProvider('provideExtensions')] + public function testEveryExtensionHasACompleteDocumentationPage(string $slug): void + { + $contents = $this->read('docs/extensions/' . $slug . '.md'); + + self::assertMatchesRegularExpression('/\A# [^\n]+\n\n\S/', $contents); + self::assertSame( + ['Install', 'Configure', 'Markdown', 'HTML', 'Options', 'Security'], + array_slice($this->levelTwoHeadings($contents), 0, 6), + ); + self::assertStringContainsString("```bash\ncomposer require alto/markdown\n```", $contents); + self::assertMatchesRegularExpression('/```markdown\n.+?\n```/s', $contents); + self::assertMatchesRegularExpression('/```html\n.+?\n```/s', $contents); + } + + #[DataProvider('provideExtensions')] + public function testEveryExtensionIsLinkedFromBothIndexes(string $slug): void + { + self::assertMatchesRegularExpression( + '/\]\(extensions\/' . $slug . '\.md\)/', + $this->read('docs/index.md'), + ); + self::assertMatchesRegularExpression( + '/\]\(' . $slug . '\.md\)/', + $this->read('docs/extensions/index.md'), + ); + } + + /** + * @return iterable + */ + public static function provideExtensions(): iterable + { + foreach (self::EXTENSIONS as $slug) { + yield $slug => [$slug]; + } + } + + private function read(string $path): string + { + $contents = file_get_contents(\dirname(__DIR__, 2) . '/' . $path); + + self::assertIsString($contents, $path); + + return $contents; + } + + /** + * @return list + */ + private function levelTwoHeadings(string $contents): array + { + $headings = []; + $fenceCharacter = null; + $fenceLength = 0; + + foreach (explode("\n", $contents) as $line) { + if (null !== $fenceCharacter) { + if (1 === preg_match('/^ {0,3}' . preg_quote($fenceCharacter, '/') . '{' . $fenceLength . ',}[ \t]*$/', $line)) { + $fenceCharacter = null; + } + + continue; + } + + if (1 === preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $matches)) { + $fenceCharacter = $matches[1][0]; + $fenceLength = \strlen($matches[1]); + + continue; + } + + if (1 === preg_match('/^## ([^#].*)$/', $line, $matches)) { + $headings[] = $matches[1]; + } + } + + return $headings; + } +} diff --git a/tests/Snippets/SnippetExtractor.php b/tests/Snippets/SnippetExtractor.php index 8ba8d0e..ec7e4c6 100644 --- a/tests/Snippets/SnippetExtractor.php +++ b/tests/Snippets/SnippetExtractor.php @@ -38,16 +38,32 @@ public function extract(string $markdown): array $snippets = []; $open = null; + $ignored = null; $body = []; $openLine = 0; foreach ($lines as $index => $rawLine) { $line = rtrim($rawLine, "\r"); + if (null !== $ignored) { + if ($this->matchesClosingFence($line, $ignored)) { + $ignored = null; + } + + continue; + } + if (null === $open) { - $fence = $this->matchOpeningFence($line); + $isPhp = false; + $fence = $this->matchOpeningFence($line, $isPhp); if (null !== $fence) { + if (!$isPhp) { + $ignored = $fence; + + continue; + } + $open = $fence; $body = []; $openLine = $index + 1; @@ -73,8 +89,10 @@ public function extract(string $markdown): array return $snippets; } - private function matchOpeningFence(string $line): ?OpeningFence + private function matchOpeningFence(string $line, bool &$isPhp): ?OpeningFence { + $isPhp = false; + if (1 !== preg_match('/^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/', $line, $matches)) { return null; } @@ -88,9 +106,7 @@ private function matchOpeningFence(string $line): ?OpeningFence return null; } - if (1 !== preg_match('/^php(?![a-zA-Z0-9_])/', $info)) { - return null; - } + $isPhp = 1 === preg_match('/^php(?![a-zA-Z0-9_])/', $info); return new OpeningFence($char, \strlen($marker), \strlen($matches[1])); } diff --git a/tests/Snippets/SnippetExtractorTest.php b/tests/Snippets/SnippetExtractorTest.php index 41ce3e8..b09d5a0 100644 --- a/tests/Snippets/SnippetExtractorTest.php +++ b/tests/Snippets/SnippetExtractorTest.php @@ -38,6 +38,13 @@ public function testIgnoresNonPhpFencedBlocks(): void self::assertSame([], (new SnippetExtractor())->extract($markdown)); } + public function testIgnoresPhpLookingFenceInsideMarkdownExample(): void + { + $markdown = "````markdown\n```php\necho 1;\n```\n````\n"; + + self::assertSame([], (new SnippetExtractor())->extract($markdown)); + } + public function testMatchesTildeFences(): void { $markdown = "~~~php\n\$y = 2;\n~~~\n";