Enhance Page Header Tag Management Functionality#7248
Conversation
- Add PageHeaderTagInfo class for managing page header tags - Add tag management interface in Page Settings - Support header tag settings at Portal and Tag levels - Migrate legacy PageHeadText to the new tag system - Update template files to support new tag format - Add XSS sanitization and tag rendering functionality - Integrate tag management into Default Site Settings Closes dnnsoftware#7184
There was a problem hiding this comment.
Pull request overview
Adds a structured “Page Header Tags” system to manage per-site and per-page <head> code snippets (named items with HTML content), replacing reliance on a single legacy PageHeadText string while keeping backward compatibility in rendering, templates, and migration.
Changes:
- Introduces a core
PageHeaderTagInfomodel (settings-backed) plus API DTOs and wiring for site/page settings. - Updates page rendering (
Default.aspx.cs) to render structured header tags with legacy fallback and robots-tag detection integration. - Updates portal template export/import and adds an upgrade SQL migration to move legacy
PageHeadTextintoPageHeaderTag_Default.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Services/SiteSettingsController.cs | Adds portal-level get/save support for PageHeaderTags. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Services/DTO/SiteSettings/UpdateDefaultPagesSettingsRequest.cs | Adds PageHeaderTags to site default pages settings request model. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Services/DTO/PageSettings.cs | Adds pageHeaderTags to page settings DTO for Pages API. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Services/DTO/PageHeaderTagItem.cs | New DTO for tag name/content serialization. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Components/Pages/XssCleaner.cs | Sanitizes PageHeaderTags[*].Name for page-level API input. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Components/Pages/PagesControllerImpl.cs | Saves tab-level PageHeaderTags and clears legacy PageHeadText on save/clone. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Components/Pages/Converters.cs | Populates PageHeaderTags from tab settings into API DTO. |
| Dnn.AdminExperience/Dnn.PersonaBar.Extensions/Components/Pages/BulkPagesController.cs | Copies tab header tags during bulk page creation. |
| Dnn.AdminExperience/ClientSide/SiteSettings.Web/src/components/defaultPagesSettings/index.jsx | Replaces legacy textarea with PageHeaderTags component for site defaults. |
| Dnn.AdminExperience/ClientSide/SiteSettings.Web/src/components/PageHeaderTags/PageHeaderTags.jsx | New SiteSettings UI for managing named header tags. |
| Dnn.AdminExperience/ClientSide/SiteSettings.Web/src/components/PageHeaderTags/style.less | Styles for SiteSettings PageHeaderTags component. |
| Dnn.AdminExperience/ClientSide/Pages.Web/src/components/Seo/Seo.jsx | Replaces legacy pageHeadText editing with PageHeaderTags UI. |
| Dnn.AdminExperience/ClientSide/Pages.Web/src/components/PageHeaderTags/PageHeaderTags.jsx | New Pages UI for managing named header tags. |
| Dnn.AdminExperience/ClientSide/Pages.Web/src/components/PageHeaderTags/style.less | Styles for Pages PageHeaderTags component. |
| Dnn.AdminExperience/ClientSide/Pages.Web/src/services/pageService.js | Initializes pageHeaderTags and omits legacy pageHeadText in outgoing payloads. |
| DNN Platform/Website/Default.aspx.cs | Renders structured header tags with legacy fallback and updates robots-tag detection logic. |
| DNN Platform/Library/Entities/Tabs/PageHeaderTagInfo.cs | New settings-backed model for reading/writing/rendering tag items. |
| DNN Platform/Library/Entities/Portals/Templates/PortalTemplateExporter.cs | Exports portal pageheadertags to templates. |
| DNN Platform/Library/Entities/Portals/Templates/PortalTemplateImporter.cs | Imports portal pageheadertags from templates and disables legacy PageHeadText. |
| DNN Platform/Website/Components/Portals/portal.template.xsd | Adds schema support for <pageheadertags> / <pageheadertag name="...">. |
| DNN Platform/Modules/DnnExportImport/Components/Services/PagesExportService.cs | Clears legacy PageHeadText when new-style header-tag settings exist. |
| DNN Platform/Website/Providers/DataProviders/SqlDataProvider/10.03.02.SqlDataProvider | Migrates portal/tab legacy PageHeadText to PageHeaderTag_Default. |
| DNN Platform/Website/Portals/_default/Default Website.template | Moves default meta snippet into <pageheadertags> (legacy emptied). |
| DNN Platform/Website/Portals/_default/Blank Website.template | Moves default meta snippet into <pageheadertags> (legacy emptied). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if (!string.IsNullOrEmpty(portalPageHeadText)) | ||
| { | ||
| var portalHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetPortalItems(this.PortalSettings.PortalId, this.PortalSettings.CultureCode)); | ||
| this.metaPanel.Controls.Add(new LiteralControl(string.IsNullOrEmpty(portalHeaderTags) ? portalPageHeadText : portalHeaderTags)); | ||
| } | ||
| else | ||
| { | ||
| var portalHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetPortalItems(this.PortalSettings.PortalId, this.PortalSettings.CultureCode)); |
There was a problem hiding this comment.
Portal header tags are fetched/rendered multiple times in this method (both branches here and again in the robots meta check). To reduce repeated portal-settings access and string concatenations, compute portalHeaderTags once (and potentially the combined legacy+new string used for HeaderTextRegex) and reuse it.
| if (!string.IsNullOrEmpty(portalPageHeadText)) | |
| { | |
| var portalHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetPortalItems(this.PortalSettings.PortalId, this.PortalSettings.CultureCode)); | |
| this.metaPanel.Controls.Add(new LiteralControl(string.IsNullOrEmpty(portalHeaderTags) ? portalPageHeadText : portalHeaderTags)); | |
| } | |
| else | |
| { | |
| var portalHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetPortalItems(this.PortalSettings.PortalId, this.PortalSettings.CultureCode)); | |
| var portalHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetPortalItems(this.PortalSettings.PortalId, this.PortalSettings.CultureCode)); | |
| if (!string.IsNullOrEmpty(portalPageHeadText)) | |
| { | |
| this.metaPanel.Controls.Add(new LiteralControl(string.IsNullOrEmpty(portalHeaderTags) ? portalPageHeadText : portalHeaderTags)); | |
| } | |
| else | |
| { |
| utils.confirm("Delete this tag?", "Delete", "Cancel", () => { | ||
| onChange((value || []).filter((item, itemIndex) => itemIndex !== index)); | ||
| if (this.state.editingIndex === index) { | ||
| this.onCloseForm(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This component hard-codes user-facing strings (delete confirmation, "Name"/"Content" labels, validation messages, "Add Tag", etc.). Elsewhere in Pages.Web, confirm dialogs and labels are localized via Localization.get(...); please switch these strings to localized resources (or accept them as props) for consistency and translation support.
| { | ||
| if (key.StartsWith(SettingPrefix, StringComparison.Ordinal) && !targetSettingNames.Contains(key)) | ||
| { | ||
| PortalController.DeletePortalSetting(portalId, key); |
There was a problem hiding this comment.
SavePortalItems deletes existing PageHeaderTag_ settings using PortalController.DeletePortalSetting(portalId, key) which targets neutral settings only. When cultureCode is provided, this will fail to remove localized settings (and may delete the neutral setting instead). Use the DeletePortalSetting(portalId, settingName, cultureCode) overload (or pass Null.NullString when culture is null) to delete in the same culture scope you are updating.
| PortalController.DeletePortalSetting(portalId, key); | |
| PortalController.DeletePortalSetting(portalId, key, cultureCode ?? Null.NullString); |
| PortalController.UpdatePortalSetting(this.portalController, pid, "Redirect_AfterLogin", this.ValidateTabId(request.RedirectAfterLoginTabId, pid).ToString(CultureInfo.InvariantCulture), false, cultureCode); | ||
| PortalController.UpdatePortalSetting(this.portalController, pid, "Redirect_AfterLogout", this.ValidateTabId(request.RedirectAfterLogoutTabId, pid).ToString(CultureInfo.InvariantCulture), false, cultureCode); | ||
| PortalController.UpdatePortalSetting(this.portalController, pid, "Redirect_AfterRegistration", this.ValidateTabId(request.RedirectAfterRegistrationTabId, pid).ToString(CultureInfo.InvariantCulture), false, cultureCode); | ||
| PageHeaderTagInfo.SavePortalItems(pid, request.PageHeaderTags?.Select(item => new PageHeaderTagInfo { Name = item.Name, Content = item.Content }), cultureCode); |
There was a problem hiding this comment.
UpdateDefaultPagesSettings persists request.PageHeaderTags without sanitizing tag names. Page-level updates run XssCleaner on PageHeaderTags[i].Name, but the site-level endpoint does not, so a crafted name can end up in setting keys (PageHeaderTag_...) and in exported templates. Apply the same no-markup filter to request.PageHeaderTags[*].Name before calling SavePortalItems.
| PageHeaderTagInfo.SavePortalItems(pid, request.PageHeaderTags?.Select(item => new PageHeaderTagInfo { Name = item.Name, Content = item.Content }), cultureCode); | |
| var sanitizedPageHeaderTags = request.PageHeaderTags?.Select(item => new PageHeaderTagInfo | |
| { | |
| Name = item.Name == null ? null : DotNetNuke.Security.PortalSecurity.Instance.InputFilter(item.Name, DotNetNuke.Security.PortalSecurity.FilterFlag.NoMarkup), | |
| Content = item.Content, | |
| }); | |
| PageHeaderTagInfo.SavePortalItems(pid, sanitizedPageHeaderTags, cultureCode); |
| this.SavePagePermissions(tab, pageSettings.Permissions); | ||
|
|
||
| this.tabController.UpdateTab(tab); | ||
| PageHeaderTagInfo.SaveTabItems(tab.TabID, pageSettings.PageHeaderTags?.Select(this.ToPageHeaderTagInfo)); |
There was a problem hiding this comment.
SaveTabItems(tab.TabID, pageSettings.PageHeaderTags?.Select(...)) will clear all existing PageHeaderTag_ settings when PageHeaderTags is omitted (null) on an update request. If null is meant to mean "unchanged" for partial updates, guard this call or ensure callers always send the current list (use an explicit empty array only when the user intends to clear tags).
| PageHeaderTagInfo.SaveTabItems(tab.TabID, pageSettings.PageHeaderTags?.Select(this.ToPageHeaderTagInfo)); | |
| if (pageSettings.PageHeaderTags != null) | |
| { | |
| PageHeaderTagInfo.SaveTabItems(tab.TabID, pageSettings.PageHeaderTags.Select(this.ToPageHeaderTagInfo)); | |
| } |
| if (this.PortalSettings.ActiveTab.PageHeadText != Null.NullString && !Globals.IsAdminControl()) | ||
| { | ||
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); | ||
| this.Page.Header.Controls.Add(new LiteralControl(string.IsNullOrEmpty(tabHeaderTags) ? this.PortalSettings.ActiveTab.PageHeadText : tabHeaderTags)); | ||
| } |
There was a problem hiding this comment.
this.PortalSettings.ActiveTab.PageHeadText != Null.NullString will evaluate true when PageHeadText is null (since null != ""). After the migration script sets Tabs.PageHeadText = NULL, this branch will run unexpectedly. Use !string.IsNullOrEmpty(this.PortalSettings.ActiveTab.PageHeadText) (or coalesce to empty) to distinguish "has legacy text" vs "no legacy text" reliably.
| utils.utilities.confirm("Delete this tag?", "Delete", "Cancel", () => { | ||
| onChange((value || []).filter((item, itemIndex) => itemIndex !== index)); | ||
| if (this.state.editingIndex === index) { | ||
| this.onCloseForm(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This component hard-codes several user-facing strings (e.g., the delete confirmation text/buttons, field labels, validation messages, and "Add Tag"). Other SiteSettings components pull these from resx.get(...); please localize these strings (either by importing resx here or passing localized strings in via props) to keep the UI translatable/consistent.
| public static void SaveTabItems(int tabId, IEnumerable<PageHeaderTagInfo> items) | ||
| { | ||
| var normalizedItems = Normalize(items); | ||
| var targetSettingNames = new HashSet<string>(normalizedItems.Select(item => item.SettingName), StringComparer.OrdinalIgnoreCase); | ||
| var existingSettingNames = TabController.Instance.GetTabSettings(tabId) | ||
| .Cast<DictionaryEntry>() | ||
| .Select(entry => Convert.ToString(entry.Key, CultureInfo.InvariantCulture)) | ||
| .Where(key => !string.IsNullOrEmpty(key)) | ||
| .ToList(); | ||
|
|
||
| foreach (var key in existingSettingNames) | ||
| { | ||
| if (key.StartsWith(SettingPrefix, StringComparison.Ordinal) && !targetSettingNames.Contains(key)) | ||
| { | ||
| TabController.Instance.DeleteTabSetting(tabId, key); | ||
| } | ||
| } | ||
|
|
||
| foreach (var item in normalizedItems) | ||
| { | ||
| TabController.Instance.UpdateTabSetting(tabId, item.SettingName, item.Content); | ||
| } | ||
| } |
There was a problem hiding this comment.
SaveTabItems/SavePortalItems treat items == null as an empty list (via Normalize), which results in deleting all existing PageHeaderTag_ settings. Several callers pass pageSettings.PageHeaderTags?.Select(...), so omitting the pageHeaderTags field from an update request will unintentionally wipe existing tags. Consider making items == null a no-op (preserve existing), and require an explicit empty list to clear tags.
| if (this.PortalSettings.ActiveTab.PageHeadText != Null.NullString && !Globals.IsAdminControl()) | ||
| { | ||
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); | ||
| this.Page.Header.Controls.Add(new LiteralControl(string.IsNullOrEmpty(tabHeaderTags) ? this.PortalSettings.ActiveTab.PageHeadText : tabHeaderTags)); | ||
| } | ||
| else if (!Globals.IsAdminControl()) | ||
| { | ||
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); |
There was a problem hiding this comment.
PageHeaderTagInfo.GetTabItems(...)/Render(...) is executed multiple times during a single request (here and again for the robots meta check). Since GetTabItems hits tab settings storage, consider retrieving/rendering the tab header tags once into a local variable and reusing it for both injection and later HeaderTextRegex checks.
| if (this.PortalSettings.ActiveTab.PageHeadText != Null.NullString && !Globals.IsAdminControl()) | |
| { | |
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); | |
| this.Page.Header.Controls.Add(new LiteralControl(string.IsNullOrEmpty(tabHeaderTags) ? this.PortalSettings.ActiveTab.PageHeadText : tabHeaderTags)); | |
| } | |
| else if (!Globals.IsAdminControl()) | |
| { | |
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); | |
| var tabHeaderTags = PageHeaderTagInfo.Render(PageHeaderTagInfo.GetTabItems(this.PortalSettings.ActiveTab.TabID)); | |
| if (this.PortalSettings.ActiveTab.PageHeadText != Null.NullString && !Globals.IsAdminControl()) | |
| { | |
| this.Page.Header.Controls.Add(new LiteralControl(string.IsNullOrEmpty(tabHeaderTags) ? this.PortalSettings.ActiveTab.PageHeadText : tabHeaderTags)); | |
| } | |
| else if (!Globals.IsAdminControl()) | |
| { |
…0.03.02.SqlDataProvider Co-authored-by: Copilot <[email protected]>
|
Another issue that should be considered is where these load in the Maybe we should consider adding an additional field for Priority (similar to CDF)? Easier - and given the fact that these are probably/usually user-level after-thoughts, hacks, etc. - might be to just add them with a priority of 200 or 500, so they add late/last. |
|
@jeremy-farrance thank you for your feedback. As I am the one funding this enhancement: would you be ok if I put extras on the backlog for further enhancements? This would go for any other input that are outside the initial scope. |
I noticed this too. But this is already the case. Never realized that but Site settings - page head injections are above the title and Page settings - page head injections just below the title. So, you have multiple places for this. TBH, I don't know if this is correct and desirable or could be hurtful. After all these years leveragin both, I have never experience any issues, though. |
All good, I am just sharing ideas, I did not intend for these to be requirements or anything. If they end up on a backlog or wish list, that is great! |
mitchelsellers
left a comment
There was a problem hiding this comment.
Thank you so much for this contribution, just a few notes and questions upon a quick initial review
|
I would like to also note that I think to be usable, given how users do things today, we need the text area to be full width for individual items otherwise it will be almost impossible to see/manage safely. |
Modify the `columnSize` parameter in GridCell: adjust the left column from 50 to 60 and the right column from 50 to 95 to optimize the page layout. dnnsoftware#7184 Pull Request dnnsoftware#7248
Remove redundant conditional checks, merge similar code blocks, and optimize the generation logic of page header tags and metadata
|
@zzmzaizai Just making sure you saw the few other comments/questions inline with this PR. |
Thank you for your feedback. I've addressed this in commit 7208846 by setting the text area for individual items to full width in the edit form. The CSS change adds width: 100%; to the .dnn-multi-line-input-with-error class within the .editTag section. Please let me know if this resolves the usability issue. |
|
Does this change make the PageHeadText column in Tabs obsolete? If so we should probably clean things up by: 1. remove the column (SQL), 2. rewire the property to point to the new data location (TabSettings) and 3. mark it as obsolete. |
The PageHeadText column is no longer used and the data has already been cleaned up.
|
mitchelsellers
left a comment
There was a problem hiding this comment.
A detailed team review of this PR was completed today to aggregate the feedback and provide an overall update to the system. I am going to dismiss most of the other comments to keep things clean here.
During the initial review, there are concerns regarding the processing that has to occur for each individual page load, including key enumeration, string concatenation, and collection copying.
A suggestion/thought would be to store a single additional "PageHeaderTagsGrouped" or some similar name that had all of the processing done at save (Tab or Portal) so that the Default.aspx processing would only be grabbing keys that were already ready for render and cached.
Additionally, regarding the existing field, there was a consensus that the following could be done for the existing "Tabs" PageHeadText.
- Remove the column after successful migration
- Update the TabInfo to mark that column as obsolete, to be removed in DNN v12
- Update the process within the DNN interface that saves the TabInfo, if a value was set in that field, update the "PageHeaderTag_Default" value
- Remove the field from PortalTemplates
1. Remove legacy pageheadtext nodes from XSD schema and template files 2. Deprecate PageHeadText property and migrate data to PageHeaderTag settings 3. Update export/import service to support the new page header tag settings 4. Add 10.03.03 SQL data migration script and remove the old 10.03.02 script
Here is a summary of the actions taken in response to each point:
|


Summary
Implements #7184 — Provides a structured, user-friendly way for site administrators and SEO specialists to manage header code snippets (e.g., Google Search Console verification, Tag Manager, Rich snippets, Hotjar, etc.) at both the site level and page level, replacing the previous single text-area approach that was cluttered and error-prone.
Problem
Previously, header code injection was done through a single
PageHeadTexttext field at both the site and page level. This approach:Solution
Introduces a structured Page Header Tags system where each snippet has:
Backend Changes
New Core Model:
PageHeaderTagInfoclass inDotNetNuke.Entities.TabsnamespacePageHeaderTag_prefixNew API DTO:
PageHeaderTagItemfor Web API serializationAPI Integration:
PageSettingsnow includesPageHeaderTagslist; saved on page create/update/cloneGetDefaultPagesSettings/UpdateDefaultPagesSettingsnow handlePageHeaderTagsPage Rendering:
Default.aspx.csupdated to render structured header tags with fallback to legacyPageHeadTextfor backward compatibilityTemplate Import/Export: Portal templates now include
<pageheadertags>XML elementsExport/Import Module: Detects new-style header tags and clears legacy
PageHeadTextto avoid conflictsFrontend Changes
New React Component:
PageHeaderTagswith:Integration:
PageHeaderTagscomponentPageHeaderTagscomponent (replacing old text area)Backward Compatibility
PageHeadTextis preserved as fallback — if no structured tags exist, the old text is still renderedPageHeadTextis set toNull.NullString/"false"to avoid duplicationScreenshots
(Add screenshots of the new UI here — the PageHeaderTags component showing the list view and add/edit form)
How to Test
<head>section.PageHeadTextstill render correctly.Files Changed
New Files:
DNN Platform/Library/Entities/Tabs/PageHeaderTagInfo.csDnn.PersonaBar.Extensions/Services/DTO/PageHeaderTagItem.csClientSide/Pages.Web/src/components/PageHeaderTags/PageHeaderTags.jsxClientSide/Pages.Web/src/components/PageHeaderTags/style.lessClientSide/SiteSettings.Web/src/components/PageHeaderTags/PageHeaderTags.jsxClientSide/SiteSettings.Web/src/components/PageHeaderTags/style.lessModified Files:
Dnn.PersonaBar.Extensions/Services/DTO/PageSettings.csDnn.PersonaBar.Extensions/Services/DTO/SiteSettings/UpdateDefaultPagesSettingsRequest.csDnn.PersonaBar.Extensions/Components/Pages/Converters.csDnn.PersonaBar.Extensions/Components/Pages/PagesControllerImpl.csDnn.PersonaBar.Extensions/Components/Pages/XssCleaner.csDnn.PersonaBar.Extensions/Components/Pages/BulkPagesController.csDnn.PersonaBar.Extensions/Services/SiteSettingsController.csDNN Platform/Website/Default.aspx.csDNN Platform/Library/Entities/Portals/Templates/PortalTemplateExporter.csDNN Platform/Library/Entities/Portals/Templates/PortalTemplateImporter.csDNN Platform/Modules/DnnExportImport/Components/Services/PagesExportService.csClientSide/Pages.Web/src/components/Seo/Seo.jsxClientSide/SiteSettings.Web/src/components/defaultPagesSettings/index.jsx