Repository: twisted/towncrier
Pull request #483 (2023-03-01 00:04:16)

Default markdown template
# Description
Provides a default markdown template.

Towncrier will use this as the default template if the configured `filename` ends with `.md`. For any other filename, `default.rst` will continue to be used. For example:
```toml
filename = "CHANGES.md"
```

This is a simplification of earlier work in #439.

## Changes to `load.py`
In `load.py`, the Config dataclass now has defaults, rather than them being dispersed elsewhere in logic.

The `parse_toml` function has been reorganized for clarity (keeping the same functionality).

## Better configuration docs
The documentation for configuration settings was rewritten for completeness, better accuracy of defaults, and missing features.

It also better highlights different required settings for python vs non-python projects.

## Reference style link support
The only thing different in the markdown default template than the restructured text one is the ability to use markdown reference style links if the config is set to `issue_format = "[{issue}]: https://<bug tracker>/{issue}"`.

For example, with `issue_format = "[{issue}]: https://github.com/twisted/towncrier/issues/{issue}"`:

```markdown
...
### Features

- Foo added. ([2], [9], [72])
- More stuff ([3])
- Stuff! ([4])

[2]: https://github.com/twisted/towncrier/issues/2
[3]: https://github.com/twisted/towncrier/issues/3
[4]: https://github.com/twisted/towncrier/issues/4
[9]: https://github.com/twisted/towncrier/issues/9
[72]: https://github.com/twisted/towncrier/issues/72
```

This is why there's a new `issues_by_category` template context variable built and passed to the template.


# Checklist
<!-- add a "X" inside the brackets to confirm -->
* [x] Make sure changes are covered by existing or new tests.
* [x] For at least one Python version, make sure local test run is green.
* [x] Create a file in `src/towncrier/newsfragments/`. Describe your
  change and include important information. Your change will be included in the public release notes.
* [x] Make sure all GitHub Actions checks are green (they are automatically checking all of the above).
* [x] Ensure `docs/tutorial.rst` is still up-to-date.
* [x] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes.


