mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix page modals leaking between pages, redirect link gate and inline modal scripts (#3087)
This commit is contained in:
1 parent
090591873a
commit
bf576f10a4
8 files changed
+73
-45
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
// Validates internal links in the docs at the source level (no build needed):
|
// Validates internal links in the docs at the source level (no build needed):
|
||||||
// - markdown links and `related:` frontmatter in docs/content/**/*.mdx
|
// - markdown links and `related:` frontmatter in docs/content/**/*.mdx
|
||||||
// - menu/link urls in shared/data/docs.json
|
// - menu/link urls in shared/data/docs.json
|
||||||
// - redirect destinations in docs/astro.config.mjs
|
// - redirect destinations in docs/lib/redirects.ts
|
||||||
// - href string literals in docs .astro components
|
// - href string literals in docs .astro components
|
||||||
// - docs paths linked from the demo site: <DocsLink path="…"> and getDocsUrl('…')
|
// - docs paths linked from the demo site: <DocsLink path="…"> and getDocsUrl('…')
|
||||||
// - asset paths that actually render: src, poster and CSS url() in mdx and in
|
// - asset paths that actually render: src, poster and CSS url() in mdx and in
|
||||||
@@ -20,6 +20,7 @@ import { join, dirname, relative } from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { sync } from 'glob'
|
import { sync } from 'glob'
|
||||||
import GithubSlugger from 'github-slugger'
|
import GithubSlugger from 'github-slugger'
|
||||||
|
import { redirects } from '../docs/lib/redirects.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const repoRoot = join(__dirname, '..')
|
const repoRoot = join(__dirname, '..')
|
||||||
@@ -53,19 +54,10 @@ for (const file of sync(join(pagesDir, '**', '*.ts'))) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect sources are valid link targets; their destinations must exist.
|
// Redirect sources are valid link targets; their destinations must exist.
|
||||||
const astroConfig = readFileSync(join(repoRoot, 'docs', 'astro.config.mjs'), 'utf8')
|
// The table lives in docs/lib/redirects.ts (shared with astro.config.mjs and
|
||||||
const redirectSources = new Set<string>()
|
// the docs middleware), so it is imported rather than parsed.
|
||||||
const redirectDestinations: string[] = []
|
const redirectSources = new Set<string>(Object.keys(redirects))
|
||||||
// static entries: '/from': { status: 301, destination: '/to' }
|
const redirectDestinations: string[] = Object.values(redirects).map((redirect) => redirect.destination)
|
||||||
for (const match of astroConfig.matchAll(/'(\/[^']+)':\s*\{\s*status:\s*\d+,\s*destination:\s*'(\/[^']+)'/g)) {
|
|
||||||
redirectSources.add(match[1] ?? '')
|
|
||||||
redirectDestinations.push(match[2] ?? '')
|
|
||||||
}
|
|
||||||
// generated entries: ['alerts', 'alert'], … mapped onto /ui/components/<slug>
|
|
||||||
for (const match of astroConfig.matchAll(/\['([a-z-]+)',\s*'([a-z-]+)'\]/g)) {
|
|
||||||
redirectSources.add(`/ui/components/${match[1]}`)
|
|
||||||
redirectDestinations.push(`/ui/components/${match[2]}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset urls are served from these source directories (synced by copyAssets).
|
// Asset urls are served from these source directories (synced by copyAssets).
|
||||||
const assetRoots: Record<string, string[]> = {
|
const assetRoots: Record<string, string[]> = {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed page modals leaking between pages and made the change-password and confirm-delete modal scripts plain inline JS.
|
||||||
@@ -4,5 +4,5 @@
|
|||||||
// Registration is synchronous (a promise) — see page-modals.ts.
|
// Registration is synchronous (a promise) — see page-modals.ts.
|
||||||
import { addPageModal } from '@shared/lib/page-modals'
|
import { addPageModal } from '@shared/lib/page-modals'
|
||||||
|
|
||||||
addPageModal(Astro.slots.render('default'))
|
addPageModal(Astro.request, Astro.slots.render('default'))
|
||||||
---
|
---
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
// Separate component so drainPageModals() executes only at this point of the
|
// Separate component so drainPageModals(Astro.request) executes only at this point of the
|
||||||
// render stream (after the page content render has STARTED — registrations are
|
// render stream (after the page content render has STARTED — registrations are
|
||||||
// synchronous, modal content may be a promise).
|
// synchronous, modal content may be a promise).
|
||||||
import { drainPageModals } from '@shared/lib/page-modals'
|
import { drainPageModals } from '@shared/lib/page-modals'
|
||||||
|
|
||||||
const modals = await Promise.all(drainPageModals())
|
const modals = await Promise.all(drainPageModals(Astro.request))
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,14 +30,18 @@ import Strength from '@ui/Strength.astro'
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- BEGIN CHANGE PASSWORD -->
|
<!-- BEGIN CHANGE PASSWORD -->
|
||||||
<script>
|
<script is:inline>
|
||||||
// Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle).
|
// Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle).
|
||||||
// The strength meter is wired by tabler.js from data-bs-strength.
|
// The strength meter is wired by tabler.js from data-bs-strength.
|
||||||
const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null
|
function initChangePassword() {
|
||||||
const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null
|
const newPasswordInput = document.getElementById('password-new')
|
||||||
const matchError = document.getElementById('password-match-error')
|
const confirmPasswordInput = document.getElementById('password-confirm')
|
||||||
|
const matchError = document.getElementById('password-match-error')
|
||||||
|
|
||||||
|
if (!newPasswordInput || !confirmPasswordInput || !matchError) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (newPasswordInput && confirmPasswordInput && matchError) {
|
|
||||||
const validateMatch = () => {
|
const validateMatch = () => {
|
||||||
const newPassword = newPasswordInput.value
|
const newPassword = newPasswordInput.value
|
||||||
const confirmPassword = confirmPasswordInput.value
|
const confirmPassword = confirmPasswordInput.value
|
||||||
@@ -58,5 +62,7 @@ import Strength from '@ui/Strength.astro'
|
|||||||
newPasswordInput.addEventListener('input', validateMatch)
|
newPasswordInput.addEventListener('input', validateMatch)
|
||||||
confirmPasswordInput.addEventListener('input', validateMatch)
|
confirmPasswordInput.addEventListener('input', validateMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.readyState !== 'loading' ? initChangePassword() : document.addEventListener('DOMContentLoaded', initChangePassword, { once: true })
|
||||||
</script>
|
</script>
|
||||||
<!-- END CHANGE PASSWORD -->
|
<!-- END CHANGE PASSWORD -->
|
||||||
@@ -43,14 +43,18 @@ import ModalClose from './ModalClose.astro'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- BEGIN CONFIRM DELETE -->
|
<!-- BEGIN CONFIRM DELETE -->
|
||||||
<script>
|
<script is:inline>
|
||||||
const checkbox = document.getElementById('confirm-delete-checkbox') as HTMLInputElement | null
|
function initConfirmDelete() {
|
||||||
const deleteButton = document.getElementById('confirm-delete-button') as HTMLButtonElement | null
|
const checkbox = document.getElementById('confirm-delete-checkbox')
|
||||||
|
const deleteButton = document.getElementById('confirm-delete-button')
|
||||||
|
|
||||||
if (checkbox && deleteButton) {
|
if (checkbox && deleteButton) {
|
||||||
checkbox.addEventListener('change', function (this: HTMLInputElement) {
|
checkbox.addEventListener('change', function () {
|
||||||
deleteButton.disabled = !this.checked
|
deleteButton.disabled = !this.checked
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.readyState !== 'loading' ? initConfirmDelete() : document.addEventListener('DOMContentLoaded', initConfirmDelete, { once: true })
|
||||||
</script>
|
</script>
|
||||||
<!-- END CONFIRM DELETE -->
|
<!-- END CONFIRM DELETE -->
|
||||||
@@ -3,15 +3,28 @@ import { addPageModal, drainPageModals } from './page-modals'
|
|||||||
|
|
||||||
describe('page-modals registry', () => {
|
describe('page-modals registry', () => {
|
||||||
it('drains registrations in insertion order and empties the registry', () => {
|
it('drains registrations in insertion order and empties the registry', () => {
|
||||||
addPageModal('<div>a</div>')
|
const render = {}
|
||||||
addPageModal('<div>b</div>')
|
addPageModal(render, '<div>a</div>')
|
||||||
expect(drainPageModals()).toEqual(['<div>a</div>', '<div>b</div>'])
|
addPageModal(render, '<div>b</div>')
|
||||||
expect(drainPageModals()).toEqual([])
|
expect(drainPageModals(render)).toEqual(['<div>a</div>', '<div>b</div>'])
|
||||||
|
expect(drainPageModals(render)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dedupes identical registrations (same modal declared twice renders once)', () => {
|
it('passes promises through untouched', async () => {
|
||||||
addPageModal('<div>same</div>')
|
const render = {}
|
||||||
addPageModal('<div>same</div>')
|
const pending = Promise.resolve('<div>later</div>')
|
||||||
expect(drainPageModals()).toHaveLength(1)
|
addPageModal(render, pending)
|
||||||
|
const drained = drainPageModals(render)
|
||||||
|
expect(drained[0]).toBe(pending)
|
||||||
|
await expect(drained[0]).resolves.toBe('<div>later</div>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the modals of one render away from another', () => {
|
||||||
|
const pageA = {}
|
||||||
|
const pageB = {}
|
||||||
|
addPageModal(pageA, '<div>a</div>')
|
||||||
|
addPageModal(pageB, '<div>b</div>')
|
||||||
|
expect(drainPageModals(pageB)).toEqual(['<div>b</div>'])
|
||||||
|
expect(drainPageModals(pageA)).toEqual(['<div>a</div>'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
// Modals declared in page content render at the end of <body>, before the
|
// Modals declared in page content render at the end of <body>, before the
|
||||||
// settings panel (via PageModals).
|
// settings panel (via PageModals).
|
||||||
//
|
//
|
||||||
|
// One registry per render, keyed by the request object (like page-scripts):
|
||||||
|
// Astro renders pages concurrently in the build and the dev server, so a
|
||||||
|
// module-level list would hand one page's modals to another.
|
||||||
|
//
|
||||||
// A string OR a promise of a string is registered: CaptureModal must register
|
// A string OR a promise of a string is registered: CaptureModal must register
|
||||||
// itself SYNCHRONOUSLY in its frontmatter (before it first yields control via
|
// itself SYNCHRONOUSLY in its frontmatter (before it first yields control via
|
||||||
// await), because Astro renders siblings concurrently and PageModals may drain
|
// await), because Astro renders siblings concurrently and PageModals may drain
|
||||||
// the registry before the asynchronous render of the slot completes.
|
// the registry before the asynchronous render of the slot completes.
|
||||||
const modals: Array<string | Promise<string>> = []
|
type Modal = string | Promise<string>
|
||||||
|
|
||||||
export function addPageModal(html: string | Promise<string>): void {
|
const registries = new WeakMap<object, Modal[]>()
|
||||||
if (!modals.includes(html)) {
|
|
||||||
modals.push(html)
|
export function addPageModal(render: object, html: Modal): void {
|
||||||
}
|
// No dedup: every slots.render() is a new promise, so identical markup
|
||||||
|
// could never be told apart here anyway.
|
||||||
|
const modals = registries.get(render) ?? []
|
||||||
|
modals.push(html)
|
||||||
|
registries.set(render, modals)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drainPageModals(): Array<string | Promise<string>> {
|
export function drainPageModals(render: object): Modal[] {
|
||||||
const out = [...modals]
|
const out = registries.get(render) ?? []
|
||||||
modals.length = 0
|
registries.delete(render)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user