Skip to content

Commit 75d6f76

Browse files
committed
Fix time string handling to avoid it breaking across lines
1 parent 0d78475 commit 75d6f76

8 files changed

Lines changed: 296 additions & 18 deletions

File tree

‎_TODO.md‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Need a tooltip component for consistency. List to add tooltips to:
9898

9999
## Support Pages to Style
100100

101-
- /404
101+
- /404 (should show search results based on query)
102102
- /search
103103
- Bug reporter modal
104104
- Email templates
@@ -130,26 +130,16 @@ https://mermaid.js.org/config/directives.html
130130

131131
- Add a copyright notice to content
132132

133-
- Add PDF download image to download CTA, it's in the directory
134-
135133
- Time in prose is causing a line break, and the colon and minutes to be removed - "2:47 AM" in `src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx`. Times like "11:59:59" are breaking across two lines - `src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx` in "Algorithm Overview" section
136134

137-
- Should we exclude "Footnotes" from the ToC list? Right now it shows at the bottom if there's a Footnotes H2.
135+
- We need to exclude "Footnotes" from the ToC list of links. Right now it shows at the bottom if there's a Footnotes H2 (footnotes in the article).
138136

139137
- If a reader has already given their email address - newsletter signup, contact form, download registration, then the download CTA on short form articles should go directly to the HTML version of the deep dive, and it should have a PDF download button. Think this workflow through - maybe a "Short / Deep-Dive" slider button on top and don't show the CTA + the PDF download button.
140138

141-
- The articles list page should show tags at top for quick navigation. There's another note about this. Show the count of articles per tag unless they're all the same on the tag.
142-
143-
- 404 page should show search results based on query
139+
- The articles list page should show tags at top for quick navigation. Show the count of articles per tag unless they're all the same on the tag.
144140

145141
- Update EXIF data on all AI generated JPGs
146142

147-
- When you click a link to a heading anchor, the heading is hidden by the page header now that it's stickied to the page
148-
149-
- The Newsletter token page spins forever. It should time out and show an error page at some point.
150-
151-
- Need a secret token to bypass the waiting state for the Newsletter token page, so we can style it. Set it via env var.
152-
153143
- Details dd / dt in Markdown need bottom margin
154144

155145
- Highlighter component in Markdown needs better colors in dark theme. It's spotlight color with content text - and there's not much contrast in dark theme between the yellow and white text.

‎src/components/CallToAction/Download/index.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const downloadUrl = `/downloads/${normalizedResource}`
5858
---
5959

6060
<section
61-
class="bg-linear-to-br from-primary/5 to-accent/5 border border-primary/20 rounded-2xl p-6 md:p-8"
61+
class="bg-linear-to-br from-primary/5 to-accent/5 border border-primary/20 rounded-2xl p-6 md:p-8 mb-6"
6262
aria-labelledby={titleId}
6363
aria-describedby={descriptionId}
6464
>

‎src/components/Content/Layout/index.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,14 @@ const { author, article, path, readingTime, section } = Astro.props
8989
Found this helpful? Share it with others who might benefit.
9090
</p>
9191
</div>
92+
<WebMentions url={`https://www.webstackbuilders.com${path}`} />
9293
<Shares
9394
url={`https://www.webstackbuilders.com${path}`}
9495
title={article.data.title}
9596
description={`Read "${article.data.title}" on Webstack Builders`}
9697
class="shrink-0"
9798
/>
9899
</div>
99-
<WebMentions url={`https://www.webstackbuilders.com${path}`} />
100100
</section>
101101

102102
{/** SLOT: related-content */}

‎src/layouts/MarkdownLayout.astro‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,10 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0
173173
<Content components={{ ...Components }} />
174174
</div>
175175
{shouldRenderToc && <Toc items={tocHeadings} />}
176-
</div>
177176

178-
{/** SLOT: after-content, includes social shares and web mentions */}
179-
<slot name="after-content" />
177+
{/** SLOT: after-content, includes social shares and web mentions */}
178+
<slot name="after-content" />
179+
</div>
180180
</article>
181181

182182
{/** SLOT: related-content, renders a suggested content carousel */}

