Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Source Loader

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Fumadocs source loaders ingest, normalize, and index document collections from diverse sources like local disk storage, Content Collections, and remote OpenAPI or AsyncAPI specs into a unified virtual file system. They expose public contracts and virtual file definitions via source and StaticSource, mapping files to typed page and metadata objects. Sources: packages/core/src/source/source.ts:1-114, packages/core/src/source/storage/content.ts:1-42

The core loader pipeline parses frontmatter, handles internationalization directories or extension codes, applies loader plugins for slugs and builds hierarchical page trees and search indices. Sources: packages/core/src/source/loader.ts:268-315, packages/core/src/source/storage/content.ts:50-126, packages/core/src/source/plugins/slugs.ts:15-42

Through dynamic loaders and client-side deserialization hooks, Fumadocs supports runtime revalidation, hot reloading, and preloaded remote document integration. Sources: packages/core/src/source/dynamic.ts:27-119, packages/core/src/source/client/index.tsx:14-60, packages/local-md/src/index.ts:84-208

Unified source interface and contracts

Core data structures and virtual file contracts

Fumadocs source loaders rely on a shared type architecture defined by SourceUnion, distinguishing between StaticSource and DynamicSource objects via guard functions isStaticSource and isDynamicSource. Static sources maintain an array of VirtualFile items directly, whereas dynamic sources supply an asynchronous or synchronous builder function files: () => Awaitable<VirtualFile<Config>[]> alongside an optional configure hook accepting a DynamicLoader. Sources: packages/core/src/source/source.ts:5-21, packages/core/src/source/source.ts:172-178

Every virtual file extends BaseVirtualFile, holding a virtualized relative path and an optional absolute path on disk. Depending on its type discriminant, a file resolves to either a VirtualPage carrying slugs and typed pageData, or a VirtualMeta carrying typed metaData. Sources: packages/core/src/source/source.ts:48-78

Property / TypeField TypePurpose & Description
pathstringVirtualized path relative to the content directory (e.g. docs/page.mdx).
absolutePathstring (optional)Absolute path of the underlying file on disk.
type'page' | 'meta'Discriminant property identifying whether the virtual file is a page or metadata file.
slugsstring[] (optional)Explicit route segments assigned to a virtual page.
dataPageData | MetaDataPayload containing frontmatter attributes, titles, icons, or navigation controls.

Sources: packages/core/src/source/source.ts:52-78

Note

The multiple helper utility is deprecated in favor of passing a record of source objects directly to the main loader() function, which automatically scopes namespaces and injects type tags. Sources: packages/core/src/source/source.ts:80-82

Metadata schemas and builder utilities

The base metadata structures define standard properties supported across documentation nodes. MetaData configures folder navigation and display parameters such as icon, title, root, pages, pagesIndex, defaultOpen, collapsible, and description. PageData configures individual document attributes including icon, title, description, and structuredData (which accepts search index payloads or asynchronous generators). Sources: packages/core/src/source/source.ts:28-46

export function source<Page extends PageData, Meta extends MetaData>(config: {
  pages: VirtualPage<Page>[];
  metas: VirtualMeta<Meta>[];
}): StaticSource<{
  pageData: Page;
  metaData: Meta;
}> {
  return {
    files: [...config.pages, ...config.metas],
  };
}
 
interface SourceUpdater<Config extends SourceConfig> {
  files: <Page extends PageData, Meta extends MetaData>(
    fn: (files: VirtualFile<Config>[]) => (VirtualPage<Page> | VirtualMeta<Meta>)[],
  ) => SourceUpdater<{
    pageData: Page;
    metaData: Meta;
  }>;
  page: <V extends PageData>(
    fn: (page: VirtualPage<Config['pageData']>) => VirtualPage<V>,
  ) => SourceUpdater<{
    pageData: V;
    metaData: Config['metaData'];
  }>;
 
  meta: <V extends MetaData>(
    fn: (meta: VirtualMeta<Config['metaData']>) => VirtualMeta<V>,
  ) => SourceUpdater<{
    pageData: Config['pageData'];
    metaData: V;
  }>;
  build: () => StaticSource<Config>;
}
 
/**
 * update a source object in-place.
 */
