Skip to content

Commit b3f0ae4

Browse files
authored
Merge pull request #821 from erusev/documentation-v2.0
Documentation 2.0
2 parents 79effc4 + 7c7d581 commit b3f0ae4

File tree

3 files changed

+227
-5
lines changed

3 files changed

+227
-5
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ jobs:
66
strategy:
77
matrix:
88
os: [ubuntu-latest]
9-
php: [8.0, 7.4, 7.3, 7.2, 7.1]
9+
php: [8.1, 8.0, 7.4, 7.3, 7.2, 7.1]
1010

1111
runs-on: ${{ matrix.os }}
1212
steps:
@@ -29,7 +29,7 @@ jobs:
2929
strategy:
3030
matrix:
3131
os: [ubuntu-latest]
32-
php: [8.0]
32+
php: [8.1]
3333

3434
runs-on: ${{ matrix.os }}
3535
steps:
@@ -50,7 +50,7 @@ jobs:
5050
strategy:
5151
matrix:
5252
os: [ubuntu-latest]
53-
php: [8.0]
53+
php: [8.1]
5454

5555
runs-on: ${{ matrix.os }}
5656
steps:
@@ -74,7 +74,7 @@ jobs:
7474
strategy:
7575
matrix:
7676
os: [ubuntu-latest]
77-
php: [8.0]
77+
php: [8.1]
7878

7979
runs-on: ${{ matrix.os }}
8080
steps:

