Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@
/docs/.vitepress/cache/
/docs/.vitepress/dist/
/docs/.vitepress/grammars/

# Superpowers working docs (never tracked)
docs/superpowers/
_*.md
71 changes: 71 additions & 0 deletions docs/extensions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Extensions provide a clean way to bundle related customizations together. Each e
| [HeadingPermalinksExtension](#headingpermalinksextension) | Adds clickable anchor links to headings |
| [InlineFootnotesExtension](#inlinefootnotesextension) | Converts `[content]{.fn}` spans to inline footnotes |
| [LineBlockDivExtension](#lineblockdivextension) | Adds a fenced `::: |` line block (verse/addresses) without prefixing every line |
| [BlockQuoteDivExtension](#blockquotedivextension) | Adds a fenced `::: >` blockquote so a quote can own lists/fences/tables without a per-line `>` |
| [MentionsExtension](#mentionsextension) | Converts `@username` patterns to profile links |
| [MermaidExtension](#mermaidextension) | Transforms mermaid code blocks into diagrams |
| [SemanticSpanExtension](#semanticspanextension) | Converts span attributes to semantic HTML elements (`<kbd>`, `<dfn>`, `<abbr>`) |
Expand Down Expand Up @@ -664,6 +665,76 @@ The pipe is consumed as the marker, so the output is a `line-block` div, never a

This follows the approach discussed in [djot issue #29](https://github.com/jgm/djot/issues/29). A leading `|` on every line (Pandoc-style line blocks) can be confused with pipe tables and is awkward to edit; an English keyword div class (`::: verse`) was undesirable. A language-neutral `|` marker on the div opener sidesteps both concerns.

## BlockQuoteDivExtension

Adds a fenced blockquote written as a `:::` div whose only token is a greater-than sign: `::: >`. It produces the same semantic `<blockquote>` as the [`>`-prefixed form](/guide/syntax#block-quotes), but without prefixing every line - which matters when the quote contains **block** content, because the `>`-prefix form needs the marker on every line of a list, nested fence, or table (lazy continuation only folds plain paragraph text into a quote, never new block structure).

```php
use Djot\Extension\BlockQuoteDivExtension;

$converter->addExtension(new BlockQuoteDivExtension());
```

A quote with a lead paragraph and a list, with no per-line marker:

```djot
::: >
Notes from the meeting:

- item one
- item two
:::
```

```html
<blockquote>
<p>Notes from the meeting:</p>
<ul>
<li>
item one
</li>
<li>
item two
</li>
</ul>
</blockquote>
```

The verbose `>`-prefix form below produces the **identical** output, but needs the marker on every line - the prose line, the blank separator, and each list item:

```djot
> Notes from the meeting:
>
> - item one
> - item two
```

The two are interchangeable; the fenced form simply drops the per-line `>`, which is what makes longer or more deeply nested quoted content practical to edit. The gap grows with the content - a quote wrapping a fenced code block or a table needs a `>` on every one of those lines too.

A `^ attribution` line right after the closing `:::` wraps the quote in a figure, exactly as the `>`-prefix form does:

```djot
::: >
Stay hungry, stay foolish.
:::
^ Steve Jobs
```

```html
<figure>
<blockquote>
<p>Stay hungry, stay foolish.</p>
</blockquote>
<figcaption>Steve Jobs</figcaption>
</figure>
```

The gt is consumed as the marker, so the output is a `<blockquote>`, never a literal `class=">"`. Because `>` is not a meaningful class, intercepting it cannot collide with real usage - which is why this needs no core parser change, the same design as the `::: |` line block. Attributes on the preceding line (`{#id .class}`) attach to the blockquote, and longer colon runs nest (`:::: >` outside, `::: >` inside).

### Why This Syntax?

The `>`-prefix blockquote is fine for prose, but every list item or nested block inside it needs its own `>`. The fenced form mirrors the language-neutral `::: |` line block: a single sigil on the opener, no English keyword, no per-line marker. `::: quote` is deliberately different - it stays an `<aside class="admonition quote">`, not a semantic `<blockquote>`.

## MentionsExtension

Converts `@username` patterns into user profile links.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ Use `^` after a block quote to add an attribution/caption. The block quote will
</template>
</OutputTabs>

Prefer not to prefix every line? The [`BlockQuoteDivExtension`](/extensions/#blockquotedivextension) adds a fenced form, `::: >`, that produces the same `<blockquote>` without the per-line `>` - useful when the quote contains lists, fences, or tables.

### Lists

#### Bullet Lists
Expand Down
47 changes: 47 additions & 0 deletions docs/reference/enhancements.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,52 @@ Round-trips through the Markdown, plain-text, and ANSI renderers (the

---

### Fenced Block Quote (`::: >`)

**Related:** [`BlockQuoteDivExtension`](/extensions/#blockquotedivextension)

**Status:** djot-php extension (opt-in)

A `:::` div whose only token is `>` produces a semantic `<blockquote>` - the same
node as the `>`-prefixed form, but without a marker on every line. This matters
for **block** content inside a quote: the `>`-prefix form needs the marker on
every line of a list, nested fence, or table, because lazy continuation only
folds plain paragraph text into a quote, never new block structure.

```djot
::: >
- item one
- item two
:::
```

**Output:**
```html
<blockquote>
<ul>
<li>
item one
</li>
<li>
item two
</li>
</ul>
</blockquote>
```

**Rules:**
- The opener is `:::` (3+ colons) followed by only `>` (optional surrounding spaces); the gt is the marker, so the output is `<blockquote>`, never `class=">"`.
- The body is parsed as ordinary block content, so any block (lists, fences, tables, nested quotes) is allowed without a per-line `>`.
- A `^ attribution` line right after the closing `:::` wraps the quote in a `<figure>`/`<figcaption>`, the same as the `>`-prefix caption form.
- A preceding `{...}` attribute block attaches to the `<blockquote>`.
- Longer colon runs nest (`:::: >` outside, `::: >` inside), per djot div semantics.

Mirrors the language-neutral design of the `::: |` line block. Distinct from
`::: quote`, which stays an `<aside class="admonition quote">` rather than a
semantic blockquote.

---

### Task List Underscore Notation

**Related:** [jgm/djot#305](https://github.com/jgm/djot/issues/305)
Expand Down Expand Up @@ -1047,6 +1093,7 @@ vendor/bin/phpunit
| Feature | Upstream PR/Issue | Status |
|-----------------------------------|---------------------------------------------------------------------|------------|
| Line blocks (`\|` poetry/address) | [Pandoc line blocks](https://pandoc.org/MANUAL.html#line-blocks) | djot-php |
| Fenced block quote (`::: >`) | [`BlockQuoteDivExtension`](/extensions/#blockquotedivextension) | djot-php |
| Task list underscore notation | [djot:305](https://github.com/jgm/djot/issues/305) | Open |
| List item attributes | [djot:262](https://github.com/jgm/djot/pull/262) | Open PR |
| Table row/cell attributes | [djot:250](https://github.com/jgm/djot/issues/250) | Open |
Expand Down
160 changes: 160 additions & 0 deletions src/Extension/BlockQuoteDivExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

declare(strict_types=1);

namespace Djot\Extension;

use Djot\DjotConverter;
use Djot\Node\Block\BlockQuote;
use Djot\Node\Node;
use Djot\Parser\Block\FencedBlockParser;
use Djot\Parser\BlockParser;

/**
* Adds a fenced blockquote div: `::: >`.
*
* A `:::` div whose only token is a greater-than sign is treated as a
* blockquote - the same `<blockquote>` the `>`-prefixed form produces, but
* without prefixing every line. This lets a quote own block content (lists,
* nested fences, tables) where the per-line `>` form would need the marker on
* every line: lazy continuation only folds plain paragraph text into a quote,
* never new block structure.
*
* Syntax:
* ```
* ::: >
* - item one
* - item two
* :::
* ```
*
* The gt is consumed as the marker, so the output is a `<blockquote>`, not a
* literal `class=">"`. This is why no core change is needed: `>` is not a
* meaningful class, so intercepting it cannot collide with real usage - the
* same reasoning behind {@see LineBlockDivExtension}'s `|`.
*
* A `^ attribution` line after the closing `:::` wraps the quote in a
* `<figure>`/`<figcaption>` via the core caption handler - no extra code here.
*
* Example usage:
* ```php
* $converter = new DjotConverter();
* $converter->addExtension(new BlockQuoteDivExtension());
* $html = $converter->convert($djot);
* ```
*/
class BlockQuoteDivExtension implements ExtensionInterface
{
/**
* Opener: 3+ colons, then only a gt (optional surrounding spaces/tabs).
*
* @var string
*/
protected const OPENER = '/^(:{3,})[ \t]*>[ \t]*$/';

public function register(DjotConverter $converter): void
{
$converter->getParser()->addBlockPattern(self::OPENER, $this->parseBlockQuoteDiv(...));
}

/**
* @param array<string> $lines
* @param int $start
* @param \Djot\Node\Node $parent
* @param \Djot\Parser\BlockParser $blockParser
*/
protected function parseBlockQuoteDiv(array $lines, int $start, Node $parent, BlockParser $blockParser): ?int
{
if (preg_match(self::OPENER, $lines[$start], $matches) !== 1) {
return null; // @codeCoverageIgnore - pattern already matched
}

$fenceLength = strlen($matches[1]);
$innerLines = $this->collectInnerLines($lines, $start, $fenceLength, $consumed);
if ($innerLines === null) {
// Unclosed fence: leave it for the core parser to report.
return null;
}

$blockQuote = new BlockQuote();

// Consume the preceding `{...}` block before parsing the body, otherwise
// the still-pending attributes are claimed by the first inner block
// instead of the blockquote (and never reach the caption's figure).
$attributes = $blockParser->consumePendingAttributes();
if ($attributes !== []) {
$blockQuote->setAttributes($attributes);
}

$blockParser->parseBlockContent($blockQuote, $innerLines);

$parent->appendChild($blockQuote);

return $consumed;
}

/**
* Collect the lines between the opener and its matching closing fence.
*
* Uses the core {@see FencedBlockParser} detectors so code-fence and
* div-closer recognition stay identical to the built-in div parser: a `:::`
* inside a fenced code block is not treated as the closer. A nested div uses
* a longer fence (djot semantics), so the closer is the first bare `:::` run
* of at least the opener length. Returns null when no closer is found, so the
* caller can decline the match.
*
* @param array<string> $lines
* @param int $start
* @param int $fenceLength
* @param int|null $consumed Set to the number of lines consumed (opener..closer).
*
* @return array<string>|null
*/
protected function collectInnerLines(array $lines, int $start, int $fenceLength, ?int &$consumed): ?array
{
$fences = new FencedBlockParser();
$inner = [];
$inCode = false;
$codeChar = '';
$codeLength = 0;
$count = count($lines);
$i = $start + 1;

while ($i < $count) {
$line = $lines[$i];

if (!$inCode) {
$opener = $fences->parseCodeFenceOpener($line);
if ($opener !== null) {
$inCode = true;
$codeChar = $opener['char'];
$codeLength = $opener['length'];
$inner[] = $line;
$i++;

continue;
}
}
if ($inCode) {
if ($fences->isCodeFenceCloser($line, $codeChar, $codeLength)) {
$inCode = false;
}
$inner[] = $line;
$i++;

continue;
}

if ($fences->isDivFenceCloser($line, $fenceLength)) {
$consumed = $i + 1 - $start;

return $inner;
}

$inner[] = $line;
$i++;
}

return null;
}
}
Loading
Loading