mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat: add PageType plugin infrastructure (Phase D Step 4)
This commit is contained in:
parent
f8a682ab45
commit
68f3c3fadd
@ -56,13 +56,14 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||
const output = argv.output
|
||||
|
||||
const pluginCount = Object.values(cfg.plugins).flat().length
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
||||
cfg.plugins[key].map((plugin) => plugin.name)
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters" | "pageTypes") =>
|
||||
(cfg.plugins[key] ?? []).map((plugin) => plugin.name)
|
||||
if (argv.verbose) {
|
||||
console.log(`Loaded ${pluginCount} plugins`)
|
||||
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
|
||||
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
|
||||
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
|
||||
console.log(` PageTypes: ${pluginNames("pageTypes").join(", ")}`)
|
||||
}
|
||||
|
||||
const release = await mut.acquire()
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
QuartzTransformerPluginInstance,
|
||||
QuartzFilterPluginInstance,
|
||||
QuartzEmitterPluginInstance,
|
||||
QuartzPageTypePluginInstance,
|
||||
} from "./types"
|
||||
import { LoadedPlugin } from "./loader/types"
|
||||
|
||||
@ -9,6 +10,7 @@ export interface PluginConfiguration {
|
||||
transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[]
|
||||
filters: (QuartzFilterPluginInstance | LoadedPlugin)[]
|
||||
emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[]
|
||||
pageTypes?: (QuartzPageTypePluginInstance | LoadedPlugin)[]
|
||||
}
|
||||
|
||||
export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin {
|
||||
@ -27,13 +29,22 @@ export function getPluginInstance<T extends object | undefined>(
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| QuartzPageTypePluginInstance
|
||||
| LoadedPlugin,
|
||||
options?: T,
|
||||
): QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance {
|
||||
):
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| QuartzPageTypePluginInstance {
|
||||
if (isLoadedPlugin(plugin)) {
|
||||
const factory = plugin.plugin as (
|
||||
opts?: T,
|
||||
) => QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance
|
||||
) =>
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| QuartzPageTypePluginInstance
|
||||
return factory(options)
|
||||
}
|
||||
return plugin
|
||||
|
||||
@ -47,6 +47,7 @@ export * from "./filters"
|
||||
export * from "./emitters"
|
||||
export * from "./types"
|
||||
export * from "./config"
|
||||
export * as PageTypes from "./pageTypes"
|
||||
export * as PluginLoader from "./loader"
|
||||
|
||||
declare module "vfile" {
|
||||
|
||||
@ -7,7 +7,12 @@ import {
|
||||
PluginResolutionOptions,
|
||||
PluginSpecifier,
|
||||
} from "./types"
|
||||
import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types"
|
||||
import {
|
||||
QuartzTransformerPlugin,
|
||||
QuartzFilterPlugin,
|
||||
QuartzEmitterPlugin,
|
||||
QuartzPageTypePlugin,
|
||||
} from "../types"
|
||||
import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader"
|
||||
|
||||
const MINIMUM_QUARTZ_VERSION = "4.5.0"
|
||||
@ -51,7 +56,9 @@ async function tryImportPlugin(packageName: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function detectPluginType(module: unknown): "transformer" | "filter" | "emitter" | null {
|
||||
function detectPluginType(
|
||||
module: unknown,
|
||||
): "transformer" | "filter" | "emitter" | "pageType" | null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
|
||||
const mod = module as Record<string, unknown>
|
||||
@ -60,6 +67,8 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
|
||||
return null
|
||||
}
|
||||
|
||||
const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod)
|
||||
|
||||
const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some(
|
||||
(key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined),
|
||||
)
|
||||
@ -70,6 +79,7 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
|
||||
|
||||
const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function")
|
||||
|
||||
if (hasPageTypeProps) return "pageType"
|
||||
if (hasEmitterProps) return "emitter"
|
||||
if (hasFilterProps) return "filter"
|
||||
if (hasTransformerProps) return "transformer"
|
||||
@ -79,8 +89,13 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
|
||||
|
||||
function extractPluginFactory(
|
||||
module: unknown,
|
||||
type: "transformer" | "filter" | "emitter",
|
||||
): QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | null {
|
||||
type: "transformer" | "filter" | "emitter" | "pageType",
|
||||
):
|
||||
| QuartzTransformerPlugin
|
||||
| QuartzFilterPlugin
|
||||
| QuartzEmitterPlugin
|
||||
| QuartzPageTypePlugin
|
||||
| null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
|
||||
const mod = module as Record<string, unknown>
|
||||
@ -88,7 +103,11 @@ function extractPluginFactory(
|
||||
const factory = mod.default ?? mod[type] ?? mod.plugin ?? null
|
||||
|
||||
if (typeof factory === "function") {
|
||||
return factory as QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin
|
||||
return factory as
|
||||
| QuartzTransformerPlugin
|
||||
| QuartzFilterPlugin
|
||||
| QuartzEmitterPlugin
|
||||
| QuartzPageTypePlugin
|
||||
}
|
||||
|
||||
return null
|
||||
@ -374,7 +393,7 @@ export async function resolvePlugins(
|
||||
|
||||
console.log(
|
||||
styleText("cyan", `External plugins loaded:`) +
|
||||
` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters`,
|
||||
` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters, ${byType.pageType ?? 0} pageTypes`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types"
|
||||
import {
|
||||
QuartzTransformerPlugin,
|
||||
QuartzFilterPlugin,
|
||||
QuartzEmitterPlugin,
|
||||
QuartzPageTypePlugin,
|
||||
} from "../types"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
|
||||
/**
|
||||
@ -25,7 +30,7 @@ export interface PluginManifest {
|
||||
author?: string
|
||||
homepage?: string
|
||||
keywords?: string[]
|
||||
category?: "transformer" | "filter" | "emitter"
|
||||
category?: "transformer" | "filter" | "emitter" | "pageType"
|
||||
quartzVersion?: string
|
||||
configSchema?: object
|
||||
/** Components provided by this plugin */
|
||||
@ -36,9 +41,9 @@ export interface PluginManifest {
|
||||
* Loaded plugin with metadata
|
||||
*/
|
||||
export interface LoadedPlugin {
|
||||
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin
|
||||
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin
|
||||
manifest: PluginManifest
|
||||
type: "transformer" | "filter" | "emitter"
|
||||
type: "transformer" | "filter" | "emitter" | "pageType"
|
||||
source: string
|
||||
}
|
||||
|
||||
|
||||
32
quartz/plugins/pageTypes/404.ts
Normal file
32
quartz/plugins/pageTypes/404.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { QuartzPageTypePlugin } from "../types"
|
||||
import { match } from "./matchers"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export const NotFoundPageType: QuartzPageTypePlugin = () => ({
|
||||
name: "404",
|
||||
priority: -1,
|
||||
match: match.none(),
|
||||
generate({ cfg }) {
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const slug = "404" as FullSlug
|
||||
const [, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: notFound,
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
slug,
|
||||
title: notFound,
|
||||
data: vfile.data,
|
||||
},
|
||||
]
|
||||
},
|
||||
layout: "404",
|
||||
body: NotFound,
|
||||
})
|
||||
182
quartz/plugins/pageTypes/dispatcher.ts
Normal file
182
quartz/plugins/pageTypes/dispatcher.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types"
|
||||
import { QuartzComponent, QuartzComponentProps } from "../../components/types"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FullSlug, pathToRoot } from "../../util/path"
|
||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||
import { write } from "../emitters/helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
|
||||
function resolveLayout(
|
||||
pageType: QuartzPageTypePluginInstance,
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): FullPageLayout {
|
||||
const overrides = byPageType[pageType.layout] ?? {}
|
||||
return {
|
||||
head: overrides.head ?? sharedDefaults.head!,
|
||||
header: overrides.header ?? sharedDefaults.header ?? [],
|
||||
beforeBody: overrides.beforeBody ?? sharedDefaults.beforeBody ?? [],
|
||||
pageBody: pageType.body(undefined),
|
||||
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
||||
left: overrides.left ?? sharedDefaults.left ?? [],
|
||||
right: overrides.right ?? sharedDefaults.right ?? [],
|
||||
footer: overrides.footer ?? sharedDefaults.footer!,
|
||||
}
|
||||
}
|
||||
|
||||
function collectComponents(
|
||||
pageTypes: QuartzPageTypePluginInstance[],
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): QuartzComponent[] {
|
||||
const seen = new Set<QuartzComponent>()
|
||||
for (const pt of pageTypes) {
|
||||
const layout = resolveLayout(pt, sharedDefaults, byPageType)
|
||||
const all = [
|
||||
layout.head,
|
||||
...layout.header,
|
||||
...layout.beforeBody,
|
||||
layout.pageBody,
|
||||
...layout.afterBody,
|
||||
...layout.left,
|
||||
...layout.right,
|
||||
layout.footer,
|
||||
]
|
||||
for (const c of all) {
|
||||
seen.add(c)
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
interface DispatcherOptions {
|
||||
defaults: Partial<FullPageLayout>
|
||||
byPageType: Record<string, Partial<FullPageLayout>>
|
||||
}
|
||||
|
||||
async function emitPage(
|
||||
ctx: BuildCtx,
|
||||
slug: FullSlug,
|
||||
tree: ProcessedContent[0],
|
||||
fileData: ProcessedContent[1]["data"],
|
||||
allFiles: ProcessedContent[1]["data"][],
|
||||
layout: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
return write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, layout, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
||||
export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>> = (userOpts) => {
|
||||
const defaults = userOpts?.defaults ?? {}
|
||||
const byPageType = userOpts?.byPageType ?? {}
|
||||
|
||||
return {
|
||||
name: "PageTypeDispatcher",
|
||||
getQuartzComponents(ctx) {
|
||||
const pageTypes = ctx.cfg.plugins.pageTypes ?? []
|
||||
return collectComponents(pageTypes, defaults, byPageType)
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const pageTypes = [...(ctx.cfg.plugins.pageTypes ?? [])].sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const fileData = file.data
|
||||
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
|
||||
for (const vp of virtualPages) {
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vp.slug,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
|
||||
yield emitPage(ctx, vp.slug, tree, vfile.data, allFiles, layout, resources)
|
||||
}
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const pageTypes = [...(ctx.cfg.plugins.pageTypes ?? [])].sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
const changedSlugs = new Set<string>()
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
changedSlugs.add(changeEvent.file.data.slug!)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (!changedSlugs.has(slug)) continue
|
||||
|
||||
const fileData = file.data
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
|
||||
for (const vp of virtualPages) {
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vp.slug,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
|
||||
yield emitPage(ctx, vp.slug, tree, vfile.data, allFiles, layout, resources)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
3
quartz/plugins/pageTypes/index.ts
Normal file
3
quartz/plugins/pageTypes/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { match } from "./matchers"
|
||||
export { NotFoundPageType } from "./404"
|
||||
export { PageTypeDispatcher } from "./dispatcher"
|
||||
39
quartz/plugins/pageTypes/matchers.ts
Normal file
39
quartz/plugins/pageTypes/matchers.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { PageMatcher } from "../types"
|
||||
|
||||
export const match = {
|
||||
ext: (extension: string): PageMatcher => {
|
||||
const normalized = extension.startsWith(".") ? extension : `.${extension}`
|
||||
return ({ slug }) => slug.endsWith(normalized) || !slug.includes(".")
|
||||
},
|
||||
|
||||
slugPrefix: (prefix: string): PageMatcher => {
|
||||
return ({ slug }) => slug.startsWith(prefix)
|
||||
},
|
||||
|
||||
frontmatter: (key: string, predicate: (value: unknown) => boolean): PageMatcher => {
|
||||
return ({ fileData }) => {
|
||||
const fm = fileData.frontmatter as Record<string, unknown> | undefined
|
||||
return fm ? predicate(fm[key]) : false
|
||||
}
|
||||
},
|
||||
|
||||
and: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.every((m) => m(args))
|
||||
},
|
||||
|
||||
or: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.some((m) => m(args))
|
||||
},
|
||||
|
||||
not: (matcher: PageMatcher): PageMatcher => {
|
||||
return (args) => !matcher(args)
|
||||
},
|
||||
|
||||
all: (): PageMatcher => {
|
||||
return () => true
|
||||
},
|
||||
|
||||
none: (): PageMatcher => {
|
||||
return () => false
|
||||
},
|
||||
}
|
||||
@ -1,15 +1,17 @@
|
||||
import { PluggableList } from "unified"
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { ProcessedContent } from "./vfile"
|
||||
import { QuartzComponent } from "../components/types"
|
||||
import { FilePath } from "../util/path"
|
||||
import { ProcessedContent, QuartzPluginData } from "./vfile"
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "../components/types"
|
||||
import { FilePath, FullSlug } from "../util/path"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { VFile } from "vfile"
|
||||
|
||||
export interface PluginTypes {
|
||||
transformers: QuartzTransformerPluginInstance[]
|
||||
filters: QuartzFilterPluginInstance[]
|
||||
emitters: QuartzEmitterPluginInstance[]
|
||||
pageTypes?: QuartzPageTypePluginInstance[]
|
||||
}
|
||||
|
||||
type OptionType = object | undefined
|
||||
@ -63,3 +65,67 @@ export type QuartzEmitterPluginInstance = {
|
||||
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
|
||||
externalResources?: ExternalResourcesFn
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PageType Plugin Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Matcher function: determines if a source file belongs to a page type.
|
||||
* Returns true if the page type should own this file.
|
||||
*/
|
||||
export type PageMatcher = (args: {
|
||||
slug: FullSlug
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
}) => boolean
|
||||
|
||||
/**
|
||||
* Virtual page descriptor for page types that generate pages
|
||||
* from aggregated data (e.g., tag indexes, folder listings).
|
||||
*/
|
||||
export interface VirtualPage {
|
||||
slug: FullSlug
|
||||
title: string
|
||||
data: Partial<QuartzPluginData>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function: produces virtual pages from all processed content.
|
||||
* Used by page types that don't match source files but instead create
|
||||
* synthetic pages (e.g., one page per tag, one page per folder).
|
||||
*/
|
||||
export type PageGenerator = (args: {
|
||||
content: ProcessedContent[]
|
||||
cfg: GlobalConfiguration
|
||||
ctx: BuildCtx
|
||||
}) => VirtualPage[]
|
||||
|
||||
/**
|
||||
* Factory function that creates a PageType plugin instance.
|
||||
*/
|
||||
export type QuartzPageTypePlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzPageTypePluginInstance
|
||||
|
||||
/**
|
||||
* A PageType plugin instance.
|
||||
*
|
||||
* PageTypes are a declarative abstraction over page-rendering emitters.
|
||||
* Each PageType declares which files it owns (via `match`), optionally
|
||||
* generates virtual pages (via `generate`), and provides a body component
|
||||
* and layout reference for rendering.
|
||||
*/
|
||||
export type QuartzPageTypePluginInstance = {
|
||||
name: string
|
||||
/** Higher priority wins when multiple page types match the same file. Default: 0. */
|
||||
priority?: number
|
||||
/** Determines which source files this page type owns. */
|
||||
match: PageMatcher
|
||||
/** Produces virtual pages from aggregated content data. */
|
||||
generate?: PageGenerator
|
||||
/** Layout key — references a key in `layout.byPageType`. */
|
||||
layout: string
|
||||
/** The body component constructor for this page type. */
|
||||
body: QuartzComponentConstructor
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user