# core/validators/metadata.validator.ts

> 169 lines of code and 39 definitions.

Tree: Bane's Lab Build Scripts
Language: typescript
Layer: processing
Canonical: https://banes-lab.com/anatomy/build-scripts#file-build-scripts-core-validators-metadata-validator-ts
Source text: https://banes-lab.com/source/build-scripts/core/validators/metadata.validator.ts.txt

Listed in [core/validators](https://banes-lab.com/api/source/build-scripts/core/validators.md), after [core/validators/link.validator.ts](https://banes-lab.com/source/build-scripts/core/validators/link.validator.ts.md) and before [core/validators/payload.validator.ts](https://banes-lab.com/source/build-scripts/core/validators/payload.validator.ts.md).

## Definitions

- `typedEntitiesOf` (lexical_declaration, line 57)
- `undatedFieldsOf` (lexical_declaration, line 112)
- `entitiesOf` (lexical_declaration, line 34)
- `checkSchema` (lexical_declaration, line 79, exported)
- `checkEncodings` (lexical_declaration, line 177, exported)
- `foreignPropertiesOf` (lexical_declaration, line 68)
- `isIsoInstant` (lexical_declaration, line 104)
- `missingFieldsOf` (lexical_declaration, line 49)
- `isPageAt` (lexical_declaration, line 45)
- `isIsoDay` (lexical_declaration, line 95)
- `hasOffset` (lexical_declaration, line 100)
- `contentUrlsOf` (lexical_declaration, line 166)
- `checkArticleImages` (lexical_declaration, line 147, exported)
- `checkDates` (lexical_declaration, line 122, exported)
- `GRAPH_KEY` (lexical_declaration, line 23)
- `TYPE_KEY` (lexical_declaration, line 24)
- `ORGANIZATION_TYPE` (lexical_declaration, line 25)
- `CONTENT_URL_KEY` (lexical_declaration, line 26)
- `ARTICLE_TYPE` (lexical_declaration, line 27)
- `ISO_DAY_LENGTH` (lexical_declaration, line 28)
- `TIME_MARK` (lexical_declaration, line 29)
- `UTC_MARK` (lexical_declaration, line 30)
- `OFFSET_LENGTH` (lexical_declaration, line 31)
- `OFFSET_SIGNS` (lexical_declaration, line 32)
- `parsed` (lexical_declaration, line 38)
- `fields` (lexical_declaration, line 51)
- `type` (lexical_declaration, line 69)
- `allowed` (lexical_declaration, line 70)
- `findings` (lexical_declaration, line 81, exported)
- `day` (lexical_declaration, line 96)
- `time` (lexical_declaration, line 108)
- `entities` (lexical_declaration, line 123, exported)
- `page` (lexical_declaration, line 124, exported)
- `dated` (lexical_declaration, line 128, exported)
- `imagesOf` (lexical_declaration, line 141)
- `{ image }` (lexical_declaration, line 142)
- `listed` (lexical_declaration, line 143)
- `pixels` (lexical_declaration, line 156, exported)
- `own` (lexical_declaration, line 173)

## Contained in

- [core/validators](https://banes-lab.com/anatomy/build-scripts/folder-build-scripts-core-validators.md)

## Uses

- [configuration/strings/site.strings.ts](https://banes-lab.com/source/build-scripts/configuration/strings/site.strings.ts.md)
- [core/predicates/asset.predicate.ts](https://banes-lab.com/source/build-scripts/core/predicates/asset.predicate.ts.md)

## Used by

- [core/validators/source.page.validator.ts](https://banes-lab.com/source/build-scripts/core/validators/source.page.validator.ts.md)

## Linked from

- [configuration/strings](https://banes-lab.com/anatomy/build-scripts/folder-build-scripts-configuration-strings.md)
- [core/predicates](https://banes-lab.com/anatomy/build-scripts/folder-build-scripts-core-predicates.md)
- [core/validators](https://banes-lab.com/anatomy/build-scripts/folder-build-scripts-core-validators.md)

## Source

```typescript
import {
    ARTICLE_MIN_PIXELS,
    PAGE_DATE_FIELDS,
    SCHEMA_FIELDS,
    SCHEMA_KEYWORDS,
    SCHEMA_PROPERTIES,
    WEB_PAGE_TYPES,
} from "#configuration/constants/site.constants";
import type { Finding, PageArtifact } from "#types/validation.types";
import {
    NO_ORGANIZATION,
    missingSchemaField,
    missingWebPage,
    smallArticleImage,
    undatedWebPage,
    unknownSchemaProperty,
    unknownSchemaType,
    unreadableArticleImage,
    unservedEncoding,
} from "#configuration/strings/site.strings";
import { isRecord } from "#core/selectors/base.selector";

const GRAPH_KEY = "@graph";
const TYPE_KEY = "@type";
const ORGANIZATION_TYPE = "Organization";
const CONTENT_URL_KEY = "contentUrl";
const ARTICLE_TYPE = "TechArticle";
const ISO_DAY_LENGTH = 10;
const TIME_MARK = "T";
const UTC_MARK = "Z";
const OFFSET_LENGTH = 6;
const OFFSET_SIGNS: ReadonlySet<string> = new Set(["+", "-"]);

const entitiesOf = function entitiesOf(schema: string | null): Record<string, unknown>[] {
    if (schema === null) {
        return [];
    }
    const parsed: unknown = JSON.parse(schema);
    if (!isRecord(parsed) || !Array.isArray(parsed[GRAPH_KEY])) {
        return [];
    }
    return parsed[GRAPH_KEY].filter(isRecord);
};

const isPageAt = function isPageAt(entity: Record<string, unknown>, address: string): boolean {
    return WEB_PAGE_TYPES.has(String(entity[TYPE_KEY])) && entity["url"] === address;
};

const missingFieldsOf = function missingFieldsOf(file: string, entity: Record<string, unknown>): Finding[] {
    const type = entity[TYPE_KEY];
    const fields = typeof type === "string" ? (SCHEMA_FIELDS.get(type) ?? []) : [];
    return fields
        .filter((field) => entity[field] === undefined)
        .map((field) => ({ file, message: missingSchemaField(String(type), field) }));
};

const typedEntitiesOf = function typedEntitiesOf(value: unknown): Record<string, unknown>[] {
    if (Array.isArray(value)) {
        return value.flatMap(typedEntitiesOf);
    }
    if (!isRecord(value)) {
        return [];
    }
    const own = typeof value[TYPE_KEY] === "string" ? [value] : [];
    return [...own, ...Object.values(value).flatMap(typedEntitiesOf)];
};

const foreignPropertiesOf = function foreignPropertiesOf(file: string, entity: Record<string, unknown>): Finding[] {
    const type = String(entity[TYPE_KEY]);
    const allowed = SCHEMA_PROPERTIES.get(type);
    if (allowed === undefined) {
        return [{ file, message: unknownSchemaType(type) }];
    }
    return Object.keys(entity)
        .filter((property) => !SCHEMA_KEYWORDS.has(property) && !allowed.has(property))
        .map((property) => ({ file, message: unknownSchemaProperty(type, property) }));
};

export const checkSchema = function checkSchema(file: string, artifact: PageArtifact, address: string): Finding[] {
    const entities = entitiesOf(artifact.schema);
    const findings: Finding[] = [];
    if (!entities.some((entity) => entity[TYPE_KEY] === ORGANIZATION_TYPE)) {
        findings.push({ file, message: NO_ORGANIZATION });
    }
    if (!entities.some((entity) => isPageAt(entity, address))) {
        findings.push({ file, message: missingWebPage(address) });
    }
    return [
        ...findings,
        ...typedEntitiesOf(entities).flatMap((entity) => missingFieldsOf(file, entity)),
        ...typedEntitiesOf(entities).flatMap((entity) => foreignPropertiesOf(file, entity)),
    ];
};

const isIsoDay = function isIsoDay(value: string): boolean {
    const day = new Date(value);
    return !Number.isNaN(day.getTime()) && day.toISOString().slice(0, ISO_DAY_LENGTH) === value;
};

const hasOffset = function hasOffset(time: string): boolean {
    return time.endsWith(UTC_MARK) || OFFSET_SIGNS.has(time.at(-OFFSET_LENGTH) ?? "");
};

const isIsoInstant = function isIsoInstant(value: unknown): boolean {
    if (typeof value !== "string" || value.at(ISO_DAY_LENGTH) !== TIME_MARK) {
        return false;
    }
    const time = value.slice(ISO_DAY_LENGTH);
    return isIsoDay(value.slice(0, ISO_DAY_LENGTH)) && hasOffset(time) && !Number.isNaN(Date.parse(value));
};

const undatedFieldsOf = function undatedFieldsOf(
    file: string,
    entity: Record<string, unknown>,
    fields: readonly string[],
): Finding[] {
    return fields
        .filter((field) => !isIsoInstant(entity[field]))
        .map((field) => ({ file, message: undatedWebPage(field, String(entity[field])) }));
};

export const checkDates = function checkDates(file: string, artifact: PageArtifact, address: string): Finding[] {
    const entities = entitiesOf(artifact.schema);
    const page = entities.find((entity) => isPageAt(entity, address));
    if (page === undefined) {
        return [];
    }
    const dated = typedEntitiesOf(entities).filter((entity) => entity !== page);
    return [
        ...undatedFieldsOf(file, page, PAGE_DATE_FIELDS),
        ...dated.flatMap((entity) =>
            undatedFieldsOf(
                file,
                entity,
                PAGE_DATE_FIELDS.filter((field) => entity[field] !== undefined),
            ),
        ),
    ];
};

const imagesOf = function imagesOf(entity: Record<string, unknown>): readonly string[] {
    const { image } = entity;
    const listed: readonly unknown[] = Array.isArray(image) ? image : [image];
    return listed.filter((url): url is string => typeof url === "string");
};

export const checkArticleImages = function checkArticleImages(
    file: string,
    artifact: PageArtifact,
    pixelsOf: (url: string) => number | null,
): Finding[] {
    return typedEntitiesOf(entitiesOf(artifact.schema))
        .filter((entity) => entity[TYPE_KEY] === ARTICLE_TYPE)
        .flatMap(imagesOf)
        .flatMap((url) => {
            const pixels = pixelsOf(url);
            if (pixels === null) {
                return [{ file, message: unreadableArticleImage(url) }];
            }
            return pixels < ARTICLE_MIN_PIXELS
                ? [{ file, message: smallArticleImage(url, pixels, ARTICLE_MIN_PIXELS) }]
                : [];
        });
};

const contentUrlsOf = function contentUrlsOf(value: unknown): string[] {
    if (Array.isArray(value)) {
        return value.flatMap(contentUrlsOf);
    }
    if (!isRecord(value)) {
        return [];
    }
    const own = typeof value[CONTENT_URL_KEY] === "string" ? [value[CONTENT_URL_KEY]] : [];
    return [...own, ...Object.values(value).flatMap(contentUrlsOf)];
};

export const checkEncodings = function checkEncodings(
    file: string,
    artifact: PageArtifact,
    isServed: (url: string) => boolean,
): Finding[] {
    return contentUrlsOf(entitiesOf(artifact.schema))
        .filter((url) => !isServed(url))
        .map((url) => ({ file, message: unservedEncoding(url) }));
};
```