export function update<Config extends SourceConfig>(
  source: StaticSource<Config>,
): SourceUpdater<Config> {
  return {
    files(fn) {
      source.files = fn(source.files);
      return this as SourceUpdater<never>;
    },
    page(fn) {
      for (let i = 0; i < source.files.length; i++) {
        const file = source.files[i];
        if (file.type === 'page') source.files[i] = fn(file);
      }
 
      return this as SourceUpdater<never>;
    },
    meta(fn) {
      for (let i = 0; i < source.files.length; i++) {
        const file = source.files[i];
        if (file.type === 'meta') source.files[i] = fn(file);
      }
 
      return this as SourceUpdater<never>;
    },
    build() {
      return source;
    },
  };
}

Sources: packages/core/src/source/source.ts:104-170

External adapters and runtime transformation

External integrations bridge third-party content providers into the standard Fumadocs source contract. The Content Collections Adapter maps allDocs and allMetas arrays into VirtualPage and VirtualMeta structures via createMDXSource, binding _meta.filePath directly to file paths. Sources: packages/content-collections/src/index.ts:4-35

Similarly, runtime content stores convert compiled MDX collections and metadata stores into standard StaticSource objects through toFumadocsSource and docsStore. Frontmatter properties are merged directly into page data objects alongside absolute path tracking. Sources: packages/content/src/runtime.ts:12-56

Warning

When integrating custom runtime stores, ensure frontmatter properties do not overwrite core VirtualPage discriminatory fields (type, path, absolutePath) during object spreading. Sources: packages/content/src/runtime.ts:23-32

In-memory filesystem and content storage

Storage construction and plugin transformation

The createContentStorageBuilder function transforms raw static or multi-namespace content inputs into an indexed virtual file system. When internationalization is enabled, parser routines strip locale codes from file paths based on either directory structures or filename extensions, mapping entries into a shared fileMap indexed by target language or an EmptyLang symbol. Sources: packages/core/src/source/storage/content.ts:43-93

