Fix incorrect URL rendering in documentation texts

This commit addresses incorrect URL rendering within documentation text
by improving auto-linkified URL labels, handling `+` symbols as spaces,
enhancing readability of encoded path segments and manually updating
some of the documetation.

Key improvements:

- Parse `+` as whitespace in URLs for accurate script labeling.
- Interpret multiple whitespaces as single for robustness.
- Decode path segments for clearer links.
- Refactor markdown renderer.
- Expand unit tests for comprehensive coverage.

Documentation has been updated to fix inline URL references and improve
linkification across several scripts, ensuring more readable and
user-friendly content.

Affected files and documentation sections have been adjusted
accordingly, including script and category names for consistency and
clarity.

Some of the script/category documentation changing fixing URL rendering
includes:

- 'Disable sending information to Customer Experience Improvement
  Program':
  - Fix reference URLs being inlined.
- 'Disable "Secure boot" button in "Windows Security"':
  - Fix rendering issue due to auto-linkification of `markdown-it`.
- 'Clear Internet Explorer DOMStore':
  - Fix rendering issue due to auto-linkification of `markdown-it`.
- 'Disable "Windows Defender Firewall" service':
  - Fix rendering issue due to auto-linkification of `markdown-it`.
  - Convert YAML comments to markdown comments visible by users.
  - Add breaking behavior to script name, changing script name to.
- 'Disable Microsoft Defender Firewall services and drivers':
  - Remove information about breaking behavior to avoid duplication and
    be consistent with the documentation of the rest of the collections.
- Use consistent styling for warning texts starting with `Caution:`.
- Rename 'Remove extensions' category to 'Remove extension apps' for
  consistency with names of its sibling categories.
This commit is contained in:
undergroundwires
2023-11-27 05:17:58 +01:00
parent bcad357017
commit d328f08952
5 changed files with 274 additions and 167 deletions

View File