‎src/lib/config/markdown.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ Object.defineProperty(remarkDirective, 'name', { value: 'remarkDirective' })
8585
import remarkVideo, { type Config as RemarkVideoConfig } from 'remark-video'
8686
Object.defineProperty(remarkVideo, 'name', { value: 'remarkVideo' })
8787

88+
import remarkRestoreTimeColons from '../markdown/plugins/remark-restore-time-colons'
89+
Object.defineProperty(remarkRestoreTimeColons, 'name', { value: 'remarkRestoreTimeColons' })
90+
8891
import remarkMarkPlus from '../markdown/plugins/remark-mark-plus'
8992
Object.defineProperty(remarkMarkPlus, 'name', { value: 'remarkMarkPlus' })
9093

@@ -400,6 +403,8 @@ export const markdownConfig: Partial<MdxOptions> = {
400403
remarkDirective,
401404
/** HTML5 video via MDX-safe :::video directive blocks */
402405
[remarkVideo, remarkVideoConfig],
406+
/** Restore numeric-only directive nodes (e.g. :59 from "11:59:59") back to text */
407+
remarkRestoreTimeColons,
403408
/** Automatically convert URL-like text to links */
404409
remarkLinkifyRegexUrls,
405410
/**
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Tests for remark-restore-time-colons plugin
3+
*
4+
* Verifies that numeric-only directive nodes created by remark-directive
5+
* (e.g. `:47` from "2:47 AM") are restored to plain text while leaving
6+
* real directives like :::video intact.
7+
*/
8+
import { describe, it, expect } from 'vitest'
9+
import { unified } from 'unified'
10+
import remarkParse from 'remark-parse'
11+
import remarkDirective from 'remark-directive'
12+
import remarkRehype from 'remark-rehype'
13+
import rehypeStringify from 'rehype-stringify'
14+
import remarkRestoreTimeColons from '@lib/markdown/plugins/remark-restore-time-colons'
15+
16+
/**
17+
* Process markdown through remarkDirective + remarkRestoreTimeColons and
18+
* return the rendered HTML string.
19+
*/
20+
async function processToHtml(markdown: string): Promise<string> {
21+
const result = await unified()
22+
.use(remarkParse)
23+
.use(remarkDirective)
24+
.use(remarkRestoreTimeColons)
25+
.use(remarkRehype)
26+
.use(rehypeStringify)
27+
.process(markdown)
28+
29+
return String(result)
30+
}
31+
32+
/**
33+
* Process markdown through remarkDirective WITHOUT the fix, to confirm
34+
* the baseline is broken.
35+
*/
36+
async function processWithoutFix(markdown: string): Promise<string> {
37+
const result = await unified()
38+
.use(remarkParse)
39+
.use(remarkDirective)
40+
.use(remarkRehype)
41+
.use(rehypeStringify)
42+
.process(markdown)
43+
44+
return String(result)
45+
}
46+
47+
describe('remark-restore-time-colons', () => {
48+
describe('baseline: confirms remarkDirective corrupts time strings', () => {
49+
it('corrupts H:MM time patterns without the fix', async () => {
50+
const output = await processWithoutFix('At 2:47 AM the alert fired.')
51+
expect(output).toContain('<div></div>')
52+
expect(output).not.toContain('2:47')
53+
})
54+
55+
it('corrupts HH:MM:SS time patterns without the fix', async () => {
56+
const output = await processWithoutFix('Reset at 11:59:59 PM.')
57+
expect(output).toContain('<div></div>')
58+
expect(output).not.toContain('11:59:59')
59+
})
60+
})
61+
62+
describe('single time strings', () => {
63+
it('preserves H:MM AM format (2:47 AM)', async () => {
64+
const output = await processToHtml('At 2:47 AM the alert fired.')
65+
expect(output).toContain('2:47 AM')
66+
expect(output).not.toContain('<div></div>')
67+
})
68+
69+
it('preserves H:MM PM format (3:30 PM)', async () => {
70+
const output = await processToHtml('The time was 3:30 PM.')
71+
expect(output).toContain('3:30 PM')
72+
expect(output).not.toContain('<div></div>')
73+
})
74+
75+
it('preserves HH:MM format (12:00)', async () => {
76+
const output = await processToHtml('Starts at 12:00 sharp.')
77+
expect(output).toContain('12:00')
78+
expect(output).not.toContain('<div></div>')
79+
})
80+
81+
it('preserves HH:MM:SS format (11:59:59)', async () => {
82+
const output = await processToHtml('At 11:59:59 PM things changed.')
83+
expect(output).toContain('11:59:59')
84+
expect(output).not.toContain('<div></div>')
85+
})
86+
87+
it('preserves HH:MM:SS format (12:00:01)', async () => {
88+
const output = await processToHtml('Reset at 12:00:01 exactly.')
89+
expect(output).toContain('12:00:01')
90+
expect(output).not.toContain('<div></div>')
91+
})
92+
})
93+
94+
describe('multiple time strings in one paragraph', () => {
95+
it('preserves both times in boundary burst example', async () => {
96+
const input =
97+
'A client can make 95 requests at 11:59:59 and 95 more at 12:00:01.'
98+
const output = await processToHtml(input)
99+
expect(output).toContain('11:59:59')
100+
expect(output).toContain('12:00:01')
101+
expect(output).not.toContain('<div></div>')
102+
})
103+
104+
it('preserves two different H:MM times', async () => {
105+
const input = 'At 2:47 AM the alert fired. By 3:15 PM it was resolved.'
106+
const output = await processToHtml(input)
107+
expect(output).toContain('2:47 AM')
108+
expect(output).toContain('3:15 PM')
109+
expect(output).not.toContain('<div></div>')
110+
})
111+
})
112+
113+
describe('non-time directives are unaffected', () => {
114+
it('does not interfere with alphabetic text directives', async () => {
115+
// A real text directive like :abbr[HTML] should not be touched
116+
const output = await processToHtml('Use the :abbr[HTML] standard.')
117+
// The directive node should still be present (not converted to text)
118+
expect(output).not.toContain(':abbr')
119+
})
120+
121+
it('does not interfere with container directives', async () => {
122+
const input = ':::video{src="/videos/demo.mp4"}\nVideo caption\n:::'
123+
const output = await processToHtml(input)
124+
// Container directive should still be processed, not restored to text
125+
expect(output).not.toContain(':::video')
126+
})
127+
})
128+
129+
describe('output HTML structure', () => {
130+
it('produces a clean paragraph without broken elements', async () => {
131+
const input =
132+
'A client can make 95 requests at 11:59:59 and 95 more at 12:00:01.'
133+
const output = await processToHtml(input)
134+
// Should be a single, unbroken paragraph
135+
expect(output).toBe(
136+
'<p>A client can make 95 requests at 11:59:59 and 95 more at 12:00:01.</p>'
137+
)
138+
})
139+
140+
it('wraps surrounding text correctly', async () => {
141+
const output = await processToHtml(
142+
'At 2:47 AM, all inter-service communication fails.'
143+
)
144+
expect(output).toBe(
145+
'<p>At 2:47 AM, all inter-service communication fails.</p>'
146+
)
147+
})
148+
})
149+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Remark plugin to restore numeric-only directive nodes back to plain text.
3+
*
4+
* The `remark-directive` parser treats colon-prefixed digit sequences (e.g.
5+
* `:47` in "2:47 AM" or `:59` in "11:59:59") as text directives. Those
6+
* unhandled nodes later render as empty `<div></div>` elements and break
7+
* paragraph flow.
8+
*
9+
* This transformer runs **after** `remarkDirective` (and after any plugins
10+
* that consume real directives, such as `remarkVideo`). It walks the tree,
11+
* finds directive nodes whose name is purely numeric, and splices them back
12+
* into the surrounding text, restoring the original colon-separated time
13+
* strings.
14+
*/
15+
import type { Parent, Root, Text } from 'mdast'
16+
import type { Plugin } from 'unified'
17+
import { visit } from 'unist-util-visit'
18+
19+
/**
20+
* Test whether a directive name is purely numeric (0-9).
21+
* No valid remark directive would use a numeric-only name, so these are
22+
* always false positives created from time-like strings.
23+
*/
24+
const isNumericName = (name: string): boolean => /^\d+$/.test(name)
25+
26+
/** Record of a directive node to restore, collected during walk. */
27+
interface PendingRestore {
28+
parent: Parent
29+
index: number
30+
name: string
31+
}
32+
33+
const remarkRestoreTimeColons: Plugin<[], Root> = () => {
34+
return (tree: Root) => {
35+
// Phase 1: Collect all numeric directive nodes
36+
const pending: PendingRestore[] = []
37+
38+
visit(tree, (node, index, parent) => {
39+
if (
40+
node.type !== 'textDirective' &&
41+
node.type !== 'leafDirective'
42+
) {
43+
return
44+
}
45+
46+
// Only restore purely-numeric names; real directives have alphabetic names
47+
if (!isNumericName(node.name)) return
48+
if (index === undefined || !parent) return
49+
50+
pending.push({ parent: parent as Parent, index, name: node.name })
51+
})
52+
53+
// Phase 2: Process in reverse order so earlier indices stay valid
54+
for (let i = pending.length - 1; i >= 0; i--) {
55+
const entry = pending[i]
56+
if (!entry) continue
57+
const { parent, index, name } = entry
58+
59+
const textNode: Text = { type: 'text', value: `:${name}` }
60+
parent.children.splice(index, 1, textNode as typeof parent.children[0])
61+
62+
// Merge with next text sibling
63+
const current = parent.children[index]
64+
const next = parent.children[index + 1]
65+
if (current && next && current.type === 'text' && next.type === 'text') {
66+
;(current as Text).value += (next as Text).value
67+
parent.children.splice(index + 1, 1)
68+
}
69+
70+
// Merge with previous text sibling
71+
const prev = parent.children[index - 1]
72+
const curr = parent.children[index]
73+
if (prev && curr && prev.type === 'text' && curr.type === 'text') {
74+
;(prev as Text).value += (curr as Text).value
75+
parent.children.splice(index, 1)
76+
}
77+
}
78+
}
79+
}
80+
81+
export default remarkRestoreTimeColons

‎test-restore-plugin.mjs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { unified } from 'unified';
2+
import remarkParse from 'remark-parse';
3+
import remarkDirective from 'remark-directive';
4+
import remarkRehype from 'remark-rehype';
5+
import rehypeStringify from 'rehype-stringify';
6+
import remarkRestoreTimeColons from './src/lib/markdown/plugins/remark-restore-time-colons/index.ts';
7+
8+
const tests = [
9+
{ input: 'At 2:47 AM, all inter-service communication fails.', expected: '2:47 AM' },
10+
{ input: 'At 11:59:59 PM things changed.', expected: '11:59:59 PM' },
11+
{ input: 'The time was 3:30 PM.', expected: '3:30 PM' },
12+
{ input: 'Reset at 12:00:01 exactly.', expected: '12:00:01' },
13+
{ input: '95 requests at 11:59:59 and 95 more at 12:00:01 — 190 requests.', expected: '11:59:59 and 95 more at 12:00:01' },
14+
{ input: 'At 2:47 AM, the alert fired. By 3:15 PM it was resolved.', expected: '2:47 AM' },
15+
// Real directives should NOT be affected
16+
{ input: ':::video{src="/videos/demo.mp4"}\nVideo caption\n:::', expected: null },
17+
];
18+
19+
let passed = 0;
20+
let failed = 0;
21+
22+
for (const { input, expected } of tests) {
23+
const result = await unified()
24+
.use(remarkParse)
25+
.use(remarkDirective)
26+
.use(remarkRestoreTimeColons)
27+
.use(remarkRehype)
28+
.use(rehypeStringify)
29+
.process(input);
30+
31+
const output = String(result);
32+
const ok = expected === null || output.includes(expected);
33+
34+
if (ok) {
35+
passed++;
36+
console.log(`✓ PASS: "${input.slice(0, 60)}"`);
37+
} else {
38+
failed++;
39+
console.log(`✗ FAIL: "${input.slice(0, 60)}"`);
40+
console.log(` Expected to contain: ${expected}`);
41+
console.log(` Got: ${output}`);
42+
}
43+
44+
// Also check no empty <div></div> in output (except for real directives)
45+
if (expected !== null && output.includes('<div></div>')) {
46+
console.log(` ✗ STILL CONTAINS <div></div>: ${output}`);
47+
failed++;
48+
passed--;
49+
}
50+
}
51+
52+
console.log(`\n${passed} passed, ${failed} failed`);
53+
process.exit(failed > 0 ? 1 : 0);

0 commit comments

Comments
 (0)