codecov[bot]: # [Codecov](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) Report
Patch coverage: **`93.87`**% and project coverage change: **`-0.79`** :warning:
> Comparison is base [(`826beac`)](https://codecov.io/gh/twisted/towncrier/commit/826beacb7e96a3360ed9e5153e4be06dd74b028d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) 100.00% compared to head [(`4eaed54`)](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) 99.21%.

> :exclamation: Current head 4eaed54 differs from pull request most recent head 3991886. Consider uploading reports for the commit 3991886 to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             trunk     #483      +/-   ##
===========================================
- Coverage   100.00%   99.21%   -0.79%     
===========================================
  Files           13       13              
  Lines          627      636       +9     
  Branches       145      125      -20     
===========================================
+ Hits           627      631       +4     
- Misses           0        2       +2     
- Partials         0        3       +3     
```


| [Impacted Files](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) | Coverage Δ | |
|---|---|---|
| [src/towncrier/\_settings/load.py](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fc2V0dGluZ3MvbG9hZC5weQ==) | `95.00% <92.85%> (-5.00%)` | :arrow_down: |
| [src/towncrier/\_builder.py](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fYnVpbGRlci5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/twisted/towncrier/pull/483?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).

SmileyChris: Oh windows, why are you being a jerk?
SmileyChris: > I have just realized, that this makes no sense with Markdown (you'd have to use an HTML comment like `<!-- towncrier release notes start -->\n`) and that we don't have a test for the case where an existing news file is amended – correct?

If things are supposed to work out of the box, I guess you'll have to special-case this too (and add a test for it). 

Ok, special-cased and added tests.

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 28607c2..7d73f50 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -2,57 +2,206 @@ Configuration Reference
 =======================
 
 ``towncrier`` has many knobs and switches you can use, to customize it to your project's needs.
-The setup in the `Tutorial <tutorial.html>`_ doesn't touch on many, but this document will detail each of these options for you!
+The setup in the :doc:`tutorial` doesn't touch on many, but this document will detail each of these options for you!
 
-For how to perform common customization tasks, see `Customization <customization/index.html>`_.
+For how to perform common customization tasks, see :doc:`customization/index`.
 
 ``[tool.towncrier]``
 --------------------
 
-All configuration for ``towncrier`` sits inside ``pyproject.toml``, under the ``tool.towncrier`` namespace.
+All configuration for ``towncrier`` sits inside ``towncrier.toml`` or ``pyproject.toml``, under the ``tool.towncrier`` namespace.
 Please see https://toml.io/ for how to write TOML.
 
+A minimal configuration for a Python project looks like this:
+
+.. code-block:: toml
+
+   # pyproject.toml
+
+   [tool.towncrier]
+   package = "myproject"
+
+A minimal configuration for a non-Python project looks like this:
+
+.. code-block:: toml
+
+   # towncrier.toml
+
+   [tool.towncrier]
+   name = "My Project"
 
 Top level keys
 ~~~~~~~~~~~~~~
 
-- ``directory`` -- If you are not storing your news fragments in your Python package, or aren't using Python, this is the path to where your newsfragments will be put.
-- ``filename`` -- The filename of your news file.
-  ``NEWS.rst`` by default.
-- ``package`` -- The package name of your project.
-  (Python projects only)
-- ``package_dir`` -- The folder your package lives. ``./`` by default, some projects might need to use ``src``.
-  (Python projects only)
-- ``template`` -- Path to an alternate template for generating the news file, if you have one.
-- ``start_string`` -- The magic string that ``towncrier`` looks for when considering where the release notes should start.
-  ``.. towncrier release notes start`` by default.
-- ``title_format`` -- A format string for the title of your project.
-  ``{name} {version} ({project_date})`` by default.
-- ``issue_format`` -- A format string for rendering the issue/ticket number in newsfiles.
-  ``#{issue}`` by default.
-- ``underlines`` -- The characters used for underlining headers.
-  ``["=", "-", "~"]`` by default.
+``name``
+    The name of your project.
+
+    For Python projects that provide a ``package`` key, if left empty then the name will be automatically determined.
+
+    ``""`` by default.
+
+``version``
+    The version of your project.
+
+    Python projects that provide the ``package`` key can have the version to be automatically determined from a ``__version__`` variable in the package's module.
+
+    If not provided or able to be determined, the version must be passed explicitly by the command line argument ``--version``.
+
+``directory``
+    The directory storing your news fragments.
+
+    For Python projects that provide a ``package`` key, the default is a ``newsfragments`` directory within the package.
+    Otherwise the default is a ``newsfragments`` directory relative to the configuration file.
+
+``filename``
+    The filename of your news file.
+
+    ``"NEWS.rst"`` by default.
+
+``template``
+    Path to the template for generating the news file.
+
+    If the path looks like ``<some.package>:<filename.ext>``, it is interpreted as a template bundled with an installed Python package.
+
+    ``"towncrier:default.rst"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"towncrier:default.md"``.
+
+``start_string``
+    The magic string that ``towncrier`` looks for when considering where the release notes should start.
+
+    ``".. towncrier release notes start\n"`` by default unless ``filename`` ends with ``.md``, in which case the default is ``"<!-- towncrier release notes start -->\n"``.
+
+``title_format``
+    A format string for the title of your project.
+
+    The explicit value of ``False`` will disable the title entirely.
+    Any other empty value means the template should render the title (the bundled templates use ``<name> <version> (<date>)``).
+    Strings should use the following keys to render the title dynamically: ``{name}``, ``{version}``, and ``{project_date}``.
+
+    ``""`` by default.
+
+``issue_format``
+    A format string for rendering the issue/ticket number in newsfiles.
+
+    If none, the issues are rendered as ``#<issue>`` if for issues that are integers, or just ``<issue>`` otherwise.
+    Use the ``{issue}`` key in your string render the issue number, for example Markdown projects may want to use ``"[{issue}]: https://<your bug tracker>/{issue}"``.
+
+    ``None`` by default.
+
+``underlines``
+    The characters used for underlining headers.
+
+    Not used in the bundled Markdown template.
+
+    ``["=", "-", "~"]`` by default.
+
+``wrap``
+    Boolean value indicating whether to wrap news fragments to a line length of 79.
+
+    ``false`` by default.
+
+``all_bullets``
+    Boolean value indicating whether the template uses bullets for each news fragment.
+
+    ``true`` by default.
+
+``single_file``
+    Boolean value indicating whether to write all news fragments to a single file.
+
+    If ``false``, the ``filename`` should use the following keys to render the filenames dynamically:
+    ``{name}``, ``{version}``, and ``{project_date}``.
+
+    ``true`` by default.
+
+``orphan_prefix``
+    The prefix used for orphaned news fragments.
+
+    ``"+"`` by default.
+
+Extra top level keys for Python projects
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``package``
+    The Python package name of your project.
+
+    Allows ``name`` and ``version`` to be automatically determined from the Python package.
+    Changes the default ``directory`` to be a ``newsfragments`` directory within this package.
+
+``package_dir``
+    The folder your package lives.
+
+    ``"."`` by default, some projects might need to use ``"src"``.
+
+
+Sections
+--------
+
+``towncrier`` supports splitting fragments into multiple sections, each with its own news of fragment types.
+
+Add an array of tables your ``.toml`` configuration file named ``[[tool.towncrier.section]]``.
+
+Each table within this array has the following mandatory keys:
+
+
+``name``
+    The name of the section.
+
+``path``
+    The path to the directory containing the news fragments for this section, relative to the configured ``directory``.
+    Use ``""`` for the root directory.
+
+For example:
+
+.. code-block:: toml
+
+   [[tool.towncrier.section]]
+   name = "Main Platform"
+   path = ""
+
+   [[tool.towncrier.section]]
+   name = "Secondary"
+   path = "secondary"
+
+Section Path Behaviour
+~~~~~~~~~~~~~~~~~~~~~~
+
+The path behaviour is slightly different depending on whether ``directory`` is explicitly set.
+
+If ``directory`` is not set, "newsfragments" is added to the end of each path. For example, with the above sections, the paths would be:
+
+:Main Platform:  ./newsfragments
+:Secondary:      ./secondary/newsfragments
+
+If ``directory`` *is* set, the section paths are appended to this path. For example, with ``directory = "changes"`` and the above sections, the paths would be:
+
+:Main Platform:  ./changes
+:Secondary:      ./changes/secondary
 
 
 Custom fragment types
 ---------------------
-``towncrier`` allows defining custom fragment types.
-Custom fragment types will be used instead ``towncrier`` default ones, they are not combined.
 
-There are two ways to add custom fragment types.
+``towncrier`` has the following default fragment types: ``feature``, ``bugfix``, ``doc``, ``removal``, and ``misc``.
+
+You can use either of the two following method to define custom types instead (you will need to redefine any of the default types you want to use).
 
 
-Defining Custom Fragment Types With a TOML Mapping
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Use TOML tables (alphabetical order)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Users can configure each of their own custom fragment types by adding tables to
-the pyproject.toml named ``[tool.towncrier.fragment.<a custom fragment type>]``.
+Adding tables to your ``.toml`` configuration file named ``[tool.towncrier.fragment.<a custom fragment type>]``.
 
-These tables may include the following optional keys:
+These may include the following optional keys:
 
- * ``name``: The description of the fragment type, as it must be included in the news file.
-   If omitted, it defaults to  its  fragment type, but capitalized.
- * ``showcontent``: Whether if the fragment contents should be included in the news file. If omitted, it defaults to ``true``
+
+``name``
+    The description of the fragment type, as it must be included in the news file.
+
+    Defaults to its fragment type, but capitalized.
+
+``showcontent``
+    A boolean value indicating whether the fragment contents should be included in the news file.
+
+    ``true`` by default.
 
 For example, if you want your custom fragment types to be ``["feat", "fix", "chore",]`` and you want all of them to use the default configuration except ``"chore"`` you can do it as follows:
 
@@ -70,25 +219,30 @@ For example, if you want your custom fragment types to be ``["feat", "fix", "cho
 
 .. warning::
 
-   Since TOML mappings aren't ordered, the sections are always rendered alphabetically.
+   Since TOML mappings aren't ordered, types defined using this method are always rendered alphabetically.
+
 
+Use a TOML Array (defined order)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Defining Custom Fragment Types With an Array of TOML Tables
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Add an array of tables to your ``.toml`` configuration file named ``[[tool.towncrier.type]]``.
 
-Users can create their own custom fragment types by adding an array of
-tables to the pyproject.toml named ``[[tool.towncrier.type]]``.
+If you use this way to configure custom fragment types, ensure there is no ``tool.towncrier.fragment`` table.
 
-If you use this way to configure custom fragment types, please note that ``fragment_types`` must be empty or not provided.
+Each table within this array has the following mandatory keys:
 
-Each custom type (``[[tool.towncrier.type]]``) has the following
-mandatory keys:
 
-* ``directory``: The type / category of the fragment.
-* ``name``: The description of the fragment type, as it must be included
-  in the news file.
-* ``showcontent``: Whether if the fragment contents should be included in the
-  news file.
+``directory``
+    The type / category of the fragment.
+
+``name``
+    The description of the fragment type, as it must be included
+    in the news file.
+
+``showcontent``
+    A boolean value indicating whether the fragment contents should be included in the news file.
+
+    ``true`` by default.
 
 For example:
 
@@ -104,40 +258,3 @@ For example:
    directory = "chore"
    name = "Other Tasks"
    showcontent = false
-
-
-All Options
------------
-
-``towncrier`` has the following global options, which can be specified in the toml file:
-
-.. code-block:: toml
-
-   [tool.towncrier]
-   package = ""
-   package_dir = "."
-   single_file = true  # if false, filename is formatted like `title_format`.
-   filename = "NEWS.rst"
-   directory = "directory/of/news/fragments"
-   version = "1.2.3"  # project version if maintained separately
-   name = "arbitrary project name"
-   template = "path/to/template.rst"
-   start_string = "Text used to detect where to add the generated content in the middle of a file. Generated content added after this text. Newline auto added."
-   title_format = "{name} {version} ({project_date})"  # or false if template includes title
-   issue_format = "format string for {issue} (issue is the first part of fragment name)"
-   underlines = "=-~"
-   wrap = false  # Wrap text to 79 characters
-   all_bullets = true  # make all fragments bullet points
-   orphan_prefix = "+"   # Prefix for orphan news fragment files, set to "" to disable.
-
-If ``single_file`` is set to ``true`` or unspecified, all changes will be written to a single fixed newsfile, whose name is literally fixed as the ``filename`` option.
-In each run of ``towncrier build``, content of new changes will append at the top of old content, and after ``start_string`` if the ``start_string`` already appears in the newsfile.
-If the corresponding ``top_line``, which is formatted as the option 'title_format', already exists in newsfile, ``ValueError`` will be raised to remind you "already produced newsfiles for this version".
-
-If ``single_file`` is set to ``false`` instead, each versioned ``towncrier build`` will generate a separate newsfile, whose name is formatted as the pattern given by option ``filename``.
-For example, if ``filename="{version}-notes.rst"``, then the release note with version "7.8.9" will be written to the file "7.8.9-notes.rst".
-If the newsfile already exists, its content will be overwritten with new release note, without throwing a ``ValueError`` warning.
-
-If ``title_format`` is unspecified or an empty string, the default format will be used.
-If set to ``false``, no title will be created.
-This can be useful if the specified template creates the title itself.
diff --git a/docs/customization/newsfile.rst b/docs/customization/newsfile.rst
index dc5a793..67f3df4 100644
--- a/docs/customization/newsfile.rst
+++ b/docs/customization/newsfile.rst
@@ -5,23 +5,36 @@ Adding Content Above ``towncrier``
 ----------------------------------
 
 If you wish to have content at the top of the news file (for example, to say where you can find the tickets), you can use a special rST comment to tell ``towncrier`` to only update after it.
-In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content::
+In your existing news file (e.g. ``NEWS.rst``), add the following line above where you want ``towncrier`` to put content:
 
-  .. towncrier release notes start
+.. code-block:: restructuredtext
 
-In an existing news file, it'll look something like this::
+    .. towncrier release notes start
 
-  This is the changelog of my project. You can find the
-  issue tracker at http://blah.
+In an existing news file, it'll look something like this:
 
-  .. towncrier release notes start
+.. code-block:: restructuredtext
 
-  myproject 1.0.2 (2018-01-01)
-  ============================
+    This is the changelog of my project. You can find the
+    issue tracker at http://blah.
 
-  Bugfixes
-  --------
+    .. towncrier release notes start
 
-  - Fixed, etc...
+    myproject 1.0.2 (2018-01-01)
+    ============================
+
+    Bugfixes
+    --------
+
+    - Fixed, etc...
 
 ``towncrier`` will not alter content above the comment.
+
+Markdown
+~~~~~~~~
+
+If your news file is in Markdown (e.g. ``NEWS.md``), use the following comment instead:
+
+.. code-block:: html
+
+    <!-- towncrier release notes start -->
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 7b893b1..0a42ff3 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -1,7 +1,7 @@
 Tutorial
 ========
 
-This tutorial assumes you have a Python project with a *reStructuredText* (rst) news file (also known as changelog) file that you wish to use ``towncrier`` on, to generate its news file.
+This tutorial assumes you have a Python project with a *reStructuredText* (rst) or *Markdown* (md) news file (also known as changelog) that you wish to use ``towncrier`` on, to generate its news file.
 It will cover setting up your project with a basic configuration, which you can then feel free to `customize <customization/index.html>`_.
 
 Install from PyPI::
@@ -144,6 +144,8 @@ You should get an output similar to this::
 
    - #1, #2
 
+Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the titles will be output in Markdown format instead.
+
 
 Producing News Files In Production
 ----------------------------------
@@ -161,6 +163,8 @@ If you wish to have content at the top of the news file (for example, to say whe
 
 ``towncrier`` will then put the version notes after this comment, and leave your existing content that was above it where it is.
 
+Note: if you configure a Markdown file (for example, ``filename = "CHANGES.md"``) in your configuration file, the comment should be ``<!-- towncrier release notes start -->`` instead.
+
 
 Finale
 ------
diff --git a/pyproject.toml b/pyproject.toml
index d54647d..8738e59 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,7 @@ requires-python = ">=3.7"
 dependencies = [
     "click",
     "click-default-group",
-    "importlib-resources>1.3; python_version<'3.9'",
+    "importlib-resources>=5; python_version<'3.10'",
     "incremental",
     "jinja2",
     "tomli; python_version<'3.11'",
@@ -136,6 +136,12 @@ profile = "attrs"
 line_length = 88
 
 
+[tool.ruff.isort]
+# Match isort's "attrs" profile
+lines-after-imports = 2
+lines-between-types = 1
+
+
 [tool.mypy]
 strict = true
 # 2022-09-04: Trial's API isn't annotated yet, which limits the usefulness of type-checking
diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py
index f6cd87b..d1b14db 100644
--- a/src/towncrier/_builder.py
+++ b/src/towncrier/_builder.py
@@ -8,7 +8,7 @@ import os
 import textwrap
 import traceback
 
-from collections import OrderedDict, defaultdict
+from collections import defaultdict
 from typing import Any, DefaultDict, Iterable, Iterator, Mapping, Sequence
 
 from jinja2 import Template
@@ -66,15 +66,14 @@ def parse_newfragment_basename(
 
 # Returns a structure like:
 #
-# OrderedDict([
-#   ("",
-#    {
-#      ("142", "misc"): u"",
-#      ("1", "feature"): u"some cool description",
-#    }),
-#   ("Names", {}),
-#   ("Web", {("3", "bugfix"): u"Fixed a thing"}),
-# ])
+# {
+#     "": {
+#         ("142", "misc"): "",
+#         ("1", "feature"): "some cool description",
+#     },
+#     "Names": {},
+#     "Web": {("3", "bugfix"): "Fixed a thing"},
+# }
 #
 # We should really use attrs.
 #
@@ -89,7 +88,7 @@ def find_fragments(
     """
     Sections are a dictonary of section names to paths.
     """
-    content = OrderedDict()
+    content = {}
     fragment_filenames = []
     # Multiple orphan news fragments are allowed per section, so initialize a counter
     # that can be incremented automatically.
@@ -164,7 +163,7 @@ def split_fragments(
     definitions: Mapping[str, Mapping[str, Any]],
     all_bullets: bool = True,
 ) -> Mapping[str, Mapping[str, Mapping[str, Sequence[str]]]]:
-    output = OrderedDict()
+    output = {}
 
     for section_name, section_fragments in fragments.items():
         section: dict[str, dict[str, list[str]]] = {}
@@ -182,7 +181,7 @@ def split_fragments(
             if definitions[category]["showcontent"] is False:
                 content = ""
 
-            texts = section.setdefault(category, OrderedDict())
+            texts = section.setdefault(category, {})
 
             tickets = texts.setdefault(content, [])
             if ticket:
@@ -255,12 +254,15 @@ def render_fragments(
 
     jinja_template = Template(template, trim_blocks=True)
 
-    data: dict[str, dict[str, dict[str, list[str]]]] = OrderedDict()
+    data: dict[str, dict[str, dict[str, list[str]]]] = {}
+    issues_by_category: dict[str, dict[str, list[str]]] = {}
 
     for section_name, section_value in fragments.items():
-        data[section_name] = OrderedDict()
+        data[section_name] = {}
+        issues_by_category[section_name] = {}
 
         for category_name, category_value in section_value.items():
+            category_issues: set[str] = set()
             # Suppose we start with an ordering like this:
             #
             # - Fix the thing (#7, #123, #2)
@@ -273,6 +275,7 @@ def render_fragments(
             entries = []
             for text, issues in category_value.items():
                 entries.append((text, sorted(issues, key=issue_key)))
+                category_issues.update(issues)
 
             # Then we sort the lines:
             #
@@ -284,12 +287,16 @@ def render_fragments(
 
             # Then we put these nicely sorted entries back in an ordered dict
             # for the template, after formatting each issue number
-            categories = OrderedDict()
+            categories = {}
             for text, issues in entries:
                 rendered = [render_issue(issue_format, i) for i in issues]
                 categories[text] = rendered
 
             data[section_name][category_name] = categories
+            issues_by_category[section_name][category_name] = [
+                render_issue(issue_format, i)
+                for i in sorted(category_issues, key=issue_key)
+            ]
 
     done = []
 
@@ -311,6 +318,7 @@ def render_fragments(
         versiondata=versiondata,
         top_underline=top_underline,
         get_indent=get_indent,  # simplify indentation in the jinja template.
+        issues_by_category=issues_by_category,
     )
 
     for line in res.split("\n"):
diff --git a/src/towncrier/_settings/fragment_types.py b/src/towncrier/_settings/fragment_types.py
index d370986..a9882c9 100644
--- a/src/towncrier/_settings/fragment_types.py
+++ b/src/towncrier/_settings/fragment_types.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import abc
-import collections as clt
 
 from typing import Any, Iterable, Mapping
 
@@ -36,16 +35,14 @@ class BaseFragmentTypesLoader:
 class DefaultFragmentTypesLoader(BaseFragmentTypesLoader):
     """Default towncrier's fragment types."""
 
-    _default_types = clt.OrderedDict(
-        [
-            # Keep in-sync with docs/tutorial.rst.
-            ("feature", {"name": "Features", "showcontent": True}),
-            ("bugfix", {"name": "Bugfixes", "showcontent": True}),
-            ("doc", {"name": "Improved Documentation", "showcontent": True}),
-            ("removal", {"name": "Deprecations and Removals", "showcontent": True}),
-            ("misc", {"name": "Misc", "showcontent": False}),
-        ]
-    )
+    _default_types = {
+        # Keep in-sync with docs/tutorial.rst.
+        "feature": {"name": "Features", "showcontent": True},
+        "bugfix": {"name": "Bugfixes", "showcontent": True},
+        "doc": {"name": "Improved Documentation", "showcontent": True},
+        "removal": {"name": "Deprecations and Removals", "showcontent": True},
+        "misc": {"name": "Misc", "showcontent": False},
+    }
 
     def load(self) -> Mapping[str, Mapping[str, Any]]:
         """Load default types."""
@@ -72,7 +69,7 @@ class ArrayFragmentTypesLoader(BaseFragmentTypesLoader):
     def load(self) -> Mapping[str, Mapping[str, Any]]:
         """Load types from toml array of mappings."""
 
-        types = clt.OrderedDict()
+        types = {}
         types_config = self.config["type"]
         for type_config in types_config:
             directory = type_config["directory"]
@@ -123,7 +120,7 @@ class TableFragmentTypesLoader(BaseFragmentTypesLoader):
             (fragment_type, self._load_options(fragment_type))
             for fragment_type in fragment_types
         ]
-        types = clt.OrderedDict(custom_types_sequence)
+        types = dict(custom_types_sequence)
         return types
 
     def _load_options(self, fragment_type: str) -> Mapping[str, Any]:
diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py
index 10bde24..92098f9 100644
--- a/src/towncrier/_settings/load.py
+++ b/src/towncrier/_settings/load.py
@@ -4,13 +4,14 @@
 from __future__ import annotations
 
 import atexit
+import dataclasses
 import os
+import re
 import sys
 
-from collections import OrderedDict
 from contextlib import ExitStack
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Mapping
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Mapping, Sequence
 
 from .._settings import fragment_types as ft
 
@@ -23,7 +24,7 @@ if TYPE_CHECKING:
     else:
         from typing import Literal
 
-if sys.version_info < (3, 9):
+if sys.version_info < (3, 10):
     import importlib_resources as resources
 else:
     from importlib import resources
@@ -35,25 +36,28 @@ else:
     import tomllib
 
 
-@dataclass
+re_resource_template = re.compile(r"[-\w.]+:[-\w.]+$")
+
+
+@dataclasses.dataclass
 class Config:
-    package: str
-    package_dir: str
-    single_file: bool
-    filename: str
-    directory: str | None
-    version: str | None
-    name: str | None
     sections: Mapping[str, str]
     types: Mapping[str, Mapping[str, Any]]
-    template: str
+    template: str | tuple[str, str]
     start_string: str
-    title_format: str | Literal[False]
-    issue_format: str | None
-    underlines: list[str]
-    wrap: bool
-    all_bullets: bool
-    orphan_prefix: str
+    package: str = ""
+    package_dir: str = "."
+    single_file: bool = True
+    filename: str = "NEWS.rst"
+    directory: str | None = None
+    version: str | None = None
+    name: str = ""
+    title_format: str | Literal[False] = ""
+    issue_format: str | None = None
+    underlines: Sequence[str] = ("=", "-", "~")
+    wrap: bool = False
+    all_bullets: bool = True
+    orphan_prefix: str = "+"
 
 
 class ConfigError(Exception):
@@ -62,12 +66,6 @@ class ConfigError(Exception):
         super().__init__(*args)
 
 
-_start_string = ".. towncrier release notes start\n"
-_title_format = None
-_template_fname = "towncrier:default"
-_underlines = ["=", "-", "~"]
-
-
 def load_config_from_options(
     directory: str | None, config_path: str | None
 ) -> tuple[str, Config]:
@@ -118,82 +116,83 @@ atexit.register(_file_manager.close)
 
 
 def parse_toml(base_path: str, config: Mapping[str, Any]) -> Config:
-    if "tool" not in config:
+    if "towncrier" not in (config.get("tool") or {}):
         raise ConfigError("No [tool.towncrier] section.", failing_option="all")
 
     config = config["tool"]["towncrier"]
+    parsed_data = {}
+
+    # Check for misspelt options.
+    for typo, correct in [
+        ("singlefile", "single_file"),
+    ]:
+        if config.get(typo):
+            raise ConfigError(
+                f"`{typo}` is not a valid option. Did you mean `{correct}`?",
+                failing_option=typo,
+            )
 
-    sections = OrderedDict()
+    # Process options.
+    for field in dataclasses.fields(Config):
+        if field.name in ("sections", "types", "template"):
+            # Skip these options, they are processed later.
+            continue
+        if field.name in config:
+            #  Interestingly, the __future__ annotation turns the type into a string.
+            if field.type in ("bool", bool):
+                if not isinstance(config[field.name], bool):
+                    raise ConfigError(
+                        f"`{field.name}` option must be boolean: false or true.",
+                        failing_option=field.name,
+                    )
+            parsed_data[field.name] = config[field.name]
+
+    # Process 'section'.
+    sections = {}
     if "section" in config:
         for x in config["section"]:
             sections[x.get("name", "")] = x["path"]
     else:
         sections[""] = ""
+    parsed_data["sections"] = sections
+
+    # Process 'types'.
     fragment_types_loader = ft.BaseFragmentTypesLoader.factory(config)
-    types = fragment_types_loader.load()
-
-    wrap = config.get("wrap", False)
-
-    single_file_wrong = config.get("singlefile")
-    if single_file_wrong:
-        raise ConfigError(
-            "`singlefile` is not a valid option. Did you mean `single_file`?",
-            failing_option="singlefile",
-        )
-
-    single_file = config.get("single_file", True)
-    if not isinstance(single_file, bool):
-        raise ConfigError(
-            "`single_file` option must be a boolean: false or true.",
-            failing_option="single_file",
-        )
-
-    all_bullets = config.get("all_bullets", True)
-    if not isinstance(all_bullets, bool):
-        raise ConfigError(
-            "`all_bullets` option must be boolean: false or true.",
-            failing_option="all_bullets",
-        )
-
-    template = config.get("template", _template_fname)
-    if template.startswith("towncrier:"):
-        resource_name = f"templates/{template.split('towncrier:', 1)[1]}.rst"
-        resource_path = _file_manager.enter_context(
-            resources.as_file(resources.files("towncrier") / resource_name)
-        )
-
-        if not resource_path.is_file():
+    parsed_data["types"] = fragment_types_loader.load()
+
+    # Process 'template'.
+    markdown_file = Path(config.get("filename", "")).suffix == ".md"
+    template = config.get("template", "towncrier:default")
+    if re_resource_template.match(template):
+        package, resource = template.split(":", 1)
+        if not Path(resource).suffix:
+            resource += ".md" if markdown_file else ".rst"
+        if not resources.is_resource(package, resource):
+            if resources.is_resource(package + ".templates", resource):
+                package += ".templates"
+            else:
+                raise ConfigError(
+                    f"'{package}' does not have a template named '{resource}'.",
+                    failing_option="template",
+                )
+        template = (package, resource)
+    else:
+        template = os.path.join(base_path, template)
+        if not os.path.isfile(template):
             raise ConfigError(
-                "Towncrier does not have a template named '%s'."
-                % (template.split("towncrier:", 1)[1],)
+                f"The template file '{template}' does not exist.",
+                failing_option="template",
             )
 
-        template = str(resource_path)
-    else:
-        template = os.path.join(base_path, template)
+    parsed_data["template"] = template
+
+    # Process 'start_string'.
+
+    start_string = config.get("start_string", "")
+    if not start_string:
+        start_string_template = "<!-- {} -->\n" if markdown_file else ".. {}\n"
+        start_string = start_string_template.format("towncrier release notes start")
+    parsed_data["start_string"] = start_string
 
-    if not os.path.exists(template):
-        raise ConfigError(
-            f"The template file '{template}' does not exist.",
-            failing_option="template",
-        )
-
-    return Config(
-        package=config.get("package", ""),
-        package_dir=config.get("package_dir", "."),
-        single_file=single_file,
-        filename=config.get("filename", "NEWS.rst"),
-        directory=config.get("directory"),
-        version=config.get("version"),
-        name=config.get("name"),
-        sections=sections,
-        types=types,
-        template=template,
-        start_string=config.get("start_string", _start_string),
-        title_format=config.get("title_format", _title_format),
-        issue_format=config.get("issue_format"),
-        underlines=config.get("underlines", _underlines),
-        wrap=wrap,
-        all_bullets=all_bullets,
-        orphan_prefix=config.get("orphan_prefix", "+"),
-    )
+    # Return the parsed config.
+    return Config(**parsed_data)
diff --git a/src/towncrier/build.py b/src/towncrier/build.py
index b5477f7..bb01644 100644
--- a/src/towncrier/build.py
+++ b/src/towncrier/build.py
@@ -25,6 +25,12 @@ from ._settings import ConfigError, config_option_help, load_config_from_options
 from ._writer import append_to_newsfile
 
 
+if sys.version_info < (3, 10):
+    import importlib_resources as resources
+else:
+    from importlib import resources
+
+
 def _get_date() -> str:
     return date.today().isoformat()
 
@@ -146,8 +152,11 @@ def __main(
     to_err = draft
 
     click.echo("Loading template...", err=to_err)
-    with open(config.template, "rb") as tmpl:
-        template = tmpl.read().decode("utf8")
+    if isinstance(config.template, tuple):
+        template = resources.read_text(*config.template)
+    else:
+        with open(config.template, encoding="utf-8") as tmpl:
+            template = tmpl.read()
 
     click.echo("Finding news fragments...", err=to_err)
 
diff --git a/src/towncrier/newsfragments/483.feature b/src/towncrier/newsfragments/483.feature
new file mode 100644
index 0000000..3397d34
--- /dev/null
+++ b/src/towncrier/newsfragments/483.feature
@@ -0,0 +1,3 @@
+Provide a default Markdown template if the configured filename ends with ``.md``.
+
+The Markdown template uses the same rendered format as the default *reStructuredText* template, but with a Markdown syntax.
diff --git a/src/towncrier/templates/default.md b/src/towncrier/templates/default.md
new file mode 100644
index 0000000..45f7a39
--- /dev/null
+++ b/src/towncrier/templates/default.md
@@ -0,0 +1,61 @@
+{% if render_title %}
+{% if versiondata.name %}
+# {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }})
+{% else %}
+{{ versiondata.version }} ({{ versiondata.date }})
+{% endif %}
+{% endif %}
+{% for section, _ in sections.items() %}
+{% if section %}
+
+## {{section}}
+{% endif %}
+
+{% if sections[section] %}
+{% for category, val in definitions.items() if category in sections[section] %}
+### {{ definitions[category]['name'] }}
+
+{% if definitions[category]['showcontent'] %}
+{% for text, values in sections[section][category].items() %}
+- {{ text }}
+{%- if values %}
+{% if "\n  - " in text or '\n  * ' in text %}
+
+
+  (
+{%- else %}
+ (
+{%- endif -%}
+{%- for issue in values %}
+{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %}
+{%- endfor %}
+)
+{% else %}
+
+{% endif %}
+{% endfor %}
+
+{% else %}
+- {% for issue in sections[section][category][''] %}
+{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %}
+{% endfor %}
+
+
+{% endif %}
+{% if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %}
+{% for issue in issues_by_category[section][category] %}
+{{ issue }}
+{% endfor %}
+
+{% endif %}
+{% if sections[section][category]|length == 0 %}
+No significant changes.
+
+{% else %}
+{% endif %}
+{% endfor %}
+{% else %}
+No significant changes.
+
+{% endif %}
+{% endfor %}


Test patch:
diff --git a/src/towncrier/test/helpers.py b/src/towncrier/test/helpers.py
index e86a9fc..ddc9ffc 100644
--- a/src/towncrier/test/helpers.py
+++ b/src/towncrier/test/helpers.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import sys
+import textwrap
 
 from functools import wraps
 from pathlib import Path
@@ -19,12 +20,14 @@ def read(filename: str | Path) -> str:
     return Path(filename).read_text()
 
 
-def write(path: str | Path, contents: str) -> None:
+def write(path: str | Path, contents: str, dedent: bool = False) -> None:
     """
     Create a file with given contents including any missing parent directories
     """
     p = Path(path)
     p.parent.mkdir(parents=True, exist_ok=True)
+    if dedent:
+        contents = textwrap.dedent(contents)
     p.write_text(contents)
 
 
diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py
index 0a76d91..4dbc07c 100644
--- a/src/towncrier/test/test_build.py
+++ b/src/towncrier/test/test_build.py
@@ -15,7 +15,7 @@ from twisted.trial.unittest import TestCase
 
 from .._shell import cli
 from ..build import _main
-from .helpers import read, setup_simple_project, with_isolated_runner
+from .helpers import read, setup_simple_project, with_isolated_runner, write
 
 
 class TestCli(TestCase):
@@ -1080,6 +1080,7 @@ Deprecations and Removals
                     "20-01-2001",
                     "--draft",
                 ],
+                catch_exceptions=False,
             )
 
         expected_output = dedent(
@@ -1162,6 +1163,103 @@ Deprecations and Removals
 
         self.assertEqual(expected_output, output)
 
+    @with_isolated_runner
+    def test_default_start_string(self, runner):
+        """
+        The default start string is ``.. towncrier release notes start``.
+        """
+        setup_simple_project()
+
+        write("foo/newsfragments/123.feature", "Adds levitation")
+        write(
+            "NEWS.rst",
+            contents="""
+                a line
+
+                another
+
+                .. towncrier release notes start
+
+                a footer!
+            """,
+            dedent=True,
+        )
+
+        result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False)
+        self.assertEqual(0, result.exit_code, result.output)
+        output = read("NEWS.rst")
+
+        expected_output = dedent(
+            """
+            a line
+
+            another
+
+            .. towncrier release notes start
+
+            Foo 1.2.3 (01-01-2001)
+            ======================
+
+            Features
+            --------
+
+            - Adds levitation (#123)
+
+
+            a footer!
+            """
+        )
+
+        self.assertEqual(expected_output, output)
+
+    @with_isolated_runner
+    def test_default_start_string_markdown(self, runner):
+        """
+        The default start string is ``<!-- towncrier release notes start -->`` for
+        Markdown.
+        """
+        setup_simple_project(extra_config='filename = "NEWS.md"')
+
+        write("foo/newsfragments/123.feature", "Adds levitation")
+        write(
+            "NEWS.md",
+            contents="""
+                a line
+
+                another
+
+                <!-- towncrier release notes start -->
+
+                a footer!
+            """,
+            dedent=True,
+        )
+
+        result = runner.invoke(_main, ["--date", "01-01-2001"], catch_exceptions=False)
+        self.assertEqual(0, result.exit_code, result.output)
+        output = read("NEWS.md")
+
+        expected_output = dedent(
+            """
+            a line
+
+            another
+
+            <!-- towncrier release notes start -->
+
+            # Foo 1.2.3 (01-01-2001)
+
+            ### Features
+
+            - Adds levitation (#123)
+
+
+            a footer!
+            """
+        )
+
+        self.assertEqual(expected_output, output)
+
     def test_with_topline_and_template_and_draft(self):
         """
         Spacing is proper when drafting with a topline and a template.
diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py
index 241cbb8..61e6d88 100644
--- a/src/towncrier/test/test_format.py
+++ b/src/towncrier/test/test_format.py
@@ -2,8 +2,6 @@
 # See LICENSE for details.
 
 
-from collections import OrderedDict
-
 from twisted.trial.unittest import TestCase
 
 from .._builder import render_fragments, split_fragments
@@ -11,6 +9,8 @@ from .helpers import read_pkg_resource
 
 
 class FormatterTests(TestCase):
+    maxDiff = None
+
     def test_split(self):
         fragments = {
             "": {
@@ -38,13 +38,11 @@ class FormatterTests(TestCase):
             },
         }
 
-        definitions = OrderedDict(
-            [
-                ("feature", {"name": "Features", "showcontent": True}),
-                ("bugfix", {"name": "Bugfixes", "showcontent": True}),
-                ("misc", {"name": "Misc", "showcontent": False}),
-            ]
-        )
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+            "bugfix": {"name": "Bugfixes", "showcontent": True},
+            "misc": {"name": "Misc", "showcontent": False},
+        }
 
         output = split_fragments(fragments, definitions)
 
@@ -55,36 +53,29 @@ class FormatterTests(TestCase):
         Basic functionality -- getting a bunch of news fragments and formatting
         them into a rST file -- works.
         """
-        fragments = OrderedDict(
-            [
-                (
-                    "",
-                    {
-                        # asciibetical sorting will do 1, 142, 9
-                        # we want 1, 9, 142 instead
-                        ("142", "misc", 0): "",
-                        ("1", "misc", 0): "",
-                        ("9", "misc", 0): "",
-                        ("bar", "misc", 0): "",
-                        ("4", "feature", 0): "Stuff!",
-                        ("2", "feature", 0): "Foo added.",
-                        ("72", "feature", 0): "Foo added.",
-                        ("9", "feature", 0): "Foo added.",
-                        ("baz", "feature", 0): "Fun!",
-                    },
-                ),
-                ("Names", {}),
-                ("Web", {("3", "bugfix", 0): "Web fixed."}),
-            ]
-        )
+        fragments = {
+            "": {
+                # asciibetical sorting will do 1, 142, 9
+                # we want 1, 9, 142 instead
+                ("142", "misc", 0): "",
+                ("1", "misc", 0): "",
+                ("9", "misc", 0): "",
+                ("bar", "misc", 0): "",
+                ("4", "feature", 0): "Stuff!",
+                ("2", "feature", 0): "Foo added.",
+                ("72", "feature", 0): "Foo added.",
+                ("9", "feature", 0): "Foo added.",
+                ("baz", "feature", 0): "Fun!",
+            },
+            "Names": {},
+            "Web": {("3", "bugfix", 0): "Web fixed."},
+        }
 
-        definitions = OrderedDict(
-            [
-                ("feature", {"name": "Features", "showcontent": True}),
-                ("bugfix", {"name": "Bugfixes", "showcontent": True}),
-                ("misc", {"name": "Misc", "showcontent": False}),
-            ]
-        )
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+            "bugfix": {"name": "Bugfixes", "showcontent": True},
+            "misc": {"name": "Misc", "showcontent": False},
+        }
 
         expected_output = """MyProject 1.0 (never)
 =====================
@@ -176,6 +167,144 @@ Bugfixes
         )
         self.assertEqual(output, expected_output_weird_underlines)
 
+    def test_markdown(self):
+        """
+        Check formating of default markdown template.
+        """
+        fragments = {
+            "": {
+                # asciibetical sorting will do 1, 142, 9
+                # we want 1, 9, 142 instead
+                ("142", "misc", 0): "",
+                ("1", "misc", 0): "",
+                ("9", "misc", 0): "",
+                ("bar", "misc", 0): "",
+                ("4", "feature", 0): "Stuff!",
+                ("2", "feature", 0): "Foo added.",
+                ("72", "feature", 0): "Foo added.",
+                ("9", "feature", 0): "Foo added.",
+                ("3", "feature", 0): "Multi-line\nhere",
+                ("baz", "feature", 0): "Fun!",
+            },
+            "Names": {},
+            "Web": {
+                ("3", "bugfix", 0): "Web fixed.",
+                ("2", "bugfix", 0): "Multi-line bulleted\n- fix\n- here",
+            },
+        }
+
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+            "bugfix": {"name": "Bugfixes", "showcontent": True},
+            "misc": {"name": "Misc", "showcontent": False},
+        }
+
+        expected_output = """# MyProject 1.0 (never)
+
+### Features
+
+- Fun! (baz)
+- Foo added. (#2, #9, #72)
+- Multi-line
+  here (#3)
+- Stuff! (#4)
+
+### Misc
+
+- bar, #1, #9, #142
+
+
+## Names
+
+No significant changes.
+
+
+## Web
+
+### Bugfixes
+
+- Multi-line bulleted
+  - fix
+  - here
+
+  (#2)
+- Web fixed. (#3)
+"""
+
+        template = read_pkg_resource("templates/default.md")
+
+        fragments = split_fragments(fragments, definitions)
+        output = render_fragments(
+            template,
+            None,
+            fragments,
+            definitions,
+            ["-", "~"],
+            wrap=True,
+            versiondata={"name": "MyProject", "version": "1.0", "date": "never"},
+        )
+        self.assertEqual(output, expected_output)
+
+        # Also test with custom issue format
+        expected_output = """# MyProject 1.0 (never)
+
+### Features
+
+- Fun! ([baz])
+- Foo added. ([2], [9], [72])
+- Multi-line
+  here ([3])
+- Stuff! ([4])
+
+[baz]: https://github.com/twisted/towncrier/issues/baz
+[2]: https://github.com/twisted/towncrier/issues/2
+[3]: https://github.com/twisted/towncrier/issues/3
+[4]: https://github.com/twisted/towncrier/issues/4
+[9]: https://github.com/twisted/towncrier/issues/9
+[72]: https://github.com/twisted/towncrier/issues/72
+
+### Misc
+
+- [bar], [1], [9], [142]
+
+[bar]: https://github.com/twisted/towncrier/issues/bar
+[1]: https://github.com/twisted/towncrier/issues/1
+[9]: https://github.com/twisted/towncrier/issues/9
+[142]: https://github.com/twisted/towncrier/issues/142
+
+
+## Names
+
+No significant changes.
+
+
+## Web
+
+### Bugfixes
+
+- Multi-line bulleted
+  - fix
+  - here
+
+  ([2])
+- Web fixed. ([3])
+
+[2]: https://github.com/twisted/towncrier/issues/2
+[3]: https://github.com/twisted/towncrier/issues/3
+"""
+
+        output = render_fragments(
+            template,
+            "[{issue}]: https://github.com/twisted/towncrier/issues/{issue}",
+            fragments,
+            definitions,
+            ["-", "~"],
+            wrap=True,
+            versiondata={"name": "MyProject", "version": "1.0", "date": "never"},
+        )
+
+        self.assertEqual(output, expected_output)
+
     def test_issue_format(self):
         """
         issue_format option can be used to format issue text.
@@ -194,7 +323,7 @@ Bugfixes
             }
         }
 
-        definitions = OrderedDict([("misc", {"name": "Misc", "showcontent": False})])
+        definitions = {"misc": {"name": "Misc", "showcontent": False}}
 
         expected_output = """MyProject 1.0 (never)
 =====================
@@ -240,9 +369,7 @@ Misc
             }
         }
 
-        definitions = OrderedDict(
-            [("feature", {"name": "Features", "showcontent": True})]
-        )
+        definitions = {"feature": {"name": "Features", "showcontent": True}}
 
         expected_output = """MyProject 1.0 (never)
 =====================
@@ -295,9 +422,7 @@ Features
             }
         }
 
-        definitions = OrderedDict(
-            [("feature", {"name": "Features", "showcontent": True})]
-        )
+        definitions = {"feature": {"name": "Features", "showcontent": True}}
 
         expected_output = """MyProject 1.0 (never)
 =====================
diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py
index 4b42064..c41c3a5 100644
--- a/src/towncrier/test/test_project.py
+++ b/src/towncrier/test/test_project.py
@@ -25,7 +25,6 @@ class VersionFetchingTests(TestCase):
         A str __version__ will be picked up.
         """
         temp = self.mktemp()
-        os.makedirs(temp)
         os.makedirs(os.path.join(temp, "mytestproj"))
 
         with open(os.path.join(temp, "mytestproj", "__init__.py"), "w") as f:
@@ -39,7 +38,6 @@ class VersionFetchingTests(TestCase):
         A tuple __version__ will be picked up.
         """
         temp = self.mktemp()
-        os.makedirs(temp)
         os.makedirs(os.path.join(temp, "mytestproja"))
 
         with open(os.path.join(temp, "mytestproja", "__init__.py"), "w") as f:
@@ -109,7 +107,6 @@ class VersionFetchingTests(TestCase):
         A __version__ of unknown type will lead to an exception.
         """
         temp = self.mktemp()
-        os.makedirs(temp)
         os.makedirs(os.path.join(temp, "mytestprojb"))
 
         with open(os.path.join(temp, "mytestprojb", "__init__.py"), "w") as f:
@@ -133,14 +130,12 @@ class VersionFetchingTests(TestCase):
         project_name = "mytestproj_already_installed_import"
 
         temp = self.mktemp()
-        os.makedirs(temp)
         os.makedirs(os.path.join(temp, project_name))
 
         with open(os.path.join(temp, project_name, "__init__.py"), "w") as f:
             f.write("__version__ = (1, 3, 12)")
 
         sys_path_temp = self.mktemp()
-        os.makedirs(sys_path_temp)
         os.makedirs(os.path.join(sys_path_temp, project_name))
 
         with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f:
@@ -161,7 +156,6 @@ class VersionFetchingTests(TestCase):
         project_name = "mytestproj_only_installed"
 
         sys_path_temp = self.mktemp()
-        os.makedirs(sys_path_temp)
         os.makedirs(os.path.join(sys_path_temp, project_name))
 
         with open(os.path.join(sys_path_temp, project_name, "__init__.py"), "w") as f:
diff --git a/src/towncrier/test/test_settings.py b/src/towncrier/test/test_settings.py
index c954e73..3c2dca9 100644
--- a/src/towncrier/test/test_settings.py
+++ b/src/towncrier/test/test_settings.py
@@ -1,59 +1,128 @@
 # Copyright (c) Amber Brown, 2015
 # See LICENSE for details.
 
-import collections as clt
 import os
-import textwrap
-
-from textwrap import dedent
 
 from twisted.trial.unittest import TestCase
 
 from .._settings import ConfigError, load_config
+from .helpers import write
 
 
 class TomlSettingsTests(TestCase):
+    def mktemp_project(
+        self, *, pyproject_toml: str = "", towncrier_toml: str = ""
+    ) -> str:
+        """
+        Create a temporary directory with a pyproject.toml file in it.
+        """
+        project_dir = self.mktemp()
+        os.makedirs(project_dir)
+
+        if pyproject_toml:
+            write(
+                os.path.join(project_dir, "pyproject.toml"),
+                pyproject_toml,
+                dedent=True,
+            )
+
+        if towncrier_toml:
+            write(
+                os.path.join(project_dir, "towncrier.toml"),
+                towncrier_toml,
+                dedent=True,
+            )
+
+        return project_dir
+
     def test_base(self):
         """
         Test a "base config".
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                """[tool.towncrier]
-package = "foobar"
-orphan_prefix = "~"
-"""
-            )
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                orphan_prefix = "~"
+            """
+        )
 
-        config = load_config(temp)
+        config = load_config(project_dir)
         self.assertEqual(config.package, "foobar")
         self.assertEqual(config.package_dir, ".")
         self.assertEqual(config.filename, "NEWS.rst")
-        self.assertEqual(config.underlines, ["=", "-", "~"])
+        self.assertEqual(config.underlines, ("=", "-", "~"))
         self.assertEqual(config.orphan_prefix, "~")
 
+    def test_markdown(self):
+        """
+        If the filename references an .md file and the builtin template doesn't have an
+        extension, add .md rather than .rst.
+        """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                filename = "NEWS.md"
+            """
+        )
+
+        config = load_config(project_dir)
+
+        self.assertEqual(config.filename, "NEWS.md")
+
+        self.assertEqual(config.template, ("towncrier.templates", "default.md"))
+
+    def test_explicit_template_extension(self):
+        """
+        If the filename references an .md file and the builtin template has an
+        extension, don't change it.
+        """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                filename = "NEWS.md"
+                template = "towncrier:default.rst"
+            """
+        )
+
+        config = load_config(project_dir)
+
+        self.assertEqual(config.filename, "NEWS.md")
+        self.assertEqual(config.template, ("towncrier.templates", "default.rst"))
+
+    def test_template_extended(self):
+        """
+        The template can be any package and resource, and although we look for a
+        resource's 'templates' package, it could also be in the specified resource
+        directly.
+        """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                template = "towncrier.templates:default.rst"
+            """
+        )
+
+        config = load_config(project_dir)
+
+        self.assertEqual(config.template, ("towncrier.templates", "default.rst"))
+
     def test_missing(self):
         """
         If the config file doesn't have the correct toml key, we error.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
                 [something.else]
                 blah='baz'
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(e.exception.failing_option, "all")
 
@@ -61,21 +130,15 @@ orphan_prefix = "~"
         """
         single_file must be a bool.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
                 [tool.towncrier]
                 single_file = "a"
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(e.exception.failing_option, "single_file")
 
@@ -83,21 +146,15 @@ orphan_prefix = "~"
         """
         all_bullets must be a bool.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
                 [tool.towncrier]
                 all_bullets = "a"
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(e.exception.failing_option, "all_bullets")
 
@@ -105,21 +162,15 @@ orphan_prefix = "~"
         """
         singlefile is not accepted, single_file is.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
                 [tool.towncrier]
                 singlefile = "a"
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(e.exception.failing_option, "singlefile")
 
@@ -127,56 +178,38 @@ orphan_prefix = "~"
         """
         Towncrier prefers the towncrier.toml for autodetect over pyproject.toml.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "towncrier.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            towncrier_toml="""
                 [tool.towncrier]
                 package = "a"
-                """
-                )
-            )
-
-        with open(os.path.join(temp, "pyproject.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+            """,
+            pyproject_toml="""
                 [tool.towncrier]
                 package = "b"
-                """
-                )
-            )
+            """,
+        )
 
-        config = load_config(temp)
+        config = load_config(project_dir)
         self.assertEqual(config.package, "a")
 
     def test_missing_template(self):
         """
         Towncrier will raise an exception saying when it can't find a template.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "towncrier.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            towncrier_toml="""
                 [tool.towncrier]
                 template = "foo.rst"
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(
             str(e.exception),
             "The template file '{}' does not exist.".format(
-                os.path.normpath(os.path.join(temp, "foo.rst")),
+                os.path.normpath(os.path.join(project_dir, "foo.rst")),
             ),
         )
 
@@ -185,24 +218,18 @@ orphan_prefix = "~"
         Towncrier will raise an exception saying when it can't find a template
         from the Towncrier templates.
         """
-        temp = self.mktemp()
-        os.makedirs(temp)
-
-        with open(os.path.join(temp, "towncrier.toml"), "w") as f:
-            f.write(
-                dedent(
-                    """
+        project_dir = self.mktemp_project(
+            towncrier_toml="""
                 [tool.towncrier]
                 template = "towncrier:foo"
-                """
-                )
-            )
+            """
+        )
 
         with self.assertRaises(ConfigError) as e:
-            load_config(temp)
+            load_config(project_dir)
 
         self.assertEqual(
-            str(e.exception), "Towncrier does not have a template named 'foo'."
+            str(e.exception), "'towncrier' does not have a template named 'foo.rst'."
         )
 
     def test_custom_types_as_tables_array_deprecated(self):
@@ -214,20 +241,22 @@ orphan_prefix = "~"
         This functionality is considered deprecated, but we continue
         to support it to keep backward compatibility.
         """
-        toml_content = """
-        [tool.towncrier]
-        package = "foobar"
-        [[tool.towncrier.type]]
-        directory="foo"
-        name="Foo"
-        showcontent=false
-
-        [[tool.towncrier.type]]
-        directory="spam"
-        name="Spam"
-        showcontent=true
-        """
-        toml_content = textwrap.dedent(toml_content)
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                [[tool.towncrier.type]]
+                directory="foo"
+                name="Foo"
+                showcontent=false
+
+                [[tool.towncrier.type]]
+                directory="spam"
+                name="Spam"
+                showcontent=true
+            """
+        )
+        config = load_config(project_dir)
         expected = [
             (
                 "foo",
@@ -244,10 +273,7 @@ orphan_prefix = "~"
                 },
             ),
         ]
-        expected = clt.OrderedDict(expected)
-        config = self.load_config_from_string(
-            toml_content,
-        )
+        expected = dict(expected)
         actual = config.types
         self.assertDictEqual(expected, actual)
 
@@ -256,60 +282,32 @@ orphan_prefix = "~"
         Custom fragment categories can be defined inside
         the toml config file using tables.
         """
-        test_project_path = self.mktemp()
-        os.makedirs(test_project_path)
-        toml_content = """
-        [tool.towncrier]
-        package = "foobar"
-        [tool.towncrier.fragment.feat]
-        ignored_field="Bazz"
-        [tool.towncrier.fragment.fix]
-        [tool.towncrier.fragment.chore]
-        name = "Other Tasks"
-        showcontent = false
-        """
-        toml_content = textwrap.dedent(toml_content)
-        expected = [
-            (
-                "chore",
-                {
-                    "name": "Other Tasks",
-                    "showcontent": False,
-                },
-            ),
-            (
-                "feat",
-                {
-                    "name": "Feat",
-                    "showcontent": True,
-                },
-            ),
-            (
-                "fix",
-                {
-                    "name": "Fix",
-                    "showcontent": True,
-                },
-            ),
-        ]
-
-        expected = clt.OrderedDict(expected)
-        config = self.load_config_from_string(
-            toml_content,
+        project_dir = self.mktemp_project(
+            pyproject_toml="""
+                [tool.towncrier]
+                package = "foobar"
+                [tool.towncrier.fragment.feat]
+                ignored_field="Bazz"
+                [tool.towncrier.fragment.fix]
+                [tool.towncrier.fragment.chore]
+                name = "Other Tasks"
+                showcontent = false
+            """
         )
+        config = load_config(project_dir)
+        expected = {
+            "chore": {
+                "name": "Other Tasks",
+                "showcontent": False,
+            },
+            "feat": {
+                "name": "Feat",
+                "showcontent": True,
+            },
+            "fix": {
+                "name": "Fix",
+                "showcontent": True,
+            },
+        }
         actual = config.types
         self.assertDictEqual(expected, actual)
-
-    def load_config_from_string(self, toml_content):
-        """Load configuration from a string.
-
-        Given a string following toml syntax,
-        obtain the towncrier configuration.
-        """
-        test_project_path = self.mktemp()
-        os.makedirs(test_project_path)
-        toml_path = os.path.join(test_project_path, "pyproject.toml")
-        with open(toml_path, "w") as f:
-            f.write(toml_content)
-        config = load_config(test_project_path)
-        return config
diff --git a/src/towncrier/test/test_write.py b/src/towncrier/test/test_write.py
index 83443f2..563fde6 100644
--- a/src/towncrier/test/test_write.py
+++ b/src/towncrier/test/test_write.py
@@ -3,7 +3,6 @@
 
 import os
 
-from collections import OrderedDict
 from pathlib import Path
 from textwrap import dedent
 
@@ -13,40 +12,31 @@ from twisted.trial.unittest import TestCase
 from .._builder import render_fragments, split_fragments
 from .._writer import append_to_newsfile
 from ..build import _main
-from .helpers import read_pkg_resource
+from .helpers import read_pkg_resource, write
 
 
 class WritingTests(TestCase):
     maxDiff = None
 
     def test_append_at_top(self):
-        fragments = OrderedDict(
-            [
-                (
-                    "",
-                    OrderedDict(
-                        [
-                            (("142", "misc", 0), ""),
-                            (("1", "misc", 0), ""),
-                            (("4", "feature", 0), "Stuff!"),
-                            (("4", "feature", 1), "Second Stuff!"),
-                            (("2", "feature", 0), "Foo added."),
-                            (("72", "feature", 0), "Foo added."),
-                        ]
-                    ),
-                ),
-                ("Names", {}),
-                ("Web", {("3", "bugfix", 0): "Web fixed."}),
-            ]
-        )
-
-        definitions = OrderedDict(
-            [
-                ("feature", {"name": "Features", "showcontent": True}),
-                ("bugfix", {"name": "Bugfixes", "showcontent": True}),
-                ("misc", {"name": "Misc", "showcontent": False}),
-            ]
-        )
+        fragments = {
+            "": {
+                ("142", "misc", 0): "",
+                ("1", "misc", 0): "",
+                ("4", "feature", 0): "Stuff!",
+                ("4", "feature", 1): "Second Stuff!",
+                ("2", "feature", 0): "Foo added.",
+                ("72", "feature", 0): "Foo added.",
+            },
+            "Names": {},
+            "Web": {("3", "bugfix", 0): "Web fixed."},
+        }
+
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+            "bugfix": {"name": "Bugfixes", "showcontent": True},
+            "misc": {"name": "Misc", "showcontent": False},
+        }
 
         expected_output = """MyProject 1.0 (never)
 =====================
@@ -84,7 +74,7 @@ Old text.
 """
 
         tempdir = self.mktemp()
-        os.mkdir(tempdir)
+        os.makedirs(tempdir)
 
         with open(os.path.join(tempdir, "NEWS.rst"), "w") as f:
             f.write("Old text.\n")
@@ -120,31 +110,24 @@ Old text.
         If there is a comment with C{.. towncrier release notes start},
         towncrier will add the version notes after it.
         """
-        fragments = OrderedDict(
-            [
-                (
-                    "",
-                    {
-                        ("142", "misc", 0): "",
-                        ("1", "misc", 0): "",
-                        ("4", "feature", 0): "Stuff!",
-                        ("2", "feature", 0): "Foo added.",
-                        ("72", "feature", 0): "Foo added.",
-                        ("99", "feature", 0): "Foo! " * 100,
-                    },
-                ),
-                ("Names", {}),
-                ("Web", {("3", "bugfix", 0): "Web fixed."}),
-            ]
-        )
-
-        definitions = OrderedDict(
-            [
-                ("feature", {"name": "Features", "showcontent": True}),
-                ("bugfix", {"name": "Bugfixes", "showcontent": True}),
-                ("misc", {"name": "Misc", "showcontent": False}),
-            ]
-        )
+        fragments = {
+            "": {
+                ("142", "misc", 0): "",
+                ("1", "misc", 0): "",
+                ("4", "feature", 0): "Stuff!",
+                ("2", "feature", 0): "Foo added.",
+                ("72", "feature", 0): "Foo added.",
+                ("99", "feature", 0): "Foo! " * 100,
+            },
+            "Names": {},
+            "Web": {("3", "bugfix", 0): "Web fixed."},
+        }
+
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+            "bugfix": {"name": "Bugfixes", "showcontent": True},
+            "misc": {"name": "Misc", "showcontent": False},
+        }
 
         expected_output = """Hello there! Here is some info.
 
@@ -192,13 +175,16 @@ Old text.
 """
 
         tempdir = self.mktemp()
-        os.mkdir(tempdir)
-
-        with open(os.path.join(tempdir, "NEWS.rst"), "w") as f:
-            f.write(
-                "Hello there! Here is some info.\n\n"
-                ".. towncrier release notes start\nOld text.\n"
-            )
+        write(
+            os.path.join(tempdir, "NEWS.rst"),
+            contents="""\
+                Hello there! Here is some info.
+
+                .. towncrier release notes start
+                Old text.
+            """,
+            dedent=True,
+        )
 
         fragments = split_fragments(fragments, definitions)
 
@@ -232,7 +218,7 @@ Old text.
         the start of the file.
         """
         tempdir = self.mktemp()
-        os.mkdir(tempdir)
+        os.makedirs(tempdir)
 
         definitions = {}
         fragments = split_fragments(fragments={}, definitions=definitions)