diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index bbc39fc3f0..d39e4072cf 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -231,6 +231,10 @@ Hypertext Application Language (HAL)
 Help
 - ?
 
+Help Topics
+- Amber Matz 'Amber Himes Matz' https://www.drupal.org/u/amber-himes-matz
+- Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost
+
 Image
 - Claudiu Cristea 'claudiu.cristea' https://www.drupal.org/u/claudiu.cristea
 
diff --git a/core/composer.json b/core/composer.json
index 770631ddfd..f3b8ea628c 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -123,6 +123,7 @@
         "drupal/forum": "self.version",
         "drupal/hal": "self.version",
         "drupal/help": "self.version",
+        "drupal/help_topics": "self.version",
         "drupal/history": "self.version",
         "drupal/image": "self.version",
         "drupal/inline_form_errors": "self.version",
diff --git a/core/modules/help_topics/config/install/filter.format.help.yml b/core/modules/help_topics/config/install/filter.format.help.yml
new file mode 100644
index 0000000000..ad488715ea
--- /dev/null
+++ b/core/modules/help_topics/config/install/filter.format.help.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  enforced:
+    module:
+      - help_topics
+name: Help
+format: help
+weight: 0
+filters:
+  filter_html:
+    id: filter_html
+    provider: filter
+    status: true
+    weight: -10
+    settings:
+      allowed_html: '<a href hreflang title rel target> <em> <strong> <cite> <abbr> <p> <ul> <ol> <li> <dl> <dt> <dd> <h2> <h3> <code> <pre> <img src alt data-entity-type data-entity-uuid width height> <table> <caption> <tbody> <thead> <tfoot> <th> <td> <tr>'
+      filter_html_help: true
+      filter_html_nofollow: false
diff --git a/core/modules/help_topics/help_topics.info.yml b/core/modules/help_topics/help_topics.info.yml
new file mode 100644
index 0000000000..57e822d327
--- /dev/null
+++ b/core/modules/help_topics/help_topics.info.yml
@@ -0,0 +1,9 @@
+name: Help Topics
+type: module
+description: 'Displays help topics provided by themes and modules.'
+core: 8.x
+package: Core (Experimental)
+version: VERSION
+dependencies:
+  - drupal:help
+  - drupal:filter
diff --git a/core/modules/help_topics/help_topics.module b/core/modules/help_topics/help_topics.module
new file mode 100644
index 0000000000..0b0916bf41
--- /dev/null
+++ b/core/modules/help_topics/help_topics.module
@@ -0,0 +1,48 @@
+<?php
+
+/**
+ * @file
+ * Displays help topics provided by modules and themes.
+ */
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Url;
+
+/**
+ * Implements hook_help().
+ */
+function help_topics_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.help_topics':
+      $output = '';
+      $output .= '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The Help Topics module adds module- and theme-provided help topics to the module overviews from the core Help module. For more information, see the <a href=":online">online documentation for the Help Topics module</a>.', [':online' => 'https://www.drupal.org/modules/help_topics']) . '</p>';
+      $output .= '<h3>' . t('Uses') . '</h3>';
+      $output .= '<dl>';
+      $output .= '<dt>' . t('Viewing help topics') . '</dt>';
+      $output .= '<dd>' . t('The top-level help topics are listed on the main <a href=":help_page">Help page</a>. Links to other topics, including non-top-level help topics, can be found under the "Related" heading when viewing a topic page.', [':help_page' => Url::fromRoute('help.main')->toString()]) . '</dd>';
+      $output .= '<dt>' . t('Providing help topics') . '</dt>';
+      $output .= '<dd>' . t("Modules and themes can provide help topics as YAML-file-based plugins in a project sub-directory called <em>help_topics</em>. Any file in a module or theme's <em>help_topics</em> directory with the suffix <em>*.help_topic.yml</em> will be discovered by the Help Topic module. Plugin-based help topics provided by modules and themes will automatically be updated when a module or theme is updated. It is advisable not to edit the YAML files of module- or theme-provided topics, to make updates easier. Use the plugins in <em>core/modules/help_topics/help_topics</em> as a guide when writing and formatting a help topic plugin for your theme or module.") . '</dd>';
+      $output .= '</dl>';
+      return ['#markup' => $output];
+
+    case 'help_topics.help_topic':
+      return '<p>' . t('See the <a href=":help_page">Help page</a> for more topics.', [
+        ':help_page' => Url::fromRoute('help.main')->toString(),
+      ]) . '</p>';
+  }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function help_topics_theme($existing, $type, $theme, $path) {
+  return [
+    'help_topic' => [
+      'variables' => [
+        'body' => [],
+        'related' => [],
+      ],
+    ],
+  ];
+}
diff --git a/core/modules/help_topics/help_topics.permissions.yml b/core/modules/help_topics/help_topics.permissions.yml
new file mode 100644
index 0000000000..b50976d1fb
--- /dev/null
+++ b/core/modules/help_topics/help_topics.permissions.yml
@@ -0,0 +1,2 @@
+view help topics:
+  title: 'View configured help topics'
diff --git a/core/modules/help_topics/help_topics.routing.yml b/core/modules/help_topics/help_topics.routing.yml
new file mode 100644
index 0000000000..5038440d20
--- /dev/null
+++ b/core/modules/help_topics/help_topics.routing.yml
@@ -0,0 +1,6 @@
+help_topics.help_topic:
+  path: '/admin/help/topic/{id}'
+  defaults:
+    _controller: '\Drupal\help_topics\Controller\HelpTopicPluginController::viewHelpTopic'
+  requirements:
+    _permission: 'view help topics'
diff --git a/core/modules/help_topics/help_topics.services.yml b/core/modules/help_topics/help_topics.services.yml
new file mode 100644
index 0000000000..8fd573f4b5
--- /dev/null
+++ b/core/modules/help_topics/help_topics.services.yml
@@ -0,0 +1,9 @@
+services:
+  help_topics.breadcrumb:
+    class: Drupal\help_topics\HelpBreadcrumbBuilder
+    arguments: ['@string_translation']
+    tags:
+      - { name: breadcrumb_builder, priority: 900 }
+  plugin.manager.help_topic:
+    class: Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManager
+    arguments: ['@module_handler', '@theme_handler']
diff --git a/core/modules/help_topics/help_topics.tokens.inc b/core/modules/help_topics/help_topics.tokens.inc
new file mode 100644
index 0000000000..4005e78197
--- /dev/null
+++ b/core/modules/help_topics/help_topics.tokens.inc
@@ -0,0 +1,95 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for help topics and routes.
+ */
+
+use Drupal\Core\Url;
+use Drupal\Core\Render\BubbleableMetadata;
+
+/**
+ * Implements hook_token_info().
+ */
+function help_topics_token_info() {
+  $types['route'] = [
+    'name' => t("Route information"),
+    'description' => t("Tokens based on route names."),
+  ];
+
+  $routes = [];
+  $routes['url'] = [
+    'name' => t('URL'),
+    'description' => t('The URL to the route. Provide the route name as route:url:ROUTE_NAME'),
+  ];
+
+  $types['help_topic'] = [
+    'name' => t("Help Topics"),
+    'description' => t("Tokens for help topics."),
+  ];
+
+  $topics = [];
+  $topics['url'] = [
+    'name' => t('URL'),
+    'description' => t('The URL to the topic. Provide the topic machine name as help_topic:url:MACHINE_NAME'),
+  ];
+
+  return [
+    'types' => $types,
+    'tokens' => [
+      'help_topic' => $topics,
+      'route' => $routes,
+    ],
+  ];
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function help_topics_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
+  $replacements = [];
+  $token_service = \Drupal::token();
+  /** @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface $plugin_manager */
+  $plugin_manager = \Drupal::service('plugin.manager.help_topic');
+
+  // Our tokens generate URLs, which depend on language. See if a language is
+  // passed in; if not, we will let the URL system use its own defaults.
+  $url_options = [];
+  if (isset($options['langcode'])) {
+    $language_manager = \Drupal::languageManager();
+    $language = $language_manager->getLanguage($options['langcode']);
+    if ($language) {
+      $url_options['language'] = $language;
+    }
+  }
+
+  if ($type == 'help_topic') {
+    $topics = $token_service->findWithPrefix($tokens, 'url');
+    foreach ($topics as $name => $original) {
+      /** @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface $topic */
+      if ($plugin_manager->hasDefinition($name) && $topic = $plugin_manager->createInstance($name, [])) {
+        $url = $topic->toUrl($url_options)->toString(TRUE);
+        $replacements[$original] = $url->getGeneratedUrl();
+        $bubbleable_metadata
+          ->addCacheableDependency($url)
+          ->addCacheableDependency($topic);
+      }
+    }
+  }
+  elseif ($type == 'route') {
+    $routes = $token_service->findWithPrefix($tokens, 'url');
+    foreach ($routes as $route_name => $original) {
+      try {
+        $url = Url::fromRoute($route_name, [], $url_options)->toString(TRUE);
+        $replacements[$original] = $url->getGeneratedUrl();
+        $bubbleable_metadata->addCacheableDependency($url);
+      }
+      catch (\Exception $e) {
+        // Invalid route or missing parameters or something like that.
+        // Do nothing.
+      }
+    }
+  }
+
+  return $replacements;
+}
diff --git a/core/modules/help_topics/help_topics/config_basic.help_topic.yml b/core/modules/help_topics/help_topics/config_basic.help_topic.yml
new file mode 100644
index 0000000000..6ae1debdb7
--- /dev/null
+++ b/core/modules/help_topics/help_topics/config_basic.help_topic.yml
@@ -0,0 +1,35 @@
+id: config_basic
+label: 'Changing basic site settings'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'The settings for your site are configured on various administrative pages, as follows:'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Site name, slogan, and email address'
+    prefix_tags: '<dl><dt>'
+    suffix_tags: '</dt>'
+  -
+    text: 'On the <a href="[route:url:system.site_information_settings]"><em>Basic site settings</em></a> page, which you can reach in the main <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>System</em> &gt; <em>Basic site settings</em>.'
+    prefix_tags: '<dd>'
+    suffix_tags: '</dd>'
+  -
+    text: 'Time zone and country'
+    prefix_tags: '<dt>'
+    suffix_tags: '</dt>'
+  -
+    text: 'On the <a href="[route:url:system.regional_settings]"><em>Regional settings</em></a> page, which you can reach in the main <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>Regional and language</em> &gt; <em>Regional settings</em>.'
+    prefix_tags: '<dd>'
+    suffix_tags: '</dd>'
+  -
+    text: 'Date and time formats'
+    prefix_tags: '<dt>'
+    suffix_tags: '</dt>'
+  -
+    text: 'On the <a href="[route:url:entity.date_format.collection]"><em>Date and time formats</em></a> page, which you can reach in the main <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>Regional and language</em> &gt; <em>Date and time formats</em>.'
+    prefix_tags: '<dd>'
+    suffix_tags: '</dd></dl>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/config_error.help_topic.yml b/core/modules/help_topics/help_topics/config_error.help_topic.yml
new file mode 100644
index 0000000000..19edb43d5f
--- /dev/null
+++ b/core/modules/help_topics/help_topics/config_error.help_topic.yml
@@ -0,0 +1,37 @@
+id: config_error
+label: 'Configuring error responses, including 403/404 pages'
+top_level: false
+related:
+  - config_basic
+  - maintenance
+list_on:
+  - config_basic
+  - maintenance
+  - menu_overview
+  - security
+body:
+  -
+    text: 'Configuring 403/404 pages'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'The core software provides default responses for 403 response (Not Authorized: when someone tries to visit a page they do not have permission to see) and 404 response (Not Found: when someone tries to visit a page that does not exist). You can change what page is displayed for these responses on the <a href="[route:url:system.site_information_settings]"><em>Basic site settings</em></a> page, which you can reach in the main <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>System</em> &gt; <em>Basic site settings</em>. Note that the pages you want to use must already exist as either system-provided pages or content that you have created.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Responding to software errors'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'Software errors on your site are logged, if you have a logging module installed (such as the core Database Logging module or the core Syslog module). You can configure whether or not error messages are also shown (to both administrators and other site visitors) on the <a href="[route:url:system.logging_settings]"><em>Logging and errors</em></a> configuration page, which you can reach in the main <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>Development</em> &gt; <em>Logging and errors</em>.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Viewing the site log'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'If you have the core Database Logging module installed, you can view recent error and informational messages by navigating in the main <em>Manage</em> administrative menu to <em>Reports</em> &gt; <em>Recent log messages</em>. If you are using the core Syslog module for logging, error messages will be logged in your web server''s log files.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/help_topic_writing.help_topic.yml b/core/modules/help_topics/help_topics/help_topic_writing.help_topic.yml
new file mode 100644
index 0000000000..bd098e65ad
--- /dev/null
+++ b/core/modules/help_topics/help_topics/help_topic_writing.help_topic.yml
@@ -0,0 +1,27 @@
+id: help_topic_writing
+label: 'Writing good help'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'Here are some suggestions for how to make your help topics as useful as possible for readers:'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Choose short titles. If the topic describes a task, start with a verb in -ing form, like "Writing good help".'
+    prefix_tags: '<ul><li>'
+    suffix_tags: '</li>'
+  -
+    text: 'Make your topics modular and short, using links to connect information together.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li>'
+  -
+    text: 'Use headings and lists to organize your topics.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li>'
+  -
+    text: 'Write in second person (you, your). When describing a task, use imperative mood (tell people directly what to do, such as "Enter information in the ABC field", rather than using words like "please" or more passive or declarative language like "the ABC field needs to be filled in").'
+    prefix_tags: '<li>'
+    suffix_tags: '</li></ul>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/maintenance.help_topic.yml b/core/modules/help_topics/help_topics/maintenance.help_topic.yml
new file mode 100644
index 0000000000..1f435a840e
--- /dev/null
+++ b/core/modules/help_topics/help_topics/maintenance.help_topic.yml
@@ -0,0 +1,11 @@
+id: maintenance
+label: 'Maintaining and troubleshooting your site'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'The related topics listed here will help you keep your site running and troubleshoot problems.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/menu_overview.help_topics.yml b/core/modules/help_topics/help_topics/menu_overview.help_topics.yml
new file mode 100644
index 0000000000..1a48f2968c
--- /dev/null
+++ b/core/modules/help_topics/help_topics/menu_overview.help_topics.yml
@@ -0,0 +1,11 @@
+id: menu_overview
+label: 'Defining navigation and URLs'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'The related topics listed here describe how to set up various aspects of site navigation and URLs.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/security.help_topic.yml b/core/modules/help_topics/help_topics/security.help_topic.yml
new file mode 100644
index 0000000000..3f4b749029
--- /dev/null
+++ b/core/modules/help_topics/help_topics/security.help_topic.yml
@@ -0,0 +1,11 @@
+id: security
+label: 'Making your site secure'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'The topics listed here will help you make and keep your site secure.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/security_account_settings.help_topic.yml b/core/modules/help_topics/help_topics/security_account_settings.help_topic.yml
new file mode 100644
index 0000000000..944c402adb
--- /dev/null
+++ b/core/modules/help_topics/help_topics/security_account_settings.help_topic.yml
@@ -0,0 +1,30 @@
+id: security_account_settings
+label: 'Defining how user accounts are created'
+top_level: false
+related:
+  - security
+list_on:
+  - config_basic
+  - security
+body:
+  -
+    text: 'On the <a href="[route:url:entity.user.admin_form]"><em>Account settings</em></a> page, which you can reach from the <em>Manage</em> administrative menu, by navigating to <em>Configuration</em> &gt; <em>People</em> &gt; <em>Account settings</em> (requires the <em>Administer account settings</em> permission), you can configure several settings related to how user accounts are created:'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'You can make it possible for new users to register themselves, with or without administrator approval. Or, you can make it so only administrators with <em>Administer users</em> permission can register new users.'
+    prefix_tags: '<ul><li>'
+    suffix_tags: '</li>'
+  -
+    text: 'You can require email verification of new user accounts.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li>'
+  -
+    text: 'You can enable or disable a password strength indicator, which is shown whenever passwords are being set up or changed.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li>'
+  -
+    text: 'You can edit the email messages that are sent to users in conjunction with the user registration process.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li></ul>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/ui_accessibility.help_topic.yml b/core/modules/help_topics/help_topics/ui_accessibility.help_topic.yml
new file mode 100644
index 0000000000..23c06d0eba
--- /dev/null
+++ b/core/modules/help_topics/help_topics/ui_accessibility.help_topic.yml
@@ -0,0 +1,21 @@
+id: ui_accessibility
+label: 'Accessibility features'
+top_level: false
+related:
+  - ui_components
+list_on:
+  - ui_components
+body:
+  -
+    text: 'The following features of the administrative user interface may help administrative users with disabilities access your site:'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Disabling drag-and-drop functionality'
+    prefix_tags: '<dl><dt>'
+    suffix_tags: '</dt>'
+  -
+    text: 'The default drag-and-drop user interface for ordering tables in the administrative interface presents a challenge for some users, including users of screen readers and other assistive technology. The drag-and-drop interface can be disabled in a table by clicking a link labeled <em>Show row weights</em> above the table. The replacement interface allows users to order the table by choosing numerical weights instead of dragging table rows.'
+    prefix_tags: '<dd>'
+    suffix_tags: '</dd></dl>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/ui_components.help_topic.yml b/core/modules/help_topics/help_topics/ui_components.help_topic.yml
new file mode 100644
index 0000000000..ff58918492
--- /dev/null
+++ b/core/modules/help_topics/help_topics/ui_components.help_topic.yml
@@ -0,0 +1,11 @@
+id: ui_components
+label: 'Using the administrative interface'
+top_level: true
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'The related topics listed here describe various aspects of the administrative interface, and tell how to use them.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/ui_contextual.help_topic.yml b/core/modules/help_topics/help_topics/ui_contextual.help_topic.yml
new file mode 100644
index 0000000000..21393789c9
--- /dev/null
+++ b/core/modules/help_topics/help_topics/ui_contextual.help_topic.yml
@@ -0,0 +1,37 @@
+id: ui_contextual
+label: 'Contextual links'
+top_level: false
+related:
+  - ui_components
+list_on:
+  - ui_components
+body:
+  -
+    text: 'What are contextual links?'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: '<em>Contextual links</em> give users with the <em>Use contextual links</em> permission quick access to administrative tasks related to areas of non-administrative pages. For example, if a page on your site displays a block, the block would have a contextual link that would allow users with permission to configure the block. If the block contains a menu or a view, it would also have a contextual link for editing the menu links or the view. Clicking a contextual link takes you to the related administrative page directly, without needing to navigate through the administrative menu system.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Displaying and using contextual links'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'If you have the core Contextual Links module installed, the contextual links related to an area on a page can be displayed by clicking the contextual links button in that area of the page. In most themes, this button looks like a pencil and is placed in the upper right corner of the page area (upper left for right-to-left languages); however, contextual links buttons are normally hidden. Here are two ways to make contextual links buttons visible:'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Hovering your mouse over an area on the page will temporarily make the contextual links button visible, if there is one for that area of the page. Also, in most themes, the page area that the contextual links pertain to will be outlined while you are hovering.'
+    prefix_tags: '<ul><li>'
+    suffix_tags: '</li>'
+  -
+    text: 'If you have the core Toolbar module enabled, there will be a contextual links toggle button at the far right end of the toolbar (far left end for right-to-left languages; this button looks like a pencil in most themes). Clicking the toggle button will make all the individual contextual links buttons on the page visible; clicking the toggle button again will make them invisible.'
+    prefix_tags: '<li>'
+    suffix_tags: '</li></ul>'
+  -
+    text: 'While the contextual links button for the area of interest is visible, click the button to display the list of links for that area. Click a link in the list to perform the task.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/ui_shortcuts.help_topic.yml b/core/modules/help_topics/help_topics/ui_shortcuts.help_topic.yml
new file mode 100644
index 0000000000..eac86fd023
--- /dev/null
+++ b/core/modules/help_topics/help_topics/ui_shortcuts.help_topic.yml
@@ -0,0 +1,33 @@
+id: ui_shortcuts
+label: Shortcuts
+top_level: false
+related:
+  - ui_components
+list_on:
+  - ui_components
+body:
+  -
+    text: 'What are shortcuts?'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: '<em>Shortcuts</em> are quick links to administrative pages; they are managed by the core Shortcut module. A site can have one or more <em>shortcut sets</em>, which can be shared by one or more users; each set contains one or more shortcuts. Users need <em>Use shortcuts</em> permission to view shortcuts; <em>Edit current shortcut set</em> permission to add, delete, or edit the shortcuts in the set assigned to them; and <em>Select any shortcut set</em> permission to select a different shortcut set when editing their user profile. There is also an <em>Administer shortcuts</em> permission, which allows an administrator to do any of these actions, and also permits assigning shortcut sets to other users.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Creating and deleting shortcuts'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'When viewing certain administrative pages, you will see a link that allows you to add the page to your current shortcut set. In the core Seven administrative theme, the link looks like a star, and is displayed next to the page title. If the page is already in your shortcut set, you will instead see a link that allows you to remove it.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Viewing and using shortcuts'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'If you have the core Toolbar module installed, click <em>Shortcuts</em> in the toolbar to display your shortcuts. Once they are displayed, click any link in the shortcut bar to go directly to the administrative page. If you are not using the Toolbar module, you can display shortcuts by placing the <em>Shortcuts</em> block in a region of your theme.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/help_topics/ui_tours.help_topic.yml b/core/modules/help_topics/help_topics/ui_tours.help_topic.yml
new file mode 100644
index 0000000000..2bb4cf370e
--- /dev/null
+++ b/core/modules/help_topics/help_topics/ui_tours.help_topic.yml
@@ -0,0 +1,25 @@
+id: ui_tours
+label: Tours
+top_level: false
+related:
+  - ui_components
+list_on:
+  - ui_components
+body:
+  -
+    text: 'What are tours?'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'The core Tour module provides users with <em>tours</em>, which are guided tours of the administrative interface. Each tour starts on a particular administrative page, and consists of one or more <em>tips</em> that highlight elements of the page, guide you through a workflow, or explain key concepts. Users need <em>Access tour</em> permission to view tours, and JavaScript must be enabled in their browsers.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+  -
+    text: 'Viewing tours'
+    prefix_tags: '<h2>'
+    suffix_tags: '</h2>'
+  -
+    text: 'If a tour is available on a page, and you have the core Toolbar module installed, a <em>Tour</em> button will appear on the right end of the toolbar (left end for right-to-left languages). Click this button to view the first tip of the tour; click the <em>Next</em> button to advance to the next tip, and <em>End tour</em> at the end to close the tour.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/src/Controller/HelpTopicPluginController.php b/core/modules/help_topics/src/Controller/HelpTopicPluginController.php
new file mode 100644
index 0000000000..8add59ebce
--- /dev/null
+++ b/core/modules/help_topics/src/Controller/HelpTopicPluginController.php
@@ -0,0 +1,164 @@
+<?php
+
+namespace Drupal\help_topics\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Url;
+use Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Render\RendererInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Drupal\Core\Utility\Token;
+
+/**
+ * Controller for help topic plugins.
+ */
+class HelpTopicPluginController extends ControllerBase {
+
+  /**
+   * The token replacement service class.
+   *
+   * @var \Drupal\Core\Utility\Token
+   */
+  protected $token;
+
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The Help Topic plugin manager.
+   *
+   * @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface
+   */
+  protected $helpTopicPluginManager;
+
+  /**
+   * Constructs a HelpTopicPluginController object.
+   *
+   * @param \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface $help_topic_plugin_manager
+   *   The help topic plugin manager service.
+   * @param \Drupal\Core\Utility\Token $token
+   *   The token service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
+   */
+  public function __construct(HelpTopicPluginManagerInterface $help_topic_plugin_manager, Token $token, RendererInterface $renderer) {
+    $this->helpTopicPluginManager = $help_topic_plugin_manager;
+    $this->token = $token;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.help_topic'),
+      $container->get('token'),
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * Displays a help topic page.
+   *
+   * @param string $id
+   *   The plugin id. Maps to the {id} placeholder
+   *     in the help_topics.help_topic route.
+   *
+   * @return array
+   *   A render array with the contents of a help topic page.
+   */
+  public function viewHelpTopic($id) {
+    $build = [];
+
+    /* @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface $help_topic */
+    $help_topic = $this->helpTopicPluginManager->createInstance($id);
+
+    $body = $help_topic->getBody();
+
+    $matches = [];
+    // Find things that look like tokens, but using | instead of :, for
+    // example [foo|bar_baz|bang]. Allowable characters for the token pieces
+    // are letters, numbers, underscores, plus . and - characters.
+    if (preg_match_all('/\[([\w.\-]+\|)+[\w.\-]+\]/', $body, $matches)) {
+      // Replace the | with : in each token that was found.
+      $tokens = array_unique($matches[0]);
+      $replacements = [];
+      foreach ($tokens as $token) {
+        $replacements[] = str_replace('|', ':', $token);
+      }
+      // Replace all the tokens in the string. This could have all been done
+      // with one preg_replace instead of this whole if() statement, but the
+      // code would have been very difficult to read.
+      $body = str_replace($tokens, $replacements, $body);
+    }
+
+    // Add in the body, with token replacement.
+    $bubbleable_metadata = new BubbleableMetadata();
+    $build['#body'] = [
+      '#type' => 'processed_text',
+      '#text' => $this->token->replace($body, [], [], $bubbleable_metadata),
+      '#format' => $help_topic->getBodyFormat(),
+    ];
+    $bubbleable_metadata->applyTo($build['#body']);
+
+    $this->renderer->addCacheableDependency($build, $help_topic);
+
+    // Build the related topics section, starting with the list this topic
+    // says are related.
+    $links = [];
+
+    $related = $help_topic->getRelated();
+    foreach ($related as $other_id) {
+      if ($other_id !== $help_topic->getPluginId()) {
+        /** @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface $topic */
+        $topic = $this->helpTopicPluginManager->createInstance($other_id);
+        if ($topic) {
+          $links[$other_id] = [
+            'title' => $topic->getLabel(),
+            'url' => Url::fromRoute('help_topics.help_topic', ['id' => $other_id]),
+          ];
+          $this->renderer->addCacheableDependency($build, $topic);
+        }
+      }
+    }
+
+    // Add in any plugins that have said "List me on this topic".
+    $liston = $this->helpTopicPluginManager->getAllListOn($id);
+    foreach ($liston as $topic) {
+      $other_id = $topic->getPluginId();
+      $links[$other_id] = [
+        'title' => $topic->getLabel(),
+        'url' => Url::fromRoute('help_topics.help_topic', ['id' => $other_id]),
+      ];
+      $this->renderer->addCacheableDependency($build, $topic);
+    }
+
+    if (count($links)) {
+      uasort($links, function ($a, $b) {
+        if ($a['title'] == $b['title']) {
+          return 0;
+        }
+        return ($a['title'] < $b['title']) ? -1 : 1;
+      });
+      $build['#related'] = [
+        '#theme' => 'links',
+        '#heading' => [
+          'text' => $this->t('Related topics'),
+          'level' => 'h2',
+        ],
+        '#links' => $links,
+      ];
+    }
+
+    $build['#theme'] = 'help_topic';
+    $build['#title'] = $help_topic->getLabel();
+    return $build;
+  }
+
+}
diff --git a/core/modules/help_topics/src/HelpBreadcrumbBuilder.php b/core/modules/help_topics/src/HelpBreadcrumbBuilder.php
new file mode 100644
index 0000000000..586be5303a
--- /dev/null
+++ b/core/modules/help_topics/src/HelpBreadcrumbBuilder.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\help_topics;
+
+use Drupal\Core\Breadcrumb\Breadcrumb;
+use Drupal\Core\Breadcrumb\BreadcrumbBuilderInterface;
+use Drupal\Core\Link;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Provides a breadcrumb builder for help topic pages.
+ */
+class HelpBreadcrumbBuilder implements BreadcrumbBuilderInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs the HelpBreadcrumbBuilder.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The translation service.
+   */
+  public function __construct(TranslationInterface $string_translation) {
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(RouteMatchInterface $route_match) {
+    return $route_match->getRouteName() == 'help_topics.help_topic';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build(RouteMatchInterface $route_match) {
+    $breadcrumb = new Breadcrumb();
+    $breadcrumb->addLink(Link::createFromRoute($this->t('Home'), '<front>'));
+    $breadcrumb->addLink(Link::createFromRoute($this->t('Administration'), 'system.admin'));
+    $breadcrumb->addLink(Link::createFromRoute($this->t('Help'), 'help.main'));
+    $breadcrumb->addCacheContexts(['route.name']);
+
+    return $breadcrumb;
+  }
+
+}
diff --git a/core/modules/help_topics/src/HtmlChunker.php b/core/modules/help_topics/src/HtmlChunker.php
new file mode 100644
index 0000000000..e87ee010ad
--- /dev/null
+++ b/core/modules/help_topics/src/HtmlChunker.php
@@ -0,0 +1,199 @@
+<?php
+
+namespace Drupal\help_topics;
+
+use Drupal\Component\Utility\Html;
+
+/**
+ * Utility for separating HTML into paragraph-sized chunks, and rejoining it.
+ */
+class HtmlChunker {
+
+  /**
+   * Breaks HTML into paragraph-sized chunks.
+   *
+   * @param string $html
+   *   HTML to break into chunks.
+   *
+   * @return array|false
+   *   FALSE if the HTML is invalid, and an empty array if $html is empty.
+   *   Otherwise, an array of paragraph-sized chunks. Each element of the array
+   *   is an array with elements:
+   *   - text: Text content of the chunk, which may contain HTML.
+   *   - prefix_tags: HTML tags that go before the text content.
+   *   - suffix_tags: HTML tags that go after the text content.
+   *
+   * @see \Drupal\help_topics\HtmlChunker::joinChunks()
+   */
+  public static function chunkHtml($html) {
+    $dom = Html::load($html);
+    if (!$dom) {
+      return FALSE;
+    }
+
+    $chunks = [];
+    $body_node = $dom->getElementsByTagName('body')->item(0);
+    if ($body_node) {
+      $chunks = self::chunkDomNode($dom, $body_node);
+    }
+    return $chunks;
+  }
+
+  /**
+   * Joins chunks into an HTML string.
+   *
+   * @param array $chunks
+   *   Array of chunks, each of which is an array with elements:
+   *   - text: Text content of the chunk, which may contain HTML.
+   *   - prefix_tags: HTML tags that go before the text content.
+   *   - suffix_tags: HTML tags that go after the text content.
+   *
+   * @return string
+   *   HTML string containing all of the chunks.
+   *
+   * @see \Drupal\help_topics\HtmlChunker::chunkHtml()
+   */
+  public static function joinChunks(array $chunks) {
+    $text = '';
+    foreach ($chunks as $chunk) {
+      $text .= $chunk['prefix_tags'] . $chunk['text'] . $chunk['suffix_tags'];
+    }
+    return $text;
+  }
+
+  /**
+   * Splits a DOM node recursively into chunks.
+   *
+   * @param \DOMDocument $dom
+   *   The DOM document this came from.
+   * @param \DOMNode $node
+   *   DOM node to split into chunks.
+   * @param string $prefix
+   *   (optional) Prefix tags to put on first chunk.
+   * @param string $suffix
+   *   (optional) Suffix tags to put on last chunk.
+   *
+   * @return array
+   *   Array of chunks from $node. Each chunk is an array with elements:
+   *   - text: Text content of the chunk, which may contain HTML.
+   *   - prefix_tags: HTML tags that go before the text content.
+   *   - suffix_tags: HTML tags that go after the text content.
+   *   The intent is that if you concatenate all the chunks' prefix_tags,
+   *   text, and suffix_tags, you will end up with the original HTML.
+   */
+  protected static function chunkDomNode(\DOMDocument $dom, \DOMNode $node, $prefix = '', $suffix = '') {
+
+    // These HTML tags generate an automatic chunk.
+    $chunk_tags = [
+      'p',
+      'h1',
+      'h2',
+      'h3',
+      'h4',
+      'h5',
+      'h6',
+      'li',
+      'dt',
+      'dd',
+      'code',
+      'pre',
+      'blockquote',
+      'td',
+      'th',
+      'caption',
+    ];
+
+    $chunks = [];
+    foreach ($node->childNodes as $child) {
+      switch ($child->nodeType) {
+        case XML_ELEMENT_NODE:
+          if (!$child->hasChildNodes()) {
+            // It's an empty element, so just add it to the prefix for the
+            // next chunk.
+            $prefix .= trim($dom->saveXML($child));
+          }
+          else {
+            $open_tag = '<' . $child->tagName;
+            if ($child->hasAttributes()) {
+              foreach ($child->attributes as $attr) {
+                $open_tag .= $dom->saveXML($attr);
+              }
+            }
+            $open_tag .= '>';
+            $close_tag = '</' . $child->tagName . '>';
+
+            if (in_array($child->tagName, $chunk_tags)) {
+              // Don't go deeper, just save everything this node contains
+              // as one chunk.
+              $text = '';
+              foreach ($child->childNodes as $inner) {
+                $text .= $dom->saveXML($inner);
+              }
+              $chunks[] = [
+                'text' => $text,
+                'prefix_tags' => $prefix . $open_tag,
+                'suffix_tags' => $close_tag,
+              ];
+              $prefix = '';
+            }
+            else {
+              // Recursively generate chunks from this node's children.
+              $new_chunks = self::chunkDomNode($dom, $child, $prefix . $open_tag, $close_tag);
+              $chunks = array_merge($chunks, $new_chunks);
+              $prefix = '';
+            }
+          }
+          break;
+
+        case XML_TEXT_NODE:
+          // Add non-empty text nodes as their own chunks. Some text nodes
+          // are purely inter-tag whitespace; leave those out.
+          $text = $dom->saveXML($child);
+          // For some reason, these text nodes ar often containing the
+          // carriage return character as an entity, so decode that
+          // specifically.
+          $text = str_replace('&#13;', "\r", $text);
+          if (trim($text)) {
+            $chunks[] = [
+              'text' => $text,
+              'prefix_tags' => $prefix,
+              'suffix_tags' => '',
+            ];
+            $prefix = '';
+          }
+          break;
+
+        default:
+          // For nodes that are anything except elements or text, just add
+          // them to the prefix we are working on. These are things like HTML
+          // comments, for example.
+          $prefix .= trim($dom->saveXML($child));
+          break;
+      }
+    }
+
+    // If we have prefix left over, add it as a chunk.
+    if ($prefix) {
+      $chunks[] = [
+        'text' => '',
+        'prefix_tags' => $prefix,
+        'suffix_tags' => '',
+      ];
+    }
+
+    // If we have a suffix left over, add it to the last chunk.
+    if ($suffix) {
+      if (!$chunks) {
+        $chunks[] = [
+          'text' => '',
+          'prefix_tags' => '',
+          'suffix_tags' => '',
+        ];
+      }
+      $chunks[count($chunks) - 1]['suffix_tags'] .= $suffix;
+    }
+
+    return $chunks;
+  }
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpSection/HelpTopicSection.php b/core/modules/help_topics/src/Plugin/HelpSection/HelpTopicSection.php
new file mode 100644
index 0000000000..8dfabf577e
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpSection/HelpTopicSection.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpSection;
+
+use Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\help\Plugin\HelpSection\HelpSectionPluginBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides the help topics list section for the help page.
+ *
+ * @HelpSection(
+ *   id = "help_topics",
+ *   title = @Translation("Topics"),
+ *   description = @Translation("Topics can be provided by modules or themes. Top-level help topics configured on your site:"),
+ *   permission = "view help topics"
+ * )
+ */
+class HelpTopicSection extends HelpSectionPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The plugin manager.
+   *
+   * @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * The render array for the list of topics.
+   *
+   * @var array
+   */
+  protected $topicList;
+
+  /**
+   * The cache tags for the list of topics.
+   *
+   * @var string[]
+   */
+  protected $cacheTagList;
+
+  /**
+   * Constructs a HelpTopicSection object.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface $plugin_manager
+   *   The help topic plugin manager service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, HelpTopicPluginManagerInterface $plugin_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('plugin.manager.help_topic')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    if (!isset($this->topicList)) {
+      $this->calculateTopics();
+    }
+
+    return $this->cacheTagList;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    // The links are checked for user access, so we need the user permissions
+    // context.
+    return ['user.permissions'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listTopics() {
+    if (!isset($this->topicList)) {
+      $this->calculateTopics();
+    }
+
+    return $this->topicList;
+  }
+
+  /**
+   * Calculates the topic list and cache tags.
+   */
+  protected function calculateTopics() {
+    /** @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface[] $plugins */
+    $plugins = $this->pluginManager->getTopLevelTopics();
+
+    $this->topicList = [];
+    $cache_tags = [];
+    foreach ($plugins as $plugin) {
+      $this->topicList[$plugin->getPluginId()] = $plugin->toLink();
+      foreach ($plugin->getCacheTagsForList() as $tag) {
+        $cache_tags[] = $tag;
+      }
+    }
+
+    $this->cacheTagList = array_unique($cache_tags);
+  }
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicDefaultPlugin.php b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicDefaultPlugin.php
new file mode 100644
index 0000000000..ae99d447af
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicDefaultPlugin.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpTopic;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Represents a help topic plugin whose definition comes from a YAML file.
+ *
+ * The YAML files are stored in subdirectory help_topics, and must be named
+ * id.help_topic.yaml, where id is the plugin ID.
+ */
+class HelpTopicDefaultPlugin extends HelpTopicPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * Constructs a HelpTopicDefaultPlugin.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+   *   The dependency injection container.
+   * @param array $configuration
+   *   The plugin configuration (not actually used).
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param array $plugin_definition
+   *   The plugin definition.
+   */
+  public function __construct(ContainerInterface $container, array $configuration, $plugin_id, array $plugin_definition) {
+    $this->stringTranslation = $container->get('string_translation');
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+  }
+
+  /**
+   * Creates a HelpTopicDefaultPlugin.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+   *   The dependency injection container.
+   * @param array $configuration
+   *   The plugin configuration (not actually used).
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param array $plugin_definition
+   *   The plugin definition.
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static($container, $configuration, $plugin_id, $plugin_definition);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLabel() {
+    return new TranslatableMarkup(parent::getLabel(), [], []);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getBody() {
+    return new TranslatableMarkup(parent::getBody(), [], []);
+  }
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginBase.php b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginBase.php
new file mode 100644
index 0000000000..72a231f0b7
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginBase.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpTopic;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Link;
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\Core\Url;
+use Drupal\help_topics\HtmlChunker;
+
+/**
+ * Base class for help topic plugins.
+ */
+abstract class HelpTopicPluginBase extends PluginBase implements HelpTopicPluginInterface {
+
+  /**
+   * The help topic plugin manager.
+   *
+   * @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * The name of the module or theme providing the help topic.
+   */
+  public function getProvider() {
+    return $this->pluginDefinition['provider'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLabel() {
+    return $this->pluginDefinition['label'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getBody() {
+    return HtmlChunker::joinChunks($this->pluginDefinition['body']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getBodyFormat() {
+    return $this->pluginDefinition['body_format'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isTopLevel() {
+    return $this->pluginDefinition['top_level'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRelated() {
+    return $this->pluginDefinition['related'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getListOn() {
+    return $this->pluginDefinition['list_on'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return Cache::PERMANENT;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return [];
+  }
+
+  /**
+   * Gets the help topic plugin manager.
+   *
+   * @return \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginManagerInterface
+   *   The help topic plugin manager.
+   */
+  protected function getPluginManager() {
+    if (!isset($this->pluginManager)) {
+      $this->pluginManager = \Drupal::service('plugin.manager.help_topic');
+    }
+    return $this->pluginManager;
+  }
+
+  /**
+   * Makes a cache tag from a help topic plugin ID.
+   *
+   * @param string $id
+   *   The plugin ID to make a cache tag from.
+   *
+   * @return string|null
+   *   The main cache tag for the topic, or NULL if there is not one.
+   */
+  protected function makeCacheTag($id) {
+    $definition = $this->getPluginManager()->getDefinition($id);
+    if (!$definition) {
+      return NULL;
+    }
+
+    return $definition['cache_tag'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    $tags = [];
+    if ($this->pluginDefinition['cache_tag']) {
+      $tags[] = $this->pluginDefinition['cache_tag'];
+    }
+
+    foreach ($this->pluginDefinition['related'] as $topic) {
+      $tag = $this->makeCacheTag($topic);
+      if ($tag) {
+        $tags[] = $tag;
+      }
+    }
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTagsForList() {
+    $tags = [];
+    // By default, there are no list cache tags, but we do want the cache
+    // tags for this plugin.
+    if ($this->pluginDefinition['cache_tag']) {
+      $tags[] = $this->pluginDefinition['cache_tag'];
+    }
+
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toUrl(array $options = []) {
+    return Url::fromRoute('help_topics.help_topic', ['id' => $this->getPluginId()], $options);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toLink($text = NULL, array $options = []) {
+    if (!$text) {
+      $text = $this->getLabel();
+    }
+    return Link::createFromRoute($text, 'help_topics.help_topic', ['id' => $this->getPluginId()], $options);
+  }
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginInterface.php b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginInterface.php
new file mode 100644
index 0000000000..51f110bd18
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginInterface.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpTopic;
+
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Component\Plugin\DerivativeInspectionInterface;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+
+/**
+ * Defines an interface for help topic plugin classes.
+ */
+interface HelpTopicPluginInterface extends PluginInspectionInterface, DerivativeInspectionInterface, CacheableDependencyInterface {
+
+  /**
+   * Returns the translated label of the topic.
+   *
+   * @return string
+   *   The label of the topic in a TranslatableMarkup object.
+   */
+  public function getLabel();
+
+  /**
+   * Returns the translated body of the topic.
+   *
+   * @return string
+   *   The HTML-formatted body of the topic in a TranslatableMarkup object.
+   */
+  public function getBody();
+
+  /**
+   * Returns text format for the body of the topic.
+   *
+   * @return string
+   *   The machine name of the text format for the topic body.
+   */
+  public function getBodyFormat();
+
+  /**
+   * Returns whether this is a top-level topic or not.
+   *
+   * @return bool
+   *   TRUE if this is a topic that should be displayed on the Help topics
+   *   list; FALSE if not.
+   */
+  public function isTopLevel();
+
+  /**
+   * Returns the IDs of related topics.
+   *
+   * @return string[]
+   *   Array of the IDs of related topics.
+   */
+  public function getRelated();
+
+  /**
+   * Returns the IDs of topics this should be listed on.
+   *
+   * @return string[]
+   *   Array of the IDs of topics that should list this one as "related".
+   */
+  public function getListOn();
+
+  /**
+   * Returns the URL for viewing the help topic.
+   *
+   * @param array $options
+   *   (optional) See
+   *   \Drupal\Core\Routing\UrlGeneratorInterface::generateFromRoute() for the
+   *    available options.
+   *
+   * @return \Drupal\Core\Url
+   *   A URL object containing the URL for viewing the help topic.
+   */
+  public function toUrl(array $options = []);
+
+  /**
+   * Returns a link for viewing the help topic.
+   *
+   * @param string|null $text
+   *   (optional) Link text to use for the link. If NULL, defaults to the
+   *   topic title.
+   * @param array $options
+   *   (optional) See
+   *   \Drupal\Core\Routing\UrlGeneratorInterface::generateFromRoute() for the
+   *    available options.
+   *
+   * @return \Drupal\Core\Link
+   *   A link object for viewing the topic.
+   */
+  public function toLink($text = NULL, array $options = []);
+
+  /**
+   * Returns the cache tags appropriate for listings of plugins of this type.
+   *
+   * @return string[]
+   *   Array of cache tags.
+   */
+  public function getCacheTagsForList();
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManager.php b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManager.php
new file mode 100644
index 0000000000..f5e4720d88
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManager.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpTopic;
+
+use Drupal\Core\Plugin\Discovery\YamlDirectoryDiscovery;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Extension\ThemeHandlerInterface;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Plugin\Factory\ContainerFactory;
+
+/**
+ * Provides the default help_topic manager.
+ *
+ * Modules and themes can provide help topics in YAML files called
+ * name_of_topic.help_topic.yml inside the module or theme sub-directory
+ * help_topics.
+ */
+class HelpTopicPluginManager extends DefaultPluginManager implements HelpTopicPluginManagerInterface {
+
+  /**
+   * Provides default values for all help_topic plugins.
+   *
+   * @var array
+   */
+  protected $defaults = [
+    // The plugin ID. Set by the plugin system based on the top-level YAML key.
+    'id' => '',
+    // The title of the help topic plugin.
+    'label' => '',
+    // Whether or not the topic should appear on the help topics list.
+    'top_level' => '',
+    // List of related topic machine names.
+    'related' => [],
+    // List of topics this one should be listed on.
+    'list_on' => [],
+    // The unprocessed topic body text.
+    'body' => [],
+    // The machine name of the text format for the body in HTML form.
+    'body_format' => [],
+    'class' => 'Drupal\help_topics\Plugin\HelpTopic\HelpTopicDefaultPlugin',
+    'cache_tag' => '',
+  ];
+
+  /**
+   * The object that discovers plugins managed by this manager.
+   *
+   * @var \Drupal\Component\Plugin\Discovery\DiscoveryInterface
+   */
+  protected $discovery;
+
+  /**
+   * The object that instantiates plugins managed by this manager.
+   *
+   * @var \Drupal\Component\Plugin\Factory\FactoryInterface
+   */
+  protected $factory;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The theme handler.
+   *
+   * @var \Drupal\Core\Extension\ThemeHandlerInterface
+   */
+  protected $themeHandler;
+
+  /**
+   * Constructs a new HelpTopicManager object.
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
+   *   The theme handler.
+   */
+  public function __construct(ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler) {
+    $this->moduleHandler = $module_handler;
+    $this->themeHandler = $theme_handler;
+  }
+
+  /**
+   * Performs extra processing on plugin definitions.
+   *
+   * By default we add defaults for the type to the definition. If a type has
+   * additional processing logic, the logic can be added by replacing or
+   * extending this method.
+   *
+   * @param array $definition
+   *   The definition to be processed and modified by reference.
+   * @param string $plugin_id
+   *   The ID of the plugin this definition is being used for.
+   */
+  public function processDefinition(array &$definition, $plugin_id) {
+    $definition = NestedArray::mergeDeep($this->defaults, $definition);
+    $definition['id'] = $plugin_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getDiscovery() {
+    if (!isset($this->discovery)) {
+      // We want to find help topic plugins in both modules and themes in
+      // a sub-directory called help_topics.
+      $directories = array_merge($this->moduleHandler->getModuleDirectories(), $this->themeHandler->getThemeDirectories());
+
+      $directories = array_map(function ($dir) {
+        return [$dir . '/help_topics'];
+      }, $directories);
+
+      $file_cache_key_suffix = 'help_topic';
+      $id_key = 'id';
+      $this->discovery = new YamlDirectoryDiscovery($directories, $file_cache_key_suffix, $id_key);
+    }
+    return $this->discovery;
+  }
+
+  /**
+   * Gets the plugin factory.
+   *
+   * @return \Drupal\Component\Plugin\Factory\FactoryInterface
+   *   An instance of the ContainerFactory object.
+   */
+  protected function getFactory() {
+    if (!isset($this->factory)) {
+      $this->factory = new ContainerFactory($this);
+    }
+    return $this->factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefinitions() {
+    // Since this function is called rarely, instantiate the discovery here.
+    // This finds all the help_topic plugins in theme and module directories.
+    $definitions = $this->getDiscovery()->getDefinitions();
+
+    foreach ($definitions as $plugin_id => &$definition) {
+      $definition['id'] = $plugin_id;
+      $this->processDefinition($definition, $plugin_id);
+    }
+
+    // If this plugin was provided by a module that does not exist, remove the
+    // plugin definition.
+    // @todo Address what to do with an invalid plugin.
+    //   https://www.drupal.org/node/2302623
+    foreach ($definitions as $plugin_id => $plugin_definition) {
+      if (!empty($plugin_definition['provider']) && !$this->moduleHandler->moduleExists($plugin_definition['provider']) &&
+        (!$this->themeHandler->themeExists($plugin_definition['provider']))) {
+        unset($definitions[$plugin_id]);
+      }
+    }
+    $this->definitions = $definitions;
+    return $definitions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTopLevelTopics() {
+    $topics = [];
+
+    foreach ($this->getDefinitions() as $definition) {
+      if ($definition['top_level']) {
+        /* @var \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface $topic */
+
+        $topic = $this->createInstance($definition['id']);
+        $label = (string) $topic->getLabel();
+        $topics[$label] = $topic;
+      }
+    }
+
+    ksort($topics);
+    return $topics;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllListOn($id) {
+    $topics = [];
+
+    foreach ($this->getDefinitions() as $definition) {
+      if (in_array($id, $definition['list_on'])) {
+        $topics[] = $this->createInstance($definition['id']);
+      }
+    }
+
+    return $topics;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function findMatches($text) {
+    $topics = [];
+    foreach ($this->getDefinitions() as $definition) {
+      if ((stripos($definition['id'], $text) !== FALSE) ||
+        (stripos($definition['label'], $text) !== FALSE)) {
+        $topics[$definition['label']] = $definition['id'];
+      }
+    }
+
+    ksort($topics);
+    return $topics;
+  }
+
+}
diff --git a/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManagerInterface.php b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManagerInterface.php
new file mode 100644
index 0000000000..d4906b8444
--- /dev/null
+++ b/core/modules/help_topics/src/Plugin/HelpTopic/HelpTopicPluginManagerInterface.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\help_topics\Plugin\HelpTopic;
+
+use Drupal\Component\Plugin\PluginManagerInterface;
+
+/**
+ * Defines an interface for managing help topics and storing their definitions.
+ */
+interface HelpTopicPluginManagerInterface extends PluginManagerInterface {
+
+  /**
+   * Returns a list of the top-level topics.
+   *
+   * @return \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface[]
+   *   Array of all of the help topics that are marked as top-level, sorted
+   *   by topic title.
+   */
+  public function getTopLevelTopics();
+
+  /**
+   * Returns a list of topics to list on the given topic.
+   *
+   * @param string $id
+   *   ID of the topic plugin to check.
+   *
+   * @return \Drupal\help_topics\Plugin\HelpTopic\HelpTopicPluginInterface[]
+   *   Array of all of the help topics that have $id in their List On property.
+   */
+  public function getAllListOn($id);
+
+  /**
+   * Returns a list of autocomplete matches for the given text.
+   *
+   * @param string $text
+   *   Text to match.
+   *
+   * @return string[]
+   *   Array of matching topic IDs, keyed by topic title, where $text matches
+   *   a substring of the title (in its base language) or ID, with case-
+   *   insensitive matching. Returned in alphabetic order by title.
+   */
+  public function findMatches($text);
+
+}
diff --git a/core/modules/help_topics/templates/help-topic.html.twig b/core/modules/help_topics/templates/help-topic.html.twig
new file mode 100644
index 0000000000..49c8f2550f
--- /dev/null
+++ b/core/modules/help_topics/templates/help-topic.html.twig
@@ -0,0 +1,16 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a help topic.
+ *
+ * Available variables:
+ *  - body: The body of the topic.
+ *  - related: List of related topic links.
+ *
+ * @ingroup themeable
+ */
+#}
+<article>
+  {{ body }}
+  {{ related }}
+</article>
diff --git a/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test.yml b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test.yml
new file mode 100644
index 0000000000..d4c800a4bd
--- /dev/null
+++ b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test.yml
@@ -0,0 +1,12 @@
+id: help_test
+label: 'ABC Help Test module'
+top_level: true
+related:
+  - help_test_linked
+list_on: {  }
+body:
+  -
+    text: 'This is a test. It should <a href="[help_topic:url:help_topic_writing]">link to the writing good help topic</a>. Also there should be a related topic link below to the Help module topic page and the linked topic.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_additional.yml b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_additional.yml
new file mode 100644
index 0000000000..704fd3786d
--- /dev/null
+++ b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_additional.yml
@@ -0,0 +1,12 @@
+id: help_test_additional
+label: 'Additional topic'
+top_level: false
+related: {  }
+list_on:
+  - help_test
+body:
+  -
+    text: 'This topic should get listed automatically on the Help test topic.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_linked.yml b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_linked.yml
new file mode 100644
index 0000000000..62e6ab1f18
--- /dev/null
+++ b/core/modules/help_topics/tests/modules/help_topics_test/help_topics/help_topics.topic.help_test_linked.yml
@@ -0,0 +1,11 @@
+id: help_test_linked
+label: 'Linked topic'
+top_level: false
+related: {  }
+list_on: {  }
+body:
+  -
+    text: 'This topic is not supposed to be top-level.'
+    prefix_tags: '<p>'
+    suffix_tags: '</p>'
+body_format: help
diff --git a/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.info.yml b/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.info.yml
new file mode 100644
index 0000000000..155c43d565
--- /dev/null
+++ b/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.info.yml
@@ -0,0 +1,7 @@
+# The name of this module is deliberately different from its machine
+# name to test the presented order of help topics.
+name: 'ABC Help Test'
+type: module
+description: 'Support module for help testing.'
+package: Testing
+core: 8.x
diff --git a/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.module b/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.module
new file mode 100644
index 0000000000..a6724446cf
--- /dev/null
+++ b/core/modules/help_topics/tests/modules/help_topics_test/help_topics_test.module
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @file
+ * Test module for help.
+ */
+
+use Drupal\Core\Routing\RouteMatchInterface;
+
+/**
+ * Implements hook_help().
+ */
+function help_topics_test_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.help_topics_test':
+      return 'Some kind of non-empty output for testing';
+  }
+}
diff --git a/core/modules/help_topics/tests/src/Functional/HelpTopicTest.php b/core/modules/help_topics/tests/src/Functional/HelpTopicTest.php
new file mode 100644
index 0000000000..94969a2a31
--- /dev/null
+++ b/core/modules/help_topics/tests/src/Functional/HelpTopicTest.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Drupal\Tests\help_topics\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Verifies help topic display and user access to help based on permissions.
+ *
+ * @group help
+ */
+class HelpTopicTest extends BrowserTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'help_topics_test',
+    'help',
+    'help_topics',
+    'block',
+    'filter',
+  ];
+
+  /**
+   * The admin user that will be created.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+
+  /**
+   * The anonymous user that will be created.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $anyUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // These tests rely on some markup from the 'Seven' theme.
+    \Drupal::service('theme_handler')->install(['seven']);
+    \Drupal::service('config.factory')->getEditable('system.theme')->set('admin', 'seven')->save();
+
+    // Place various blocks.
+    $settings = [
+      'theme' => 'seven',
+      'region' => 'help',
+    ];
+    $this->placeBlock('help_block', $settings);
+    $this->placeBlock('local_tasks_block', $settings);
+    $this->placeBlock('local_actions_block', $settings);
+    $this->placeBlock('page_title_block', $settings);
+
+    // Create users.
+    $this->adminUser = $this->createUser([
+      'access administration pages',
+      'view the administration theme',
+      'administer permissions',
+      'view help topics',
+    ]);
+    $this->anyUser = $this->createUser([]);
+  }
+
+  /**
+   * Tests the main help page and individual pages for topics.
+   */
+  public function testHelp() {
+    // Log in the regular user.
+    $this->drupalLogin($this->anyUser);
+    $this->verifyHelp(403, FALSE);
+
+    // Log in the admin user.
+    $this->drupalLogin($this->adminUser);
+    $this->verifyHelp();
+    $this->verifyHelpLinks();
+
+    // Verify that help topics text appears on admin/help.
+    $this->drupalGet('admin/help');
+    $session = $this->assertSession();
+    $session->responseContains('<h2>Topics</h2>');
+    $session->pageTextContains('Topics can be provided by modules or themes');
+
+    // Verify the cache tag for the cache context for user permissions.
+    $this->assertCacheContext('user.permissions');
+
+    // Verify links for for help topics and order.
+    $page_text = $this->getTextContent();
+    $start = strpos($page_text, 'Topics can be provided');
+    $pos = $start;
+    foreach ($this->getTopicList() as $info) {
+      $name = $info['name'];
+      $session->linkExists($name);
+      $new_pos = strpos($page_text, $name, $start);
+      $this->assertTrue($new_pos > $pos, 'Order of ' . $name . ' is correct on page');
+      $pos = $new_pos;
+    }
+
+    // Uninstall the test module and verify the topics are gone, after
+    // reloading page.
+    $this->container->get('module_installer')->uninstall(['help_topics_test']);
+    $this->drupalGet('admin/help');
+    $session = $this->assertSession();
+    $session->linkNotExists('ABC Help Test module');
+    $session->linkNotExists('ABC Help Test');
+  }
+
+  /**
+   * Verifies the logged in user has access to various help links and pages.
+   *
+   * @param int $response
+   *   (optional) The HTTP response code to test for. If it's 200 (default),
+   *   the test verifies the user sees the help; if it's not, it verifies they
+   *   are denied access.
+   * @param bool $check_tags
+   *   (optional) TRUE (default) to verify that the cache tags are on the page,
+   *   even though the response is not a 200. Cache tags should be there except
+   *   for users who don't even have 'view help topics' permission.
+   */
+  protected function verifyHelp($response = 200, $check_tags = TRUE) {
+    // Verify access to help topic pages.
+    foreach ($this->getTopicList() as $topic => $info) {
+      // View help topic page.
+      $this->drupalGet('admin/help/topic/' . $topic);
+      $session = $this->assertSession();
+      $session->statusCodeEquals($response);
+      if ($response == 200) {
+        $name = $info['name'];
+        $session->titleEquals($name . ' | Drupal');
+        $session->responseContains('<h1 class="page-title">' . $name . '</h1>');
+        // Check the cache tags.
+        foreach ($info['cache_tags'] as $tag) {
+          $this->assertCacheTag($tag);
+        }
+      }
+      elseif ($check_tags) {
+        // Check that the cache tags are there.
+        foreach ($info['cache_tags'] as $tag) {
+          $this->assertCacheTag($tag);
+        }
+      }
+    }
+  }
+
+  /**
+   * Verifies links on the test help topic page and other pages.
+   *
+   * Assumes an admin user is logged in.
+   */
+  protected function verifyHelpLinks() {
+    // Verify links on the test top-level page.
+    $page = 'admin/help/topic/help_test';
+    $links = [
+      'link to the writing good help topic' => 'Writing good help',
+      'Linked topic' => 'This topic is not supposed to be top-level',
+      'Additional topic' => 'This topic should get listed automatically',
+    ];
+    foreach ($links as $link_text => $page_text) {
+      $this->drupalGet($page);
+      $this->clickLink($link_text);
+      $session = $this->assertSession();
+      $session->pageTextContains($page_text);
+    }
+
+    // Verify that the non-top-level topics do not appear on the Help page.
+    $this->drupalGet('admin/help');
+    $session = $this->assertSession();
+    $session->linkNotExists('Linked topic');
+    $session->linkNotExists('Additional topic');
+  }
+
+  /**
+   * Gets a list of topic IDs to test.
+   *
+   * @return array
+   *   A list of topics to test, in the order in which they should appear. The
+   *   keys are the machine names of the topics. The values are arrays with the
+   *   following elements:
+   *   - name: Displayed name.
+   *   - cache_tags: Cache tags to verify are present on the topic display page.
+   */
+  protected function getTopicList() {
+    return [
+      'help_test' => [
+        'name' => 'ABC Help Test module',
+        'cache_tags' => [
+          'config:filter.format.help',
+        ],
+      ],
+      'help_topic_writing' => [
+        'name' => 'Writing good help',
+        'cache_tags' => [
+          'config:filter.format.help',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Asserts that a given cache context exists.
+   *
+   * This was part of the Simpletest base class, but doesn't exist in
+   * BrowserTestBase.
+   */
+  protected function assertCacheContext($expected_cache_context) {
+    $contexts = explode(' ', $this->getSession()->getResponseHeader('X-Drupal-Cache-Contexts'));
+    $this->assertTrue(in_array($expected_cache_context, $contexts), "'" . $expected_cache_context . "' is present in the X-Drupal-Cache-Contexts header.");
+  }
+
+  /**
+   * Asserts that a given cache tag exists.
+   *
+   * This was part of the Simpletest base class, but doesn't exist in
+   * BrowserTestBase except in a legacy trait.
+   */
+  protected function assertCacheTag($expected_cache_tag) {
+    $tags = explode(' ', $this->getSession()->getResponseHeader('X-Drupal-Cache-Tags'));
+    $this->assertTrue(in_array($expected_cache_tag, $tags), "'" . $expected_cache_tag . "' is present in the X-Drupal-Cache-Tags header.");
+  }
+
+}
diff --git a/core/modules/help_topics/tests/src/Kernel/HelpTopicTokensTest.php b/core/modules/help_topics/tests/src/Kernel/HelpTopicTokensTest.php
new file mode 100644
index 0000000000..695d3422e4
--- /dev/null
+++ b/core/modules/help_topics/tests/src/Kernel/HelpTopicTokensTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\Tests\help_topics\Kernel;
+
+use Drupal\Core\Url;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+
+/**
+ * Tests token generation for help topics.
+ *
+ * @group help
+ */
+class HelpTopicTokensTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'system',
+    'help_topics',
+    'help_topics_test',
+    'user',
+    'help',
+    'filter',
+    'language',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installSchema('system', ['router', 'sequences']);
+
+    // Set up as multi-lingual with English and Spanish, so that we can
+    // test for cache metadata from URL generation.
+    $this->installConfig(['help_topics', 'help_topics_test', 'language']);
+    $this->installEntitySchema('configurable_language');
+    ConfigurableLanguage::create(['id' => 'es'])->save();
+    $this
+      ->config('language.negotiation')
+      ->set('url.prefixes', [
+        'en' => 'en',
+        'es' => 'es',
+      ])
+      ->save();
+    \Drupal::service('kernel')->rebuildContainer();
+    \Drupal::service('router.builder')->rebuild();
+  }
+
+  /**
+   * Tests that help topic tokens work.
+   */
+  public function testHelpTopicTokens() {
+    // Verify a URL token for a help topic that is a plugin.
+    $bubbleable_metadata = new BubbleableMetadata();
+    $text = 'This should <a href="[help_topic:url:help_topic_writing]">Link to help topic</a>';
+    $replaced = \Drupal::token()->replace($text, [], [], $bubbleable_metadata);
+    $this->assertTrue(strpos($replaced, '<a href="' . Url::fromRoute('help_topics.help_topic', ['id' => 'help_topic_writing'])->toString() . '"') !== FALSE, 'Topic URL token replacement worked');
+
+    // Check for cache metadata. It should have a cache context for language
+    // negotiation. As this is a plugin, there is no cache tag for the topic.
+    $contexts = $bubbleable_metadata->getCacheContexts();
+    $this->assertTrue(in_array('languages:language_url', $contexts, 'Language negotiation cache context was added'));
+
+    // Verify correct URL if we tell the Token system to use Spanish.
+    $bubbleable_metadata = new BubbleableMetadata();
+    $replaced = \Drupal::token()->replace($text, [], ['langcode' => 'es'], $bubbleable_metadata);
+    $spanish = \Drupal::languageManager()->getLanguage('es');
+    $this->assertTrue(strpos($replaced, '<a href="' . Url::fromRoute('help_topics.help_topic', ['id' => 'help_topic_writing'], ['language' => $spanish])->toString() . '"') !== FALSE, 'Topic URL token replacement worked in Spanish');
+
+    // Verify that replacement does not happen for topic that does not exist.
+    $text = 'This should <a href="[help_topic:url:nonexistent]">Not link to help topic</a>';
+    $replaced = \Drupal::token()->replace($text);
+    $this->assertTrue(strpos($replaced, '[help_topic:url:nonexistent]') !== FALSE, 'Nonexistent help topic did not get replaced');
+
+    // Check for cache metadata. It should have a cache context for language
+    // negotiation and a tag for the text format.
+    $tags = $bubbleable_metadata->getCacheTags();
+    $contexts = $bubbleable_metadata->getCacheContexts();
+    $this->assertTrue(in_array('languages:language_url', $contexts, 'Language negotiation cache context was added'));
+  }
+
+  /**
+   * Tests that route tokens work.
+   */
+  public function testRouteTokens() {
+    // Verify correct URL for a system route.
+    $bubbleable_metadata = new BubbleableMetadata();
+    $text = 'This should <a href="[route:url:system.admin]">Link to admin</a>';
+    $replaced = \Drupal::token()->replace($text, [], [], $bubbleable_metadata);
+    $this->assertTrue(strpos($replaced, '<a href="' . Url::fromRoute('system.admin')->toString() . '"') !== FALSE, 'Route token was replaced correctly');
+
+    // Check for cache metadata for language negotiation.
+    $contexts = $bubbleable_metadata->getCacheContexts();
+    $this->assertTrue(in_array('languages:language_url', $contexts, 'Language negotiation cache context was added'));
+
+    // Verify there is no replacement for an invalid route.
+    $text = 'This should <a href="[route:url:system.nonexistent]">Not link to admin</a>';
+    $replaced = \Drupal::token()->replace($text);
+    $this->assertTrue(strpos($replaced, '[route:url:system.nonexistent]') !== FALSE, 'Nonexistent route was not replaced');
+  }
+
+}
