almostnode 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/dist/__sw__.js +25 -16
- package/dist/frameworks/next-dev-server.d.ts +61 -0
- package/dist/frameworks/next-dev-server.d.ts.map +1 -1
- package/dist/frameworks/tailwind-config-loader.d.ts +32 -0
- package/dist/frameworks/tailwind-config-loader.d.ts.map +1 -0
- package/dist/index.cjs +869 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +845 -27
- package/dist/index.mjs.map +1 -1
- package/dist/macaly-demo.d.ts +42 -0
- package/dist/macaly-demo.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/frameworks/next-dev-server.ts +913 -34
- package/src/frameworks/tailwind-config-loader.ts +206 -0
- package/src/macaly-demo.ts +172 -0
|
@@ -7,6 +7,7 @@ import { DevServer, DevServerOptions, ResponseData, HMRUpdate } from '../dev-ser
|
|
|
7
7
|
import { VirtualFS } from '../virtual-fs';
|
|
8
8
|
import { Buffer } from '../shims/stream';
|
|
9
9
|
import { simpleHash } from '../utils/hash';
|
|
10
|
+
import { loadTailwindConfig } from './tailwind-config-loader';
|
|
10
11
|
|
|
11
12
|
// Check if we're in a real browser environment (not jsdom or Node.js)
|
|
12
13
|
const isBrowser = typeof window !== 'undefined' &&
|
|
@@ -77,6 +78,8 @@ export interface NextDevServerOptions extends DevServerOptions {
|
|
|
77
78
|
preferAppRouter?: boolean;
|
|
78
79
|
/** Environment variables (NEXT_PUBLIC_* are available in browser code via process.env) */
|
|
79
80
|
env?: Record<string, string>;
|
|
81
|
+
/** Asset prefix for static files (e.g., '/marketing'). Auto-detected from next.config if not specified. */
|
|
82
|
+
assetPrefix?: string;
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/**
|
|
@@ -283,25 +286,31 @@ const applyVirtualBase = (url) => {
|
|
|
283
286
|
|
|
284
287
|
export default function Link({ href, children, ...props }) {
|
|
285
288
|
const handleClick = (e) => {
|
|
289
|
+
console.log('[Link] Click handler called, href:', href);
|
|
290
|
+
|
|
286
291
|
if (props.onClick) {
|
|
287
292
|
props.onClick(e);
|
|
288
293
|
}
|
|
289
294
|
|
|
290
295
|
// Allow cmd/ctrl click to open in new tab
|
|
291
296
|
if (e.metaKey || e.ctrlKey) {
|
|
297
|
+
console.log('[Link] Meta/Ctrl key pressed, allowing default behavior');
|
|
292
298
|
return;
|
|
293
299
|
}
|
|
294
300
|
|
|
295
301
|
if (typeof href !== 'string' || !href || href.startsWith('#') || href.startsWith('?')) {
|
|
302
|
+
console.log('[Link] Skipping navigation for href:', href);
|
|
296
303
|
return;
|
|
297
304
|
}
|
|
298
305
|
|
|
299
306
|
if (/^(https?:)?\\/\\//.test(href)) {
|
|
307
|
+
console.log('[Link] External URL, allowing default behavior:', href);
|
|
300
308
|
return;
|
|
301
309
|
}
|
|
302
310
|
|
|
303
311
|
e.preventDefault();
|
|
304
312
|
const resolvedHref = applyVirtualBase(href);
|
|
313
|
+
console.log('[Link] Navigating to:', resolvedHref);
|
|
305
314
|
window.history.pushState({}, '', resolvedHref);
|
|
306
315
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
307
316
|
};
|
|
@@ -670,6 +679,323 @@ export default function Head({ children }) {
|
|
|
670
679
|
}
|
|
671
680
|
`;
|
|
672
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Next.js Image shim code
|
|
684
|
+
* Provides a simple img-based implementation of next/image
|
|
685
|
+
*/
|
|
686
|
+
const NEXT_IMAGE_SHIM = `
|
|
687
|
+
import React from 'react';
|
|
688
|
+
|
|
689
|
+
function Image({
|
|
690
|
+
src,
|
|
691
|
+
alt = '',
|
|
692
|
+
width,
|
|
693
|
+
height,
|
|
694
|
+
fill,
|
|
695
|
+
loader,
|
|
696
|
+
quality = 75,
|
|
697
|
+
priority,
|
|
698
|
+
loading,
|
|
699
|
+
placeholder,
|
|
700
|
+
blurDataURL,
|
|
701
|
+
unoptimized,
|
|
702
|
+
onLoad,
|
|
703
|
+
onError,
|
|
704
|
+
style,
|
|
705
|
+
className,
|
|
706
|
+
sizes,
|
|
707
|
+
...rest
|
|
708
|
+
}) {
|
|
709
|
+
// Handle src - could be string or StaticImageData object
|
|
710
|
+
const imageSrc = typeof src === 'object' ? src.src : src;
|
|
711
|
+
|
|
712
|
+
// Build style object
|
|
713
|
+
const imgStyle = { ...style };
|
|
714
|
+
if (fill) {
|
|
715
|
+
imgStyle.position = 'absolute';
|
|
716
|
+
imgStyle.width = '100%';
|
|
717
|
+
imgStyle.height = '100%';
|
|
718
|
+
imgStyle.objectFit = imgStyle.objectFit || 'cover';
|
|
719
|
+
imgStyle.inset = '0';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return React.createElement('img', {
|
|
723
|
+
src: imageSrc,
|
|
724
|
+
alt,
|
|
725
|
+
width: fill ? undefined : width,
|
|
726
|
+
height: fill ? undefined : height,
|
|
727
|
+
loading: priority ? 'eager' : (loading || 'lazy'),
|
|
728
|
+
decoding: 'async',
|
|
729
|
+
style: imgStyle,
|
|
730
|
+
className,
|
|
731
|
+
onLoad,
|
|
732
|
+
onError,
|
|
733
|
+
...rest
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export default Image;
|
|
738
|
+
export { Image };
|
|
739
|
+
`;
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* next/dynamic shim - Dynamic imports with loading states
|
|
743
|
+
*/
|
|
744
|
+
const NEXT_DYNAMIC_SHIM = `
|
|
745
|
+
import React from 'react';
|
|
746
|
+
|
|
747
|
+
function dynamic(importFn, options = {}) {
|
|
748
|
+
const {
|
|
749
|
+
loading: LoadingComponent,
|
|
750
|
+
ssr = true,
|
|
751
|
+
} = options;
|
|
752
|
+
|
|
753
|
+
// Create a lazy component
|
|
754
|
+
const LazyComponent = React.lazy(importFn);
|
|
755
|
+
|
|
756
|
+
// Wrapper component that handles loading state
|
|
757
|
+
function DynamicComponent(props) {
|
|
758
|
+
const fallback = LoadingComponent
|
|
759
|
+
? React.createElement(LoadingComponent, { isLoading: true })
|
|
760
|
+
: null;
|
|
761
|
+
|
|
762
|
+
return React.createElement(
|
|
763
|
+
React.Suspense,
|
|
764
|
+
{ fallback },
|
|
765
|
+
React.createElement(LazyComponent, props)
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return DynamicComponent;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export default dynamic;
|
|
773
|
+
export { dynamic };
|
|
774
|
+
`;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* next/script shim - Loads external scripts
|
|
778
|
+
*/
|
|
779
|
+
const NEXT_SCRIPT_SHIM = `
|
|
780
|
+
import React from 'react';
|
|
781
|
+
|
|
782
|
+
function Script({
|
|
783
|
+
src,
|
|
784
|
+
strategy = 'afterInteractive',
|
|
785
|
+
onLoad,
|
|
786
|
+
onReady,
|
|
787
|
+
onError,
|
|
788
|
+
children,
|
|
789
|
+
dangerouslySetInnerHTML,
|
|
790
|
+
...rest
|
|
791
|
+
}) {
|
|
792
|
+
React.useEffect(function() {
|
|
793
|
+
if (!src && !children && !dangerouslySetInnerHTML) return;
|
|
794
|
+
|
|
795
|
+
var script = document.createElement('script');
|
|
796
|
+
|
|
797
|
+
if (src) {
|
|
798
|
+
script.src = src;
|
|
799
|
+
script.async = strategy !== 'beforeInteractive';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
Object.keys(rest).forEach(function(key) {
|
|
803
|
+
script.setAttribute(key, rest[key]);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
if (children) {
|
|
807
|
+
script.textContent = children;
|
|
808
|
+
} else if (dangerouslySetInnerHTML && dangerouslySetInnerHTML.__html) {
|
|
809
|
+
script.textContent = dangerouslySetInnerHTML.__html;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
script.onload = function() {
|
|
813
|
+
if (onLoad) onLoad();
|
|
814
|
+
if (onReady) onReady();
|
|
815
|
+
};
|
|
816
|
+
script.onerror = onError;
|
|
817
|
+
|
|
818
|
+
document.head.appendChild(script);
|
|
819
|
+
|
|
820
|
+
return function() {
|
|
821
|
+
if (script.parentNode) {
|
|
822
|
+
script.parentNode.removeChild(script);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}, [src]);
|
|
826
|
+
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export default Script;
|
|
831
|
+
export { Script };
|
|
832
|
+
`;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* next/font/google shim - Loads Google Fonts via CDN
|
|
836
|
+
* Uses a Proxy to dynamically handle ANY Google Font without hardcoding
|
|
837
|
+
*/
|
|
838
|
+
const NEXT_FONT_GOOGLE_SHIM = `
|
|
839
|
+
// Track loaded fonts to avoid duplicate style injections
|
|
840
|
+
const loadedFonts = new Set();
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Convert font function name to Google Fonts family name
|
|
844
|
+
* Examples:
|
|
845
|
+
* DM_Sans -> DM Sans
|
|
846
|
+
* Open_Sans -> Open Sans
|
|
847
|
+
* Fraunces -> Fraunces
|
|
848
|
+
*/
|
|
849
|
+
function toFontFamily(fontName) {
|
|
850
|
+
return fontName.replace(/_/g, ' ');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Inject font CSS into document
|
|
855
|
+
* - Adds preconnect links for faster font loading
|
|
856
|
+
* - Loads the font from Google Fonts CDN
|
|
857
|
+
* - Creates a CSS class that sets the CSS variable
|
|
858
|
+
*/
|
|
859
|
+
function injectFontCSS(fontFamily, variableName, weight, style) {
|
|
860
|
+
const fontKey = fontFamily + '-' + (variableName || 'default');
|
|
861
|
+
if (loadedFonts.has(fontKey)) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
loadedFonts.add(fontKey);
|
|
865
|
+
|
|
866
|
+
if (typeof document === 'undefined') {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Add preconnect links for faster loading (only once)
|
|
871
|
+
if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) {
|
|
872
|
+
const preconnect1 = document.createElement('link');
|
|
873
|
+
preconnect1.rel = 'preconnect';
|
|
874
|
+
preconnect1.href = 'https://fonts.googleapis.com';
|
|
875
|
+
document.head.appendChild(preconnect1);
|
|
876
|
+
|
|
877
|
+
const preconnect2 = document.createElement('link');
|
|
878
|
+
preconnect2.rel = 'preconnect';
|
|
879
|
+
preconnect2.href = 'https://fonts.gstatic.com';
|
|
880
|
+
preconnect2.crossOrigin = 'anonymous';
|
|
881
|
+
document.head.appendChild(preconnect2);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Build Google Fonts URL
|
|
885
|
+
const escapedFamily = fontFamily.replace(/ /g, '+');
|
|
886
|
+
|
|
887
|
+
// Build axis list based on options
|
|
888
|
+
let axisList = '';
|
|
889
|
+
const axes = [];
|
|
890
|
+
|
|
891
|
+
// Handle italic style
|
|
892
|
+
if (style === 'italic') {
|
|
893
|
+
axes.push('ital');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Handle weight - use specific weight or variable range
|
|
897
|
+
if (weight && weight !== '400' && !Array.isArray(weight)) {
|
|
898
|
+
// Specific weight requested
|
|
899
|
+
axes.push('wght');
|
|
900
|
+
if (style === 'italic') {
|
|
901
|
+
axisList = ':ital,wght@1,' + weight;
|
|
902
|
+
} else {
|
|
903
|
+
axisList = ':wght@' + weight;
|
|
904
|
+
}
|
|
905
|
+
} else if (Array.isArray(weight)) {
|
|
906
|
+
// Multiple weights
|
|
907
|
+
axes.push('wght');
|
|
908
|
+
axisList = ':wght@' + weight.join(';');
|
|
909
|
+
} else {
|
|
910
|
+
// Default: request common weights for flexibility
|
|
911
|
+
axisList = ':wght@400;500;600;700';
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const fontUrl = 'https://fonts.googleapis.com/css2?family=' +
|
|
915
|
+
escapedFamily + axisList + '&display=swap';
|
|
916
|
+
|
|
917
|
+
// Add link element for Google Fonts (if not already present)
|
|
918
|
+
if (!document.querySelector('link[href*="family=' + escapedFamily + '"]')) {
|
|
919
|
+
const link = document.createElement('link');
|
|
920
|
+
link.rel = 'stylesheet';
|
|
921
|
+
link.href = fontUrl;
|
|
922
|
+
document.head.appendChild(link);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Create style element for CSS variable at :root level (globally available)
|
|
926
|
+
// This makes the variable work without needing to apply the class to body
|
|
927
|
+
if (variableName) {
|
|
928
|
+
const styleEl = document.createElement('style');
|
|
929
|
+
styleEl.setAttribute('data-font-var', variableName);
|
|
930
|
+
styleEl.textContent = ':root { ' + variableName + ': "' + fontFamily + '", ' + (fontFamily.includes('Serif') ? 'serif' : 'sans-serif') + '; }';
|
|
931
|
+
document.head.appendChild(styleEl);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Create a font loader function for a specific font
|
|
937
|
+
*/
|
|
938
|
+
function createFontLoader(fontName) {
|
|
939
|
+
const fontFamily = toFontFamily(fontName);
|
|
940
|
+
|
|
941
|
+
return function(options = {}) {
|
|
942
|
+
const {
|
|
943
|
+
weight,
|
|
944
|
+
style = 'normal',
|
|
945
|
+
subsets = ['latin'],
|
|
946
|
+
variable,
|
|
947
|
+
display = 'swap',
|
|
948
|
+
preload = true,
|
|
949
|
+
fallback = ['sans-serif'],
|
|
950
|
+
adjustFontFallback = true
|
|
951
|
+
} = options;
|
|
952
|
+
|
|
953
|
+
// Inject the font CSS
|
|
954
|
+
injectFontCSS(fontFamily, variable, weight, style);
|
|
955
|
+
|
|
956
|
+
// Generate class name from variable (--font-inter -> __font-inter)
|
|
957
|
+
const className = variable
|
|
958
|
+
? variable.replace('--', '__')
|
|
959
|
+
: '__font-' + fontName.toLowerCase().replace(/_/g, '-');
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
className,
|
|
963
|
+
variable: className,
|
|
964
|
+
style: {
|
|
965
|
+
fontFamily: '"' + fontFamily + '", ' + fallback.join(', ')
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Use a Proxy to dynamically create font loaders for ANY font name
|
|
973
|
+
* This allows: import { AnyGoogleFont } from "next/font/google"
|
|
974
|
+
*/
|
|
975
|
+
const fontProxy = new Proxy({}, {
|
|
976
|
+
get(target, prop) {
|
|
977
|
+
// Handle special properties
|
|
978
|
+
if (prop === '__esModule') return true;
|
|
979
|
+
if (prop === 'default') return fontProxy;
|
|
980
|
+
if (typeof prop !== 'string') return undefined;
|
|
981
|
+
|
|
982
|
+
// Create a font loader for this font name
|
|
983
|
+
return createFontLoader(prop);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Export the proxy as both default and named exports
|
|
988
|
+
export default fontProxy;
|
|
989
|
+
|
|
990
|
+
// Re-export through proxy for named imports
|
|
991
|
+
export const {
|
|
992
|
+
Fraunces, Inter, DM_Sans, DM_Serif_Text, Roboto, Open_Sans, Lato,
|
|
993
|
+
Montserrat, Poppins, Playfair_Display, Merriweather, Raleway, Nunito,
|
|
994
|
+
Ubuntu, Oswald, Quicksand, Work_Sans, Fira_Sans, Barlow, Mulish, Rubik,
|
|
995
|
+
Noto_Sans, Manrope, Space_Grotesk, Geist, Geist_Mono
|
|
996
|
+
} = fontProxy;
|
|
997
|
+
`;
|
|
998
|
+
|
|
673
999
|
/**
|
|
674
1000
|
* NextDevServer - A lightweight Next.js-compatible development server
|
|
675
1001
|
*
|
|
@@ -718,6 +1044,18 @@ export class NextDevServer extends DevServer {
|
|
|
718
1044
|
/** Transform result cache for performance */
|
|
719
1045
|
private transformCache: Map<string, { code: string; hash: string }> = new Map();
|
|
720
1046
|
|
|
1047
|
+
/** Path aliases from tsconfig.json (e.g., @/* -> ./*) */
|
|
1048
|
+
private pathAliases: Map<string, string> = new Map();
|
|
1049
|
+
|
|
1050
|
+
/** Cached Tailwind config script (injected before CDN) */
|
|
1051
|
+
private tailwindConfigScript: string = '';
|
|
1052
|
+
|
|
1053
|
+
/** Whether Tailwind config has been loaded */
|
|
1054
|
+
private tailwindConfigLoaded: boolean = false;
|
|
1055
|
+
|
|
1056
|
+
/** Asset prefix for static files (e.g., '/marketing') */
|
|
1057
|
+
private assetPrefix: string = '';
|
|
1058
|
+
|
|
721
1059
|
constructor(vfs: VirtualFS, options: NextDevServerOptions) {
|
|
722
1060
|
super(vfs, options);
|
|
723
1061
|
this.options = options;
|
|
@@ -733,6 +1071,131 @@ export class NextDevServer extends DevServer {
|
|
|
733
1071
|
// Prefer App Router if /app directory exists with a page.jsx file
|
|
734
1072
|
this.useAppRouter = this.hasAppRouter();
|
|
735
1073
|
}
|
|
1074
|
+
|
|
1075
|
+
// Load path aliases from tsconfig.json
|
|
1076
|
+
this.loadPathAliases();
|
|
1077
|
+
|
|
1078
|
+
// Load assetPrefix from options or auto-detect from next.config
|
|
1079
|
+
this.loadAssetPrefix(options.assetPrefix);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Load path aliases from tsconfig.json
|
|
1084
|
+
* Supports common patterns like @/* -> ./*
|
|
1085
|
+
*/
|
|
1086
|
+
private loadPathAliases(): void {
|
|
1087
|
+
try {
|
|
1088
|
+
const tsconfigPath = '/tsconfig.json';
|
|
1089
|
+
if (!this.vfs.existsSync(tsconfigPath)) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const content = this.vfs.readFileSync(tsconfigPath, 'utf-8');
|
|
1094
|
+
const tsconfig = JSON.parse(content);
|
|
1095
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
1096
|
+
|
|
1097
|
+
if (!paths) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Convert tsconfig paths to a simple alias map
|
|
1102
|
+
// e.g., "@/*": ["./*"] becomes "@/" -> "/"
|
|
1103
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
1104
|
+
if (Array.isArray(targets) && targets.length > 0) {
|
|
1105
|
+
// Remove trailing * from alias and target
|
|
1106
|
+
const aliasPrefix = alias.replace(/\*$/, '');
|
|
1107
|
+
const targetPrefix = (targets[0] as string).replace(/\*$/, '').replace(/^\./, '');
|
|
1108
|
+
this.pathAliases.set(aliasPrefix, targetPrefix);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
} catch (e) {
|
|
1112
|
+
// Silently ignore tsconfig parse errors
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Load assetPrefix from options or auto-detect from next.config.ts/js
|
|
1118
|
+
* The assetPrefix is used to prefix static asset URLs (e.g., '/marketing')
|
|
1119
|
+
*/
|
|
1120
|
+
private loadAssetPrefix(optionValue?: string): void {
|
|
1121
|
+
// If explicitly provided in options, use it
|
|
1122
|
+
if (optionValue !== undefined) {
|
|
1123
|
+
// Normalize: ensure it starts with / and doesn't end with /
|
|
1124
|
+
this.assetPrefix = optionValue.startsWith('/') ? optionValue : `/${optionValue}`;
|
|
1125
|
+
if (this.assetPrefix.endsWith('/')) {
|
|
1126
|
+
this.assetPrefix = this.assetPrefix.slice(0, -1);
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Try to auto-detect from next.config.ts or next.config.js
|
|
1132
|
+
try {
|
|
1133
|
+
const configFiles = ['/next.config.ts', '/next.config.js', '/next.config.mjs'];
|
|
1134
|
+
|
|
1135
|
+
for (const configPath of configFiles) {
|
|
1136
|
+
if (!this.vfs.existsSync(configPath)) {
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const content = this.vfs.readFileSync(configPath, 'utf-8');
|
|
1141
|
+
|
|
1142
|
+
// Extract assetPrefix from config using regex
|
|
1143
|
+
// Matches: assetPrefix: "/marketing" or assetPrefix: '/marketing'
|
|
1144
|
+
const match = content.match(/assetPrefix\s*:\s*["']([^"']+)["']/);
|
|
1145
|
+
if (match) {
|
|
1146
|
+
let prefix = match[1];
|
|
1147
|
+
// Normalize: ensure it starts with / and doesn't end with /
|
|
1148
|
+
if (!prefix.startsWith('/')) {
|
|
1149
|
+
prefix = `/${prefix}`;
|
|
1150
|
+
}
|
|
1151
|
+
if (prefix.endsWith('/')) {
|
|
1152
|
+
prefix = prefix.slice(0, -1);
|
|
1153
|
+
}
|
|
1154
|
+
this.assetPrefix = prefix;
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
// Silently ignore config parse errors
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Resolve path aliases in transformed code
|
|
1165
|
+
* Converts imports like "@/components/foo" to "/__virtual__/PORT/components/foo"
|
|
1166
|
+
* This ensures imports go through the virtual server instead of the main server
|
|
1167
|
+
*/
|
|
1168
|
+
private resolvePathAliases(code: string, currentFile: string): string {
|
|
1169
|
+
if (this.pathAliases.size === 0) {
|
|
1170
|
+
return code;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Get the virtual server base path
|
|
1174
|
+
const virtualBase = `/__virtual__/${this.port}`;
|
|
1175
|
+
|
|
1176
|
+
let result = code;
|
|
1177
|
+
|
|
1178
|
+
for (const [alias, target] of this.pathAliases) {
|
|
1179
|
+
// Match import/export statements with the alias
|
|
1180
|
+
// Handles: import ... from "@/...", export ... from "@/...", import("@/...")
|
|
1181
|
+
const aliasEscaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1182
|
+
|
|
1183
|
+
// Pattern to match the alias in import/export statements
|
|
1184
|
+
// This matches: from "@/...", from '@/...', import("@/..."), import('@/...')
|
|
1185
|
+
const pattern = new RegExp(
|
|
1186
|
+
`(from\\s*['"]|import\\s*\\(\\s*['"])${aliasEscaped}([^'"]+)(['"])`,
|
|
1187
|
+
'g'
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
result = result.replace(pattern, (match, prefix, path, quote) => {
|
|
1191
|
+
// Convert alias to virtual server path
|
|
1192
|
+
// e.g., @/components/faq -> /__virtual__/3001/components/faq
|
|
1193
|
+
const resolvedPath = `${virtualBase}${target}${path}`;
|
|
1194
|
+
return `${prefix}${resolvedPath}${quote}`;
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return result;
|
|
736
1199
|
}
|
|
737
1200
|
|
|
738
1201
|
/**
|
|
@@ -762,28 +1225,58 @@ export class NextDevServer extends DevServer {
|
|
|
762
1225
|
/**
|
|
763
1226
|
* Generate a script tag that defines process.env with NEXT_PUBLIC_* variables
|
|
764
1227
|
* This makes environment variables available to browser code via process.env.NEXT_PUBLIC_*
|
|
1228
|
+
* Also includes all env variables for Server Component compatibility
|
|
765
1229
|
*/
|
|
766
1230
|
private generateEnvScript(): string {
|
|
767
1231
|
const env = this.options.env || {};
|
|
768
1232
|
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
return '';
|
|
1233
|
+
// Only include NEXT_PUBLIC_* vars in the HTML (client-side accessible)
|
|
1234
|
+
// Non-public vars should never be exposed in HTML for security
|
|
1235
|
+
const publicEnvVars: Record<string, string> = {};
|
|
1236
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1237
|
+
if (key.startsWith('NEXT_PUBLIC_')) {
|
|
1238
|
+
publicEnvVars[key] = value;
|
|
1239
|
+
}
|
|
777
1240
|
}
|
|
778
1241
|
|
|
1242
|
+
// Always create process.env even if empty (some code checks for process.env existence)
|
|
1243
|
+
// This prevents "process is not defined" errors
|
|
779
1244
|
return `<script>
|
|
780
|
-
//
|
|
1245
|
+
// Environment variables (injected by NextDevServer)
|
|
781
1246
|
window.process = window.process || {};
|
|
782
1247
|
window.process.env = window.process.env || {};
|
|
783
1248
|
Object.assign(window.process.env, ${JSON.stringify(publicEnvVars)});
|
|
784
1249
|
</script>`;
|
|
785
1250
|
}
|
|
786
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* Load Tailwind config from tailwind.config.ts and generate a script
|
|
1254
|
+
* that configures the Tailwind CDN at runtime
|
|
1255
|
+
*/
|
|
1256
|
+
private async loadTailwindConfigIfNeeded(): Promise<string> {
|
|
1257
|
+
// Return cached script if already loaded
|
|
1258
|
+
if (this.tailwindConfigLoaded) {
|
|
1259
|
+
return this.tailwindConfigScript;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
const result = await loadTailwindConfig(this.vfs, this.root);
|
|
1264
|
+
|
|
1265
|
+
if (result.success) {
|
|
1266
|
+
this.tailwindConfigScript = result.configScript;
|
|
1267
|
+
} else if (result.error) {
|
|
1268
|
+
console.warn('[NextDevServer] Tailwind config warning:', result.error);
|
|
1269
|
+
this.tailwindConfigScript = '';
|
|
1270
|
+
}
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
console.warn('[NextDevServer] Failed to load tailwind.config:', error);
|
|
1273
|
+
this.tailwindConfigScript = '';
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
this.tailwindConfigLoaded = true;
|
|
1277
|
+
return this.tailwindConfigScript;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
787
1280
|
/**
|
|
788
1281
|
* Check if App Router is available
|
|
789
1282
|
*/
|
|
@@ -813,13 +1306,39 @@ export class NextDevServer extends DevServer {
|
|
|
813
1306
|
body?: Buffer
|
|
814
1307
|
): Promise<ResponseData> {
|
|
815
1308
|
const urlObj = new URL(url, 'http://localhost');
|
|
816
|
-
|
|
1309
|
+
let pathname = urlObj.pathname;
|
|
1310
|
+
|
|
1311
|
+
// Strip virtual prefix if present (e.g., /__virtual__/3001/foo -> /foo)
|
|
1312
|
+
const virtualPrefixMatch = pathname.match(/^\/__virtual__\/\d+/);
|
|
1313
|
+
if (virtualPrefixMatch) {
|
|
1314
|
+
pathname = pathname.slice(virtualPrefixMatch[0].length) || '/';
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Strip assetPrefix if present (e.g., /marketing/images/foo.png -> /images/foo.png)
|
|
1318
|
+
// This allows static assets to be served from /public when using assetPrefix in next.config
|
|
1319
|
+
// Also handles double-slash case: /marketing//images/foo.png (when assetPrefix ends with /)
|
|
1320
|
+
if (this.assetPrefix && pathname.startsWith(this.assetPrefix)) {
|
|
1321
|
+
const rest = pathname.slice(this.assetPrefix.length);
|
|
1322
|
+
// Handle both /marketing/images and /marketing//images cases
|
|
1323
|
+
if (rest === '' || rest.startsWith('/')) {
|
|
1324
|
+
pathname = rest || '/';
|
|
1325
|
+
// Normalize double slashes that may occur from assetPrefix concatenation
|
|
1326
|
+
if (pathname.startsWith('//')) {
|
|
1327
|
+
pathname = pathname.slice(1);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
817
1331
|
|
|
818
1332
|
// Serve Next.js shims
|
|
819
1333
|
if (pathname.startsWith('/_next/shims/')) {
|
|
820
1334
|
return this.serveNextShim(pathname);
|
|
821
1335
|
}
|
|
822
1336
|
|
|
1337
|
+
// Route info endpoint for client-side navigation params extraction
|
|
1338
|
+
if (pathname === '/_next/route-info') {
|
|
1339
|
+
return this.serveRouteInfo(urlObj.searchParams.get('pathname') || '/');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
823
1342
|
// Serve page components for client-side navigation (Pages Router)
|
|
824
1343
|
if (pathname.startsWith('/_next/pages/')) {
|
|
825
1344
|
return this.servePageComponent(pathname);
|
|
@@ -851,6 +1370,16 @@ export class NextDevServer extends DevServer {
|
|
|
851
1370
|
return this.transformAndServe(pathname, pathname);
|
|
852
1371
|
}
|
|
853
1372
|
|
|
1373
|
+
// Try to resolve file with different extensions (for imports without extensions)
|
|
1374
|
+
// e.g., /components/faq -> /components/faq.tsx
|
|
1375
|
+
const resolvedFile = this.resolveFileWithExtension(pathname);
|
|
1376
|
+
if (resolvedFile) {
|
|
1377
|
+
if (this.needsTransform(resolvedFile)) {
|
|
1378
|
+
return this.transformAndServe(resolvedFile, pathname);
|
|
1379
|
+
}
|
|
1380
|
+
return this.serveFile(resolvedFile);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
854
1383
|
// Serve regular files directly if they exist
|
|
855
1384
|
if (this.exists(pathname) && !this.isDirectory(pathname)) {
|
|
856
1385
|
return this.serveFile(pathname);
|
|
@@ -880,6 +1409,18 @@ export class NextDevServer extends DevServer {
|
|
|
880
1409
|
case 'navigation':
|
|
881
1410
|
code = NEXT_NAVIGATION_SHIM;
|
|
882
1411
|
break;
|
|
1412
|
+
case 'image':
|
|
1413
|
+
code = NEXT_IMAGE_SHIM;
|
|
1414
|
+
break;
|
|
1415
|
+
case 'dynamic':
|
|
1416
|
+
code = NEXT_DYNAMIC_SHIM;
|
|
1417
|
+
break;
|
|
1418
|
+
case 'script':
|
|
1419
|
+
code = NEXT_SCRIPT_SHIM;
|
|
1420
|
+
break;
|
|
1421
|
+
case 'font/google':
|
|
1422
|
+
code = NEXT_FONT_GOOGLE_SHIM;
|
|
1423
|
+
break;
|
|
883
1424
|
default:
|
|
884
1425
|
return this.notFound(pathname);
|
|
885
1426
|
}
|
|
@@ -897,6 +1438,32 @@ export class NextDevServer extends DevServer {
|
|
|
897
1438
|
};
|
|
898
1439
|
}
|
|
899
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* Serve route info for client-side navigation
|
|
1443
|
+
* Returns params extracted from dynamic route segments
|
|
1444
|
+
*/
|
|
1445
|
+
private serveRouteInfo(pathname: string): ResponseData {
|
|
1446
|
+
const route = this.resolveAppRoute(pathname);
|
|
1447
|
+
|
|
1448
|
+
const info = route
|
|
1449
|
+
? { params: route.params, found: true }
|
|
1450
|
+
: { params: {}, found: false };
|
|
1451
|
+
|
|
1452
|
+
const json = JSON.stringify(info);
|
|
1453
|
+
const buffer = Buffer.from(json);
|
|
1454
|
+
|
|
1455
|
+
return {
|
|
1456
|
+
statusCode: 200,
|
|
1457
|
+
statusMessage: 'OK',
|
|
1458
|
+
headers: {
|
|
1459
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1460
|
+
'Content-Length': String(buffer.length),
|
|
1461
|
+
'Cache-Control': 'no-cache',
|
|
1462
|
+
},
|
|
1463
|
+
body: buffer,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
900
1467
|
/**
|
|
901
1468
|
* Serve static assets from /_next/static/
|
|
902
1469
|
*/
|
|
@@ -1510,7 +2077,7 @@ export class NextDevServer extends DevServer {
|
|
|
1510
2077
|
/**
|
|
1511
2078
|
* Resolve App Router route to page and layout files
|
|
1512
2079
|
*/
|
|
1513
|
-
private resolveAppRoute(pathname: string): { page: string; layouts: string[] } | null {
|
|
2080
|
+
private resolveAppRoute(pathname: string): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
|
|
1514
2081
|
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1515
2082
|
const segments = pathname === '/' ? [] : pathname.split('/').filter(Boolean);
|
|
1516
2083
|
|
|
@@ -1545,7 +2112,8 @@ export class NextDevServer extends DevServer {
|
|
|
1545
2112
|
for (const ext of extensions) {
|
|
1546
2113
|
const pagePath = `${dirPath}/page${ext}`;
|
|
1547
2114
|
if (this.exists(pagePath)) {
|
|
1548
|
-
|
|
2115
|
+
// Static route - no params
|
|
2116
|
+
return { page: pagePath, layouts, params: {} };
|
|
1549
2117
|
}
|
|
1550
2118
|
}
|
|
1551
2119
|
|
|
@@ -1555,18 +2123,20 @@ export class NextDevServer extends DevServer {
|
|
|
1555
2123
|
|
|
1556
2124
|
/**
|
|
1557
2125
|
* Resolve dynamic App Router routes like /app/[id]/page.jsx
|
|
2126
|
+
* Also extracts route params from dynamic segments
|
|
1558
2127
|
*/
|
|
1559
2128
|
private resolveAppDynamicRoute(
|
|
1560
2129
|
pathname: string,
|
|
1561
2130
|
segments: string[]
|
|
1562
|
-
): { page: string; layouts: string[] } | null {
|
|
2131
|
+
): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null {
|
|
1563
2132
|
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
1564
2133
|
|
|
1565
2134
|
const tryPath = (
|
|
1566
2135
|
dirPath: string,
|
|
1567
2136
|
remainingSegments: string[],
|
|
1568
|
-
layouts: string[]
|
|
1569
|
-
|
|
2137
|
+
layouts: string[],
|
|
2138
|
+
params: Record<string, string | string[]>
|
|
2139
|
+
): { page: string; layouts: string[]; params: Record<string, string | string[]> } | null => {
|
|
1570
2140
|
// Check for layout at current level
|
|
1571
2141
|
for (const ext of extensions) {
|
|
1572
2142
|
const layoutPath = `${dirPath}/layout${ext}`;
|
|
@@ -1580,7 +2150,7 @@ export class NextDevServer extends DevServer {
|
|
|
1580
2150
|
for (const ext of extensions) {
|
|
1581
2151
|
const pagePath = `${dirPath}/page${ext}`;
|
|
1582
2152
|
if (this.exists(pagePath)) {
|
|
1583
|
-
return { page: pagePath, layouts };
|
|
2153
|
+
return { page: pagePath, layouts, params };
|
|
1584
2154
|
}
|
|
1585
2155
|
}
|
|
1586
2156
|
return null;
|
|
@@ -1591,18 +2161,34 @@ export class NextDevServer extends DevServer {
|
|
|
1591
2161
|
// Try exact match first
|
|
1592
2162
|
const exactPath = `${dirPath}/${current}`;
|
|
1593
2163
|
if (this.isDirectory(exactPath)) {
|
|
1594
|
-
const result = tryPath(exactPath, rest, layouts);
|
|
2164
|
+
const result = tryPath(exactPath, rest, layouts, params);
|
|
1595
2165
|
if (result) return result;
|
|
1596
2166
|
}
|
|
1597
2167
|
|
|
1598
|
-
// Try dynamic
|
|
2168
|
+
// Try dynamic segments
|
|
1599
2169
|
try {
|
|
1600
2170
|
const entries = this.vfs.readdirSync(dirPath);
|
|
1601
2171
|
for (const entry of entries) {
|
|
1602
|
-
|
|
2172
|
+
// Handle catch-all routes [...slug]
|
|
2173
|
+
if (entry.startsWith('[...') && entry.endsWith(']')) {
|
|
2174
|
+
const dynamicPath = `${dirPath}/${entry}`;
|
|
2175
|
+
if (this.isDirectory(dynamicPath)) {
|
|
2176
|
+
// Extract param name from [...slug]
|
|
2177
|
+
const paramName = entry.slice(4, -1);
|
|
2178
|
+
// Catch-all captures all remaining segments
|
|
2179
|
+
const newParams = { ...params, [paramName]: [current, ...rest] };
|
|
2180
|
+
const result = tryPath(dynamicPath, [], layouts, newParams);
|
|
2181
|
+
if (result) return result;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// Handle single dynamic segment [param]
|
|
2185
|
+
else if (entry.startsWith('[') && entry.endsWith(']') && !entry.includes('.')) {
|
|
1603
2186
|
const dynamicPath = `${dirPath}/${entry}`;
|
|
1604
2187
|
if (this.isDirectory(dynamicPath)) {
|
|
1605
|
-
|
|
2188
|
+
// Extract param name from [id]
|
|
2189
|
+
const paramName = entry.slice(1, -1);
|
|
2190
|
+
const newParams = { ...params, [paramName]: current };
|
|
2191
|
+
const result = tryPath(dynamicPath, rest, layouts, newParams);
|
|
1606
2192
|
if (result) return result;
|
|
1607
2193
|
}
|
|
1608
2194
|
}
|
|
@@ -1624,14 +2210,14 @@ export class NextDevServer extends DevServer {
|
|
|
1624
2210
|
}
|
|
1625
2211
|
}
|
|
1626
2212
|
|
|
1627
|
-
return tryPath(this.appDir, segments, layouts);
|
|
2213
|
+
return tryPath(this.appDir, segments, layouts, {});
|
|
1628
2214
|
}
|
|
1629
2215
|
|
|
1630
2216
|
/**
|
|
1631
2217
|
* Generate HTML for App Router with nested layouts
|
|
1632
2218
|
*/
|
|
1633
2219
|
private async generateAppRouterHtml(
|
|
1634
|
-
route: { page: string; layouts: string[] },
|
|
2220
|
+
route: { page: string; layouts: string[]; params: Record<string, string | string[]> },
|
|
1635
2221
|
pathname: string
|
|
1636
2222
|
): Promise<string> {
|
|
1637
2223
|
// Use virtual server prefix for all file imports so the service worker can intercept them
|
|
@@ -1662,6 +2248,9 @@ export class NextDevServer extends DevServer {
|
|
|
1662
2248
|
// Generate env script for NEXT_PUBLIC_* variables
|
|
1663
2249
|
const envScript = this.generateEnvScript();
|
|
1664
2250
|
|
|
2251
|
+
// Load Tailwind config if available (must be injected BEFORE CDN script)
|
|
2252
|
+
const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
|
|
2253
|
+
|
|
1665
2254
|
return `<!DOCTYPE html>
|
|
1666
2255
|
<html lang="en">
|
|
1667
2256
|
<head>
|
|
@@ -1671,6 +2260,7 @@ export class NextDevServer extends DevServer {
|
|
|
1671
2260
|
<title>Next.js App</title>
|
|
1672
2261
|
${envScript}
|
|
1673
2262
|
${TAILWIND_CDN_SCRIPT}
|
|
2263
|
+
${tailwindConfigScript}
|
|
1674
2264
|
${CORS_PROXY_SCRIPT}
|
|
1675
2265
|
${globalCssLinks.join('\n ')}
|
|
1676
2266
|
${REACT_REFRESH_PREAMBLE}
|
|
@@ -1692,7 +2282,11 @@ export class NextDevServer extends DevServer {
|
|
|
1692
2282
|
"next/link": "${virtualPrefix}/_next/shims/link.js",
|
|
1693
2283
|
"next/router": "${virtualPrefix}/_next/shims/router.js",
|
|
1694
2284
|
"next/head": "${virtualPrefix}/_next/shims/head.js",
|
|
1695
|
-
"next/navigation": "${virtualPrefix}/_next/shims/navigation.js"
|
|
2285
|
+
"next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
|
|
2286
|
+
"next/image": "${virtualPrefix}/_next/shims/image.js",
|
|
2287
|
+
"next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
|
|
2288
|
+
"next/script": "${virtualPrefix}/_next/shims/script.js",
|
|
2289
|
+
"next/font/google": "${virtualPrefix}/_next/shims/font/google.js"
|
|
1696
2290
|
}
|
|
1697
2291
|
}
|
|
1698
2292
|
</script>
|
|
@@ -1706,6 +2300,39 @@ export class NextDevServer extends DevServer {
|
|
|
1706
2300
|
|
|
1707
2301
|
const virtualBase = '${virtualPrefix}';
|
|
1708
2302
|
|
|
2303
|
+
// Initial route params (embedded by server for initial page load)
|
|
2304
|
+
const initialRouteParams = ${JSON.stringify(route.params)};
|
|
2305
|
+
const initialPathname = '${pathname}';
|
|
2306
|
+
|
|
2307
|
+
// Route params cache for client-side navigation
|
|
2308
|
+
const routeParamsCache = new Map();
|
|
2309
|
+
routeParamsCache.set(initialPathname, initialRouteParams);
|
|
2310
|
+
|
|
2311
|
+
// Extract route params from server for client-side navigation
|
|
2312
|
+
async function extractRouteParams(pathname) {
|
|
2313
|
+
// Strip virtual base if present
|
|
2314
|
+
let route = pathname;
|
|
2315
|
+
if (route.startsWith(virtualBase)) {
|
|
2316
|
+
route = route.slice(virtualBase.length);
|
|
2317
|
+
}
|
|
2318
|
+
route = route.replace(/^\\/+/, '/') || '/';
|
|
2319
|
+
|
|
2320
|
+
// Check cache first
|
|
2321
|
+
if (routeParamsCache.has(route)) {
|
|
2322
|
+
return routeParamsCache.get(route);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
try {
|
|
2326
|
+
const response = await fetch(virtualBase + '/_next/route-info?pathname=' + encodeURIComponent(route));
|
|
2327
|
+
const info = await response.json();
|
|
2328
|
+
routeParamsCache.set(route, info.params || {});
|
|
2329
|
+
return info.params || {};
|
|
2330
|
+
} catch (e) {
|
|
2331
|
+
console.error('[Router] Failed to extract route params:', e);
|
|
2332
|
+
return {};
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
1709
2336
|
// Convert URL path to app router page module path
|
|
1710
2337
|
function getAppPageModulePath(pathname) {
|
|
1711
2338
|
let route = pathname;
|
|
@@ -1772,11 +2399,60 @@ export class NextDevServer extends DevServer {
|
|
|
1772
2399
|
return layouts;
|
|
1773
2400
|
}
|
|
1774
2401
|
|
|
2402
|
+
// Wrapper for async Server Components
|
|
2403
|
+
function AsyncComponent({ component: Component, pathname, search }) {
|
|
2404
|
+
const [content, setContent] = React.useState(null);
|
|
2405
|
+
const [error, setError] = React.useState(null);
|
|
2406
|
+
|
|
2407
|
+
React.useEffect(() => {
|
|
2408
|
+
let cancelled = false;
|
|
2409
|
+
async function render() {
|
|
2410
|
+
try {
|
|
2411
|
+
// Create searchParams as a Promise (Next.js 15 pattern)
|
|
2412
|
+
const url = new URL(window.location.href);
|
|
2413
|
+
const searchParamsObj = Object.fromEntries(url.searchParams);
|
|
2414
|
+
const searchParams = Promise.resolve(searchParamsObj);
|
|
2415
|
+
|
|
2416
|
+
// Extract route params from pathname (fetches from server for dynamic routes)
|
|
2417
|
+
const routeParams = await extractRouteParams(pathname);
|
|
2418
|
+
const params = Promise.resolve(routeParams);
|
|
2419
|
+
|
|
2420
|
+
// Call component with props like Next.js does for page components
|
|
2421
|
+
const result = Component({ searchParams, params });
|
|
2422
|
+
if (result && typeof result.then === 'function') {
|
|
2423
|
+
// It's a Promise (async component)
|
|
2424
|
+
const resolved = await result;
|
|
2425
|
+
if (!cancelled) setContent(resolved);
|
|
2426
|
+
} else {
|
|
2427
|
+
// Synchronous component - result is already JSX
|
|
2428
|
+
if (!cancelled) setContent(result);
|
|
2429
|
+
}
|
|
2430
|
+
} catch (e) {
|
|
2431
|
+
console.error('[AsyncComponent] Error rendering:', e);
|
|
2432
|
+
if (!cancelled) setError(e);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
render();
|
|
2436
|
+
return () => { cancelled = true; };
|
|
2437
|
+
}, [Component, pathname, search]);
|
|
2438
|
+
|
|
2439
|
+
if (error) {
|
|
2440
|
+
return React.createElement('div', { style: { color: 'red', padding: '20px' } },
|
|
2441
|
+
'Error: ' + error.message
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
if (!content) {
|
|
2445
|
+
return React.createElement('div', { style: { padding: '20px' } }, 'Loading...');
|
|
2446
|
+
}
|
|
2447
|
+
return content;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
1775
2450
|
// Router component
|
|
1776
2451
|
function Router() {
|
|
1777
2452
|
const [Page, setPage] = React.useState(null);
|
|
1778
2453
|
const [layouts, setLayouts] = React.useState([]);
|
|
1779
2454
|
const [path, setPath] = React.useState(window.location.pathname);
|
|
2455
|
+
const [search, setSearch] = React.useState(window.location.search);
|
|
1780
2456
|
|
|
1781
2457
|
React.useEffect(() => {
|
|
1782
2458
|
Promise.all([loadPage(path), loadLayouts(path)]).then(([P, L]) => {
|
|
@@ -1788,21 +2464,35 @@ export class NextDevServer extends DevServer {
|
|
|
1788
2464
|
React.useEffect(() => {
|
|
1789
2465
|
const handleNavigation = async () => {
|
|
1790
2466
|
const newPath = window.location.pathname;
|
|
2467
|
+
const newSearch = window.location.search;
|
|
2468
|
+
console.log('[Router] handleNavigation called, newPath:', newPath, 'current path:', path);
|
|
2469
|
+
|
|
2470
|
+
// Always update search params
|
|
2471
|
+
if (newSearch !== search) {
|
|
2472
|
+
setSearch(newSearch);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
1791
2475
|
if (newPath !== path) {
|
|
2476
|
+
console.log('[Router] Path changed, loading new page...');
|
|
1792
2477
|
setPath(newPath);
|
|
1793
2478
|
const [P, L] = await Promise.all([loadPage(newPath), loadLayouts(newPath)]);
|
|
2479
|
+
console.log('[Router] Page loaded:', !!P, 'Layouts:', L.length);
|
|
1794
2480
|
if (P) setPage(() => P);
|
|
1795
2481
|
setLayouts(L);
|
|
2482
|
+
} else {
|
|
2483
|
+
console.log('[Router] Path unchanged, skipping navigation');
|
|
1796
2484
|
}
|
|
1797
2485
|
};
|
|
1798
2486
|
window.addEventListener('popstate', handleNavigation);
|
|
2487
|
+
console.log('[Router] Added popstate listener for path:', path);
|
|
1799
2488
|
return () => window.removeEventListener('popstate', handleNavigation);
|
|
1800
|
-
}, [path]);
|
|
2489
|
+
}, [path, search]);
|
|
1801
2490
|
|
|
1802
2491
|
if (!Page) return null;
|
|
1803
2492
|
|
|
1804
|
-
//
|
|
1805
|
-
|
|
2493
|
+
// Use AsyncComponent wrapper to handle async Server Components
|
|
2494
|
+
// Pass search to force re-render when query params change
|
|
2495
|
+
let content = React.createElement(AsyncComponent, { component: Page, pathname: path, search: search });
|
|
1806
2496
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
1807
2497
|
content = React.createElement(layouts[i], null, content);
|
|
1808
2498
|
}
|
|
@@ -1962,6 +2652,9 @@ export class NextDevServer extends DevServer {
|
|
|
1962
2652
|
// Generate env script for NEXT_PUBLIC_* variables
|
|
1963
2653
|
const envScript = this.generateEnvScript();
|
|
1964
2654
|
|
|
2655
|
+
// Load Tailwind config if available (must be injected BEFORE CDN script)
|
|
2656
|
+
const tailwindConfigScript = await this.loadTailwindConfigIfNeeded();
|
|
2657
|
+
|
|
1965
2658
|
return `<!DOCTYPE html>
|
|
1966
2659
|
<html lang="en">
|
|
1967
2660
|
<head>
|
|
@@ -1971,6 +2664,7 @@ export class NextDevServer extends DevServer {
|
|
|
1971
2664
|
<title>Next.js App</title>
|
|
1972
2665
|
${envScript}
|
|
1973
2666
|
${TAILWIND_CDN_SCRIPT}
|
|
2667
|
+
${tailwindConfigScript}
|
|
1974
2668
|
${CORS_PROXY_SCRIPT}
|
|
1975
2669
|
${globalCssLinks.join('\n ')}
|
|
1976
2670
|
${REACT_REFRESH_PREAMBLE}
|
|
@@ -1984,7 +2678,12 @@ export class NextDevServer extends DevServer {
|
|
|
1984
2678
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
|
1985
2679
|
"next/link": "${virtualPrefix}/_next/shims/link.js",
|
|
1986
2680
|
"next/router": "${virtualPrefix}/_next/shims/router.js",
|
|
1987
|
-
"next/head": "${virtualPrefix}/_next/shims/head.js"
|
|
2681
|
+
"next/head": "${virtualPrefix}/_next/shims/head.js",
|
|
2682
|
+
"next/navigation": "${virtualPrefix}/_next/shims/navigation.js",
|
|
2683
|
+
"next/image": "${virtualPrefix}/_next/shims/image.js",
|
|
2684
|
+
"next/dynamic": "${virtualPrefix}/_next/shims/dynamic.js",
|
|
2685
|
+
"next/script": "${virtualPrefix}/_next/shims/script.js",
|
|
2686
|
+
"next/font/google": "${virtualPrefix}/_next/shims/font/google.js"
|
|
1988
2687
|
}
|
|
1989
2688
|
}
|
|
1990
2689
|
</script>
|
|
@@ -2106,6 +2805,39 @@ export class NextDevServer extends DevServer {
|
|
|
2106
2805
|
};
|
|
2107
2806
|
}
|
|
2108
2807
|
|
|
2808
|
+
/**
|
|
2809
|
+
* Try to resolve a file path by adding common extensions
|
|
2810
|
+
* e.g., /components/faq -> /components/faq.tsx
|
|
2811
|
+
* Also handles index files in directories
|
|
2812
|
+
*/
|
|
2813
|
+
private resolveFileWithExtension(pathname: string): string | null {
|
|
2814
|
+
// If the file already has an extension and exists, return it
|
|
2815
|
+
if (/\.\w+$/.test(pathname) && this.exists(pathname)) {
|
|
2816
|
+
return pathname;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Common extensions to try, in order of preference
|
|
2820
|
+
const extensions = ['.tsx', '.ts', '.jsx', '.js'];
|
|
2821
|
+
|
|
2822
|
+
// Try adding extensions directly
|
|
2823
|
+
for (const ext of extensions) {
|
|
2824
|
+
const withExt = pathname + ext;
|
|
2825
|
+
if (this.exists(withExt)) {
|
|
2826
|
+
return withExt;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// Try as a directory with index file
|
|
2831
|
+
for (const ext of extensions) {
|
|
2832
|
+
const indexPath = pathname + '/index' + ext;
|
|
2833
|
+
if (this.exists(indexPath)) {
|
|
2834
|
+
return indexPath;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2109
2841
|
/**
|
|
2110
2842
|
* Check if a file needs transformation
|
|
2111
2843
|
*/
|
|
@@ -2139,7 +2871,8 @@ export class NextDevServer extends DevServer {
|
|
|
2139
2871
|
};
|
|
2140
2872
|
}
|
|
2141
2873
|
|
|
2142
|
-
|
|
2874
|
+
// Use filePath (with extension) for transform so loader is correctly determined
|
|
2875
|
+
const transformed = await this.transformCode(content, filePath);
|
|
2143
2876
|
|
|
2144
2877
|
// Cache the transform result
|
|
2145
2878
|
this.transformCache.set(filePath, { code: transformed, hash });
|
|
@@ -2191,12 +2924,15 @@ export class NextDevServer extends DevServer {
|
|
|
2191
2924
|
// CSS imports in ESM would fail with MIME type errors
|
|
2192
2925
|
const codeWithoutCssImports = this.stripCssImports(code);
|
|
2193
2926
|
|
|
2927
|
+
// Resolve path aliases (e.g., @/ -> /) before transformation
|
|
2928
|
+
const codeWithResolvedAliases = this.resolvePathAliases(codeWithoutCssImports, filename);
|
|
2929
|
+
|
|
2194
2930
|
let loader: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
|
|
2195
2931
|
if (filename.endsWith('.jsx')) loader = 'jsx';
|
|
2196
2932
|
else if (filename.endsWith('.tsx')) loader = 'tsx';
|
|
2197
2933
|
else if (filename.endsWith('.ts')) loader = 'ts';
|
|
2198
2934
|
|
|
2199
|
-
const result = await esbuild.transform(
|
|
2935
|
+
const result = await esbuild.transform(codeWithResolvedAliases, {
|
|
2200
2936
|
loader,
|
|
2201
2937
|
format: 'esm',
|
|
2202
2938
|
target: 'esnext',
|
|
@@ -2206,12 +2942,80 @@ export class NextDevServer extends DevServer {
|
|
|
2206
2942
|
sourcefile: filename,
|
|
2207
2943
|
});
|
|
2208
2944
|
|
|
2945
|
+
// Redirect bare npm imports to esm.sh CDN
|
|
2946
|
+
const codeWithCdnImports = this.redirectNpmImports(result.code);
|
|
2947
|
+
|
|
2209
2948
|
// Add React Refresh registration for JSX/TSX files
|
|
2210
2949
|
if (/\.(jsx|tsx)$/.test(filename)) {
|
|
2211
|
-
return this.addReactRefresh(
|
|
2950
|
+
return this.addReactRefresh(codeWithCdnImports, filename);
|
|
2212
2951
|
}
|
|
2213
2952
|
|
|
2214
|
-
return
|
|
2953
|
+
return codeWithCdnImports;
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
2957
|
+
* Redirect bare npm package imports to esm.sh CDN
|
|
2958
|
+
* e.g., import { Crisp } from "crisp-sdk-web" -> import { Crisp } from "https://esm.sh/crisp-sdk-web?external=react"
|
|
2959
|
+
*
|
|
2960
|
+
* IMPORTANT: We redirect ALL npm packages to esm.sh URLs (including React)
|
|
2961
|
+
* because import maps don't work reliably for dynamically imported modules.
|
|
2962
|
+
*/
|
|
2963
|
+
private redirectNpmImports(code: string): string {
|
|
2964
|
+
// Explicit mappings for common packages (ensures correct esm.sh URLs)
|
|
2965
|
+
const explicitMappings: Record<string, string> = {
|
|
2966
|
+
'react': 'https://esm.sh/react@18.2.0?dev',
|
|
2967
|
+
'react/jsx-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-runtime',
|
|
2968
|
+
'react/jsx-dev-runtime': 'https://esm.sh/react@18.2.0&dev/jsx-dev-runtime',
|
|
2969
|
+
'react-dom': 'https://esm.sh/react-dom@18.2.0?dev',
|
|
2970
|
+
'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client?dev',
|
|
2971
|
+
};
|
|
2972
|
+
|
|
2973
|
+
// Packages that are local or have custom shims (NOT npm packages)
|
|
2974
|
+
const localPackages = new Set([
|
|
2975
|
+
'next/link', 'next/router', 'next/head', 'next/navigation',
|
|
2976
|
+
'next/dynamic', 'next/image', 'next/script', 'next/font/google',
|
|
2977
|
+
'convex/_generated/api'
|
|
2978
|
+
]);
|
|
2979
|
+
|
|
2980
|
+
// Pattern to match import statements with bare package specifiers
|
|
2981
|
+
// Matches: from "package" or from 'package' where package doesn't start with . / or http
|
|
2982
|
+
const importPattern = /(from\s*['"])([^'"./][^'"]*?)(['"])/g;
|
|
2983
|
+
|
|
2984
|
+
return code.replace(importPattern, (match, prefix, packageName, suffix) => {
|
|
2985
|
+
// Skip if already a URL or local virtual path
|
|
2986
|
+
if (packageName.startsWith('http://') ||
|
|
2987
|
+
packageName.startsWith('https://') ||
|
|
2988
|
+
packageName.startsWith('/__virtual__')) {
|
|
2989
|
+
return match;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// Check explicit mappings first
|
|
2993
|
+
if (explicitMappings[packageName]) {
|
|
2994
|
+
return `${prefix}${explicitMappings[packageName]}${suffix}`;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// Skip local/shimmed packages (they're handled via import map or virtual paths)
|
|
2998
|
+
if (localPackages.has(packageName)) {
|
|
2999
|
+
return match;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// Check if it's a subpath import of a local package
|
|
3003
|
+
const basePkg = packageName.includes('/') ? packageName.split('/')[0] : packageName;
|
|
3004
|
+
|
|
3005
|
+
// Handle scoped packages (@org/pkg)
|
|
3006
|
+
const isScoped = basePkg.startsWith('@');
|
|
3007
|
+
const scopedBasePkg = isScoped && packageName.includes('/')
|
|
3008
|
+
? packageName.split('/').slice(0, 2).join('/')
|
|
3009
|
+
: basePkg;
|
|
3010
|
+
|
|
3011
|
+
if (localPackages.has(scopedBasePkg)) {
|
|
3012
|
+
return match;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// Redirect to esm.sh with external=react to avoid bundling React twice
|
|
3016
|
+
const esmUrl = `https://esm.sh/${packageName}?external=react`;
|
|
3017
|
+
return `${prefix}${esmUrl}${suffix}`;
|
|
3018
|
+
});
|
|
2215
3019
|
}
|
|
2216
3020
|
|
|
2217
3021
|
/**
|
|
@@ -2221,13 +3025,18 @@ export class NextDevServer extends DevServer {
|
|
|
2221
3025
|
private stripCssImports(code: string): string {
|
|
2222
3026
|
// Match import statements for CSS files (with or without semicolon)
|
|
2223
3027
|
// Handles: import './styles.css'; import "./globals.css" import '../path/file.css'
|
|
2224
|
-
|
|
3028
|
+
// NOTE: Don't match trailing whitespace (\s*) as it would consume newlines
|
|
3029
|
+
// and break subsequent imports that start on the next line
|
|
3030
|
+
return code.replace(/import\s+['"][^'"]+\.css['"]\s*;?/g, '');
|
|
2225
3031
|
}
|
|
2226
3032
|
|
|
2227
3033
|
/**
|
|
2228
3034
|
* Transform API handler code to CommonJS for eval execution
|
|
2229
3035
|
*/
|
|
2230
3036
|
private async transformApiHandler(code: string, filename: string): Promise<string> {
|
|
3037
|
+
// Resolve path aliases first
|
|
3038
|
+
const codeWithResolvedAliases = this.resolvePathAliases(code, filename);
|
|
3039
|
+
|
|
2231
3040
|
if (isBrowser) {
|
|
2232
3041
|
// Use esbuild in browser
|
|
2233
3042
|
await initEsbuild();
|
|
@@ -2242,7 +3051,7 @@ export class NextDevServer extends DevServer {
|
|
|
2242
3051
|
else if (filename.endsWith('.tsx')) loader = 'tsx';
|
|
2243
3052
|
else if (filename.endsWith('.ts')) loader = 'ts';
|
|
2244
3053
|
|
|
2245
|
-
const result = await esbuild.transform(
|
|
3054
|
+
const result = await esbuild.transform(codeWithResolvedAliases, {
|
|
2246
3055
|
loader,
|
|
2247
3056
|
format: 'cjs', // CommonJS for eval execution
|
|
2248
3057
|
target: 'esnext',
|
|
@@ -2254,7 +3063,7 @@ export class NextDevServer extends DevServer {
|
|
|
2254
3063
|
}
|
|
2255
3064
|
|
|
2256
3065
|
// Simple ESM to CJS transform for Node.js/test environment
|
|
2257
|
-
let transformed =
|
|
3066
|
+
let transformed = codeWithResolvedAliases;
|
|
2258
3067
|
|
|
2259
3068
|
// Convert: import X from 'Y' -> const X = require('Y')
|
|
2260
3069
|
transformed = transformed.replace(
|
|
@@ -2420,6 +3229,76 @@ ${registrations}
|
|
|
2420
3229
|
}
|
|
2421
3230
|
}
|
|
2422
3231
|
|
|
3232
|
+
/**
|
|
3233
|
+
* Override serveFile to wrap JSON files as ES modules
|
|
3234
|
+
* This is needed because browsers can't dynamically import raw JSON files
|
|
3235
|
+
*/
|
|
3236
|
+
protected serveFile(filePath: string): ResponseData {
|
|
3237
|
+
// For JSON files, wrap as ES module so they can be dynamically imported
|
|
3238
|
+
if (filePath.endsWith('.json')) {
|
|
3239
|
+
try {
|
|
3240
|
+
const normalizedPath = this.resolvePath(filePath);
|
|
3241
|
+
const content = this.vfs.readFileSync(normalizedPath);
|
|
3242
|
+
|
|
3243
|
+
// Properly convert content to string
|
|
3244
|
+
// VirtualFS may return string, Buffer, or Uint8Array
|
|
3245
|
+
let jsonContent: string;
|
|
3246
|
+
if (typeof content === 'string') {
|
|
3247
|
+
jsonContent = content;
|
|
3248
|
+
} else if (content instanceof Uint8Array) {
|
|
3249
|
+
// Use TextDecoder for Uint8Array (includes Buffer in browser)
|
|
3250
|
+
jsonContent = new TextDecoder('utf-8').decode(content);
|
|
3251
|
+
} else {
|
|
3252
|
+
// Fallback for other buffer-like objects
|
|
3253
|
+
jsonContent = Buffer.from(content).toString('utf-8');
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// Wrap JSON as ES module
|
|
3257
|
+
const esModuleContent = `export default ${jsonContent};`;
|
|
3258
|
+
const buffer = Buffer.from(esModuleContent);
|
|
3259
|
+
|
|
3260
|
+
return {
|
|
3261
|
+
statusCode: 200,
|
|
3262
|
+
statusMessage: 'OK',
|
|
3263
|
+
headers: {
|
|
3264
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
3265
|
+
'Content-Length': String(buffer.length),
|
|
3266
|
+
'Cache-Control': 'no-cache',
|
|
3267
|
+
},
|
|
3268
|
+
body: buffer,
|
|
3269
|
+
};
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
3272
|
+
return this.notFound(filePath);
|
|
3273
|
+
}
|
|
3274
|
+
return this.serverError(error);
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// For all other files, use the parent implementation
|
|
3279
|
+
return super.serveFile(filePath);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
/**
|
|
3283
|
+
* Resolve a path (helper to access protected method from parent)
|
|
3284
|
+
*/
|
|
3285
|
+
protected resolvePath(urlPath: string): string {
|
|
3286
|
+
// Remove query string and hash
|
|
3287
|
+
let path = urlPath.split('?')[0].split('#')[0];
|
|
3288
|
+
|
|
3289
|
+
// Normalize path
|
|
3290
|
+
if (!path.startsWith('/')) {
|
|
3291
|
+
path = '/' + path;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
// Join with root
|
|
3295
|
+
if (this.root !== '/') {
|
|
3296
|
+
path = this.root + path;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
return path;
|
|
3300
|
+
}
|
|
3301
|
+
|
|
2423
3302
|
/**
|
|
2424
3303
|
* Stop the server
|
|
2425
3304
|
*/
|