docs/Migrating-Extensions-v2.0.md

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Implementing "Extensions" in v2.0
2+
3+
Parsedown v1.x allowed extensability through class extensions, where an developer
4+
could extend the core Parsedown class, and access or override any of the `protected`
5+
level methods and variables.
6+
7+
Whilst this approach allows huge breadth to the type of functionality that can
8+
be added by an extension, it has some downsides too:
9+
10+
* ### Composability: extensions cannot be combined easily
11+
An extension must extend another extension for two extensions to work together.
12+
This limits the usefulness of small extensions, because they cannot be combined with another small or popular extension.
13+
If an extension author wishes the extension to be compatible with another extension, they can only pick one.
14+
15+
* ### API stability
16+
Because extensions have access to functions and variables at the `protected` API layer, it is hard to determine impacts of
17+
internal changes. Yet, without being able to make a certain amount of internal change it is impractical to fix bugs or develop
18+
new features. In the `1.x` branch, `1.8` was never released outside of a "beta" version for this reason: changes in the
19+
`protected` API layer would break extensions.
20+
21+
In order to address these concerns, "extensions" in Parsedown v2.0 will become more like "plugins", and with that comes a lot of
22+
flexability.
23+
24+
ParsedownExtra is a popular extension for Parsedown, and this has been completely re-implemented for 2.0. In order to use
25+
ParsedownExtra with Parsedown, a user simply needs to write the following:
26+
27+
```php
28+
$Parsedown = new Parsedown(new ParsedownExtra);
29+
$actualMarkup = $Parsedown->toHtml($markdown);
30+
```
31+
32+
Here, ParsedownExtra is *composed* with Parsedown, but does not extend it.
33+
34+
A key feature of *composability* is the ability to compose *multiple* extensions together, for example another
35+
extension, say, `ParsedownMath` could be composed with `ParsedownExtra` in a user-defined order.
36+
37+
This time using the `::from` method, rather than the convinence constructor provided by `ParsedownExtra`.
38+
39+
```php
40+
$Parsedown = new Parsedown(ParsedownExtra::from(ParsedownMath::from(new State)));
41+
```
42+
43+
```php
44+
$Parsedown = new Parsedown(ParsedownMath::from(ParsedownExtra::from(new State)));
45+
```
46+
47+
In the above, the first object that we initialise the chain of composed extensions is the `State` object. This `State`
48+
object is passed from `ParsedownExtra` to `ParsedownMath`, and then finally, to `Parsedown`. At each stage new
49+
information is added to the `State`: adding or removing parsing instructions, and to enabling or disabling features.
50+
51+
The `State` object both contains instructions for how to parse a document (e.g. new blocks and inlines), as well as
52+
information used throughout parsing (such as link reference definitions, or recursion depth). By writing `new State`,
53+
we create a `State` object that is setup with Parsedown's default behaviours, and by passing that object through
54+
different extensions (using the `::from` method), these extensions are free to alter, add to, or remove from that
55+
default behaviour.
56+
57+
## Introduction to the `State` Object
58+
Key to Parsedown's new composability for extensions is the `State` object.
59+
60+
This name is a little obtuse, but is importantly accurate.
61+
62+
A `State` object incorporates `Block`s, `Inline`s, some additional render steps, and any custom configuration options that
63+
the user might want to set. This can **fully** control how a document is parsed and rendered.
64+
65+
In the above code, `ParsedownExtra` and `ParsedownMath` would both be implementing the `StateBearer` interface, which
66+
essentially means "this class holds onto a particular Parsedown State". A `StateBearer` should be constructable from
67+
an existing `State` via `::from(StateBearer $StateBearer)`, and reveals the `State` it is holding onto via `->state(): State`.
68+
69+
Implementing the `StateBearer` interface is **strongly encouraged** if implementing an extension, but not necessarily required.
70+
In the end, you can modify Parsedown's behaviour by producing an appropriate `State` object (which itself is trivially a
71+
`StateBearer`).
72+
73+
In general, extensions are encouraged to go further still, and split each self-contained piece of functionality out into its own
74+
`StateBearer`. This will allow your users to cherry-pick specific pieces of functionality and combine it with other
75+
functionality from different authors as they like. For example, a feature of ParsedownExtra is the ability to define and expand
76+
"abbreviations". This feature is self-contained, and does not depend on other features (e.g. "footnotes").
77+
78+
A user could import *only* the abbreviations feature from ParsedownExtra by using the following:
79+
80+
```php
81+
use Erusev\Parsedown\State;
82+
use Erusev\Parsedown\Parsedown;
83+
use Erusev\ParsedownExtra\Features\Abbreviations;
84+
85+
$State = Abbreviations::from(new State);
86+
87+
$Parsedown = new Parsedown($State);
88+
$actualMarkup = $Parsedown->toHtml($markdown);
89+
```
90+
91+
This allows a user to have fine-grained control over which features they import, and will allow them much more control over
92+
combining features from multiple sources. E.g. a user may not like the way ParsedownExtra has implemented the "footnotes" feature,
93+
and so may wish to utilise an implementation from another source. By implementing each feature as its own `StateBearer`, we give
94+
users the freedom to compose features in a way that works for them.
95+
96+
## Anatomy of the `State` Object
97+
98+
The `State` object, generically, consists of a set of `Configurable`s. The word "set" is important here: only one instance of each
99+
`Configurable` may exist in a `State`. If you need to store related data in a `Configurable`, your `Configurable` needs to handle
100+
this containerisation itself.
101+
102+
`State` has a special property: all `Configurable`s "exist" in any `State` object when retrieving that `Configurable` with `->get`.
103+
104+
This means that retrieval cannot fail when using this method, though does mean that all `Configurable`s need to be "default constructable" (i.e. can be constructed into a "default" state). All `Configurable`s must therefore implement the static method
105+
`initial`, which must return an instance of the given `Configurable`. No initial data will be provided, but the `Configurable` **must** arrive at some sane default instance.
106+
107+
`Configurable`s must also be immutable, unless they declare themeslves otherwise by implementing the `MutableConfigurable` interface.
108+
109+
### Blocks
110+
One of the "core" `Configurable`s in Parsedown is `BlockTypes`. This contains a mapping of "markers" (a character that Parsedown
111+
looks for, before handing off to the block-specific parser), and a list of `Block`s that can begin parsing from a specific marker.
112+
Also contained, is a list of "unmarked" blocks, which Parsedown will hand off to prior to trying any marked blocks. Within marked
113+
blocks there is also a precedence order, where the first block type to successfully parse in this list will be the one chosen.
114+
115+
The default value given by `BlockTypes::initial()` consists of Parsedown's default blocks. The following is a snapshot of this list:
116+
117+
```php
118+
const DEFAULT_BLOCK_TYPES = [
119+
'#' => [Header::class],
120+
'*' => [Rule::class, TList::class],
121+
'+' => [TList::class],
122+
'-' => [SetextHeader::class, Table::class, Rule::class, TList::class],
123+
...
124+
```
125+
126+
This means that if a `-` marker is found, Parsedown will first try to parse a `SetextHeader`, then try to parse a `Table`, and
127+
so on...
128+
129+
A new block can be added to this list in several ways. ParsedownExtra, for example, adds a new `Abbreviation` block as follows:
130+
131+
```php
132+
$BlockTypes = $State->get(BlockTypes::class)
133+
->addingMarkedLowPrecedence('*', [Abbreviation::class])
134+
;
135+
136+
$State = $State->setting($BlockTypes);
137+
```
138+
139+
This first retrieves the current value of the `BlockTypes` configurable, adds `Abbreviation` with low precedence (i.e. the
140+
back of the list) to the `*` marker, and then updates the `$State` object by using the `->setting` method.
141+
142+
### Immutability
143+
144+
Note that the `->setting` method must be used to create a new instance of the `State` object because `BlockTypes` is immutable,
145+
the same will be true of most configurables. This approach is preferred because mutations to `State` are localised by default: i.e.
146+
only affect copies of `$State` which we provide to other methods, but does not affect copies of `$State` which were provided to our
147+
code by a parent caller.
148+
149+
Localised mutability allows for more sensible reasoning by default, for example (this time talking about `Inline`s), the `Link` inline
150+
can enforce that no inline `Url`s are parsed (which would cause double links in output when parsing something like:
151+
`[https://example.com](https://example.com)`). This can be done by updating the copy of `$State` which is passed down to lower level
152+
parsers to simply no longer include parsing of `Url`s:
153+
154+
```php
155+
$State = $State->setting(
156+
$State->get(InlineTypes::class)->removing([Url::class])
157+
);
158+
```
159+
160+
If `InlineTypes` were mutable, this change would not only affect decendent parsing, but would also affect all parsing which occured after our link was parsed (i.e. would stop URL parsing from that point on in the document).
161+
162+
Another use case for this is implementing a recursion limiter (which *is* implemented as a configurable). After a user-specifiable
163+
max-depth is exceeded: further parsing will halt. The implementaion for this is extremely simple, only because of immutability.
164+
165+
### Mutability
166+
The preference toward immutability by default is not an assertion that "mutability is bad", rather that "unexpected mutability
167+
is bad". By opting-in to mutability, we can treat mutability with the care it deserves.
168+
169+
While immutabiltiy can do a lot to simplify reasoning in the majority of cases, there are some cirumstances where mutability is
170+
required to implement a specific feature. An exmaple of this is found in ParsedownExtra's "abbreviations" feature, which implements
171+
the following:
172+
173+
```php
174+
final class AbbreviationBook implements MutableConfigurable
175+
{
176+
/** @var array<string, string> */
177+
private $book;
178+
179+
/**
180+
* @param array<string, string> $book
181+
*/
182+
public function __construct(array $book = [])
183+
{
184+
$this->book = $book;
185+
}
186+
187+
/** @return self */
188+
public static function initial()
189+
{
190+
return new self;
191+
}
192+
193+
public function mutatingSet(string $abbreviation, string $definition): void
194+
{
195+
$this->book[$abbreviation] = $definition;
196+
}
197+
198+
public function lookup(string $abbreviation): ?string
199+
{
200+
return $this->book[$abbreviation] ?? null;
201+
}
202+
203+
/** @return array<string, string> */
204+
public function all()
205+
{
206+
return $this->book;
207+
}
208+
209+
/** @return self */
210+
public function isolatedCopy(): self
211+
{
212+
return new self($this->book);
213+
}
214+
}
215+
```
216+
217+
Under the hood, `AbbreviationBook` is nothing more than a string-to-string mapping between an abbreviation, and its definition.
218+
219+
The powerful feature here is that when an abbreviation is identified during parsing, that definition can be updated immediately
220+
everywhere, without needing to worry about the current parsing depth, or organise an alternate method to sharing this data. Footnotes
221+
also make use of this with a `FootnoteBook`, with slightly more complexity in what is stored (so that inline references can be
222+
individually numbered).

psalm.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0"?>
22
<psalm
3-
totallyTyped="true"
3+
errorLevel="1"
44
strictBinaryOperands="true"
55
checkForThrowsDocblock="true"
66
>

0 commit comments

Comments
 (0)