1#!/usr/bin/env node2'use strict';34// oxlint-disable no-console56/**7 * Checks a component built from the user's code before `validate`: one command,8 * one JSON report.9 *10 * node <SKILL_ROOT>/scripts/scan-user-source.cjs <component-dir> [--source <file|dir> ...]11 *12 * Run from the app root. The user's files default to `.wix/agentInput/attachments`13 * (unzipped folders included); pass `--source` for code saved anywhere else.14 * Each failure names the `USER-SOURCES.md` step it enforces. Warnings are things15 * the script can't decide; check each one yourself.
479 `${Math.round(mainShare * 100)}% of ${scaffold.main} is the user's code: the source's component was copied into the scaffold file. Rewrite it as the scaffold component: named parts with their global and module classes and elementProps (PARTS.md), the props from ${scaffold.props}, and the source's handlers and effects moved in as they are.`,
480 );
481 }
482 // A file beside the scaffold may hold a sub-component kept as the source wrote it (a canvas
483 // child with its effect). It fails when it holds the source's exported component, which the
484 // main file then renders, or when it is the engine copy and still has React in it.
506 `${Math.round(share * 100)}% of this file is the user's code and it still has React code (hooks or JSX)${
507 imported ? `, and ${scaffold.main} imports it` : ''
508 }. Keep only the logic with no hooks or JSX in an engine file, and move the source's component into ${scaffold.main} as the scaffold component (named parts, props from ${scaffold.props}, its hooks and effects as they are); delete this copy.`,
509 );
510 } else if (hasReact && /\.[jt]sx$/.test(base)) {
511 warn(
512 'extra-react-file',
513 relative(file),
514 1,
515 `A React file outside the scaffold. If it is the source's component or a copy of it, move its markup, props and styles into ${scaffold.main} instead.`,
516 );
517 }
518 }
519
520 // Step 3: an inline style beats every design-panel edit of the same property,
521 // including a design-panel variable the module CSS declares.
522 const panelVariables = new Set([
523 ...matchAll(css, /(--[\w-]+)\s*:/g),
524 ...matchAll(css, /@property\s+(--[\w-]+)/g),
525 ]);
526 for (const [file, text] of texts) {
527 for (const [, body] of text.matchAll(/style=\{\{([^{}]*)\}/g)) {
541 `An inline style sets ${[...new Set(blocked)].join(', ')}, which overrides the design panel. Put the source's values in the part's module CSS rule; for a theme or mode, toggle a class or data attribute and keep each value in CSS.`,
542 );
543 }
544 }
545 }
546
547 // Step 3: styles live in module CSS, never in a plain stylesheet or a <style> tag.
562 `This file ${[...sheets.map((sheet) => `imports ${sheet}`), ...(styleTag ? ['adds a <style> tag'] : [])].join(' and ')}, which the design panel can't edit. Move each rule into ${scaffold.css}, on the part it styles (a named part's rule, or the root), with the same values, and delete the import or tag.`,
563 );
564 }
565 }
566 // The source's stylesheet moved into module CSS as :global(.its-classes) is still a stylesheet
567 // the design panel can't edit: the parts' rules must be on their own module classes.
575 if (foreignGlobals.length >= FOREIGN_GLOBAL_CLASSES) {
576 fail(
577 'global-stylesheet',
578 relative(cssPath),
579 3,
580 `${scaffold.css} styles the source's own global classes (${foreignGlobals
581 .slice(0, 6)
582 .map((cls) => `.${cls}`)
583 .join(
584 ', ',
585 )}${foreignGlobals.length > 6 ? ', …' : ''}), so the design panel can't edit them. Move each rule onto the module class of the part it styles, with the same values, and drop the global class.`,
586 );
587 }
588 // Step 3: the root's display is the editor's (`--display`, CSS-GUIDELINES.md); a copied
589 // `display` on the root rule overrides it and breaks the source's flex or grid layout.
590 // The root's module class is the first styles.X in the clsx call that starts with the
591 // component's own class, also after other strings (`clsx('name', 'sh-root', styles.x)`).
592 const rootCall = new RegExp(`clsx\\(\\s*['"\`]${componentName}['"\`]([^)]*)\\)`).exec(mainText);
604 `The .${rootClass} rule sets display directly, which overrides the editor's --display and breaks the layout. Write the source's root display as --display (e.g. --display: flex; flex-direction: column) and keep its other values.`,
605 );
606 }
607 // Step 5: the source's breakpoints and dark mode stay: @container queries at the same widths
608 // (Tailwind's md: is 768px) and a prefers-color-scheme rule for its dark: variants.
648 `The port drops the source's ${droppedRules.join(' and ')}. Write each source breakpoint as a @container query at the same width with the source's values (Tailwind sm/md/lg/xl/2xl are 640/768/1024/1280/1536px), and its dark: variants or dark @media rules as a @media (prefers-color-scheme: dark) rule.`,
649 );
650 }
651 for (const sheet of matchAll(css, /@import\s+(?:url\()?['"]?(\.{1,2}\/[^'")\s]+\.css)/g)) {
652 fail(
653 'global-stylesheet',
654 relative(cssPath),
655 3,
656 `${scaffold.css} imports ${sheet}, whose rules the design panel can't edit. Move them into ${scaffold.css} on the parts they style, and delete the import.`,
657 );
658 }
659
660 // Step 4: a view or mode the whole component switches into is a root ElementState.
661 if (!/\bElementState\b/.test(declaredPropsBlock(propsText))) {
662 const views = [];
663 for (const [file, text] of texts) {
664 const base = path.basename(file);
665 if (!/\.[jt]sx$/.test(base) || base === 'component.tsx' || base === 'component.preview.tsx')
684 `The component switches views with ${[...new Set(views)].join(', ')} in useState, and ${scaffold.props} has no ElementState prop, so the editor has no state to design it in. Add a root ElementState prop per DESIGN-STATES.md and keep the state in sync with it.`,
685 );
686 }
687 }
688
689 // Step 7: every declared prop is read by the component.
690 const readers = [...texts]
691 .filter(([file]) => {
692 const base = path.basename(file);
693 return (
694 base !== scaffold.props &&
695 base !== 'component.tsx' &&
696 base !== 'component.preview.tsx' &&
697 !base.endsWith('.extension.ts')
698 );
699 })
700 .map(([, text]) => text)
701 .join('\n');
702 const readerCode = withoutComments(readers);
703 for (const prop of declaredProps(propsText)) {
704 if (SCAFFOLD_PROPS.has(prop)) continue;
705 if (!propIsRead(prop, readerCode)) {
706 fail(
707 'unread-prop',
708 relative(propsPath),
709 7,
710 `"${prop}" is declared but nothing reads it. Wire it to what it controls, or remove it if the source has no such option.`,
711 prop,
712 );
713 }
714 }
715
716 // Step 2: visible elements are JSX named parts, never markup the logic builds.
727 `This file builds visible markup itself (${[...new Set(built)].join(', ')}), so the editor can't select or style it. Render each element (hotspots, tooltips, labels, slides) in JSX as a named part per PARTS.md, and have the logic find it through a data-* attribute and only position, show or update it.`,
728 );
729 }
730 }
731
732 // Step 2: every list of items the source holds becomes an array prop.
747 if (sourceLists.length && !/\bArray<|\[\]\s*;/.test(declaredPropsBlock(propsText))) {
748 fail(
749 'source-list-without-array-prop',
750 relative(propsPath),
751 2,
752 `The source holds ${sourceLists.join(', ')} as lists of items, and ${scaffold.props} has no array prop. Make each an Array<{…}> prop whose defaults are the source's items, so Manage Items can edit them, and have the logic read the list from props.`,
753 );
754 }
755 // An array prop with no defaults starts Manage Items empty, while the code still shows the
756 // source's own list as a fallback (`hotspotList || DEFAULT_HOTSPOTS`).
769 `${emptyLists.join(', ')} ${emptyLists.length > 1 ? 'have' : 'has'} no defaults, so Manage Items starts empty while the code falls back to the source's own list. Put the source's items in defaultProps and have the code read only the prop.`,
770 emptyLists[0],
771 );
772 }
773 // A list of texts written into the component's own JSX files is never editable.
774 for (const [file, text] of texts) {
775 const base = path.basename(file);
776 if (!/\.[jt]sx$/.test(base) || base === 'component.tsx' || base === 'component.preview.tsx')
777 continue;
778 const lists = textLists(text);
779 if (lists.length) {
780 fail(
781 'source-list-without-array-prop',
782 relative(file),
783 2,
784 `${lists.join(', ')} ${lists.length > 1 ? 'are lists' : 'is a list'} of texts written into the component, so the owner can't edit them. Move each into ${scaffold.props} as an Array<{…}> prop (or text props) with these values as defaults.`,
785 );
786 }
787 }
788
789 // Step 3: numbers carry their range (COMPONENT-CONTRACT.md: @min/@max).
796 `${unranged.join(', ')} ${unranged.length > 1 ? 'are numbers' : 'is a number'} without @min/@max. Add the range the source allows (its clamp, slider or docs) in the unit the owner thinks in.`,
797 );
798 }
799
800 // Step 2: colors are design-panel variables, not text the owner types.
812 `${colorProps.join(', ')} ${colorProps.length > 1 ? 'are colors' : 'is a color'} typed as a string prop, a text field in the settings panel. Make each a CSS custom property declared on the root (registered with @property on a canvas) that the code reads, so the owner picks it in the design panel.`,
813 );
814 }
815
816 // Step 3: every option the source offers stays a prop ("never drop it").
833 `The source offers ${dropped.join(', ')} as options, and ${scaffold.props} has none of them. Keep each as a prop with the source's default (numbers with @min/@max), and pass it to what it controls; never hardcode it.`,
834 dropped.slice(0, 3).map(humanize).join(', '),
835 );
836 }
837
838 // Step 6: the source's own images (painted or linked) are the defaults, never the stock pool.
865 `The source has its own images, but ${poolDefaults.length} default${poolDefaults.length > 1 ? 's come' : ' comes'} from the contract's stock pool. Upload the source's images (render the ones it paints with render-images.cjs) and use those as the defaults. If uploading or rendering one fails, keeping the pool image for it is fine: say so in your final message with the script's error.`,
866 );
867 }
868
869 // Step 6: every Image default is a Wix Media file.
870 for (const literal of propsText.match(/\{[^{}]*\burl\s*:[^{}]*\}/g) ?? []) {
871 if (!/\buri\s*:\s*['"`][^'"`]+['"`]/.test(literal)) {
872 fail(
873 'image-without-uri',
874 relative(propsPath),
875 6,
876 `This default has a url and no uri: ${literal.replace(/\s+/g, ' ').slice(0, 120)}. Upload it with upload-media.cjs and use the { uri, url } it prints.`,
877 );
878 }
879 }
880
881 // Step 6: images the code generates become uploaded defaults too, also when the port
882 // dropped the painter (a CSS gradient in its place): the source still paints them.
889 if (hasImageField && !hasImageDefault && (PAINTS.test(allCode) || PAINTS.test(sourceCode))) {
890 fail(
891 'generated-image-without-default',
892 relative(propsPath),
893 6,
894 `The ${PAINTS.test(allCode) ? 'code generates' : 'source paints'} its images at runtime (toDataURL, toBlob or createObjectURL), and no Image default has a uri, so the panel shows no images. Copy the painter from the source unchanged into the engine file and export it (step 1), render each default from there with render-images.cjs, upload the files with upload-media.cjs, and use the results as the Image defaults; keep the generator for items left without an image. Never replace the painter with CSS or a new drawing.`,
895 );
896 }
897
898 // Step 3: the source's handlers move as they are; an added element stops its own events.
899 // `event.target === ref.current`, `e.target !== e.currentTarget`, either way round.
905 if (!targetGuard.test(withoutComments(sourceCode))) {
906 for (const [file, text] of texts) {
907 if (!targetGuard.test(withoutComments(text))) continue;
908 fail(
909 'new-handler-condition',
910 relative(file),
911 3,
912 "A handler checks event.target against a ref, which the source never does, so clicks on the component's children (its slides, images or text) are ignored. Keep the source's handler as it is, and have the element you added (the play button) call event.stopPropagation() in its own onPointerDown, onPointerUp and onClick.",
913 );
914 }
915 }
916
917 // Step 3: a source that measures its parts to place them (the arc picker's anchor and
918 // selection frame) loses its layout when a port replaces the measuring with fixed CSS.
930 if (sourceMeasures.length && !Object.values(MEASURES).some((pattern) => pattern.test(portCode))) {
931 fail(
932 'dropped-source-measurement',
933 relative(mainPath),
934 3,
935 `The source measures its parts to place them (${sourceMeasures.join(', ')}), and the port measures nothing, so its layout comes from fixed values instead. Port the source's measuring code and what it positions as they are, with only prop and ref names changed.`,
936 );
937 }
938
939 // Step 3: an attribute only the source's code sets (`root.dataset.gl = 'on'`) starts unset;
940 // a fixed value in the port's JSX (`data-gl="off"`) trips the source's own checks of it.
960 `The JSX sets ${attribute}="${value}", which the source never does: only the source's code sets ${attribute} (dataset.${key}), and its checks expect it to start unset. Remove ${attribute}="${value}" from the JSX and keep the source's code as it is.`,
961 );
962 }
963 }
964
965 // Step 2: CSS variables the code or CSS reads are declared on the root.
966 const declaredInCss = new Set(matchAll(css, /(--[\w-]+)\s*:/g));
967 const registered = new Set(matchAll(css, /@property\s+(--[\w-]+)/g));
980 const readByCss = new Set(matchAll(css, /var\(\s*(--[\w-]+)/g));
981 for (const variable of readByCode) {
982 if (PLATFORM_VARIABLE.test(variable) || declaredInCss.has(variable)) continue;
983 fail(
984 'undeclared-css-variable',
985 relative(cssPath),
986 2,
987 `The code reads ${variable}, but ${scaffold.css} never declares it on the root${registered.has(variable) ? ' (an @property rule alone is not a declaration)' : ''}. Declare it on the root with the source's value, so it shows in the design panel.`,
988 );
989 }
990 for (const variable of readByCss) {
991 if (
992 PLATFORM_VARIABLE.test(variable) ||
993 declaredInCss.has(variable) ||
994 registered.has(variable) ||
995 setByCode.has(variable)
996 )
997 continue;
998 fail(
999 'undeclared-css-variable',
1000 relative(cssPath),
1001 2,
1002 `The CSS reads ${variable}, but nothing declares or sets it.`,
1003 );
1004 }
1005
1006 const mentionedByCode = new Set(matchAll(allCode, /['"`](--[\w-]+)['"`]/g));
1007 for (const variable of declaredInCss) {
1008 if (PLATFORM_VARIABLE.test(variable) || SCAFFOLD_VARIABLES.has(variable)) continue;
1009 if (readByCss.has(variable) || readByCode.has(variable) || mentionedByCode.has(variable))
1010 continue;
1011 fail(
1012 'unread-css-variable',
1013 relative(cssPath),
1014 2,
1015 `${variable} is declared, but neither the CSS nor the code reads it, so changing it in the design panel does nothing. Read it where the source uses that value${
1016 CANVAS_MARKERS.test(allCode)
1017 ? ' (on a canvas, through the cached reader in CANVAS-COMPONENTS.md)'
1018 : ''
1019 }, or remove it.`,
1020 );
1021 }
1022
1023 // CANVAS-COMPONENTS.md: values a canvas draws with are registered, and edits are heard.
1024 if (CANVAS_MARKERS.test(allCode)) {
1025 for (const variable of readByCode) {
1026 if (PLATFORM_VARIABLE.test(variable) || registered.has(variable)) continue;
1027 fail(
1028 'unregistered-canvas-variable',
1029 relative(cssPath),
1030 2,
1031 `The canvas reads ${variable}, but it has no @property rule, so the design panel can't type it. Register it per CANVAS-COMPONENTS.md.`,
1032 );
1033 }
1034 // An edit is heard only with both: a transitionend listener, and a transition on the variable.
1052 heard ? '' : 'no CSS transition names the variables it reads'
1053 }, so a design-panel edit does not repaint it. Add the 1ms transition on those variables and the transitionend listener with the cached reader from CANVAS-COMPONENTS.md.`,
1054 );
1055 }
1056 if (readByCode.size === 0) {
1057 warn(
1058 'no-design-panel-variables',
1059 relative(cssPath),
1060 2,
1061 'The component draws on a canvas but reads no CSS variables, so the owner cannot change the colors or fonts it draws in the design panel. If the source draws with colors or fonts, expose them per CANVAS-COMPONENTS.md.',
1062 );
1063 }
1064 }
1065
1066 if (sourceFiles.length === 0) {
1067 warn(
1068 'no-source-files',
1069 DEFAULT_SOURCE_DIR,
1070 1,
1071 'No user files found, so copied code could not be compared. Pass --source <file> for pasted code you saved.',