export function createContentStorageBuilder(loaderConfig: ResolvedLoaderConfig) {
  const { input, plugins, i18n } = loaderConfig;
 
  let parser: (normalizedPath: string) => [string, (string | string[])?];
  if (!i18n) {
    parser = (path) => [path];
  } else if (i18n.parser === 'dir') {
    const langSet = new Set(i18n.languages);
 
    parser = (path) => {
      const [locale, ...segs] = path.split('/');
      if (!locale || segs.length === 0) return [path];
 
      if (langSet.has(locale)) return [segs.join('/'), locale];
      if (locale === '$') return [segs.join('/'), i18n.languages];
 
      return [path];
    };
  } else {
    const langSet = new Set(i18n.languages);
 
    parser = (path) => {
      const segs = path.split('/');
      const base = segs.pop();
      if (!base) return [path];
 
      const parts = base.split('.');
      if (parts.length < 3) return [path];
 
      const [locale] = parts.splice(parts.length - 2, 1);
      segs.push(parts.join('.'));
 
      if (langSet.has(locale)) return [segs.join('/'), locale];
      if (locale === '$') return [segs.join('/'), i18n.languages];
 
      return [path];
    };
  }

Sources: packages/core/src/source/storage/content.ts:50-87

During the scanning phase, input files are categorized as either page or metadata files. Each file receives a normalized path, format descriptor, source type, and underlying data payload before being inserted into the storage map. Sources: packages/core/src/source/storage/content.ts:94-126

Storage File TypeFormat DiscriminantRequired PropertiesOptional Properties
ContentStoragePageFile'page'format, path, slugs, datatype, absolutePath
ContentStorageMetaFile'meta'format, path, datatype, absolutePath

Sources: packages/core/src/source/storage/content.ts:17-41

Note

When evaluating language parsers, if a file path segment equals $, the parser maps the file across all configured internationalization languages simultaneously. Sources: packages/core/src/source/storage/content.ts:64-83

Once files are indexed within fileMap, makeStorage instantiates a FileSystem instance for a given locale. It populates the filesystem with corresponding storage entries and executes any registered plugin storage transformation hooks. Sources: packages/core/src/source/storage/content.ts:134-146

function makeStorage(locale: string | typeof EmptyLang, inherit?: ContentStorage) {
  const storage = new FileSystem(inherit) as ContentStorage;
  for (const [storageKey, file] of fileMap.get(locale) ?? []) {
    storage.write(storageKey, file);
  }
 
  const context = { storage };
  for (const plugin of plugins) {
    plugin.transformStorage?.(context);
  }
 
  return storage;
}

Sources: packages/core/src/source/storage/content.ts:134-146

Dynamic loader resolution and caching

Sources: packages/core/src/source/dynamic.ts:52-79, packages/core/src/source/source.ts:172-174

The dynamicLoader factory handles runtime content resolution, source configuration, and memoization for dynamic and static documentation sources. During initialization, configureSources iterates over input sources and executes v.configure?.(dynamicLoader) when an input matches dynamic source criteria. Sources: packages/core/src/source/dynamic.ts:40-50, packages/core/src/source/dynamic.ts:116-116

The resolution pipeline follows a strict call chain: dynamicLoader invokes resolveSources, which calls resolveSource on each source object, delegating to isStaticSource for type discrimination. Sources: packages/core/src/source/dynamic.ts:52-79

async function resolveSources(skipCache = false): Promise<ResolvedSource> {
  if (isStaticSource(input) || isDynamicSource(input)) {
    return resolveSource(input, skipCache);
  }
 
  const entries = await Promise.all(
    Object.entries(input).map(async ([k, v]) => [k, await resolveSource(v, skipCache)]),
  );
 
  return Object.fromEntries(entries);
}

Sources: packages/core/src/source/dynamic.ts:52-62

Warning

Bypassing cache via skipCache = true forces resolveSource to re-execute v.files() and overwrite existing entries in sourceCache, discarding previously memoized virtual file arrays. Sources: packages/core/src/source/dynamic.ts:64-79

Source equality is evaluated by isEqual, which checks reference identity for static sources or compares key counts and strict reference equality for multi-source records. Sources: packages/core/src/source/dynamic.ts:121-133

function isEqual(a: ResolvedSource, b: ResolvedSource): boolean {
  if (isStaticSource(a) && isStaticSource(b)) {
    return a === b;
  }
 
  if (!isStaticSource(a) && !isStaticSource(b)) {
    const aKeys = Object.keys(a);
    const bKeys = Object.keys(b);
    return aKeys.length === bKeys.length && aKeys.every((k) => a[k] === b[k]);
  }
 
  return false;
}

Sources: packages/core/src/source/dynamic.ts:121-133

Loader configuration, plugin pipeline, and execution walkthrough

Sources: packages/core/src/source/dynamic.ts:90-93, packages/core/src/source/loader.ts:287-301, packages/core/src/source/loader.ts:474-499

The execution walkthrough for building plugins and resolving configurations traces through dynamicLoader → loader → resolveConfig. When dynamicLoader computes its cache via get(), it invokes loader() with the resolved source and options. The loader function immediately delegates to resolveConfig(), which merges base settings, slugs options, icons, and plugins into a unified ResolvedLoaderConfig. Sources: packages/core/src/source/dynamic.ts:27-119, packages/core/src/source/loader.ts:287-499

export function loader<I extends ResolvedInput, I18n extends I18nConfig | undefined = undefined>(
  ...args:
    | [
        LoaderOptions & {
          source: I;
        },
      ]
    | [I, LoaderOptions]
): LoaderOutput<{
  meta: GenerateMeta<I>;
  page: GeneratePage<I>;
  i18n: I18n;
}> {
  const loaderConfig =
    args.length === 2 ? resolveConfig(args[0], args[1]) : resolveConfig(args[0].source, args[0]);

Sources: packages/core/src/source/loader.ts:287-301

Note

resolveConfig unpacks base loader configurations and validates internationalization parameters before content storage builder initialization. Sources: packages/core/src/source/loader.ts:474-479

The slugs plugin (slugsPlugin) inspects files via storage.getFiles(), processing non-page items or files with pre-existing slugs. When a custom slugFn is absent, it identifies index files via isIndex, pushing them to an index queue to prevent collision conflicts between dir/index.mdx and dir.mdx. Sources: packages/core/src/source/plugins/slugs.ts:15-35

transformStorage({ storage }) {
  const indexFiles: string[] = [];
  const taken = new Set<string>();
 
  for (const path of storage.getFiles()) {
    const file = storage.read(path);
    if (!file || file.format !== 'page' || file.slugs) continue;
 
    const customSlugs = slugFn?.(file);
    // for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
    if (customSlugs === undefined && isIndex(path)) {
      indexFiles.push(path);
      continue;
    }
 
    file.slugs = customSlugs ?? getSlugs(path);
    const key = file.slugs.join('/');
    if (taken.has(key)) throw new Error(`Duplicated slugs: ${key}`);
    taken.add(key);
  }

Sources: packages/core/src/source/plugins/slugs.ts:22-41

Warning

Duplicate slug keys detected across storage entries trigger an immediate error throw via if (taken.has(key)) throw new Error(...). Sources: packages/core/src/source/plugins/slugs.ts:38-40

Path segregation filters out empty directory names and folder groups matching GroupRegex (/^\(.+\)$/). Any attempt to use a folder group directly inside a file name throws an error. Sources: packages/core/src/source/plugins/slugs.ts:69-85

export function getSlugs(file: string): string[] {
  const dir = dirname(file);
  const name = basename(file, extname(file));
  const slugs: string[] = [];
 
  for (const seg of dir.split('/')) {
    // filter empty names and file groups like (group_name)
    if (seg.length > 0 && !GroupRegex.test(seg)) slugs.push(encodeURI(seg));
  }
 
  if (GroupRegex.test(name)) throw new Error(`Cannot use folder group in file names: ${file}`);
 
  if (name !== 'index') {
    slugs.push(encodeURI(name));
  }
 
  return slugs;

Sources: packages/core/src/source/plugins/slugs.ts:74-90

Local markdown and disk storage

File scanning and storage implementation

Sources: packages/local-md/src/storage.ts:23-56, packages/local-md/src/storage.ts:110-126

Local markdown collections are managed by createStorage, which scans content directories using tinyglobby with configurable include patterns. Discovered files are batched into chunks of 100 items (CHUNK_SIZE) and processed concurrently via buildFile. Files are discriminated by extension: .json files are parsed as metadata collections, while .md and .mdx files are processed for frontmatter and body content. Sources: packages/local-md/src/storage.ts:23-56, packages/local-md/src/storage.ts:110-126

async getPages() {
  const files = await glob(include, {
    cwd: dir,
  });
  const chunks: Promise<($Page | $Meta | undefined)[]>[] = [];
 
  for (let i = 0; i < files.length; i += CHUNK_SIZE) {
    const promises: Promise<$Page | $Meta | undefined>[] = [];
    const L = Math.min(files.length, i + CHUNK_SIZE);
 
    for (let j = i; j < L; j++) {
      promises.push(buildFile(files[j]));
    }
 
    chunks.push(Promise.all(promises));
  }
 
  const pages: $Page[] = [];
  const metas: $Meta[] = [];
  for await (const chunk of chunks) {
    for (const item of chunk) {
      if (!item) continue;
      if ('frontmatter' in item) pages.push(item);
      else metas.push(item);
    }
  }
 
  return { pages, metas };
},

Sources: packages/local-md/src/storage.ts:110-138

Warning

If building a file throws an error, the catch block logs the failure to the console and deletes the corresponding absolute path entry from filesCache. Sources: packages/local-md/src/storage.ts:62-66

Frontmatter parsing and schema validation

async function md(absolutePath: string, file: string): Promise<$Page> {
  const content = await fs.readFile(absolutePath, 'utf-8');
  const parsed = parseFrontmatter(content);
 
  const frontmatterResult = await frontmatterSchema['~standard'].validate(parsed.data);
  if (frontmatterResult.issues) {
    const message = frontmatterResult.issues
      .map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
      .join('\n');
    throw new Error(`invalid frontmatter in "${absolutePath}": ${message}`);
  }
 
  return {
    path: file,
    absolutePath,
    content: parsed.content,
    frontmatter: frontmatterResult.value,
  };
}

Sources: packages/local-md/src/storage.ts:68-86

Markdown and MDX files are read from disk and processed with parseFrontmatter to separate content from metadata headers. The extracted frontmatter object is validated against standard schemas using frontmatterSchema['~standard'].validate(). If validation fails, issues are mapped into a detailed multi-line error string reporting path and message specifics. Sources: packages/local-md/src/storage.ts:68-86

ExtensionTarget InterfaceValidation SchemaParser Utility
.md, .mdxRawPagefrontmatterSchemaparseFrontmatter
.jsonRawMetametaSchemaJSON.parse

Sources: packages/local-md/src/storage.ts:10-21, packages/local-md/src/storage.ts:72-97

Caution

Schema validation errors halt execution immediately by throwing an Error containing formatted validation issues for either invalid frontmatter or invalid JSON data. Sources: packages/local-md/src/storage.ts:73-78, packages/local-md/src/storage.ts:92-97

Runtime adapters and dev server integration

export function localMd<
  FrontmatterSchema extends StandardSchemaV1 = typeof defaultSchemas.pageSchema,
  MetaSchema extends StandardSchemaV1 = typeof defaultSchemas.metaSchema,
>(
  config: LocalMarkdownConfig<FrontmatterSchema, MetaSchema>,
): LocalMarkdown<FrontmatterSchema, MetaSchema> {
  const storage = createStorage(config);
  const compiler = createMarkdownCompiler(config);
  const compilerCache = new WeakMap<RawPage<unknown>, Promise<CompileResult>>();
  const registeredLoaders = new Set<DynamicLoader>();
  let cachedStaticSource: Promise<
    StaticSource<{
      pageData: LocalMarkdownPage<StandardSchemaV1.InferOutput<FrontmatterSchema>, never>;
      metaData: StandardSchemaV1.InferOutput<MetaSchema> & MetaData;
    }>
  > | null = null;
 
  async function createFiles(options?: SourceOptions) {
    const { metas, pages } = await storage.getPages();
    const baseDir = options?.baseDir;
    const files: VirtualFile<{
      pageData: LocalMarkdownPage<StandardSchemaV1.InferOutput<FrontmatterSchema>, never>;
      metaData: StandardSchemaV1.InferOutput<MetaSchema> & MetaData;
    }>[] = [];
 
    for (const page of pages) {
      const frontmatter = page.frontmatter as PageData & { _openapi?: unknown };
 
      files.push({
        type: 'page',
        path: baseDir ? path.join(baseDir, page.path) : page.path,
        absolutePath: page.absolutePath,
        data: {
          title: frontmatter.title ?? path.basename(page.path, path.extname(page.path)),
          description: frontmatter.description,
          icon: frontmatter.icon,
          // for Fumadocs OpenAPI
          ['_openapi' as never]: frontmatter._openapi,
 
          content: page.content,
          frontmatter: page.frontmatter,
          async load() {
            let promise = compilerCache.get(page);
            if (!promise) {
              promise = compiler.compile({
                path: page.absolutePath,
                value: page.content,
                data: { frontmatter: page.frontmatter },
              });
            }
 
            const res = await promise;
            return res.type === 'ast'
              ? fromAst({
                  tree: res.tree,
                  filePath: res.file.path,
                  rehypeToc: res.file.data.rehypeToc,
                  structuredData: res.file.data.structuredData,
                  ...config.rendererOptions,
                })
              : fromJS({
                  code: res.code,
                  filePath: res.file.path,
                  baseUrl: pathToFileURL(res.file.path).href,
                  structuredData: res.file.data.structuredData,
                });
          },
        },
      });
    }

Sources: packages/local-md/src/index.ts:84-153

The localMd function exposes staticSource and dynamicSource runtime adapters that transform raw storage entities into virtual files consumed by core loaders. Each page object provides an asynchronous load method that compiles content via a markdown compiler instance, utilizing compilerCache to memoize compilation promises per page. When connected to a development server via devServer, the runner watches the target directory and dispatches file invalidation events. Sources: packages/local-md/src/index.ts:84-153, packages/local-md/src/index.ts:174-195

Note

Calling invalidateFile resets cachedStaticSource to null, evicts the path from storage, and triggers invalidation callbacks on all registered dynamic loaders. Sources: packages/local-md/src/index.ts:168-173

Client deserialization and preview runtime

Client-side page tree consumption

export function deserializePageTree(serialized: SerializedPageTree): PageTree.Root {
  const root = serialized.data as PageTree.Root;
  visit(root, (item) => {
    if ('icon' in item && typeof item.icon === 'string') {
      item.icon = (
        <span
          dangerouslySetInnerHTML={{
            __html: item.icon,
          }}
        />
      );
    }
    if (typeof item.name === 'string') {
      item.name = (
        <span
          className="fd-page-tree-item-name"
          dangerouslySetInnerHTML={{
            __html: item.name,
          }}
        />
      );
    }
  });
 
  return root;
}

Sources: packages/core/src/source/client/index.tsx:14-39

The client package handles serialized navigation trees generated by the server-side loader through deserializePageTree. It inspects incoming objects using isSerializedPageTree, verifying that $fumadocs_loader is set to 'page-tree'. Once validated, visit traverses the tree nodes, parsing raw HTML strings for item icons and names into React span elements configured with dangerouslySetInnerHTML. Sources: packages/core/src/source/client/index.tsx:5-8, packages/core/src/source/client/index.tsx:14-39, packages/core/src/source/client/index.tsx:62-69

export function useFumadocsLoader<V>(serialized: V): Serialized<V> {
  return useMemo(() => {
    const out: Record<string, unknown> = {};
    for (const k in serialized) {
      const v = serialized[k];
      if (isSerializedPageTree(v)) {
        out[k] = deserializePageTree(v);
      } else {
        out[k] = v;
      }
    }
    return out as Serialized<V>;
  }, [serialized]);
}

Sources: packages/core/src/source/client/index.tsx:47-60

The useFumadocsLoader hook memoizes deserialization across re-renders. It iterates over the properties of the provided serialized object, converting matching page tree structures while preserving all unrelated properties in the output. Sources: packages/core/src/source/client/index.tsx:47-60

Note

useFumadocsLoader preserves any non-loader properties present on the input object alongside the freshly parsed page tree elements. Sources: packages/core/src/source/client/index.tsx:42-46, packages/core/src/source/client/index.tsx:54-56

Preview revalidation hooks and route rendering

export const getSource = revalidable({
  async create(config: ParsedAppConfig) {
    return loader({
      source: source(await getPages(config.content)),
      plugins: [lucideIconsPlugin()],
      baseUrl: '/',
    });
  },
});

Sources: packages/preview/src/lib/source/index.ts:7-15

The preview application defines getSource via a revalidable wrapper that instantiates a content loader using source with local page data and the lucideIconsPlugin. Document paths and page image routes are managed by getPageImage, which appends image.webp segments to the URL path array. Sources: packages/preview/src/lib/source/index.ts:7-27

export default async function DocPage({ slugs }: PageProps<'/docs/[...slugs]'>) {
  const config = await getConfigRuntime();
  const source = await getSource(config);
  const page = source.getPage(slugs);
  const mdPreset = config.layout.presets?.md ?? 'docs';
  let mdPresetComponents: MdPresetComponents;
 
  if (mdPreset === 'docs') {
    mdPresetComponents = {
      layout: await import('fumadocs-ui/layouts/docs'),
      page: await import('fumadocs-ui/layouts/docs/page'),
    };
  } else {
    mdPresetComponents = {
      layout: await import('fumadocs-ui/layouts/flux'),
      page: await import('fumadocs-ui/layouts/flux/page'),
    };
  }
 
  return <MdContent slugs={slugs} page={page} components={mdPresetComponents} />;
}

Sources: packages/preview/src/pages/...slugs.tsx:76-96

The dynamic route component DocPage resolves runtime configurations and dynamic layout presets (docs or flux) before rendering document contents. During compilation, remark and rehype plugins process markdown tables, mathematics, mermaid diagrams, code blocks, and table of contents data. Sources: packages/preview/src/pages/...slugs.tsx:29-46, packages/preview/src/pages/...slugs.tsx:76-96

Preset NameLayout ImportPage Import
docsfumadocs-ui/layouts/docsfumadocs-ui/layouts/docs/page
Otherfumadocs-ui/layouts/fluxfumadocs-ui/layouts/flux/page

Sources: packages/preview/src/pages/...slugs.tsx:83-93

Warning

If markdown compilation encounters an error, DocPage catches the exception and renders a fallback error view containing the exception message and stack trace inside a code block. Sources: packages/preview/src/pages/[...slugs].tsx:169-194

Was this page helpful?