import { CallExpressionShape, Plugin, StringShape, type Factory, //type Diagnostics, type Record, type RecordId, type Shape, type ObjectShape, } from '@servicenow/sdk-build-core' import isEmpty from 'lodash/isEmpty' import { NowIncludeShape } from '../now-include-plugin' import { toReference, getFieldAsNumber, noThrow } from '../utils' import { getRolesString, serializeWidgetParametersForPage } from './utils' type Dict = { [key: string]: unknown } const default_placeholder_dimensions = `{ "mobile": { "height": "250px", "width": "100%" }, "desktop": { "height": "250px", "width": "100%" }, "tablet": { "height": "250px", "width": "100%" } }` const default_placeholder_template = `
` const default_placeholder_script = `function evaluateConfig(options) { return { "mobile": { "height": "250px", "width": "100%" }, "desktop": { "height": "250px", "width": "100%" }, "tablet": { "height": "250px", "width": "100%" } }; }` const defaultValues = { container: { width: 'container', backgroundStyle: 'default', subheader: false, bootstrapAlt: false, }, instance: { active: true, color: 'default', size: 'md', asyncLoadDeviceType: 'desktop,tablet,mobile', asyncLoad: false, asyncLoadTrigger: 'viewport', preservePlaceholderSize: false, advancedPlaceholderDimensions: false, }, column: { size: 12, }, page: { category: 'custom', useSeoScript: false, shortDescription: '', public: false, draft: false, omitWatcher: false, internal: false, }, } /** * Safely parse a size value from a Shape, handling non-numeric strings * @param shape - The shape to extract the size from * @param fieldName - The field name to extract (e.g., "size", "size_sm") * @param defaultValue - Default value if parsing fails * @returns Numeric size value or undefined */ /** * Sort an array of shapes by their order field * @param shapes - Array of shapes to sort * @returns Sorted array */ function sortByOrder(shapes: T[]): T[] { return shapes.sort((a, b) => { const aOrder = getFieldAsNumber(a, 'order', 1) const bOrder = getFieldAsNumber(b, 'order', 1) return (aOrder ?? 1) - (bOrder ?? 1) }) } /** * Conditionally adds a property to an object if the value is not empty or undefined. * Filters out undefined values, empty arrays, and empty strings to keep objects clean. * @param obj - The target object to add the property to * @param key - The property key to add * @param value - The value to add (will be filtered if empty/undefined) */ const addProperty = (obj: Dict, key: string, value: unknown, objectType?: keyof typeof defaultValues) => { if ( value === undefined || (Array.isArray(value) && value.length === 0) || (typeof value === 'string' && value === '') ) { return } // Check if value matches default for this object type if (objectType && defaultValues[objectType] && (defaultValues[objectType] as Dict)[key] === value) { return } obj[key] = value } /** * Creates a container object from a ServiceNow sp_container record Shape. * Transforms database fields to Fluent API format and includes nested rows. * @param container - The sp_container record Shape * @param rows - Array of row objects that belong to this container * @returns Formatted container object or undefined if container is empty */ const getContainerObject = (container: Record, rows: object[]): Dict | undefined => { if (!container || isEmpty(container)) { return } const name = container.get('name').ifString()?.getValue() const width = container.get('width').ifString()?.getValue() const backgroundStyle = container.get('background_style').ifString()?.getValue() const backgroundColor = container.get('background_color').ifString()?.getValue() const backgroundImage = container.get('background_image').ifString()?.getValue() const cssClass = container.get('class_name').ifString()?.getValue() const parentClass = container.get('container_class_name').ifString()?.getValue() const subheader = container.get('subheader').toBoolean()?.getValue() const bootstrapAlt = container.get('bootstrap_alt').toBoolean()?.getValue() const semanticTag = container.get('semantic_tag').ifString()?.getValue() const title = container.get('title').ifString()?.getValue() const containerObject: Dict = {} addProperty(containerObject, '$id', container.getId()) addProperty(containerObject, 'order', getFieldAsNumber(container, 'order', 1)) addProperty(containerObject, 'name', name) addProperty(containerObject, 'width', width, 'container') addProperty(containerObject, 'backgroundStyle', backgroundStyle, 'container') addProperty(containerObject, 'backgroundColor', backgroundColor, 'container') addProperty(containerObject, 'backgroundImage', backgroundImage) addProperty(containerObject, 'cssClass', cssClass) addProperty(containerObject, 'parentClass', parentClass) addProperty(containerObject, 'subheader', subheader, 'container') addProperty(containerObject, 'bootstrapAlt', bootstrapAlt, 'container') addProperty(containerObject, 'semanticTag', semanticTag) addProperty(containerObject, 'title', title) addProperty(containerObject, 'rows', rows) return containerObject } /** * Creates a row object from a ServiceNow sp_row record Shape. * Transforms database fields to Fluent API format and includes nested columns. * @param row - The sp_row record Shape * @param columns - Array of column objects that belong to this row * @returns Formatted row object or undefined if row is empty */ const getRowObject = (row: Record, columns: object[]): Dict | undefined => { if (!row || isEmpty(row)) { return } const cssClass = row.get('class_name').ifString()?.getValue() const semanticTag = row.get('semantic_tag').ifString()?.getValue() const rowObject: Dict = {} addProperty(rowObject, '$id', row.getId()) addProperty(rowObject, 'cssClass', cssClass) addProperty(rowObject, 'semanticTag', semanticTag) addProperty(rowObject, 'order', getFieldAsNumber(row, 'order', 1)) addProperty(rowObject, 'columns', columns) return rowObject } /** * Creates a column object from a ServiceNow sp_column record Shape. * @param column - The sp_column record Shape * @param instances - Array of instance objects that belong to this column * @param nestedRows - Array of nested row objects within this column * @returns Formatted column object or undefined if column is empty */ const getColumnObject = (column: Record, instances: object[], nestedRows: object[]): Dict | undefined => { if (!column || isEmpty(column)) { return } const cssClass = column.get('class_name').ifString()?.getValue() const semanticTag = column.get('semantic_tag').ifString()?.getValue() const columnObject: Dict = {} addProperty(columnObject, '$id', column.getId()) addProperty(columnObject, 'size', getFieldAsNumber(column, 'size', 12), 'column') addProperty(columnObject, 'sizeSm', getFieldAsNumber(column, 'size_sm')) addProperty(columnObject, 'sizeLg', getFieldAsNumber(column, 'size_lg')) addProperty(columnObject, 'sizeXs', getFieldAsNumber(column, 'size_xs')) addProperty(columnObject, 'cssClass', cssClass) addProperty(columnObject, 'semanticTag', semanticTag) addProperty(columnObject, 'order', getFieldAsNumber(column, 'order', 1)) addProperty(columnObject, 'instances', instances) addProperty(columnObject, 'nestedRows', nestedRows) return columnObject } /** * Extracts and parses roles from a ServiceNow instance record. * Converts comma-separated role string to an array of role names. * @param instance - The sp_instance record Shape * @returns Array of role names or undefined if no roles exist */ const getRolesArray = (instance: Record): string[] | undefined => { const rolesStr = instance.get('roles').ifString()?.getValue() if (!rolesStr || rolesStr === '') { return } const rolesArray = rolesStr .split(',') .map((role) => role.trim()) .filter((role) => role !== '') return rolesArray.length > 0 ? rolesArray : undefined } /** * Creates an instance object from a ServiceNow sp_instance record Shape. * Transforms all instance properties from database format to Fluent API format, * including widget references, styling, roles, and configuration. * @param instance - The sp_instance record Shape * @returns Formatted instance object or undefined if instance is empty */ function getInstanceObject(instance: Record): object | undefined { if (!instance || isEmpty(instance)) { return } const title = instance.get('title').ifString()?.getValue() const id = instance.get('id').ifString()?.getValue() const widget = instance.get('sp_widget').ifString()?.getValue() const widgetParameters = instance.get('widget_parameters').ifString()?.getValue() const css = instance.get('css').ifString()?.getValue() const url = instance.get('url').ifString()?.getValue() const glyph = instance.get('glyph').ifString()?.getValue() const size = instance.get('size').ifString()?.getValue() const color = instance.get('color').ifString()?.getValue() const cssClass = instance.get('class_name').ifString()?.getValue() const shortDescription = instance.get('short_description').ifString()?.getValue() const active = instance.get('active').toBoolean()?.getValue() const asyncLoad = instance.get('async_load').ifBoolean()?.getValue() const asyncLoadTrigger = instance.get('async_load_trigger').ifString()?.getValue() const asyncLoadDeviceType = instance.get('async_load_device_type').ifString()?.getValue() const preservePlaceholderSize = instance.get('preserve_placeholder_size').ifBoolean()?.getValue() const advancedPlaceholderDimensions = instance.get('advanced_placeholder_dimensions').ifBoolean()?.getValue() const placeholderDimensionsRaw = instance.get('placeholder_dimensions').ifString()?.getValue() let placeholderDimensions: unknown if (placeholderDimensionsRaw && placeholderDimensionsRaw !== default_placeholder_dimensions) { const parsed = noThrow(() => JSON.parse(placeholderDimensionsRaw)) placeholderDimensions = parsed instanceof Error ? placeholderDimensionsRaw : parsed } const placeholderConfigurationScriptRaw = instance.get('placeholder_dimensions_script').ifString()?.getValue() const placeholderConfigurationScript = placeholderConfigurationScriptRaw && placeholderConfigurationScriptRaw !== default_placeholder_script ? placeholderConfigurationScriptRaw : undefined const placeholderTemplateRaw = instance.get('placeholder_template').ifString()?.getValue() const placeholderTemplate = placeholderTemplateRaw && placeholderTemplateRaw !== default_placeholder_template ? placeholderTemplateRaw : undefined const instanceObject: Dict = {} addProperty(instanceObject, '$id', instance.getId()) addProperty(instanceObject, 'title', title) addProperty(instanceObject, 'id', id) addProperty(instanceObject, 'widget', widget) addProperty(instanceObject, 'widgetParameters', widgetParameters) addProperty(instanceObject, 'css', css) addProperty(instanceObject, 'url', url) addProperty(instanceObject, 'glyph', glyph) addProperty(instanceObject, 'size', size, 'instance') addProperty(instanceObject, 'color', color, 'instance') addProperty(instanceObject, 'cssClass', cssClass) addProperty(instanceObject, 'active', active, 'instance') addProperty(instanceObject, 'order', getFieldAsNumber(instance, 'order', 1)) addProperty(instanceObject, 'roles', getRolesArray(instance)) addProperty(instanceObject, 'shortDescription', shortDescription) addProperty(instanceObject, 'asyncLoad', asyncLoad, 'instance') addProperty(instanceObject, 'asyncLoadTrigger', asyncLoadTrigger, 'instance') addProperty(instanceObject, 'asyncLoadDeviceType', asyncLoadDeviceType, 'instance') addProperty(instanceObject, 'preservePlaceholderSize', preservePlaceholderSize, 'instance') addProperty(instanceObject, 'advancedPlaceholderDimensions', advancedPlaceholderDimensions, 'instance') addProperty(instanceObject, 'placeholderDimensions', placeholderDimensions) addProperty(instanceObject, 'placeholderConfigurationScript', placeholderConfigurationScript) addProperty(instanceObject, 'placeholderTemplate', placeholderTemplate) return instanceObject } /** * Recursively processes nested rows within a column * @param columnRows - Rows that belong to the column * @param allColumns - All available columns * @param allInstances - All available instances * @returns Array of processed SPRow objects with nested structure */ function getNestedRows(columnRows: Record[], allRows: Record[], allColumns: Record[], allInstances: Record[]): Dict[] { if (!columnRows || columnRows.length === 0) { return [] } // Sort rows by order const sortedRows = sortByOrder(columnRows) return sortedRows .map((row) => { const rowId = row.getId() // Get columns for this nested row const rowColumns = sortByOrder(allColumns.filter((column) => column.get('sp_row').equals(rowId))) const columns = rowColumns .map((column) => { const columnId = column.getId() // Get instances for this column const columnInstances = sortByOrder( allInstances.filter((instance) => instance.get('sp_column').equals(columnId)) ) // Recursively get nested rows for this column const nestedColumnRows = allRows.filter((row) => row.get('sp_column').equals(columnId)) const nestedRows = getNestedRows(nestedColumnRows, allRows, allColumns, allInstances) const instances = columnInstances .map((instance) => { return getInstanceObject(instance) }) .filter((instanceObject): instanceObject is Dict => Boolean(instanceObject)) return getColumnObject(column, instances, nestedRows) }) .filter((col): col is Dict => Boolean(col)) const rowObj = getRowObject(row, columns) return rowObj as Dict }) .filter((row): row is Dict => Boolean(row)) } /** * Generates a container name from the container shape or creates a default name. * Uses the explicit name if provided, otherwise generates a name based on page title and order. * @param $ - The container shape object * @param pageTitle - The title of the parent page * @param index - The zero-based index of the container in the page * @returns The container name string */ const getContainerName = ($: ObjectShape, pageTitle: string, index: number): string => { let name = $.get('name').ifString()?.getValue() if (!name) { const order = $.get('order').ifNumber()?.getValue() || index + 1 name = `${pageTitle} - Container ${order}` } return name } /** * Creates ServiceNow sp_container records from Fluent container shapes and their nested rows/columns/instances. * * @param containersArray - Array of Fluent container Shape objects to transform * @param pageId - RecordId of the parent sp_page record that owns these containers * @param pageTitle - Title of the parent page, used for generating default container names * @param factory - Factory instance for creating ServiceNow records with proper relationships * @returns Promise that resolves to an array of all created records (containers, rows, columns, instances) */ const createContainerRecords = async ( containersArray: Shape[], pageId: RecordId, pageTitle: string, factory: Factory ): Promise => { const records: Record[] = [] for (let index = 0; index < containersArray.length; index++) { const containerShape = containersArray[index] if (containerShape?.isObject()) { const container = containerShape.asObject() const name = getContainerName(container, pageTitle, index) const containerRecord = await factory.createRecord({ source: containerShape, table: 'sp_container', explicitId: container.get('$id'), properties: container.transform(({ $ }) => ({ name: $.val(name), sp_page: $.val(pageId), width: $.from('width').def('container'), background_style: $.from('backgroundStyle').def('default'), background_color: $.from('backgroundColor').def(''), background_image: $.from('backgroundImage').def(''), class_name: $.from('cssClass').def(''), container_class_name: $.from('parentClass').def(''), subheader: $.from('subheader').def(false), bootstrap_alt: $.from('bootstrapAlt').def(false), semantic_tag: $.from('semanticTag').def(''), title: $.from('title').def(''), order: $.from('order').def(index + 1), })), }) records.push(containerRecord) // Handle rows const rows = container.get('rows').ifArray()?.getElements() || [] const rowRecords = await createRowRecords(rows, containerRecord.getId(), factory, undefined) records.push(...rowRecords) } } return records } /** * Creates ServiceNow sp_row records from Fluent row shapes and their nested columns/instances. * * @param rowsArray - Array of Fluent row Shape objects to transform * @param containerId - RecordId of the parent sp_container record that owns these rows * @param factory - Factory instance for creating ServiceNow records with proper relationships * @param columnId - RecordId of the parent sp_column record (for nested rows within columns) * @returns Promise that resolves to an array of all created records (rows, columns, instances) */ const createRowRecords = async ( rowsArray: Shape[], containerId: RecordId | undefined, factory: Factory, columnId: RecordId | undefined ): Promise => { const records: Record[] = [] for (let index = 0; index < rowsArray.length; index++) { const rowShape = rowsArray[index] if (rowShape?.isObject()) { const row = rowShape.asObject() const rowRecord = await factory.createRecord({ source: rowShape, table: 'sp_row', explicitId: row.get('$id'), properties: row.transform(({ $ }) => ({ sp_container: $.val(containerId), class_name: $.from('cssClass').def(''), semantic_tag: $.from('semanticTag').def(''), order: $.from('order').def(index + 1), sp_column: $.val(columnId), })), }) records.push(rowRecord) // Handle columns const columns = row.get('columns').ifArray()?.getElements() || [] const columnRecords = await createColumnRecords(columns, rowRecord.getId(), factory) records.push(...columnRecords) } } return records } /** * Creates ServiceNow sp_column records from Fluent column shapes and their nested instances/rows. * * @param columnsArray - Array of Fluent column Shape objects to transform * @param rowId - RecordId of the parent sp_row record that owns these columns * @param factory - Factory instance for creating ServiceNow records with proper relationships * @returns Promise that resolves to an array of all created records (columns, instances, nested rows) */ async function createColumnRecords(columnsArray: Shape[], rowId: RecordId, factory: Factory): Promise { const records: Record[] = [] for (let index = 0; index < columnsArray.length; index++) { const columnShape = columnsArray[index] if (columnShape?.isObject()) { const column = columnShape.asObject() const columnRecord = await factory.createRecord({ source: columnShape, table: 'sp_column', explicitId: column.get('$id'), properties: column.transform(({ $ }) => ({ sp_row: $.val(rowId), size: $.from('size').def(12), size_sm: $.from('sizeSm'), size_lg: $.from('sizeLg'), size_xs: $.from('sizeXs'), class_name: $.from('cssClass').def(''), semantic_tag: $.from('semanticTag').def(''), order: $.from('order').def(index + 1), })), }) records.push(columnRecord) // Handle instances const instances = column.get('instances').ifArray()?.getElements() || [] const nestedRows = column.get('nestedRows').ifArray()?.getElements() || [] const nestedRowRecords = await createRowRecords(nestedRows, undefined, factory, columnRecord.getId()) records.push(...nestedRowRecords) const instanceRecords = await createInstanceRecords(instances, columnRecord.getId(), factory) records.push(...instanceRecords) } } return records } /** * Creates ServiceNow sp_instance records from Fluent instance shapes. * * @param instancesArray - Array of Fluent instance Shape objects to transform * @param columnId - RecordId of the parent sp_column record that owns these instances * @param factory - Factory instance for creating ServiceNow records with proper relationships * @returns Promise that resolves to an array of all created instance records */ async function createInstanceRecords(instancesArray: Shape[], columnId: RecordId, factory: Factory): Promise { const records: Record[] = [] for (let index = 0; index < instancesArray.length; index++) { const instanceShape = instancesArray[index] if (instanceShape?.isObject()) { const instance = instanceShape.asObject() // Process roles if they exist as an array const rolesString = getRolesString(instance.get('roles')) const instanceRecord = await factory.createRecord({ source: instanceShape, table: 'sp_instance', explicitId: instance.get('$id'), properties: instance.transform(({ $ }) => ({ sp_column: $.val(columnId), title: $.from('title').def(''), id: $.from('id').def(''), sp_widget: $.from('widget').map(toReference).def(''), widget_parameters: $.from('widgetParameters').map(serializeWidgetParametersForPage).def(''), short_description: $.from('shortDescription').def(''), css: $.from('css').def(''), url: $.from('url').def(''), glyph: $.from('glyph').def(''), size: $.from('size').def('md'), color: $.from('color').def('default'), class_name: $.from('cssClass').def(''), active: $.from('active').def(defaultValues.instance.active), order: $.from('order').def(index + 1), roles: $.val(rolesString).def(''), async_load: $.from('asyncLoad').def(false), async_load_trigger: $.from('asyncLoadTrigger').def('viewport'), async_load_device_type: $.from('asyncLoadDeviceType').def( defaultValues.instance.asyncLoadDeviceType ), preserve_placeholder_size: $.from('preservePlaceholderSize').def(false), placeholder_dimensions: $.from('placeholderDimensions') .map((v) => (v.is([StringShape, NowIncludeShape]) ? v : JSON.stringify(v.getValue()))) .def(default_placeholder_dimensions), advanced_placeholder_dimensions: $.from('advancedPlaceholderDimensions').def(false), placeholder_dimensions_script: $.from('placeholderConfigurationScript').def( default_placeholder_script ), placeholder_template: $.from('placeholderTemplate').def(default_placeholder_template), })), }) records.push(instanceRecord) } } return records } export const SPPagePlugin = Plugin.create({ name: 'SPPagePlugin', records: { sp_page: { coalesce: ['id'], relationships: { sp_container: { via: 'sp_page', descendant: true, relationships: { sp_row: { via: 'sp_container', descendant: true, relationships: { sp_column: { via: 'sp_row', descendant: true, relationships: { sp_instance: { via: 'sp_column', descendant: true, }, sp_row: { via: 'sp_column', descendant: true, }, }, }, }, }, }, }, }, toShape(record, { descendants }) { // Build hierarchical structure from descendants in a single pass // to avoid "node that was removed or forgotten" errors // Get all descendants at once to avoid multiple queries const allContainers = sortByOrder( descendants .query('sp_container') .filter((container) => container.get('sp_page').equals(record.getId())) ) const allRows = descendants.query('sp_row') const allColumns = descendants.query('sp_column') const allInstances = descendants.query('sp_instance') const containers = allContainers.map((container) => { const containerId = container.getId() // Get rows for this container const containerRows = sortByOrder( allRows.filter((row) => row.get('sp_container').equals(containerId)) ) const rows = containerRows .map((row) => { const rowId = row.getId() // Get columns for this row const rowColumns = sortByOrder( allColumns.filter((column) => column.get('sp_row').equals(rowId)) ) const columns = rowColumns .map((column) => { const columnId = column.getId() // Get instances for this column const columnInstances = sortByOrder( allInstances.filter((instance) => instance.get('sp_column').equals(columnId)) ) const columnRows = allRows.filter((row) => row.get('sp_column').equals(columnId)) const nestedRows = getNestedRows(columnRows, allRows, allColumns, allInstances) const instances = columnInstances .map((instance) => { return getInstanceObject(instance) }) .filter((instanceObject): instanceObject is object => Boolean(instanceObject)) return getColumnObject(column, instances, nestedRows) }) .filter((columnObject): columnObject is Dict => Boolean(columnObject)) return getRowObject(row, columns) }) .filter((rowObject): rowObject is Dict => Boolean(rowObject)) return getContainerObject(container, rows) }) // Process roles to check if they should be included const rolesArray = getRolesArray(record) return { success: true, value: new CallExpressionShape({ source: record, callee: 'SPPage', args: [ record.transform(({ $ }) => { const pageObject: { [key: string]: typeof $ | undefined } = { title: $, category: $, pageId: $.from('id').def(''), draft: $.toBoolean().def(false), internal: $.toBoolean().def(false), omitWatcher: $.from('omit_watcher').toBoolean().def(false), public: $.toBoolean().def(false), useSeoScript: $.from('use_seo_script').toBoolean().def(false), css: $.def(''), shortDescription: $.from('short_description').def(''), seoScript: $.from('seo_script').def(''), dynamicTitleStructure: $.from('dynamic_title_structure').def(''), humanReadableUrlStructure: $.from('human_readable_url_structure').def(''), } // Only add roles if they exist if (rolesArray) { pageObject['roles'] = $.val(rolesArray) } // Only add containers if they exist if (containers.length > 0) { pageObject['containers'] = $.val(containers) } return pageObject }), ], }), } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { diagnostics, factory }) { if (callExpression.getCallee() !== 'SPPage') { return { success: false } } const page = callExpression.getArgument(0).asObject() const containers = page.get('containers').ifArray()?.getElements() || [] const useSeoScriptShape = page.get('useSeoScript') const useSeoScript = useSeoScriptShape.ifBoolean()?.getValue() ?? false const seoScriptRef = toReference(page.get('seoScript')) const seoScript = typeof seoScriptRef === 'string' ? seoScriptRef : seoScriptRef.getValue() if (useSeoScript && (!seoScript || seoScript.trim() === '')) { diagnostics.error( useSeoScriptShape.getOriginalNode(), `Invalid SPPage configuration: when "useSeoScript" is true, "seoScript" must be added.` ) } // Process roles if they exist as an array const rolesString = getRolesString(page.get('roles')) const pageIdShape = page.get('pageId') const pageId = pageIdShape.asString().getValue() if (!pageId.trim()) { diagnostics.error( pageIdShape.getOriginalNode(), 'Invalid SPPage configuration: "pageId" must be a non-empty string.' ) } const urlStructureShape = page.get('humanReadableUrlStructure') const urlStructure = urlStructureShape.ifString()?.getValue() if (urlStructure && urlStructure.length > 0) { const delimiter = String(Math.floor(Math.random() * 100000)) const urlStrings = urlStructure .replaceAll(/[/-]/g, delimiter) .replaceAll('%', delimiter + '%') .split(delimiter) const nonVariables: string[] = [] for (const segment of urlStrings) { if (segment.length > 1 && segment.indexOf('%') === 0) { continue } nonVariables.push(segment) } if (!/^[a-zA-Z0-9/-]*$/.test(nonVariables.join(''))) { diagnostics.error( urlStructureShape.getOriginalNode(), `Only alphanumeric characters, - and / are allowed in humanReadableUrlStructure.` ) } if (urlStructure.indexOf('/') !== urlStructure.lastIndexOf('/')) { diagnostics.error( urlStructureShape.getOriginalNode(), `No more than one "/" character is allowed in humanReadableUrlStructure.` ) } } const title = page.get('title').ifString()?.getValue() || pageId // Create the main page record const pageRecord = await factory.createRecord({ source: callExpression, table: 'sp_page', properties: page.transform(({ $ }) => ({ id: $.from('pageId'), title: $.val(title), category: $.def('custom'), css: $.def(''), draft: $.def(false), dynamic_title_structure: $.from('dynamicTitleStructure').def(''), human_readable_url_structure: $.from('humanReadableUrlStructure').def(''), internal: $.def(false), omit_watcher: $.from('omitWatcher').def(false), public: $.def(false), roles: $.val(rolesString).def(''), seo_script: $.from('seoScript').map(toReference).def(''), short_description: $.from('shortDescription').def(''), use_seo_script: $.from('useSeoScript').def(false), })), }) // Create container, row, column, and instance records const allRecords = await createContainerRecords(containers, pageRecord.getId(), title, factory) return { success: true, value: pageRecord.with(...allRecords), } }, }, ], })