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),
}
},
},
],
})