@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
408 lines • 16.5 kB
JavaScript
import { getObjMember, getObjMembers } from '@terrazzo/json-schema-tools';
/**
* Determine whether this is likely a resolver
* We use terms the word “likely” because this occurs before validation. Since
* we may be dealing with a doc _intended_ to be a resolver, but may be lacking
* some critical information, how can we determine intent? There’s a bit of
* guesswork here, but we try and find a reasonable edge case where we sniff out
* invalid DTCG syntax that a resolver doc would have.
*/
export function isLikelyResolver(doc) {
if (doc.body.type !== 'Object') {
return false;
}
// This is a resolver if…
for (const member of doc.body.members) {
if (member.name.type !== 'String') {
continue;
}
switch (member.name.value) {
case 'name':
case 'description':
case 'version': {
// 1. name, description, or version are a string
if (member.value.type === 'String') {
return true;
}
break;
}
case 'sets':
case 'modifiers': {
if (member.value.type !== 'Object') {
continue;
}
// 2. sets.description or modifiers.description is a string
if (getObjMember(member.value, 'description')?.type === 'String') {
return true;
}
// 3. sets.sources is an array
if (member.name.value === 'sets' &&
getObjMember(member.value, 'sources')?.type === 'Array') {
return true;
}
else if (member.name.value === 'modifiers') {
const contexts = getObjMember(member.value, 'contexts');
if (contexts?.type === 'Object' &&
contexts.members.some((m) => m.value.type === 'Array')) {
// 4. contexts[key] is an array
// (note: modifiers.contexts as an object is technically valid token format! We need to check for the array)
return true;
}
}
break;
}
case 'resolutionOrder': {
// 4. resolutionOrder is an array
if (member.value.type === 'Array') {
return true;
}
break;
}
}
}
return false;
}
const MESSAGE_EXPECTED = {
STRING: 'Expected string.',
OBJECT: 'Expected object.',
ARRAY: 'Expected array.',
};
/**
* Validate a resolver document.
* There’s a ton of boilerplate here, only to surface detailed code frames. Is there a better abstraction?
*/
export function validateResolver(node, { logger, src }) {
const entry = { group: 'parser', label: 'resolver', src };
if (node.body.type !== 'Object') {
logger.error({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node });
}
const errors = [];
let hasVersion = false;
let hasResolutionOrder = false;
for (const member of node.body.members) {
if (member.name.type !== 'String') {
continue; // IDK, don’t ask
}
switch (member.name.value) {
case 'name':
case 'description': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING });
}
break;
}
case 'version': {
hasVersion = true;
if (member.value.type !== 'String' || member.value.value !== '2025.10') {
errors.push({
...entry,
message: `Expected "version" to be "2025.10".`,
node: member.value,
});
}
break;
}
case 'sets':
case 'modifiers': {
if (member.value.type === 'Object') {
for (const item of member.value.members) {
if (item.value.type === 'Object') {
const validator = member.name.value === 'sets' ? validateSet : validateModifier;
errors.push(...validator(item.value, false, { logger, src }));
}
else {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: item.value });
}
}
}
else {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: member.value });
}
break;
}
case 'resolutionOrder': {
hasResolutionOrder = true;
if (member.value.type !== 'Array') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: member.value });
}
else if (member.value.elements.length === 0) {
errors.push({
...entry,
message: `"resolutionOrder" can’t be empty array.`,
node: member.value,
});
}
else {
for (const item of member.value.elements) {
if (item.value.type === 'Object') {
const itemMembers = getObjMembers(item.value);
if (itemMembers.$ref?.type === 'String') {
continue; // we can’t validate this just yet, assume it’s correct
}
// Validate "type"
if (itemMembers.type?.type === 'String') {
if (itemMembers.type.value === 'set') {
validateSet(item.value, true, { logger, src });
}
else if (itemMembers.type.value === 'modifier') {
validateModifier(item.value, true, { logger, src });
}
else {
errors.push({
...entry,
message: `Unknown type ${JSON.stringify(itemMembers.type.value)}`,
node: itemMembers.type,
});
}
}
// validate sets & modifiers if they’re missing "type"
if (itemMembers.sources?.type === 'Array') {
validateSet(item.value, true, { logger, src });
}
else if (itemMembers.contexts?.type === 'Object') {
validateModifier(item.value, true, { logger, src });
}
else if (itemMembers.name?.type === 'String' ||
itemMembers.description?.type === 'String') {
validateSet(item.value, true, { logger, src }); // if this has a "name" or "description", guess set
}
}
else {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: item.value });
}
}
}
break;
}
case '$defs':
case '$extensions': {
if (member.value.type !== 'Object') {
errors.push({ ...entry, message: `Expected object`, node: member.value });
}
break;
}
case '$schema':
case '$ref': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: `Expected string`, node: member.value });
}
break;
}
default: {
errors.push({
...entry,
message: `Unknown key ${JSON.stringify(member.name.value)}`,
node: member.name,
src,
});
break;
}
}
}
// handle required keys
if (!hasVersion) {
errors.push({ ...entry, message: `Missing "version".`, node, src });
}
if (!hasResolutionOrder) {
errors.push({ ...entry, message: `Missing "resolutionOrder".`, node, src });
}
if (errors.length > 0) {
logger.error(...errors);
}
}
export function validateSet(node, isInline = false, { src }) {
const entry = { group: 'parser', label: 'resolver', src };
const errors = [];
let hasName = !isInline;
let hasType = !isInline;
let hasSources = false;
for (const member of node.members) {
if (member.name.type !== 'String') {
continue;
}
switch (member.name.value) {
case 'name': {
hasName = true;
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
break;
}
case 'description': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
break;
}
case 'type': {
hasType = true;
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
else if (member.value.value !== 'set') {
errors.push({ ...entry, message: '"type" must be "set".' });
}
break;
}
case 'sources': {
hasSources = true;
if (member.value.type !== 'Array') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: member.value });
}
else if (member.value.elements.length === 0) {
errors.push({ ...entry, message: `"sources" can’t be empty array.`, node: member.value });
}
else {
for (const source of member.value.elements) {
if (source.value.type !== 'Object') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: source.value });
}
}
}
break;
}
case '$defs':
case '$extensions': {
if (member.value.type !== 'Object') {
errors.push({ ...entry, message: `Expected object`, node: member.value });
}
break;
}
case '$ref': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: `Expected string`, node: member.value });
}
break;
}
default: {
errors.push({
...entry,
message: `Unknown key ${JSON.stringify(member.name.value)}`,
node: member.name,
});
break;
}
}
}
// handle required keys
if (!hasName) {
errors.push({ ...entry, message: `Missing "name".`, node });
}
if (!hasType) {
errors.push({ ...entry, message: `"type": "set" missing.`, node });
}
if (!hasSources) {
errors.push({ ...entry, message: `Missing "sources".`, node });
}
return errors;
}
export function validateModifier(node, isInline = false, { src }) {
const errors = [];
const entry = { group: 'parser', label: 'resolver', src };
let hasName = !isInline;
let hasType = !isInline;
let hasContexts = false;
for (const member of node.members) {
if (member.name.type !== 'String') {
continue;
}
switch (member.name.value) {
case 'name': {
hasName = true;
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
break;
}
case 'description': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
break;
}
case 'type': {
hasType = true;
if (member.value.type !== 'String') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
}
else if (member.value.value !== 'modifier') {
errors.push({ ...entry, message: '"type" must be "modifier".' });
}
break;
}
case 'contexts': {
hasContexts = true;
if (member.value.type !== 'Object') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: member.value });
}
else if (member.value.members.length === 0) {
errors.push({
...entry,
message: `"contexts" can’t be empty object.`,
node: member.value,
});
}
else {
for (const context of member.value.members) {
if (context.value.type === 'Array') {
for (const source of context.value.elements) {
if (source.value.type !== 'Object') {
errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: source.value });
}
}
}
else {
errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: context.value });
}
}
}
break;
}
case 'default': {
if (member.value.type === 'String') {
const contexts = getObjMember(node, 'contexts');
if (!contexts || !getObjMember(contexts, member.value.value)) {
errors.push({ ...entry, message: 'Invalid default context', node: member.value });
}
}
else {
errors.push({ ...entry, message: `Expected string`, node: member.value });
}
break;
}
case '$defs':
case '$extensions': {
if (member.value.type !== 'Object') {
errors.push({ ...entry, message: `Expected object`, node: member.value });
}
break;
}
case '$ref': {
if (member.value.type !== 'String') {
errors.push({ ...entry, message: `Expected string`, node: member.value });
}
break;
}
default: {
errors.push({
...entry,
message: `Unknown key ${JSON.stringify(member.name.value)}`,
node: member.name,
});
break;
}
}
}
// handle required keys
if (!hasName) {
errors.push({ ...entry, message: `Missing "name".`, node });
}
if (!hasType) {
errors.push({ ...entry, message: `"type": "modifier" missing.`, node });
}
if (!hasContexts) {
errors.push({ ...entry, message: `Missing "contexts".`, node });
}
return errors;
}
//# sourceMappingURL=validate.js.map