import { CallExpressionShape, Plugin, Shape, Database, path, SourceFileShape, isSNScope, unzipSync, ts, type Logger, type Diagnostics, type Project, FileSystem, type Record, type NowConfig, type Factory, } from '@servicenow/sdk-build-core' import { parseDocument, DomUtils } from 'htmlparser2' import { XMLParser, XMLBuilder, type X2jOptions, type XmlBuilderOptions } from 'fast-xml-parser' import { NowIdShape } from './now-id-plugin' import { NowIncludeShape } from './now-include-plugin' import { TRANSLATIONS_SUFFIX } from '@servicenow/isomorphic-rollup' import { getAttachmentContent, createAttachmentRecords, SOURCE_ARTIFACT_RELATIONSHIPS, createArtifactRecord, createArtifactM2mRecord, serializeWithArtifact, } from './source-artifact-utils' const parserOptions: X2jOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, htmlEntities: true, preserveOrder: true, attributeNamePrefix: '@_', commentPropName: '@_comment', processEntities: false, } const builderOptions: XmlBuilderOptions = { ignoreAttributes: false, preserveOrder: true, attributeNamePrefix: '@_', commentPropName: '@_comment', format: true, processEntities: true, // A valid but undocumented option // https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/src/xmlbuilder/json2xml.js#L26 // @ts-expect-error entities: [ // Match on &, &test, but not &, <, >, ', and " // See: https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references#Standard_public_entity_sets_for_characters for the list of default XML entities { regex: /&(?!(amp;|lt;|gt;|apos;|quot;))/g, val: '$[AMP]' }, ], } const LT_PLACEHOLDER = '\0__SDK_LT__\0' const AMP_PLACEHOLDER = '\0__SDK_AMP__\0' const RAW_CONTENT_TAGS = ['script', 'style', 'textarea'] as const /** * Uses htmlparser2 to find script/style/textarea content and replaces `<` and * `&` with placeholders. This prevents fast-xml-parser from misinterpreting * JavaScript comparison operators (e.g. `a < b`) as XML tag openers, and * prevents the XMLBuilder's entity escaping from converting `&` to `$[AMP]` * inside script content. * * htmlparser2 is used instead of regex because it correctly handles edge cases * like `>` inside attribute values and script tags inside HTML comments. */ function escapeRawContent(html: string): string { const doc = parseDocument(html, { withStartIndices: true, withEndIndices: true }) const regions: { start: number; end: number }[] = [] for (const tag of RAW_CONTENT_TAGS) { for (const el of DomUtils.getElementsByTagName(tag, doc, true)) { for (const child of el.children) { if (child.type === 'text' && child.startIndex != null && child.endIndex != null) { const text = child.data if (text.includes('<') || text.includes('&')) { regions.push({ start: child.startIndex, end: child.endIndex + 1 }) } } } } } if (regions.length === 0) { return html } // Sort by position descending so replacements don't shift earlier indices regions.sort((a, b) => b.start - a.start) let result = html for (const { start, end } of regions) { const content = result.slice(start, end) const escaped = content.replace(/&/g, AMP_PLACEHOLDER).replace(/ s.replace(/&/g, '&') const escapeSingle = (s: string): string => escapeHtml(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") const escapeDouble = (s: string): string => escapeHtml(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') const getTranslationMessages = ( fs: FileSystem, config: NowConfig, rootDir: string, htmlRelPathFromClientDir: string ): string[] => { // The rollup plugin emits `.translations.json` next to each bundled html, preserving // the html's subdirectory structure under clientDir. So a source html at // `/admin/page.html` produces `/admin/page.translations.json`. if (htmlRelPathFromClientDir.startsWith('..')) { return [] } const translationsRelPath = htmlRelPathFromClientDir.replace(/\.html$/, TRANSLATIONS_SUFFIX) const translationsPath = path.join(rootDir, config.staticContent.buildDir, translationsRelPath) try { fs.accessSync(translationsPath) } catch { return [] } try { const content = fs.readFileSync(translationsPath, { encoding: 'utf-8' }) const parsed = JSON.parse(content) as { messages: Array } return parsed.messages.map((entry) => (typeof entry === 'string' ? entry : (entry['code'] ?? ''))) } catch { return [] } } const translationsJellyScript = (scope: string, messages: string[]): string => ` ` // Script groups emitted only in 'full' mode. See nowUxGlobals below. // Classic Jelly form helpers plus the legacy Glide client APIs. prototype.js is their // shared dependency, and it reassigns the native Array.from to a length-based // implementation that returns [] for iterables such as Set and Map. Modern client // libraries that pass a Set to Array.from break silently under it, so pages that // don't call these APIs should opt out. const glideLegacyScripts = ` ` // GlideAjax plus the g_user bootstrap. `client_script type="user"` renders the // GlideUser initialization, whose emitted code calls addTopRenderEvent() (from // functions_bootstrap14.js) and constructs a GlideUser, so it can only ship // alongside those scripts — dropping them but keeping the tag throws // "addTopRenderEvent is not defined" at page load. const glideAjaxScripts = ` ` // The UX framework runtime, needed only to render now-* web components, and the // preload for the asset cache buster it loads through. Emitting the preload without // the runtime would fetch a script nothing uses, so the two travel together. const uxFrameworkPreload = ` ` const uxFrameworkRuntime = ` ` // TODO: Remove this shim tag once we've shipped Glide support for this feature. /** * Expands the `sdk:now-ux-globals` tag into the platform globals, scripts, and theme * assets a UI page needs. * * @param themeId - Polaris theme whose variables the page loads * @param mode - `'full'` emits every script a UI page has historically received. * `'minimal'` emits only what a modern client app needs: the globals script, * CustomEventManager, and the theme assets. Notably `g_user` is not defined under * `'minimal'` — see the comments on the omitted script groups above for what each costs. */ const nowUxGlobals = (themeId: string = POLARIS_APPSHELL_THEME_ID, mode: 'full' | 'minimal' = 'full') => { const legacy = mode !== 'minimal' return parser.parse(` ${legacy ? glideLegacyScripts : ''} ${legacy ? glideAjaxScripts : ''}${legacy ? uxFrameworkPreload : ''} ${legacy ? uxFrameworkRuntime : ''} `) } /** * Allows us to replace some tags at build time for an improved authoring experience. * These are tags that should eventually be supported natively by Glide. These tags * should resolve to behavior that will work on earlier versions of Glide in lieu * of platform support. * @param nodes - parsed XML nodes * @returns - parsed XML nodes and any replacements of synthetic tags */ // biome-ignore lint/suspicious/noExplicitAny: Fast-xml-parser types are not defined const nodeTransformer = (nodes: any[]) => { for (let i = 0; i < nodes.length; i++) { const node = nodes[i] const tag = Object.keys(node)[0] if (!tag) { continue } if (tag === 'sdk:now-ux-globals') { const themeId = node[':@']?.['@_theme-id'] // Only an explicit mode="minimal" opts out; an absent or unrecognized value // keeps the full set so existing pages are unaffected. const mode = String(node[':@']?.['@_mode'] ?? '').toLowerCase() === 'minimal' ? 'minimal' : 'full' nodes.splice(i, 1, ...nowUxGlobals(themeId, mode)) continue } const body = node[tag] if (Array.isArray(body)) { node[tag] = nodeTransformer(body) } } return nodes } /** * Resolves the endpoint for a UI page. Returns the explicit endpoint if provided, * otherwise synthesizes one from the page name. Global-scope pages use `{name}.do`, * while scoped pages use `{scope}_{name}.do`. */ function getEffectiveEndpoint(endpoint: string, name: string, scope: string): string { if (endpoint) { return endpoint } if (!name) { return '' } return scope === 'global' ? `${name}.do` : `${scope}_${name}.do` } export const UiPagePlugin = Plugin.create({ name: 'UiPagePlugin', records: { sys_ui_page: { composite: true, coalesce: (properties) => { // Use endpoint for scoped pages (backward compatible), fall back to name for global scope const endpoint = properties.get('endpoint').pipe((v) => v.ifDefined()?.toString().getValue()) if (endpoint) { return { endpoint } } const name = properties.get('name').pipe((v) => v.ifDefined()?.toString().getValue()) ?? 'NULL' return { name } }, relationships: SOURCE_ARTIFACT_RELATIONSHIPS, async toShape(record, { descendants, fs, project, config, logger, diagnostics }) { const shapeWithSourceArtifacts = await getShapeWithSourceArtifacts(record, descendants, { fs, project, config, logger, diagnostics, }) if (shapeWithSourceArtifacts) { return { success: true, value: shapeWithSourceArtifacts, } } // Endpoint can be empty in the DB — synthesize from name. // Global scope pages have no scope prefix; all other scopes do. const endpointValue = record.get('endpoint').toString().getValue() const nameValue = record.get('name').toString().getValue() const effectiveEndpoint = getEffectiveEndpoint(endpointValue, nameValue, config.scope) return { success: true, value: new CallExpressionShape({ source: record, callee: 'UiPage', args: [ record.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(record)), category: $.def(''), endpoint: $.val(effectiveEndpoint).def(''), description: $.def(''), direct: $.toBoolean().def(false), html: $.def(''), clientScript: $.from('client_script').def(''), processingScript: $.from('processing_script').def(''), })), ], }), } }, async toFile(uiPage, { config, database, descendants, transform }) { if (!uiPage.has('endpoint') && !uiPage.has('name')) { return { success: false } } // Only use custom serialization if source artifacts are present const sourceArtifacts = descendants.query('sn_glider_source_artifact_m2m') if (sourceArtifacts.length === 0) { // No source artifacts, use default serialization return { success: false } } const uiPagePropsToSerialize = [ 'name', 'endpoint', 'description', 'direct', 'category', 'html', 'client_script', 'processing_script', ] const result = await serializeWithArtifact(uiPage, uiPagePropsToSerialize, { config, database, descendants, transform, }) return { success: true, value: result, } }, }, // These records are embedded as descendants of sys_ui_page XML but are also // downloaded as standalone records during `init + transform`. Defining coalesce // here ensures their sys_ids are registered in keys.ts during transform, so // subsequent builds by any user reuse the same IDs rather than generating fresh UUIDs. sn_glider_source_artifact: { coalesce: ['name'], relationships: SOURCE_ARTIFACT_RELATIONSHIPS.sn_glider_source_artifact_m2m.relationships.sn_glider_source_artifact .relationships, async toShape(record) { return { success: true, value: Shape.noOp(record) } }, // Custom diff: attachments use hash-based immutable IDs. New content → new attachment ID. // Stale cleanup is handled via delete_multiple in serializeWithArtifact, so we must NOT // emit DELETE records here — the default diff would conflict with that mechanism. async diff(existing, incoming) { if (incoming.query().length === 0 || existing.query().length === 0) { return { success: true, value: incoming.query().length === 0 ? new Database() : new Database(incoming.query()), } } const changedRecords = [] const existingArtifacts = existing.query('sn_glider_source_artifact') const incomingArtifacts = incoming.query('sn_glider_source_artifact') const incomingAttachments = incoming.query('sys_attachment') const incomingAttachmentDocs = incoming.query('sys_attachment_doc') for (const incomingArtifact of incomingArtifacts) { const existingArtifact = existingArtifacts.find( (a) => a.getId().getValue() === incomingArtifact.getId().getValue() ) changedRecords.push(existingArtifact ? existingArtifact.merge(incomingArtifact) : incomingArtifact) // Always add new attachments — unique hash-based IDs per content version. const artifactAttachments = incomingAttachments.filter( (att) => att.get('table_sys_id').toString().getValue() === incomingArtifact.getId().getValue() ) for (const attachment of artifactAttachments) { changedRecords.push(attachment) changedRecords.push( ...incomingAttachmentDocs.filter( (doc) => doc.get('sys_attachment').toString().getValue() === attachment.getId().getValue() ) ) } } return { success: true, value: new Database(changedRecords) } }, }, sn_glider_source_artifact_m2m: { coalesce: ['application_file', 'source_artifact'], relationships: SOURCE_ARTIFACT_RELATIONSHIPS.sn_glider_source_artifact_m2m.relationships, async toShape(record) { return { success: true, value: Shape.noOp(record) } }, async toFile() { // Page M2Ms are already in handledGuids as descendants of sys_ui_page, so // this handler only runs for asset M2Ms (application_file = sys_ux_lib_asset // sys_id). Those are embedded in each sys_ux_lib_asset XML by // static-content-plugin via sourceArtifactRelationships. Return success with // no output to mark them as handled and prevent RecordPlugin's catch-all from // serializing them as standalone XMLs. return { success: true, value: [] } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { config, factory, diagnostics, fs, project, logger }) { if (callExpression.getCallee() !== 'UiPage') { return { success: false } } const arg = callExpression.getArgument(0).asObject() const endpoint = arg.get('endpoint').asString() const scope = config.scope const isGlobalScope = scope === 'global' if (!isGlobalScope && !isSNScope(scope) && !endpoint.getValue().startsWith(`${scope}_`)) { diagnostics.error(endpoint.getOriginalNode(), `endpoint must begin with '${scope}_'`) return { success: false } } // For global scope, name is the endpoint without .do (e.g., 'my-page.do' → 'my-page') // For scoped, strip scope prefix and .do (e.g., 'x_test_boats.do' → 'boats') const name = isGlobalScope ? endpoint.getValue().replace(/\.do$/, '') : endpoint.getValue().replace(`${scope}_`, '').replace(/\.do$/, '') if (!name) { diagnostics.error(endpoint.getOriginalNode(), 'endpoint must include a page name before .do') return { success: false } } const htmlShape = arg.get('html') let html = htmlShape.toString().getValue() let sourceFilePaths: string[] = [] let assetNames: string[] = [] let vendorAssetNames: string[] = [] let htmlRelPathFromClientDir: string | undefined const clientAbsDir = path.join(project.getRootDir(), config.clientDir) const computeRelFromClientDir = (htmlIncludePath: string): string => { const sourceFileDir = path.dirname(callExpression.getOriginalNode().getSourceFile().getFilePath()) const absoluteHtmlPath = path.resolve(sourceFileDir, htmlIncludePath) return path.relative(clientAbsDir, absoluteHtmlPath).replace(/\\/g, '/') } // `html: Now.include('../ui/hello.html')` resolves to a NowIncludeShape whose path // we can read directly. The included content has no HTML_IMPORT_PREFIX, so detect // this case before the prefix check below. if (htmlShape.is(NowIncludeShape)) { const includePath = htmlShape.as(NowIncludeShape).getPath() if (includePath.endsWith('.html')) { htmlRelPathFromClientDir = computeRelFromClientDir(includePath) } } // HtmlImportPlugin (which runs before toRecord) resolves `html: _html` identifiers // to the file content and prepends an HTML_IMPORT_PREFIX warning comment. // Source artifacts are only created when that prefix is present, meaning the html // argument actually referenced an imported .html file. // An inline string (e.g. `html: '

...

'`) never gets the prefix, so an // unrelated `.html` import in the same file does NOT trigger artifact creation. if (html.trimStart().startsWith(HTML_IMPORT_PREFIX)) { // Resolve the import declaration from the specific identifier passed as the // `html` argument — NOT the first .html import in the file. A single .now.ts // may declare several UiPages with different html imports, and each one must // pick up only the manifest/translations for the file it actually imports. const htmlNode = htmlShape.getOriginalNode() const moduleSpecifier = ts.Node.isIdentifier(htmlNode) ? htmlNode .getSymbol() ?.getDeclarations()[0] ?.getParent() ?.asKind(ts.SyntaxKind.ImportDeclaration) ?.getModuleSpecifierValue() : undefined if (moduleSpecifier?.endsWith('.html')) { htmlRelPathFromClientDir = computeRelFromClientDir(moduleSpecifier) const sourceFileDir = path.dirname(htmlNode.getSourceFile().getFilePath()) const absoluteHtmlPath = path.resolve(sourceFileDir, moduleSpecifier) const manifest = getUIPageSourceFilePaths( absoluteHtmlPath, fs, logger, config, project.getRootDir() ) sourceFilePaths = manifest.files assetNames = manifest.assetNames vendorAssetNames = manifest.vendorAssetNames } } if (html) { try { html = escapeRawContent(html) const nodes = parser.parse(html) const transformed = nodeTransformer(nodes) html = new XMLBuilder(builderOptions).build(transformed) html = restoreRawContent(html) } catch (error: unknown) { if (error instanceof Error) { diagnostics.error(arg.get('html'), error.message) } else { diagnostics.error(arg.get('html'), `html must be valid`) } return { success: false } } if (htmlRelPathFromClientDir) { const messages = getTranslationMessages( fs, config, project.getRootDir(), htmlRelPathFromClientDir ) if (messages.length > 0) { html += translationsJellyScript(config.scope, messages) } } } const record = await factory.createRecord({ source: callExpression, table: 'sys_ui_page', explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ name: $.val(name), endpoint: $.val(isGlobalScope ? '' : endpoint), description: $, direct: $.def(false), category: $, html: $.val(html).toCdata(), client_script: $.from('clientScript').toCdata(), processing_script: $.from('processingScript').toCdata(), })), }) // Build source artifact if source files are present in the manifest if (sourceFilePaths.length > 0) { logger.debug(`Found ${sourceFilePaths.length} source files in manifest`) // Manifest files already contain paths relative to project root const files = sourceFilePaths.map((file) => file.replace(/\\/g, '/')) const prebuildPath = path.join(project.getRootDir(), 'now.prebuild.mjs') if (FileSystem.existsSync(fs, prebuildPath)) { files.push('now.prebuild.mjs') } const artifactName = `${endpoint.getValue()} - ${BYOUI_ARTIFACT_NAME_SUFFIX}` const sourceArtifactRecords = await buildArtifact( artifactName, files, record, { fs, project, factory, config, logger, diagnostics, }, assetNames ) // Link this page's source artifact to each vendor chunk it depends on. // Emitted for both configuration and package builds: configuration // deploys need the M2Ms so the platform knows which vendor chunks belong // to this page; package builds need them so vendor refs are tracked in // keys.ts and not flagged as deletes on the next sync. if (vendorAssetNames.length > 0) { const artifactRecord = sourceArtifactRecords.find( (r) => r.getTable() === 'sn_glider_source_artifact' ) if (artifactRecord) { const vendorM2ms = await Promise.all( vendorAssetNames.map((name) => createAssetArtifactM2mRecord(artifactRecord, name, record, { factory, config, }) ) ) sourceArtifactRecords.push(...vendorM2ms) } } if (sourceArtifactRecords.length > 0) { record.with(...sourceArtifactRecords) } else { diagnostics.warn( record, 'No source artifact records were created despite source files being present' ) } } return { success: true, value: record, } }, }, ], }) /** * Reads source file paths and vendor asset names from the UI source manifest file. * * The manifest is generated by isomorphic-rollup's sourceManifest plugin during build. * It's a JSON file with the structure: * { html, entry, files: string[], vendors: [fileName, contentHash][] } * * @param htmlFilePath - Path to the HTML file * @param fs - File system interface * @param logger - Logger for diagnostics * @param config - NowConfig with staticContent.buildDir * @param rootDir - Project root directory * @returns Source file paths, entry asset names, and vendor asset names */ const getUIPageSourceFilePaths = ( htmlFilePath: string, fs: FileSystem, logger: Logger, config: NowConfig, rootDir: string ): { files: string[]; assetNames: string[]; vendorAssetNames: string[] } => { const empty = { files: [], assetNames: [], vendorAssetNames: [] } try { // Derive manifest path from HTML path by mirroring the directory structure // from clientDir into staticContent.buildDir and swapping the extension. // e.g., src/client/index.html -> dist/static/index.ui-source-manifest.json // src/client/admin/settings.html -> dist/static/admin/settings.ui-source-manifest.json const clientAbsDir = path.join(rootDir, config.clientDir) const staticContentAbsDir = path.join(rootDir, config.staticContent.buildDir) const htmlRelPath = path.relative(clientAbsDir, htmlFilePath) const manifestPath = path.join(staticContentAbsDir, htmlRelPath).replace(/\.html$/, '.ui-source-manifest.json') // Check if manifest file exists try { fs.accessSync(manifestPath) } catch { logger.debug(`No source manifest found at ${manifestPath}`) return empty } const manifestContent = fs.readFileSync(manifestPath, { encoding: 'utf-8' }) const manifest = JSON.parse(manifestContent) if (!manifest.files || !Array.isArray(manifest.files)) { logger.warn(`Invalid manifest format at ${manifestPath}`) return empty } // Derive the JS asset name from the manifest's entry field, matching // static-content-plugin's formula: path.join(scope, relativePath_without_ext). // The entry path is relative to the client directory and preserves subdirectories. // e.g., src/client/main.tsx -> scope/main // src/client/admin/settings.tsx -> scope/admin/settings if (!manifest.entry || typeof manifest.entry !== 'string') { logger.warn(`No entry field in manifest at ${manifestPath}`) return empty } const entryRelativePath = path.relative(config.clientDir, manifest.entry).replace(/\\/g, '/') const entryRelativeWithoutExt = entryRelativePath.replace(/\.[^.]+$/, '') const entryAssetName = path.join(config.scope, entryRelativeWithoutExt).replace(/\\/g, '/') // Check if a source map bundle also exists in staticContent.buildDir. // static-content-plugin names source map assets as: path.join(scope, relativePath.replace('dbx', '')) // e.g. main.jsdbx.map -> scope/main.js.map // admin/settings.jsdbx.map -> scope/admin/settings.js.map const assetNames = [entryAssetName] const sourceMapFilePath = path.join(staticContentAbsDir, `${entryRelativeWithoutExt}.jsdbx.map`) try { fs.accessSync(sourceMapFilePath) const sourceMapAssetName = path.join(config.scope, `${entryRelativeWithoutExt}.js.map`).replace(/\\/g, '/') assetNames.push(sourceMapAssetName) } catch { // no source map in this build output — skip } // Derive vendor asset names from the manifest's vendors field. // Each vendor entry is [fileName, contentHash], e.g. // ["vendor-react-dom--d217b640.jsdbx", "d217b640"] // The asset name mirrors static-content-plugin's formula: // path.join(scope, fileNameWithoutExt) const vendorAssetNames: string[] = [] const vendors: [string, string][] = manifest.vendors ?? [] for (const [fileName] of vendors) { const ext = path.extname(fileName) const nameWithoutExt = fileName.substring(0, fileName.length - ext.length) const vendorAssetName = path.join(config.scope, nameWithoutExt).replace(/\\/g, '/') vendorAssetNames.push(vendorAssetName) // Check for vendor sourcemap const vendorMapPath = path.join(staticContentAbsDir, `${nameWithoutExt}.jsdbx.map`) try { fs.accessSync(vendorMapPath) vendorAssetNames.push(path.join(config.scope, `${nameWithoutExt}.js.map`).replace(/\\/g, '/')) } catch { // no sourcemap for this vendor chunk } } return { files: manifest.files, assetNames, vendorAssetNames } } catch (error) { logger.warn(`Failed to read source manifest: ${error}`) return empty } } /** * Extracts source files from artifacts and generates a SourceFileShape for the UI page. * * This function is called during transform to: * 1. Find the source artifact associated with the UI page * 2. Extract source files to the project directory * 3. Generate a .now.ts file that imports the extracted HTML * * @param uiPageRecord - The UI page record being transformed * @param descendants - Database containing descendant records (artifacts, attachments) * @param context - Transform context with config, file system, project, diagnostics, and logger * @returns SourceFileShape for the UI page, or undefined if no source artifact found */ async function getShapeWithSourceArtifacts( uiPageRecord: Record, descendants: Database, context: { config: NowConfig; fs: FileSystem; project: Project; diagnostics: Diagnostics; logger: Logger } ): Promise { const sourceArtifact = getSourceArtifact(descendants, { name: new RegExp(`${BYOUI_ARTIFACT_NAME_SUFFIX}$`) }) if (!sourceArtifact) { return undefined } const artifactName = sourceArtifact.get('name').toString().getValue() context.logger.debug(`Found source artifact ${artifactName}`) const unpackedFiles = await extractArtifact(sourceArtifact, descendants, '', { fs: context.fs, project: context.project, logger: context.logger, diagnostics: context.diagnostics, }) context.logger.debug(`Unpacked ${unpackedFiles.length} files from artifact ${artifactName}`) const entryHtmlFilePath = unpackedFiles.find((file) => file.endsWith('.html')) if (!entryHtmlFilePath) { context.logger.debug(`Failed to find entry HTML file in artifact ${artifactName}`) return undefined } const { generatedDir, taxonomy } = context.config const tableName = uiPageRecord.getTable() const fluentFileDirByTaxonomy = tableName && taxonomy.mapping[tableName] ? path.join(generatedDir, taxonomy.mapping[tableName]) : generatedDir const originalFilePath = uiPageRecord.getOriginalFilePath() let dirWhereTheFluentFileWillExistEventually = path.dirname(originalFilePath) if (context.project.isInMetadataDir(originalFilePath) || !context.project.isInRootDir(originalFilePath)) { // context.project.isInMetadataDir(originalFilePath) => init --from downloads XML to metadata dir // !context.project.isInRootDir(originalFilePath) => simple transform downloads XML is in a temp metadata dir dirWhereTheFluentFileWillExistEventually = fluentFileDirByTaxonomy } // need an import statement for the html file; normalize to forward slashes for cross-platform imports const htmlFileRelativePath = path .relative(dirWhereTheFluentFileWillExistEventually, entryHtmlFilePath) .replace(/\\/g, '/') if (htmlFileRelativePath && !context.project.isInFluentDir(originalFilePath)) { const endpoint = uiPageRecord.get('endpoint').toString().getValue() const pageName = uiPageRecord.get('name').toString().getValue() const effectiveEndpoint = getEffectiveEndpoint(endpoint, pageName, context.config.scope) const description = uiPageRecord.get('description').toString().getValue() const category = uiPageRecord.get('category').toString().getValue() const direct = uiPageRecord.get('direct').toString().getValue() const clientScript = uiPageRecord.get('client_script').toString().getValue() const processingScript = uiPageRecord.get('processing_script').toString().getValue() const fileContent = ` import '@servicenow/sdk/global' import { UiPage } from '@servicenow/sdk/core' import htmlFile from '${htmlFileRelativePath}' UiPage({ $id: Now.ID['${uiPageRecord.getId().getValue()}'], endpoint: ${JSON.stringify(effectiveEndpoint)}, description: ${JSON.stringify(description)}, category: ${JSON.stringify(category)}, direct: ${direct}, html: htmlFile, clientScript: ${JSON.stringify(clientScript)}, processingScript: ${JSON.stringify(processingScript)}, }) ` const newFileName = effectiveEndpoint.replace('.do', '.now.ts') const sourceFileShape = new SourceFileShape({ source: uiPageRecord, path: `${path.join(fluentFileDirByTaxonomy, newFileName)}`, content: fileContent, }) return sourceFileShape } return undefined } // ─── Source Artifact: Build ─────────────────────────────────────────────────── // // Asset M2M coordination between UiPagePlugin and StaticContentPlugin: // // UiPagePlugin creates two kinds of M2M records: // - Page M2M (application_file = sys_ui_page sys_id): embedded in sys_ui_page XML. // - Asset M2Ms (application_file = sys_ux_lib_asset sys_id): embedded in // sys_ux_lib_asset XML by StaticContentPlugin (transform direction), or claimed // with no output by sn_glider_source_artifact_m2m.toFile (build direction) to // prevent RecordPlugin from serializing them as standalone XMLs. // // A dummy sys_ux_lib_asset is created (but not added to the build database) solely // to derive the same stable sys_id that StaticContentPlugin would use, ensuring both // plugins always agree on the asset's sys_id. /** * Builds a source artifact record from the given files and associates it with the provided metadata record. * * Reads files from disk, encodes them as base64, compresses with gzip, creates * artifact/attachment/attachment_doc records with hash-based IDs, and links to metadata via M2M. * * @param artifactName - Descriptive name for the artifact (e.g., "home_page.do - BYOUI Files") * @param files - Array of file paths relative to project root * @param record - Parent metadata record to link to (the artifact will be associated via M2M) * @param context - Build context (fs, project, factory, config, logger, diagnostics) * @returns Array of records: artifact, attachment, attachment docs, M2M link record, and * per-asset M2M records (one per assetName). Returns empty array if no files * provided or all files were skipped. */ async function buildArtifact( artifactName: string, files: string[], record: Record, context: { fs: FileSystem project: Project factory: Factory config: NowConfig logger: Logger diagnostics: Diagnostics }, assetNames?: string[] ): Promise { if (files.length === 0) { return [] } const { files: attachmentFiles, skippedFiles, totalSize, } = await getAttachmentContent(context.fs, context.project.getRootDir(), files) // Report skipped files as warnings before the empty-content check so the // caller knows *why* no records were created (e.g. all files exceeded size limits). for (const warning of skippedFiles) { context.diagnostics.warn(record, warning) } if (!attachmentFiles.size) { context.logger.warn( `No source artifact records created for "${artifactName}": all ${files.length} source file(s) were skipped` ) return [] } const artifactRecord = await createArtifactRecord(artifactName, record, context.factory) const attachmentRecords = await createAttachmentRecords(context.factory, artifactRecord, attachmentFiles, totalSize) const m2mRecord = await createArtifactM2mRecord(artifactRecord, record, context.factory) const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2) context.logger.info(`Built source artifact "${artifactName}" (${totalSizeMB} MB, ${files.length} files)`) const assetM2mRecords = await Promise.all( (assetNames ?? []).map((assetName) => createAssetArtifactM2mRecord(artifactRecord, assetName, record, context)) ) return [artifactRecord, ...attachmentRecords, m2mRecord, ...assetM2mRecords] } /** * Creates a sn_glider_source_artifact_m2m record linking the given asset to the source artifact. * * A dummy sys_ux_lib_asset record is created with the same explicitId as static-content-plugin's * real asset so both plugins resolve to the same sys_id. Only the M2M record is returned and * added to the page's .with() tree so it enters the build database. static-content-plugin picks * up the M2M via sourceArtifactRelationships (application_file = assetSysId) and embeds it in * the sys_ux_lib_asset XML. UiPagePlugin's sn_glider_source_artifact_m2m.toFile marks the M2M as * handled so RecordPlugin does not serialize it as a standalone XML in fluentFile.getOutput(). */ const createAssetArtifactM2mRecord = async ( artifactRecord: Record, entryAssetName: string, metadataRecord: Record, context: { factory: Factory; config: NowConfig } ): Promise => { const dummyAsset = await context.factory.createRecord({ source: metadataRecord.getSource(), table: 'sys_ux_lib_asset', explicitId: entryAssetName, properties: { name: entryAssetName, }, }) return context.factory.createRecord({ source: metadataRecord.getSource(), table: 'sn_glider_source_artifact_m2m', explicitId: `${dummyAsset.getId().getValue()}-${artifactRecord.getId().getValue()}`, properties: { application_file: dummyAsset.getId().getValue(), source_artifact: artifactRecord.getId().getValue(), }, }) } // ─── Source Artifact: Extract ───────────────────────────────────────────────── /** * Extracts and unpacks files from a source artifact record. * * Finds attachments for the artifact, reconstructs compressed data from chunks, * decompresses with unzipSync, and writes files to disk. * * @param artifactRecord - The source artifact record (type: sn_glider_source_artifact) * @param allDescendants - Query interface to access attachment and attachment_doc records * @param targetDir - Target directory path relative to project root (use '' for project root) * @param context - Context with fs, project, logger, diagnostics * @returns Array of file paths that were unpacked (relative to project root) */ async function extractArtifact( artifactRecord: Record, allDescendants: { query: (table: string) => Record[] }, targetDir: string = '', context: { fs: FileSystem project: Project logger: Logger diagnostics: Diagnostics } ): Promise { const artifactSysId = artifactRecord.getId().getValue() // Find all attachments for this artifact and select the most recent one const allAttachments = allDescendants.query('sys_attachment') const matchingAttachments = allAttachments .filter((att) => att.get('table_sys_id').toString().getValue() === artifactSysId) .sort((a, b) => { const aTime = a.get('sys_created_on').toString().getValue() const bTime = b.get('sys_created_on').toString().getValue() return aTime.localeCompare(bTime) }) if (matchingAttachments.length === 0) { context.logger.debug(`No attachments found for artifact ${artifactSysId}`) return [] } // Use the most recent attachment (last after sorting by creation time) const latestAttachment = matchingAttachments.at(-1) if (!latestAttachment) { return [] } // Get attachment docs (chunks) for the latest attachment const allAttachmentDocs = allDescendants.query('sys_attachment_doc') const attachmentDocs = allAttachmentDocs.filter( (doc) => doc.get('sys_attachment').toString().getValue() === latestAttachment.getId().getValue() ) if (attachmentDocs.length === 0) { context.logger.debug(`No attachment docs found for attachment ${latestAttachment.getId().getValue()}`) return [] } // Sort docs by position to ensure correct byte order const sortedDocs = attachmentDocs.sort( (a, b) => Number(a.get('position').toString().getValue()) - Number(b.get('position').toString().getValue()) ) // Reconstruct zip bytes from all chunks uniformly const chunks = sortedDocs.map((doc) => Buffer.from(doc.get('data').toString().getValue(), 'base64')) const compressedData = Buffer.concat(chunks) // Decompress — returns { [filePath]: Uint8Array } const entries = unzipSync(compressedData) const unpackedFiles: string[] = [] for (const [filePath, data] of Object.entries(entries)) { try { const fileContent = Buffer.from(data) const absolutePath = path.join(context.project.getRootDir(), targetDir, filePath) const dir = path.dirname(absolutePath) context.fs.mkdirSync(dir, { recursive: true }) context.fs.writeFileSync(absolutePath, fileContent) context.project.addFile({ path: absolutePath, content: fileContent.toString('utf-8') }) unpackedFiles.push(filePath) context.logger.debug(`Extracted file: ${filePath}`) } catch (error) { context.diagnostics.error(artifactRecord, `Failed to write file ${filePath}: ${error}`) } } if (unpackedFiles.length > 0) { context.logger.info( `Extracted ${unpackedFiles.length} files from artifact "${artifactRecord.get('name').toString().getValue()}"` ) } return unpackedFiles } /** * Finds a source artifact record by ID or name pattern in descendants. * * @param descendants - Query interface to access source artifact records * @param query - Search criteria: use either id (exact sys_id) OR name (regex against name) * @returns The matching source artifact record, or undefined if not found */ function getSourceArtifact( descendants: { query: (table: string) => Record[] }, query: { id?: string; name?: RegExp } ): Record | undefined { return descendants.query('sn_glider_source_artifact').find((record) => { if (query.id) { return record.getId().getValue() === query.id } if (query.name) { const nameMatch = record.get('name').toString().getValue().match(query.name) return nameMatch && nameMatch.length > 0 } return false }) }