diff --git a/app/app.vue b/app/app.vue index c695e88cb2..2e9d67ea24 100644 --- a/app/app.vue +++ b/app/app.vue @@ -6,8 +6,8 @@ const route = useRoute() const router = useRouter() const { locale, locales } = useI18n() -// Initialize accent color before hydration to prevent flash -initAccentOnPrehydrate() +// Initialize user preferences (accent color, package manager) before hydration to prevent flash/CLS +initPreferencesOnPrehydrate() const isHomepage = computed(() => route.name === 'index') diff --git a/app/components/ExecuteCommandTerminal.vue b/app/components/ExecuteCommandTerminal.vue new file mode 100644 index 0000000000..e21b82f467 --- /dev/null +++ b/app/components/ExecuteCommandTerminal.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/app/components/InstallCommandTerminal.vue b/app/components/InstallCommandTerminal.vue new file mode 100644 index 0000000000..37bd3441f4 --- /dev/null +++ b/app/components/InstallCommandTerminal.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/app/components/PackageManagerTabs.vue b/app/components/PackageManagerTabs.vue new file mode 100644 index 0000000000..5b66ab99f9 --- /dev/null +++ b/app/components/PackageManagerTabs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/app/components/PackageSkeleton.vue b/app/components/PackageSkeleton.vue index c21bf63c25..d297bb3f02 100644 --- a/app/components/PackageSkeleton.vue +++ b/app/components/PackageSkeleton.vue @@ -106,7 +106,7 @@ id="install-heading-skeleton" class="text-xs text-fg-subtle uppercase tracking-wider mb-3" > - {{ $t('package.skeleton.install') }} + {{ $t('package.skeleton.get_started') }}
diff --git a/app/composables/useSelectedPackageManager.ts b/app/composables/useSelectedPackageManager.ts index 0780ca31d4..63b2efd63c 100644 --- a/app/composables/useSelectedPackageManager.ts +++ b/app/composables/useSelectedPackageManager.ts @@ -1,4 +1,28 @@ -/** @public */ -export function useSelectedPackageManager() { - return useLocalStorage('npmx-pm', 'npm') -} +/** + * Composable for managing the selected package manager preference. + * + * This composable syncs the selected PM to both localStorage and the + * `data-pm` attribute on ``. The attribute enables CSS-based + * visibility of PM-specific content without JavaScript. + * + * @public + */ +export const useSelectedPackageManager = createSharedComposable( + function useSelectedPackageManager() { + const pm = useLocalStorage('npmx-pm', 'npm') + + // Sync to data-pm attribute on the client + if (import.meta.client) { + // Watch for changes and update the attribute + watch( + pm, + newPM => { + document.documentElement.dataset.pm = newPM + }, + { immediate: true }, + ) + } + + return pm + }, +) diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 8b5fdf177c..1bf4a98188 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -84,28 +84,3 @@ export function useAccentColor() { setAccentColor, } } - -/** - * Applies accent color before hydration to prevent flash of default color. - * Call this from app.vue to ensure accent color is applied on every page. - * @public - */ -export function initAccentOnPrehydrate() { - // Callback is stringified by Nuxt - external variables won't be available. - // Colors must be hardcoded since ACCENT_COLORS can't be referenced. - onPrehydrate(() => { - const colors: Record = { - rose: 'oklch(0.797 0.084 11.056)', - amber: 'oklch(0.828 0.165 84.429)', - emerald: 'oklch(0.792 0.153 166.95)', - sky: 'oklch(0.787 0.128 230.318)', - violet: 'oklch(0.714 0.148 286.067)', - coral: 'oklch(0.704 0.177 14.75)', - } - const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') - const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null - if (color) { - document.documentElement.style.setProperty('--accent-color', color) - } - }) -} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index b32e1bbafe..3c5e5165bb 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -13,8 +13,6 @@ definePageMeta({ const router = useRouter() -const isMounted = useMounted() - const { packageName, requestedVersion, orgName } = usePackageRoute() if (import.meta.server) { @@ -273,15 +271,6 @@ const typesPackageName = computed(() => { return packageAnalysis.value.types.packageName }) -const { - selectedPM, - installCommandParts, - typesInstallCommandParts, - showTypesInInstall, - copied, - copyInstallCommand, -} = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) - // Executable detection for run command const executableInfo = computed(() => { if (!displayVersion.value || !pkg.value) return null @@ -306,46 +295,6 @@ const isCreatePkg = computed(() => { return isCreatePackage(pkg.value.name) }) -// Run command parts for a specific command (local execute after install) -function getRunParts(command?: string) { - if (!pkg.value) return [] - return getRunCommandParts({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - command, - isBinaryOnly: false, // Local execute - }) -} - -// Execute command parts for binary-only packages (remote execute) -const executeCommandParts = computed(() => { - if (!pkg.value) return [] - return getExecuteCommandParts({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - isBinaryOnly: true, - isCreatePackage: isCreatePkg.value, - }) -}) - -// Full execute command string for copying -const executeCommand = computed(() => { - if (!pkg.value) return '' - return getExecuteCommand({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - isBinaryOnly: true, - isCreatePackage: isCreatePkg.value, - }) -}) - -// Copy execute command (for binary-only packages) -const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 }) -const copyExecuteCommand = () => copyExecute(executeCommand.value) - // Get associated create-* package info (e.g., vite -> create-vite) const createPackageInfo = computed(() => { if (!packageAnalysis.value?.createPackage) return null @@ -354,60 +303,6 @@ const createPackageInfo = computed(() => { return packageAnalysis.value.createPackage }) -// Create command parts for associated create-* package -const createCommandParts = computed(() => { - if (!createPackageInfo.value) return [] - const pm = packageManagers.find(p => p.id === selectedPM.value) - if (!pm) return [] - - // Extract short name: create-vite -> vite - const createPkgName = createPackageInfo.value.packageName - let shortName: string - if (createPkgName.startsWith('@')) { - // @scope/create-foo -> foo - const slashIndex = createPkgName.indexOf('/') - const name = createPkgName.slice(slashIndex + 1) - shortName = name.startsWith('create-') ? name.slice('create-'.length) : name - } else { - // create-vite -> vite - shortName = createPkgName.startsWith('create-') - ? createPkgName.slice('create-'.length) - : createPkgName - } - - return [...pm.create.split(' '), shortName] -}) - -// Full create command string for copying -const createCommand = computed(() => { - return createCommandParts.value.join(' ') -}) - -// Copy create command -const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 }) -const copyCreateCommand = () => copyCreate(createCommand.value) - -// Primary run command parts -const runCommandParts = computed(() => { - if (!executableInfo.value?.hasExecutable) return [] - return getRunParts(executableInfo.value.primaryCommand) -}) - -// Full run command string for copying -function getFullRunCommand(command?: string) { - if (!pkg.value) return '' - return getRunCommand({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - command, - }) -} - -// Copy run command -const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 }) -const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command)) - // Expandable description const descriptionExpanded = ref(false) const descriptionRef = useTemplateRef('descriptionRef') @@ -943,65 +838,13 @@ function handleClick(event: MouseEvent) { {{ $t('package.run.title') }} -
- -
-
-
- -
-
- - - -
-
- -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - -
-
-
+
+ @@ -1023,178 +866,16 @@ function handleClick(event: MouseEvent) { -
- -
- -
- -
-
- - - -
-
- -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - -
- - -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - - -
- - - - - - -
-
+
+
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts new file mode 100644 index 0000000000..c101b649f0 --- /dev/null +++ b/app/utils/prehydrate.ts @@ -0,0 +1,60 @@ +import type { ACCENT_COLORS } from '#shared/utils/constants' + +type AccentColorId = keyof typeof ACCENT_COLORS + +/** + * Initialize user preferences before hydration to prevent flash/layout shift. + * This sets CSS custom properties and data attributes that CSS can use + * to show the correct content before Vue hydration occurs. + * + * Call this in app.vue or any page that needs early access to user preferences. + * @public + */ +export function initPreferencesOnPrehydrate() { + // Callback is stringified by Nuxt - external variables won't be available. + // All constants must be hardcoded inside the callback. + onPrehydrate(() => { + // Accent colors - hardcoded since ACCENT_COLORS can't be referenced + const colors: Record = { + rose: 'oklch(0.797 0.084 11.056)', + amber: 'oklch(0.828 0.165 84.429)', + emerald: 'oklch(0.792 0.153 166.95)', + sky: 'oklch(0.787 0.128 230.318)', + violet: 'oklch(0.714 0.148 286.067)', + coral: 'oklch(0.704 0.177 14.75)', + } + + // Valid package manager IDs + const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt']) + + // Read settings from localStorage + const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + + // Apply accent color + const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null + if (color) { + document.documentElement.style.setProperty('--accent-color', color) + } + + // Read and apply package manager preference + const storedPM = localStorage.getItem('npmx-pm') + // Parse the stored value (it's stored as a JSON string by useLocalStorage) + let pm = 'npm' + if (storedPM) { + try { + const parsed = JSON.parse(storedPM) + if (validPMs.has(parsed)) { + pm = parsed + } + } catch { + // If parsing fails, check if it's a plain string (legacy format) + if (validPMs.has(storedPM)) { + pm = storedPM + } + } + } + + // Set data attribute for CSS-based visibility + document.documentElement.dataset.pm = pm + }) +} diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts index 612bf5d483..dd5973662a 100644 --- a/test/nuxt/components/DateTime.spec.ts +++ b/test/nuxt/components/DateTime.spec.ts @@ -10,7 +10,6 @@ vi.mock('~/composables/useSettings', () => ({ settings: ref({ relativeDates: mockRelativeDates.value }), }), useAccentColor: () => ({}), - initAccentOnPrehydrate: () => {}, })) describe('DateTime', () => { diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 0b2e81b58e..20a827a3fe 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -3,8 +3,11 @@ import type { JsrPackageInfo } from '#shared/types/jsr' describe('useInstallCommand', () => { beforeEach(() => { - // Reset localStorage before each test + // Reset localStorage and package manager state before each test localStorage.clear() + // Reset the shared composable state to default 'npm' + const pm = useSelectedPackageManager() + pm.value = 'npm' }) afterEach(() => { diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts index 001dfa03f1..bac1e54b77 100644 --- a/tests/create-command.spec.ts +++ b/tests/create-command.spec.ts @@ -7,12 +7,12 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create vite/i) // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) - await expect(page.locator('a[href="/create-vite"]')).toBeAttached() + await expect(page.locator('a[href="/create-vite"]').first()).toBeAttached() }) test('/next - should show create command (shared maintainer, same repo)', async ({ @@ -23,12 +23,12 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) - await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() + await expect(page.locator('a[href="/create-next-app"]').first()).toBeAttached() }) test('/nuxt - should show create command (same maintainer, same org)', async ({ @@ -40,7 +40,7 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // nuxt has create-nuxt package, so command is "npm create nuxt" // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) }) @@ -55,7 +55,8 @@ test.describe('Create Command', () => { await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() // Create command section should NOT be visible (different maintainers) - const createCommandSection = page.locator('.group\\/createcmd') + // Use .first() for consistency, though none should exist + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).not.toBeVisible() }) @@ -69,7 +70,8 @@ test.describe('Create Command', () => { await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() // Create command section should NOT be visible (no create-lodash exists) - const createCommandSection = page.locator('.group\\/createcmd') + // Use .first() for consistency, though none should exist + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).not.toBeVisible() }) }) @@ -83,7 +85,7 @@ test.describe('Create Command', () => { await expect(page.locator('h1')).toContainText('vite') // Find the create command container (wait longer for API response) - const createCommandContainer = page.locator('.group\\/createcmd') + const createCommandContainer = page.locator('.group\\/createcmd').first() await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) // Copy button should initially be hidden (opacity-0) @@ -108,7 +110,7 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) // Find and hover over the create command container - const createCommandContainer = page.locator('.group\\/createcmd') + const createCommandContainer = page.locator('.group\\/createcmd').first() await createCommandContainer.hover() // Click the copy button @@ -132,7 +134,7 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) // Find the install command container - const installCommandContainer = page.locator('.group\\/installcmd') + const installCommandContainer = page.locator('.group\\/installcmd').first() await expect(installCommandContainer).toBeVisible() // Copy button should initially be hidden @@ -157,7 +159,7 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) // Find and hover over the install command container - const installCommandContainer = page.locator('.group\\/installcmd') + const installCommandContainer = page.locator('.group\\/installcmd').first() await installCommandContainer.hover() // Click the copy button