@@ -463,7 +463,8 @@ actions:
-
name: Clear Internet Explorer DOMStore
recommend: standard
docs: https://web.archive.org/web/20100416135352/http://msdn.microsoft.com/en-us/library/cc197062(VS.85).aspx
docs: |-
[Introduction to DOM Storage | msdn.microsoft.com](https://web.archive.org/web/20100416135352/http://msdn.microsoft.com/en-us/library/cc197062(VS.85).aspx)
call:
function: ClearDirectoryContents
parameters:
@@ -593,10 +594,10 @@ actions:
- `C:\Documents and Settings\<Windows login/user name>\Application Data\Mozilla\Firefox\Profiles\<profile folder>` on Windows XP and earlier [1],
- `%APPDATA%\Mozilla\Firefox\Profiles\<profile folder>` on Windows 10 and later [1].
**Considerations**:
- Using this script results in a total loss of all personalized Firefox data.
- If your goal is solely to clear browsing data while retaining settings and extensions, this script is not recommended.
- Close Firefox before running this script to prevent potential issues.
> **Caution**:
> - Using this script results in a total loss of all personalized Firefox data.
> - If your goal is solely to clear browsing data while retaining settings and extensions, this script is not recommended.
> - Close Firefox before running this script to prevent potential issues.
[1]: https://web.archive.org/web/20231101125909/https://kb.mozillazine.org/Profile_folder_-_Firefox#Windows "Profile folder - Firefox - MozillaZine Knowledge Base | kb.mozillazine.org"
call:
@@ -1204,7 +1205,7 @@ actions:
(`%ProgramData%\Microsoft\Windows Defender\Scans\History\Service\DetectionHistory\[numbered folder]\`), and it contains a
system-generated ID for the event [2].
> **Caution**: Deleting these logs may decrease your security. These logs help in keeping track of potential threats and their sources,
> **Caution:** Deleting these logs may decrease your security. These logs help in keeping track of potential threats and their sources,
allowing for a more proactive response in future encounters. Without this history, Microsoft Defender might not recognize recurring threats
as quickly, possibly leaving your system more vulnerable. It's essential to understand that you're making a trade-off between enhanced
privacy and potentially reduced security.
@@ -1298,7 +1299,7 @@ actions:
**Windows Component Store** contains all the files that are required to Windows features on demand [3].
WARNING: Once the "Reset Base" operation is activated, you will not be able to uninstall previous updates. However, this
> **Caution:** Once the "Reset Base" operation is activated, you will not be able to uninstall previous updates. However, this
small trade-off improves your privacy and control over system data.
[1]: https://web.archive.org/web/20230806160623/https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/what-is-dism?view=windows-11 "DISM Overview | Microsoft Learn"
@@ -1887,9 +1888,6 @@ actions:
name: Disable sending information to Customer Experience Improvement Program
recommend: standard
docs: |-
[Turn off the Windows Customer Experience program - gHacks Tech News](https://www.ghacks.net/2016/10/26/turn-off-the-windows-customer-experience-program/)
[Permanently Disabling Windows Compatibility Telemetry - Microsoft Community](https://answers.microsoft.com/en-us/windows/forum/windows_10-performance/permanently-disabling-windows-compatibility/6bf71583-81b0-4a74-ae2e-8fd73305aad1)
### Overview of default task statuses
`\Microsoft\Windows\Application Experience\ProgramDataUpdater`:
@@ -1898,6 +1896,11 @@ actions:
| ---------------- | -------------- |
| Windows 10 22H2 | 🟢 Ready |
| Windows 11 22H2 | 🟡 N/A (missing) |
### Additional documentation
- [Turn off the Windows Customer Experience program - gHacks Tech News](https://www.ghacks.net/2016/10/26/turn-off-the-windows-customer-experience-program/)
- [Permanently Disabling Windows Compatibility Telemetry - Microsoft Community](https://answers.microsoft.com/en-us/windows/forum/windows_10-performance/permanently-disabling-windows-compatibility/6bf71583-81b0-4a74-ae2e-8fd73305aad1)
call:
function: DisableScheduledTask
parameters:
@@ -2390,7 +2393,7 @@ actions:
Taking control of this service prevents Microsoft from activating peer-to-peer sharing, enhancing user privacy. It ensures your device doesn't share update data
or fetch it from arbitrary peers.
> **Caution**: Disabling this service affects the functionality of Windows Store. It plays a role not just in Windows Updates but also in Microsoft Store app
> **Caution:** Disabling this service affects the functionality of Windows Store. It plays a role not just in Windows Updates but also in Microsoft Store app
downloads, especially since Windows 11 [7]. There have been reported issues with some app downloads on Windows 10 [8].
[1]: https://web.archive.org/web/20230914164204/https://learn.microsoft.com/en-us/windows/deployment/do/waas-delivery-optimization "What is Delivery Optimization? - Windows Deployment | Microsoft Learn"
@@ -3878,7 +3881,7 @@ actions:
| Windows 10 22H2 | 🟡 N/A (missing) |
| Windows 11 22H2 | 🟡 N/A (missing) |
> **CAUTION:** Consider that while disabling this task may lead to increased privacy, it could also impact license compliance and the overall functionality
> **Caution:** Consider that while disabling this task may lead to increased privacy, it could also impact license compliance and the overall functionality
of Microsoft Office products in the long run.
[1]: https://web.archive.org/web/20231024130456/https://learn.microsoft.com/en-us/microsoft-365/troubleshoot/licensing/subscription-automatic-license-renew-fails "Microsoft 365 subscription automatic license renewal fails when heartbeatcache in wrong location - Microsoft 365 | Microsoft Learn | learn.microsoft.com"
@@ -4959,7 +4962,7 @@ actions:
category: Disable Microsoft Defender firewall # Also known as Windows Firewall, Microsoft Defender Firewall
children:
-
category: Disable Microsoft Defender Firewall services and drivers (breaks Microsoft Store and `netsh advfirewall` CLI)
category: Disable Microsoft Defender Firewall services and drivers
children:
-
name: Disable "Windows Defender Firewall Authorization Driver" service
@@ -4985,32 +4988,38 @@ actions:
fileGlob: '%SYSTEMROOT%\System32\drivers\mpsdrv.sys'
grantPermissions: true # 🔒️ Protected on Windows 10 since 22H2 | 🔒️ Protected on Windows 11 since 22H2
-
name: Disable "Windows Defender Firewall" service
docs:
- http://batcmd.com/windows/10/services/mpssvc/
- https://en.wikipedia.org/wiki/Windows_Firewall
# More information about MpsSvc:
- https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx
# More information about boot time protection and stopping the firewall service:
- https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx
# Stopping the service associated with Windows Firewall is not supported by Microsoft:
- https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx
# ❗️ Breaks Microsoft Store
# Can no longer update nor install apps, they both fail with 0x80073D0A
# Also breaks some of Store apps such as Photos:
# - https://answers.microsoft.com/en-us/windows/forum/all/microsoft-store-windows-defender-windows-firewall/f2f68cd7-64ec-4fe1-ade4-9d12cde057f9
# - https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791
# > The MpsSvc service host much more functionality than just windows firewall. For instance, Windows
# Service hardening which is a windows protection of system services. It also host network isolatio
# which is a crucial part of the confidence model for Windows Store based applications. 3rd party firewalls
# know this fact and instead of disabling the firewall service they coordinate through public APIs with Windows
# Firewall so that they can have ownership of the firewall policies of the computer. Hence you do not have to do
# anything special once you install a 3rd party security product.
# Source: https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/
# ❗️ Breaks: `netsh advfirewall set`
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
name: Disable "Windows Defender Firewall" service (breaks Microsoft Store downloads and `netsh advfirewall` CLI)
docs: |-
This script disables the "Windows Defender Firewall" service, also known as `MpsSvc` [1] [2] [3].
The Windows Defender Firewall, previously known as Windows Firewall [4], is a component that helps protect against unauthorized network access [3] [4].
It operates by filtering both incoming and outgoing network traffic based on predefined security rules [1].
Disabling the Windows Defender Firewall has significant impacts, including:
- **Microsoft Store app downloads**: Disabling this service prevents updates and installations from the Microsoft Store, resulting in error code `0x80073D0A` [5] [6].
- **`netsh advfirewall` commands**: The script renders the `netsh advfirewall` command-line context, which manages Windows Firewall settings [7], becomes inoperative.
- **Activation of boot-time filters**: Deactivating the service may trigger boot-time filters that protect the computer during startup or when the firewall service stops unexpectedly [2].
This feature was introduced to minimize vulnerabilities during startup [2].
Beyond firewall functionality, the MpsSvc service is integral to Windows Service hardening and network isolation [6], essential for Windows Store applications [6]. As a result, third-party
firewalls typically interact with Windows Firewall via public APIs, rather than disabling the service outright [6].
The `MpsSvc` service is set to start automatically by default [3] and runs the `%WINDIR%\System32\MPSSVC.dll` driver [3]. This file is also referred to as "Microsoft Protection Service" [8].
> **Caution:** Disabling this service significantly compromises system security [9] and is not recommended by Microsoft [9].
> It affects not only the firewall's protective capabilities but also the functionality of other Windows components like the Store [5] [6] and command-line utilities.
> Users should be aware of these considerable trade-offs when considering this script for privacy enhancement.
[1]: https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx "Windows Firewall Service | technet.microsoft.com"
[2]: https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx "Stopping the Windows Authenticating Firewall Service and the boot time policy - Microsoft Enterprise Networking Team - Site Home - TechNet Blogs | blogs.technet.com"
[3]: https://web.archive.org/web/20231122132143/https://batcmd.com/windows/10/services/mpssvc/ "Windows Defender Firewall - Windows 10 Service - batcmd.com | batcmd.com"
[4]: https://en.wikipedia.org/w/index.php?title=Windows_Firewall&oldid=1183396285 "Windows Firewall - Wikipedia | wikipedia.org"
[5]: https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791 "[BUG][help wanted]: Cannot enable Windows Defender · Issue #104 · undergroundwires/privacy.sexy | github.com/undergroundwires/privacy.sexy"
[6]: https://web.archive.org/web/20200620033533/https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/ "How To Fix Windows Store App Update Error Code 0x80073D0A? Walker News | www.walkernews.net"
[7]: https://learn.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior "Use netsh advfirewall firewall context - Windows Server | Microsoft Learn | learn.microsoft.com"
[8]: https://web.archive.org/web/20231122132150/https://strontic.github.io/xcyclopedia/library/MPSSVC.dll-AA441F7C99AAACBA2538E90D7693637A.html "MPSSVC.dll | Microsoft Protection Service | STRONTIC | strontic.github.io"
[9]: https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx "Basic Firewall Policy Design | technet.microsoft.com"
call:
-
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
@@ -6475,7 +6484,7 @@ actions:
-
name: Disable "Secure boot" button in "Windows Security"
docs: |-
[Hide the Secure boot area | admx.help](https://web.archive.org/web/20231013162210/https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_HideSecureBoot
[Hide the Secure boot area | admx.help](https://web.archive.org/web/20231013162210/https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_HideSecureBoot)
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "HideSecureBoot" /t REG_DWORD /d "1" /f
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "HideSecureBoot" /f 2>nul
-
@@ -6973,7 +6982,7 @@ actions:
4. **Configurational integrity**: Updates have the capacity to change pre-configured settings without explicit user consent. This could
result in unintended alteration of your privacy settings, leaving you exposed until you realize the change.
**Security implications**: While controlling updates enhances your privacy, it can leave your system vulnerable to unpatched exploits.
> **Caution**: While controlling updates enhances your privacy, it can leave your system vulnerable to unpatched exploits.
Ensure that you manually review and apply updates on a regular basis. You're essentially trading off some security for a heightened level of
privacy.
@@ -8804,7 +8813,7 @@ actions:
packageName: Microsoft.Getstarted
publisherId: 8wekyb3d8bbwe
-
category: Remove extensions
category: Remove extension apps
docs: |-
This category focuses on scripts designed to uninstall specific extensions from Windows.

View File

@@ -9,7 +9,7 @@
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { createRenderer } from './MarkdownRenderer';
import { createMarkdownRenderer } from './MarkdownRenderer';
export default defineComponent({
props: {
@@ -27,7 +27,7 @@ export default defineComponent({
},
});
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
function renderText(docs: readonly string[] | undefined): string {
if (!docs || docs.length === 0) {

View File

@@ -2,85 +2,107 @@ import MarkdownIt from 'markdown-it';
import Renderer from 'markdown-it/lib/renderer';
import Token from 'markdown-it/lib/token';
export function createRenderer(): IRenderer {
const md = new MarkdownIt({
linkify: true, // Auto-convert URL-like text to links.
breaks: false, // Do not convert single '\n's into <br>.
export function createMarkdownRenderer(): MarkdownRenderer {
const markdownParser = new MarkdownIt({
linkify: false, // Disables auto-linking; handled manually for custom formatting.
breaks: false, // Disables conversion of single newlines (`\n`) to HTML breaks (`<br>`).
});
openUrlsInNewTab(md);
configureLinksToOpenInNewTab(markdownParser);
return {
render: (markdown: string) => {
markdown = beatifyAutoLinks(markdown);
return md.render(markdown);
render: (markdownContent: string) => {
markdownContent = beautifyAutoLinkedUrls(markdownContent);
return markdownParser.render(markdownContent);
},
};
}
export interface IRenderer {
render(markdown: string): string;
interface MarkdownRenderer {
render(markdownContent: string): string;
}
function beatifyAutoLinks(content: string): string {
const PlainTextUrlInMarkdownRegex = /(?<!\]\(|\[\d+\]:\s+|https?\S+|`)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm;
function beautifyAutoLinkedUrls(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(/(?<!\]\(|\[\d+\]:\s+|https?\S+|`)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm, (_$, urlMatch) => {
return toReadableLink(urlMatch);
return content.replaceAll(PlainTextUrlInMarkdownRegex, (_fullMatch, url) => {
return formatReadableLink(url);
});
}
function toReadableLink(url: string): string {
const parts = new URL(url);
let displayName = toReadableHostName(parts.hostname);
const pageName = extractPageName(parts);
if (pageName) {
displayName += ` - ${truncateRight(capitalizeEachLetter(pageName), 50)}`;
function formatReadableLink(url: string): string {
const urlParts = new URL(url);
let displayText = formatHostName(urlParts.hostname);
const pageLabel = extractPageLabel(urlParts);
if (pageLabel) {
displayText += ` - ${truncateTextFromEnd(capitalizeEachWord(pageLabel), 50)}`;
}
return `[${displayName}](${parts.href})`;
}
function toReadableHostName(hostname: string): string {
const wwwStripped = hostname.replace(/^(www\.)/, '');
const truncated = truncateLeft(wwwStripped, 30);
return truncated;
}
function extractPageName(parts: URL): string | undefined {
const path = toReadablePath(parts.pathname);
if (path) {
return path;
if (displayText.includes('Msdn.microsoft')) {
console.log(`[${displayText}](${urlParts.href})`);
}
return toReadableQuery(parts);
return buildMarkdownLink(displayText, urlParts.href);
}
function toReadableQuery(parts: URL): string | undefined {
const queryValues = [...parts.searchParams.values()];
function formatHostName(hostname: string): string {
const withoutWww = hostname.replace(/^(www\.)/, '');
const truncatedHostName = truncateTextFromStart(withoutWww, 30);
return truncatedHostName;
}
function extractPageLabel(urlParts: URL): string | undefined {
const readablePath = makePathReadable(urlParts.pathname);
if (readablePath) {
return readablePath;
}
return formatQueryParameters(urlParts.searchParams);
}
function buildMarkdownLink(label: string, url: string): string {
return `[${label}](${url})`;
}
function formatQueryParameters(queryParameters: URLSearchParams): string | undefined {
const queryValues = [...queryParameters.values()];
if (queryValues.length === 0) {
return undefined;
}
return selectMostDescriptiveName(queryValues);
return findMostDescriptiveName(queryValues);
}
function truncateLeft(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(phrase.length - threshold, phrase.length)}` : phrase;
function truncateTextFromStart(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(text.length - maxLength)}` : text;
}
function isDigit(value: string): boolean {
function truncateTextFromEnd(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(0, maxLength)}` : text;
}
function isNumeric(value: string): boolean {
return /^\d+$/.test(value);
}
function toReadablePath(path: string): string | undefined {
const decodedPath = decodeURI(path); // Fixes e.g. %20 to whitespaces
const pathPart = selectMostDescriptiveName(decodedPath.split('/'));
if (!pathPart) {
function makePathReadable(path: string): string | undefined {
const decodedPath = decodeURI(path); // Decode URI components, e.g., '%20' to space
const pathParts = decodedPath.split('/');
const decodedPathParts = pathParts // Split then decode to correctly handle '%2F' as '/'
.map((pathPart) => decodeURIComponent(pathPart));
const descriptivePart = findMostDescriptiveName(decodedPathParts);
if (!descriptivePart) {
return undefined;
}
const extensionStripped = removeTrailingExtension(pathPart);
const humanlyTokenized = extensionStripped.replaceAll(/[-_]/g, ' ');
return humanlyTokenized;
const withoutExtension = removeFileExtension(descriptivePart);
const tokenizedText = tokenizeTextForReadability(withoutExtension);
return tokenizedText;
}
function removeTrailingExtension(value: string): string {
function tokenizeTextForReadability(text: string): string {
return text
.replaceAll(/[-_+]/g, ' ') // Replace hyphens, underscores, and plus signs with spaces
.replaceAll(/\s+/g, ' '); // Collapse multiple consecutive spaces into a single space
}
function removeFileExtension(value: string): string {
const parts = value.split('.');
if (parts.length === 1) {
return value;
@@ -92,71 +114,68 @@ function removeTrailingExtension(value: string): string {
return parts.slice(0, -1).join('.');
}
function capitalizeEachLetter(phrase: string): string {
return phrase
function capitalizeEachWord(text: string): string {
return text
.split(' ')
.map((word) => capitalizeFirstLetter(word))
.join(' ');
function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}
function truncateRight(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(0, threshold)}` : phrase;
function capitalizeFirstLetter(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function selectMostDescriptiveName(parts: readonly string[]): string | undefined {
const goodParts = parts.filter(isGoodPathPart);
if (goodParts.length === 0) {
function findMostDescriptiveName(segments: readonly string[]): string | undefined {
const meaningfulSegments = segments.filter(isMeaningfulPathSegment);
if (meaningfulSegments.length === 0) {
return undefined;
}
const longestGoodPart = goodParts.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodPart;
const longestGoodSegment = meaningfulSegments.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodSegment;
}
function isGoodPathPart(part: string): boolean {
return part.length > 2 // This is often non-human categories like T5 etc.
&& !isDigit(part) // E.g. article numbers, issue numbers
&& !/^index(?:\.\S{0,10}$|$)/.test(part) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(part) // Locale string e.g. fr-FR
&& !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(part) // GUID
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
function isMeaningfulPathSegment(segment: string): boolean {
return segment.length > 2 // This is often non-human categories like T5 etc.
&& !isNumeric(segment) // E.g. article numbers, issue numbers
&& !/^index(?:\.\S{0,10}$|$)/.test(segment) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(segment) // Locale string e.g. fr-FR
&& !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(segment) // GUID
&& !/^[0-9a-f]{40}$/.test(segment); // Git SHA (e.g. GitHub links)
}
const ExternalAnchorElementAttributes: Record<string, string> = {
const AnchorAttributesForExternalLinks: Record<string, string> = {
target: '_blank',
rel: 'noopener noreferrer',
};
function openUrlsInNewTab(md: MarkdownIt) {
function configureLinksToOpenInNewTab(markdownParser: MarkdownIt): void {
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
const defaultRender = getOrDefaultRenderer(md, 'link_open');
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const defaultLinkRenderer = getDefaultRenderer(markdownParser, 'link_open');
markdownParser.renderer.rules.link_open = (tokens, index, options, env, self) => {
const currentToken = tokens[index];
Object.entries(ExternalAnchorElementAttributes).forEach(([name, value]) => {
const currentValue = getAttribute(token, name);
if (!currentValue) {
token.attrPush([name, value]);
} else if (currentValue !== value) {
setAttribute(token, name, value);
Object.entries(AnchorAttributesForExternalLinks).forEach(([attribute, value]) => {
const existingValue = getTokenAttribute(currentToken, attribute);
if (!existingValue) {
addAttributeToToken(currentToken, attribute, value);
} else if (existingValue !== value) {
updateTokenAttribute(currentToken, attribute, value);
}
});
return defaultRender(tokens, idx, options, env, self);
return defaultLinkRenderer(tokens, index, options, env, self);
};
}
function getOrDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const renderer = md.renderer.rules[ruleName];
return renderer || defaultRenderer;
function defaultRenderer(tokens, idx, options, _env, self) {
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const ruleRenderer = md.renderer.rules[ruleName];
return ruleRenderer || renderTokenAsDefault;
function renderTokenAsDefault(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
}
}
function getAttribute(token: Token, name: string): string | undefined {
const attributeIndex = token.attrIndex(name);
function getTokenAttribute(token: Token, attributeName: string): string | undefined {
const attributeIndex = token.attrIndex(attributeName);
if (attributeIndex < 0) {
return undefined;
}
@@ -164,10 +183,14 @@ function getAttribute(token: Token, name: string): string | undefined {
return value;
}
function setAttribute(token: Token, name: string, value: string): void {
const attributeIndex = token.attrIndex(name);
if (attributeIndex < 0) {
throw new Error('Attribute does not exist');
}
token.attrs[attributeIndex][1] = value;
function addAttributeToToken(token: Token, attributeName: string, value: string): void {
token.attrPush([attributeName, value]);
}
function updateTokenAttribute(token: Token, attributeName: string, newValue: string): void {
const attributeIndex = token.attrIndex(attributeName);
if (attributeIndex < 0) {
throw new Error(`Attribute "${attributeName}" not found in token.`);
}
token.attrs[attributeIndex][1] = newValue;
}

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
import { createMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('can render all docs', () => {
// arrange
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
for (const node of collectAllDocumentableNodes()) {
it(`${node.nodeLabel}`, () => {
// act

View File

@@ -1,37 +1,37 @@
import { describe, it, expect } from 'vitest';
import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
import { createMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('createRenderer', () => {
it('can create', () => {
describe('createMarkdownRenderer', () => {
it('creates renderer instance', () => {
// arrange & act
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
// assert
expect(renderer !== undefined);
});
describe('sets expected anchor attributes', () => {
describe('sets default anchor attributes', () => {
const attributes: ReadonlyArray<{
readonly name: string,
readonly attributeName: string,
readonly expectedValue: string,
readonly invalidMarkdown: string
}> = [
{
name: 'target',
attributeName: 'target',
expectedValue: '_blank',
invalidMarkdown: '<a href="https://undergroundwires.dev" target="_self">example</a>',
},
{
name: 'rel',
attributeName: 'rel',
expectedValue: 'noopener noreferrer',
invalidMarkdown: '<a href="https://undergroundwires.dev" rel="nooverride">example</a>',
},
];
for (const attribute of attributes) {
const { name, expectedValue, invalidMarkdown } = attribute;
const { attributeName, expectedValue, invalidMarkdown } = attribute;
it(`adds "${name}" attribute to anchor elements`, () => {
it(`adds "${attributeName}" attribute to anchors`, () => {
// arrange
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
// act
@@ -40,12 +40,12 @@ describe('MarkdownRenderer', () => {
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.getAttribute(name)).to.equal(expectedValue);
expect(aElement.getAttribute(attributeName)).to.equal(expectedValue);
});
it(`overrides existing "${name}" attribute`, () => {
it(`overrides existing "${attributeName}" attribute`, () => {
// arrange
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
// act
const htmlString = renderer.render(invalidMarkdown);
@@ -53,13 +53,13 @@ describe('MarkdownRenderer', () => {
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.getAttribute(name)).to.equal(expectedValue);
expect(aElement.getAttribute(attributeName)).to.equal(expectedValue);
});
}
});
it('does not convert single linebreak to <br>', () => {
it('does not convert single line breaks to <br> elements', () => {
// arrange
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
const markdown = 'Text with\nSingle\nLinebreaks';
// act
const htmlString = renderer.render(markdown);
@@ -68,9 +68,9 @@ describe('MarkdownRenderer', () => {
const totalBrElements = html.getElementsByTagName('br').length;
expect(totalBrElements).to.equal(0);
});
it('creates links for plain URL', () => {
it('converts plain URLs into hyperlinks', () => {
// arrange
const renderer = createRenderer();
const renderer = createMarkdownRenderer();
const expectedUrl = 'https://privacy.sexy/';
const markdown = `Visit ${expectedUrl} now!`;
// act
@@ -81,18 +81,93 @@ describe('MarkdownRenderer', () => {
const href = aElement.getAttribute('href');
expect(href).to.equal(expectedUrl);
});
it('it generates beautiful labels for auto-linkified URL', () => {
describe('generates readable labels for automatically linked URLs', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly urlText: string;
readonly expectedLabel: string;
}> = [
{
description: 'displays only domain name for URLs without path or query',
urlText: 'https://privacy.sexy',
expectedLabel: 'privacy.sexy',
},
{
description: 'includes subdomains in labels',
urlText: 'https://subdomain.privacy.sexy',
expectedLabel: 'subdomain.privacy.sexy',
},
{
description: 'includes longer URL segment in label',
urlText: 'https://privacy.sexy/LongerExpectedPart/ShorterPart',
expectedLabel: 'privacy.sexy - LongerExpectedPart',
},
{
description: 'capitalizes first letter of URL path in label',
urlText: 'https://privacy.sexy/capitalized',
expectedLabel: 'privacy.sexy - Capitalized',
},
...['-', '%20', '+'].map((urlSegmentDelimiter) => ({
description: `parses \`${urlSegmentDelimiter}\` as a delimiter in URL`,
urlText: `https://privacy.sexy/privacy${urlSegmentDelimiter}scripts`,
expectedLabel: 'privacy.sexy - Privacy Scripts',
})),
{
description: 'treats multiple spaces as single in URLs',
urlText: 'https://privacy.sexy/word--with-multiple---spaces',
expectedLabel: 'privacy.sexy - Word With Multiple Spaces',
},
{
description: 'handles query parameters in URLs correctly',
urlText: 'https://privacy.sexy/?query=parameter',
expectedLabel: 'privacy.sexy - Parameter',
},
{
description: 'truncates long hostnames to a readable length',
urlText: 'https://averylongwebsitedomainnamethatexceedsthetypicalthreshold.com',
expectedLabel: '…exceedsthetypicalthreshold.com',
},
{
description: 'ignores purely numeric path segments in labels',
urlText: 'https://privacy.sexy/20230302/expected',
expectedLabel: 'privacy.sexy - Expected',
},
{
description: 'ignores non-standard ports in labels',
urlText: 'https://privacy.sexy:8080/configure',
expectedLabel: 'privacy.sexy - Configure',
},
{
description: 'removes common file extensions from labels',
urlText: 'https://privacy.sexy/image.png',
expectedLabel: 'privacy.sexy - Image',
},
{
description: 'handles complex queries in URLs by selecting the most descriptive part',
urlText: 'https://privacy.sexy/?product=123&name=PrivacyTool',
expectedLabel: 'privacy.sexy - PrivacyTool',
},
{
description: 'decodes special encoded characters in URLs for labels',
urlText: 'https://privacy.sexy/privacy%2Fscripts',
expectedLabel: 'privacy.sexy - Privacy/scripts',
},
];
testScenarios.forEach(({
description, urlText, expectedLabel,
}) => {
it(description, () => {
// arrange
const renderer = createRenderer();
const url = 'https://privacy.sexy';
const expectedText = 'privacy.sexy';
const markdown = `Visit ${url} now!`;
const renderer = createMarkdownRenderer();
const markdown = `Visit ${urlText} now!`;
// act
const htmlString = renderer.render(markdown);
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.text).to.equal(expectedText);
expect(aElement.text).to.equal(expectedLabel);
});
});
});
});
});